mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e24e5fe6e8 | ||
|
|
194dd0035b | ||
|
|
c3629255ea | ||
|
|
345a0db49b | ||
|
|
5276bce060 | ||
|
|
0abbda1160 | ||
|
|
97c74859c0 | ||
|
|
4d5c6459fe | ||
|
|
c835d5c570 | ||
|
|
59872daf52 | ||
|
|
3bd3935042 | ||
|
|
77a6912ade | ||
|
|
4b83aaed61 | ||
|
|
7268188e40 | ||
|
|
8503c92f3a | ||
|
|
069d836dea | ||
|
|
95384a4a92 | ||
|
|
9b82ca663b | ||
|
|
f5633a29cb | ||
|
|
766cd94788 | ||
|
|
b6d287a299 | ||
|
|
4c25236070 | ||
|
|
c1224cb612 | ||
|
|
5036639aa1 | ||
|
|
9593ca0f54 | ||
|
|
b441287d54 | ||
|
|
9b82d29037 | ||
|
|
0f270366aa | ||
|
|
12b8fb34c6 | ||
|
|
774346d503 | ||
|
|
b97e1f4bc6 | ||
|
|
4b2395a8d2 | ||
|
|
1337bcb7de | ||
|
|
b8f08d54c8 | ||
|
|
24399f1d70 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -21,14 +21,15 @@ Agent Hub 是 TraceMemo 内置的微信机器人入口,也是应用一级导
|
||||
- “帮我看看最近跟某人聊了些什么。”
|
||||
- “生成产品交流群今天的群聊总结图片。”
|
||||
|
||||
当前已实现的实时任务包括:
|
||||
Hub 把入站文字分成两类处理。
|
||||
|
||||
- 查看最近会话(数量限制为 1–20);
|
||||
- 查询你和某位联系人的近期聊天;
|
||||
- 用已配置的 AI 总结你和某位联系人近 7 天的聊天;
|
||||
- 生成今天、昨天或近 7 天的群聊总结图片;
|
||||
- 总结指定群成员在群里的近期发言;
|
||||
- 对不需要读取聊天的普通文字请求返回简短 AI 回复。
|
||||
**确定性的快捷动作**(不经过模型,命中就执行):
|
||||
|
||||
- 查看最近会话:数量限制为 1–20;
|
||||
- 生成群聊总结图片:今天、昨天或近 7 天(需要同时提到“群”和“图片 / 长图 / 日报 / 报告”);
|
||||
- 分析某个群成员的近期发言:可以指定“今天 / 昨天 / 最近 N 天”。
|
||||
|
||||
**其余问题**交给本机的 Query Agent:它和桌面端“问问微信”使用的是同一个实现,可以按需读取联系人、会话和时间范围来回答,必要时调用你在“设置 → AI 模型”里配置的 AI。例如“帮我看看最近跟某人聊了些什么”“上个月讨论过的项目地址在哪里”。
|
||||
|
||||
任务完成后,回复会发送回触发这次请求的微信用户。群聊总结会先发送进度提示,完成后发送图片。
|
||||
|
||||
@@ -56,12 +57,12 @@ Agent Hub 页面会记录系统、Agent Hub 和微信连接器日志。日志支
|
||||
|
||||
## 安全与边界
|
||||
|
||||
- Hub 使用本机通信,不把数据库直接暴露到公网;
|
||||
- Hub 在主进程内运行,不开放本地监听端口;它不会把数据库暴露到公网;
|
||||
- 机器人账号和个人微信账号是不同的登录边界,请确认你连接的是正确账号;
|
||||
- 机器人回复会发送给当前发消息的人;开发者 API 另有受保护的测试发送入口,使用前必须确认接收者;
|
||||
- Hub 生成群聊总结时仍可能调用你配置的 AI Provider;
|
||||
- 当前实时自然语言入口主要处理文字消息。底层连接器可以接收图片、语音、文件和视频,但 Agent Hub 尚未为这些媒体提供同等的实时意图处理;
|
||||
- 当前没有实现群发、广播、定时任务或通用自主操作微信;
|
||||
- Hub 理解请求或生成总结时,会调用你在“设置 → AI 模型”配置的 Provider;
|
||||
- 当前实时入口只处理文字消息。连接器会归一化收到的消息条目,但 Agent Hub 只把文本条目当作意图处理,尚未为图片、语音、文件和视频提供同等能力;
|
||||
- 当前没有实现群发、广播、定时任务或通用自主操作微信(定时日报属于「自动化」,不是 Agent Hub);
|
||||
- 本页面的“Agent Hub 状态”可以通过 Local HTTP API 查询,但不要把它误认为外部 Agent 的实时消息订阅接口或 MCP Server。
|
||||
|
||||
## 无法连接时
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||
TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑上的 API Center、Reader Skill、CLI 和 Agent,不是公网网关,也不是带用户账户和细粒度权限 Scope 的服务。
|
||||
|
||||
它同时包含**写入型**端点:生成报告并渲染 PNG(`/report`)、通过已连接机器人发送微信消息(`/agent/send`)、创建/修改/删除/启停定时日报任务并触发立即执行(`/scheduled-reports*`)。因此这个 Token 相当于本机敏感凭据,而不是一个只读查询键。
|
||||
|
||||
## Bearer Token
|
||||
|
||||
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。v2.2.0 仍兼容读取历史变量 `WECHATEXPLORER_API_TOKEN`,优先级为新变量高于旧变量。
|
||||
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。历史变量名 `WECHATEXPLORER_API_TOKEN` 仍被兼容读取,优先级为新变量高于旧变量;当前没有设定旧变量名的移除时间,新配置不要再使用它。
|
||||
|
||||
- `/api/v1/health` 是公开健康检查;
|
||||
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`;
|
||||
@@ -14,7 +16,8 @@ TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑
|
||||
- Token 由 Electron `safeStorage` 加密保存在用户数据目录的 `local-api-token.bin`;
|
||||
- 文件权限设置为 `0600`;
|
||||
- 在“API Center”中可以显示、复制和重新生成;
|
||||
- 重新生成后旧 Token 立即失效。
|
||||
- 重新生成后旧 Token 立即失效;
|
||||
- 服务端只认这个 Token,**不接受用环境变量覆盖**——Agent 一侧的环境变量只是把 Token 交给 Agent 自己的方式,不是鉴权来源。
|
||||
|
||||
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
|
||||
|
||||
|
||||
+46
-27
@@ -24,31 +24,48 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
|
||||
|
||||
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
|
||||
|
||||
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可在 v2.2.0 兼容期内继续读取 `WECHATEXPLORER_API_TOKEN`;如果两个变量都存在,以新变量为准。
|
||||
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。应用生成的安装指令仍会提示:尚未升级的旧配置可以继续读取 `WECHATEXPLORER_API_TOKEN`,但新配置必须使用新变量名;如果两个变量都存在,以新变量为准。当前没有设定旧变量名的移除时间。
|
||||
|
||||
Token 由应用生成并保存在本机,**不接受用环境变量覆盖**:Agent 侧的环境变量只是把 Token 传给 Agent 自己的方式,不是服务端的鉴权来源。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 | 路径 | 作用 | 参数/请求体 |
|
||||
| ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- |
|
||||
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
|
||||
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
|
||||
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
|
||||
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
||||
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
||||
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
||||
| GET | `/api/v1/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 |
|
||||
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
||||
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
||||
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
||||
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
|
||||
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
|
||||
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
|
||||
| 方法 | 路径 | 作用 | 参数/请求体 |
|
||||
| ------ | --------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------- |
|
||||
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
|
||||
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
|
||||
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
|
||||
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
||||
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
||||
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
||||
| GET | `/api/v1/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 |
|
||||
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
||||
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
||||
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
||||
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
|
||||
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
|
||||
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
|
||||
| GET | `/api/v1/wechat-personal/send-capability` | 个人微信发送能力状态 | 无 |
|
||||
| GET | `/api/v1/scheduled-reports` | 定时日报任务列表 | 无 |
|
||||
| POST | `/api/v1/scheduled-reports` | 创建定时日报任务 | `ScheduledReportApiCreateRequest` JSON |
|
||||
| GET | `/api/v1/scheduled-reports/{id}` | 查询单个定时日报任务 | 无 |
|
||||
| PATCH | `/api/v1/scheduled-reports/{id}` | 修改定时日报任务 | `ScheduledReportApiUpdateRequest` JSON |
|
||||
| DELETE | `/api/v1/scheduled-reports/{id}` | 删除定时日报任务 | 无 |
|
||||
| POST | `/api/v1/scheduled-reports/{id}/enable` | 启用定时日报任务 | 无 |
|
||||
| POST | `/api/v1/scheduled-reports/{id}/disable` | 暂停定时日报任务 | 无 |
|
||||
| POST | `/api/v1/scheduled-reports/{id}/run` | 立即执行一次并返回 execution | 无 |
|
||||
| GET | `/api/v1/scheduled-reports/{id}/executions` | 查询某个任务的执行记录 | 无 |
|
||||
| POST | `/api/v1/scheduled-reports/executions/{executionId}/retry-send` | 复用已有 PNG 重试发送 | 无 |
|
||||
|
||||
`/api/v1/query/*` 是一组结构化的 Query 端点,见下方[LLM-friendly Query Tool API](#llm-friendly-query-tool-api)。
|
||||
|
||||
### 这些端点与实时机器人有什么关系
|
||||
|
||||
- `/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 +100,12 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
||||
## 响应与错误
|
||||
|
||||
- `200`:请求成功;
|
||||
- `201`:定时日报任务创建成功;
|
||||
- `401`:缺少、错误或已失效的 Bearer Token;
|
||||
- `400`:参数或 JSON 请求体无效;
|
||||
- `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`);
|
||||
- `403`:浏览器 Origin 不在允许的 loopback 列表;
|
||||
- `409`:定时日报任务重复(`error === "duplicate"`,响应里会带回已存在的任务),或群聊名称匹配到多个目标(`ambiguous_contact`);
|
||||
- `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在;
|
||||
- `503`:数据库或 Agent Hub 尚未就绪;
|
||||
- `500`:服务端处理或报告渲染失败。
|
||||
@@ -154,12 +173,12 @@ curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/convers
|
||||
|
||||
`query/messages`、`query/search`、`query/message-context` 和 `query/conversation-overview` 都接受一个可选的 `scope`,用来把检索限制在一个确定的语料边界内:
|
||||
|
||||
| scope | 含义 |
|
||||
| ----- | ---- |
|
||||
| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) |
|
||||
| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) |
|
||||
| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 |
|
||||
| `{"kind":"current","conversationId":"…"}` | 只搜指定的那个会话(单聊或群聊) |
|
||||
| scope | 含义 |
|
||||
| ----------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) |
|
||||
| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) |
|
||||
| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 |
|
||||
| `{"kind":"current","conversationId":"…"}` | 只搜指定的那个会话(单聊或群聊) |
|
||||
|
||||
`conversationId` 是会话标识,可用 `/api/v1/resolve` 或 `/api/v1/contact` 得到。`scope` 一旦给出就是**权威边界**:`target` 落在范围之外会被拒绝(`status: "invalid_tool_arguments"`、`constraint: "target_outside_scope"`),不会静默扩大范围;范围里包含多个会话时,`query/messages` 与 `query/conversation-overview` 必须显式指定 `target`(`constraint: "target_required_for_scope"`)。
|
||||
|
||||
@@ -186,11 +205,11 @@ curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/convers
|
||||
|
||||
`query/search` 依赖本地索引,而本地索引是异步建立的派生数据,可能落后于聊天数据库。因此它的响应会显式给出覆盖口径:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| ---- | ---- |
|
||||
| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 |
|
||||
| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 |
|
||||
| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 |
|
||||
| 字段 | 含义 |
|
||||
| ------------------- | ------------------------------------------------------------------- |
|
||||
| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 |
|
||||
| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 |
|
||||
| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 |
|
||||
| `freshness.catchUp` | 本次为追赶索引做了什么:`none` / `reused` / `completed` / `pending` |
|
||||
|
||||
调用方**必须**把 `coverage` 当真:`coverage.state` 不是 `complete` 且 `evidence` 为空时,只能说明"这段范围暂时无法确认",**不能**下"没有找到"的结论。索引落后时服务端会自动请求一次追赶同步,但不会让请求无限等待;`freshness.catchUp` 为 `pending` 表示追赶仍在后台进行,稍后重试即可拿到更新的覆盖。
|
||||
|
||||
@@ -8,7 +8,7 @@ Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Cod
|
||||
|
||||
Reader Skill 只负责“外部 Agent 主动查询历史微信数据”。它不负责二维码登录、监听微信实时消息、接收机器人消息或管理 Agent Hub。想让机器人收到微信消息后处理并回复,请阅读[Agent Hub](./agent-hub.md)。
|
||||
|
||||
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 可在 v2.2.0 兼容期内继续使用旧变量。
|
||||
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 仍可继续使用旧变量 `WECHATEXPLORER_API_TOKEN`(当前没有设定移除时间),但新安装请使用新名称与新变量名。
|
||||
|
||||
## 推荐安装流程
|
||||
|
||||
@@ -53,8 +53,13 @@ Reader Skill 可以指导 Agent 使用:
|
||||
- 指定会话、日期或时间戳范围的聊天记录;
|
||||
- 群成员快照;
|
||||
- 结构化日报渲染和按群聊生成总结图片;
|
||||
- 定时日报任务的查询、创建、修改、启停、删除、立即执行和执行记录;删除不可逆,Skill 要求先列出唯一任务并取得用户明确确认;
|
||||
- 个人微信发送能力状态查询(`/wechat-personal/send-capability`);
|
||||
- `query/*` 一组结构化 Query 端点:`messages`、`search`、`message-context`、`conversation-overview`;
|
||||
- Agent Hub 状态检查与已连接机器人发送测试。这里的发送接口是开发者/测试用途,不是实时机器人入口,也不会让 Reader Skill 自动监听微信消息。
|
||||
|
||||
注意这个 API 不只是只读的:`/report`、`/agent/send` 和 `/scheduled-reports*` 会写入状态或真的发出微信消息。
|
||||
|
||||
端点、参数、错误码和鉴权细节以[Local HTTP API](./api.md)为准。Skill 文件保持短小,避免在多个文档中复制会变化的完整响应 schema。
|
||||
|
||||
## 隐私边界
|
||||
|
||||
@@ -4,39 +4,54 @@
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[本机微信数据] --> B[读取与解析]
|
||||
B --> C[聊天档案与普通搜索]
|
||||
B --> D[本地知识索引]
|
||||
D --> E[筛选相关消息]
|
||||
E --> F[用户配置的 AI Provider]
|
||||
F --> G[回答与可核对来源]
|
||||
B --> H[聊天导出]
|
||||
B --> I[整理日报输入]
|
||||
I --> F
|
||||
F --> J[本地保存 HTML 与 PNG]
|
||||
B --> K[Local HTTP API]
|
||||
K --> L[外部 Agent]
|
||||
M[微信机器人消息] --> N[Agent Hub]
|
||||
N --> B
|
||||
N --> F
|
||||
B --> O[Monitor / Snapshot]
|
||||
O --> P[Proposed Action]
|
||||
F --> P
|
||||
P --> Q[Policy]
|
||||
Q --> R[Action Gateway]
|
||||
R --> S[Personal WeChat Send Capability]
|
||||
S --> T[Action Audit / Logs]
|
||||
WX["本机微信数据"] --> PARSE["读取与解析"]
|
||||
|
||||
PARSE --> ARCHIVE["聊天档案与普通搜索"]
|
||||
PARSE --> EXPORT["聊天导出"]
|
||||
|
||||
PARSE --> IDX["本机索引"]
|
||||
IDX --> TEXTIDX["聊天记录索引"]
|
||||
IDX --> IMGIDX["图片文字索引(本机识别)"]
|
||||
|
||||
TEXTIDX --> UNDERSTAND["Understand:AI Search / 问问微信"]
|
||||
IMGIDX --> UNDERSTAND
|
||||
UNDERSTAND --> PROVIDER["你配置的 AI Provider"]
|
||||
PROVIDER --> ANSWER["回答与可核对来源"]
|
||||
|
||||
PARSE --> REPORTINPUT["整理日报输入"]
|
||||
REPORTINPUT --> PROVIDER
|
||||
PROVIDER --> REPORTFILE["本机保存 HTML 与 PNG"]
|
||||
|
||||
PARSE --> MONITOR["Monitor:退群监控 / 成员快照"]
|
||||
MONITOR --> RULE["自动化规则"]
|
||||
REPORTFILE --> RULE
|
||||
RULE --> POLICY["Policy"]
|
||||
POLICY --> GATEWAY["Action Gateway"]
|
||||
GATEWAY --> CAP["本机发送能力"]
|
||||
CAP --> AUDIT["执行记录与审计"]
|
||||
|
||||
PARSE --> API["Local HTTP API"]
|
||||
API --> EXTAGENT["外部 Agent / Reader Skill"]
|
||||
BOT["微信机器人消息"] --> HUB["Agent Hub"]
|
||||
HUB --> PARSE
|
||||
HUB --> PROVIDER
|
||||
```
|
||||
|
||||
## Remember → Understand → Monitor → Act
|
||||
## Remember → 图片文字 → Understand → Monitor → Act
|
||||
|
||||
TraceMemo 的工作方式可以概括为:
|
||||
|
||||
```text
|
||||
Remember → Understand → Monitor → Act
|
||||
Remember → 图片文字 → Understand → Monitor → Act
|
||||
```
|
||||
|
||||
先读取和整理微信信息,再由 AI、Knowledge 或日报帮助理解;Monitor 负责发现成员变化,明确的业务动作再进入执行边界。回答和动作结果都应能回到来源或记录核对。
|
||||
- **Remember**:读取并解析本机微信数据,建立聊天档案、普通搜索和导出。
|
||||
- **图片文字**:在本机识别图片里的文字,把截图、公告、报价图也变成可检索的内容。这一步不联网。
|
||||
- **Understand**:Knowledge、AI Search / 问问微信、群聊日报。需要模型时,只把完成这次任务所需的受控上下文交给 Provider。
|
||||
- **Monitor**:用成员快照对比发现群成员变化,产出成员退出事件。
|
||||
- **Act**:自动化规则把前面的步骤串起来(定时日报、退群通知);动作经过统一执行边界,并留下执行记录。
|
||||
|
||||
回答和动作结果都应能回到来源或记录核对。
|
||||
|
||||
## 退群监控
|
||||
|
||||
@@ -46,7 +61,9 @@ Remember → Understand → Monitor → Act
|
||||
Current Membership → Snapshot Diff → Member Event
|
||||
```
|
||||
|
||||
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。
|
||||
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。监控关闭期间发生的变化,不会在重新开启后补报。
|
||||
|
||||
成员退出事件同时是「自动化」里「退群通知」规则的触发条件。
|
||||
|
||||
## 动作执行与审计
|
||||
|
||||
@@ -58,17 +75,20 @@ Feature → Policy → Gateway → Capability → Execution → Audit
|
||||
|
||||
Policy blocked 表示策略不允许,Capability unavailable 表示当前发送能力不可用,Send failed 表示已经尝试但执行失败。Action Audit / Logs 会保留执行结果;定时日报即使发送失败,也会保留已生成的报告记录。
|
||||
|
||||
这些动作统一由「自动化」管理,当前有三类规则:**@我生成日报**、**定时日报**、**退群通知**。发送目标支持当前群聊、文件传输助手、自己、指定好友,不是任意群发。
|
||||
|
||||
## 哪些步骤在本机
|
||||
|
||||
- 微信数据库读取与解析;
|
||||
- 聊天档案浏览和普通搜索;
|
||||
- Knowledge 索引与增量同步;
|
||||
- 图片文字索引:识别图片中的文字完全在本机进行,原始图片不会因为本地识别而上传;
|
||||
- 离线语音转写;
|
||||
- 聊天导出文件、日报 HTML/PNG 和本地历史记录的保存。
|
||||
|
||||
## 哪些步骤可能调用外部服务
|
||||
|
||||
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库。
|
||||
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库,本机 OCR、离线语音转写和普通搜索也不会触发外发。
|
||||
|
||||
Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生成总结调用已配置的 Provider。Reader Skill 调用的是本机 API;外部 Agent 是否把读取结果继续交给云端模型,取决于外部 Agent 自己的配置。
|
||||
|
||||
@@ -76,13 +96,15 @@ Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生
|
||||
|
||||
## 产品名词和用户任务的对应关系
|
||||
|
||||
| 用户想做什么 | 产品中可能看到的名称 |
|
||||
| ------------------------ | ---------------------------- |
|
||||
| 让 AI 找相关聊天 | AI Search、Retrieval |
|
||||
| 让答案能回到原消息 | Evidence、Citation |
|
||||
| 查看 AI 查找过程 | Search Trace |
|
||||
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
|
||||
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
|
||||
| 让微信机器人调用本机能力 | Agent Hub |
|
||||
| 用户想做什么 | 产品中可能看到的名称 |
|
||||
| ------------------------------ | ---------------------------- |
|
||||
| 让 AI 找相关聊天 | AI Search、Retrieval |
|
||||
| 让答案能回到原消息 | Evidence、Citation |
|
||||
| 查看 AI 查找过程 | Search Trace |
|
||||
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
|
||||
| 搜到截图、公告图里写过的文字 | 图片文字索引、本机 OCR |
|
||||
| 让日报、退群通知按规则自动执行 | 自动化、Policy、执行记录 |
|
||||
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
|
||||
| 让微信机器人调用本机能力 | Agent Hub |
|
||||
|
||||
先按任务使用,再在需要排查或开发集成时阅读术语。
|
||||
|
||||
@@ -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、微信数据路径或聊天内容。
|
||||
|
||||
@@ -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,21 +29,28 @@ 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/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
|
||||
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
|
||||
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
|
||||
| 代码区域 | 需要同步检查的文档 |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
|
||||
| `src/shared/ai-search.ts`、AI Search pipeline | `user-guide/ai-search.md`、`concepts/answer-sources.md` |
|
||||
| `src/shared/knowledge.ts`、`src/main/knowledge/` | `user-guide/knowledge.md`、`concepts/how-it-works.md` |
|
||||
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
|
||||
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
|
||||
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
|
||||
| `src/main/services/system-ocr-service.ts`、`image-text-index-service.ts` | `user-guide/knowledge.md`、`concepts/how-it-works.md`、`user-guide/privacy.md` |
|
||||
| `src/main/services/image-insight-service.ts`、AI Provider | `user-guide/report.md`、`user-guide/privacy.md` |
|
||||
| `src/shared/automation.ts`、自动化服务与执行网关 | `user-guide/report.md`、`concepts/how-it-works.md`、`docs/README.md` |
|
||||
| `src/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
|
||||
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
|
||||
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
|
||||
|
||||
两条容易被写错的边界:
|
||||
|
||||
- **防撤回已下线**(`src/main/services/recall-archive-service.ts` 保留但不再启动):设置入口隐藏,`recallProtectionEnabled` 在所有读写路径上被强制收敛为 `false`。不要把它写回用户指南。
|
||||
- **图片文字索引(本机 OCR)与图片理解(需要 Provider)是两条不同的路径**:前者写入本地索引、能被搜索,且不联网;后者只在日报和设置里的模型检测中使用。改其中一条时不要把另一条的隐私口径带过去。
|
||||
|
||||
## 文档检查
|
||||
|
||||
@@ -52,7 +58,10 @@ pnpm test:e2e:build
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!development/overview.md'
|
||||
# 过时版本号、旧品牌名、旧结构叙述、MCP 误解
|
||||
rg -n "v2\.1\.7|2\.4\.0|v2\.2\.0 兼容期|无鉴权|mcpServers" README.md docs --glob '*.md' --glob '!development/overview.md'
|
||||
# 不存在的产品结构(定时日报已并入自动化)
|
||||
rg -n "日报 → 定时日报|Monitor / Automation" README.md docs --glob '*.md'
|
||||
```
|
||||
|
||||
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
|
||||
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。发版前额外确认 `README.md` 里的版本号与 `package.json` 的 `version` 一致。
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
然后重启电脑,恢复系统安全设置。
|
||||
|
||||
@@ -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#连接微信失败)处理。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: tracememo-reader
|
||||
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
|
||||
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体,并管理定时日报任务。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结、查询或修改定时日报时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
|
||||
---
|
||||
|
||||
# TraceMemo Reader
|
||||
@@ -27,31 +27,39 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
|
||||
## 端点速查
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| ------ | ----------------------------------- | ------------------------------------------------- |
|
||||
| GET | `/health` | 健康和数据库状态 |
|
||||
| GET | `/current_time` | 本机时间与时区 |
|
||||
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
|
||||
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
|
||||
| GET | `/recent_chat` | 最近会话;可传 `limit` |
|
||||
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
|
||||
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
|
||||
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
|
||||
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
|
||||
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
|
||||
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
|
||||
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
|
||||
| POST | `/scheduled-reports` | 创建定时日报任务 |
|
||||
| PATCH | `/scheduled-reports/:id` | 修改定时日报任务 |
|
||||
| DELETE | `/scheduled-reports/:id` | 删除定时日报任务(执行前必须获得用户确认) |
|
||||
| POST | `/scheduled-reports/:id/enable` | 启用定时日报任务 |
|
||||
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
|
||||
| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution |
|
||||
| GET | `/scheduled-reports/:id/executions` | 查询执行记录 |
|
||||
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
|
||||
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
|
||||
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
|
||||
| POST | `/agent/send` | 已连接机器人发送测试 |
|
||||
| 方法 | 路径 | 用途 |
|
||||
| ------ | ------------------------------------------------------- | ------------------------------------------------- |
|
||||
| GET | `/health` | 健康和数据库状态 |
|
||||
| GET | `/current_time` | 本机时间与时区 |
|
||||
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
|
||||
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
|
||||
| GET | `/recent_chat` | 最近会话;可传 `limit` |
|
||||
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
|
||||
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
|
||||
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
|
||||
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
|
||||
| POST | `/query/messages` | 按目标与时间范围取消息(结构化,不调用 AI) |
|
||||
| POST | `/query/search` | 受限语义关键词检索(依赖本地索引,见 freshness) |
|
||||
| POST | `/query/message-context` | 用 `messageRef` 取某条消息的前后文 |
|
||||
| POST | `/query/conversation-overview` | 按会话与时间范围提取可总结的证据 |
|
||||
| GET | `/query/capabilities` | Query 端点能力目录 |
|
||||
| GET | `/wechat-personal/send-capability` | 个人微信发送能力状态(文字 / 图片 / 语音) |
|
||||
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
|
||||
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
|
||||
| POST | `/scheduled-reports` | 创建定时日报任务 |
|
||||
| PATCH | `/scheduled-reports/:id` | 修改定时日报任务 |
|
||||
| DELETE | `/scheduled-reports/:id` | 删除定时日报任务(执行前必须获得用户确认) |
|
||||
| POST | `/scheduled-reports/:id/enable` | 启用定时日报任务 |
|
||||
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
|
||||
| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution |
|
||||
| GET | `/scheduled-reports/:id/executions` | 查询执行记录 |
|
||||
| POST | `/scheduled-reports/executions/:executionId/retry-send` | 复用已有 PNG 重试发送 |
|
||||
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
|
||||
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
|
||||
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
|
||||
| POST | `/agent/send` | 已连接机器人发送测试(文字或本地图片) |
|
||||
|
||||
这个 API **不只是只读的**:`/report` 会渲染并写文件,`/agent/send` 会真的发出微信消息,`/scheduled-reports*` 会创建、修改、删除或立刻执行定时任务。这些调用都要先确认用户意图;`DELETE` 与 `/agent/send` 尤其需要用户明确确认。
|
||||
|
||||
## 定时日报管理
|
||||
|
||||
@@ -78,7 +86,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
}
|
||||
```
|
||||
|
||||
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态为 `unsupported`、`unconfigured`、`needs_binding`、`needs_verification` 或 `error` 时,直接说明需要先在 TraceMemo 设置中完成个人微信绑定和消息能力检测。
|
||||
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态不是 `ready` 时(`unsupported`、`unconfigured`、`needs_binding`、`initializing` 或 `error`),直接说明需要先在 TraceMemo 的“设置 → 发送能力”里完成个人微信绑定和能力检测,不要继续创建任务。
|
||||
|
||||
### 查看、修改和执行
|
||||
|
||||
@@ -102,6 +110,20 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
- 根据多条消息整理出的总结;
|
||||
- 没有来源支持的推断。
|
||||
|
||||
## 结构化查询(query/\*)
|
||||
|
||||
需要按目标 + 时间范围稳定取数时,优先使用 `query/*`,而不是自己拼 `chatlog`:
|
||||
|
||||
- `query/messages`:按 `target`、`timeRange`、`direction`、`messageTypes` 取消息;
|
||||
- `query/search`:受限语义关键词检索,依赖本地索引;
|
||||
- `query/message-context`:用返回的 `messageRef` 取前后文;
|
||||
- `query/conversation-overview`:按会话与时间范围提取可总结的证据。
|
||||
|
||||
两个要点:
|
||||
|
||||
- `messageRef` 是服务端生成的不透明引用,**不要**自行构造 wxid、md5 或数据库路径;
|
||||
- `query/search` 依赖异步建立的本地索引。`coverage.state` 不是 `complete` 且 `evidence` 为空时,只能说“这段范围暂时无法确认”,**不能**下“没有找到”的结论。
|
||||
|
||||
## 媒体消息
|
||||
|
||||
当 `/chatlog` 返回图片消息时:
|
||||
|
||||
-674
@@ -1,674 +0,0 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2026 yincongcyincong
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
# wechat_chatter / OneBot 第三方组件说明
|
||||
|
||||
TraceMemo 的 macOS 个人微信发送功能会按需使用以下第三方组件:
|
||||
|
||||
- 项目:`yincongcyincong/wechat_chatter`
|
||||
- 上游仓库:https://github.com/yincongcyincong/wechat_chatter
|
||||
- 当前运行时版本:`v0.0.18`
|
||||
- 运行时文件:`onebot_mac_arm64.tar.gz`
|
||||
- 许可证:GNU General Public License version 3(GPL-3.0)
|
||||
- 上游版权:Copyright (C) 2026 yincongcyincong
|
||||
- TraceMemo 修改日期:2026-08-17
|
||||
|
||||
## 集成方式
|
||||
|
||||
OneBot 运行时不会随 TraceMemo 安装包一起分发。用户启用该实验性功能时,TraceMemo 会从上述上游项目的 GitHub Release 按需下载运行时,并将其安装到应用的用户数据目录。
|
||||
|
||||
运行时作为独立进程启动,TraceMemo 通过本机 HTTP 接口与其通信。
|
||||
|
||||
## 本地修改
|
||||
|
||||
为适配连续发送、图片上传 Hook 状态检测以及微信核心模块基址定位,TraceMemo 会在用户设备上对上游 `onebot/script.js` 应用兼容性补丁。补丁逻辑位于:
|
||||
|
||||
- `scripts/prepare-wechat-chatter-runtime.cjs`
|
||||
- `src/main/services/personal-wechat-runtime-manager.ts`
|
||||
|
||||
补丁中源自或修改自上游 `script.js` 的部分,以及补丁应用后产生的修改版 `script.js`,继续按照 GPL-3.0 提供。本说明只针对该第三方组件及相关修改,不用于声明 TraceMemo 仓库其他部分的许可证。
|
||||
|
||||
GPL-3.0 的完整文本见本目录下的 `LICENSE`。
|
||||
@@ -33,7 +33,7 @@ Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取
|
||||
- 上表中的版本是当前 TraceMemo 已适配或推荐使用的版本,并不代表只有这些版本可以运行。
|
||||
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
|
||||
- 你需要有权访问要读取的微信账号和聊天数据。
|
||||
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
|
||||
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。「图片文字索引」不在此列——它在本机识别图片里的文字,不需要配置 AI。
|
||||
|
||||
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
|
||||
|
||||
@@ -134,6 +134,7 @@ Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取
|
||||
- [生成群聊日报或总结](./report.md)
|
||||
- [转写微信语音](./voice.md)
|
||||
- [导出聊天档案](./export.md)
|
||||
- [让日报、退群通知按规则自动运行](../README.md#日报与自动化)
|
||||
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
|
||||
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
||||
|
||||
@@ -159,14 +160,17 @@ Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Sk
|
||||
|
||||
## 8. 需要配置 AI 吗?
|
||||
|
||||
不一定。浏览聊天、普通关键词搜索、建立本地知识库和导出不要求在线 AI 服务。
|
||||
不一定。浏览聊天、普通关键词搜索、建立本地知识库、图片文字识别、离线语音转写和导出都不要求在线 AI 服务。
|
||||
|
||||
使用“问问微信”、群聊日报或图片理解时,需要在“设置 → AI 模型”中添加并测试 AI 服务。你主动开始并确认远程 AI 功能后,完成任务所需的内容才可能发送给该服务;计费、留存和地区规则由对应服务商决定。
|
||||
|
||||
这两种情况容易混淆:**本机识别图片里的文字**(图片文字索引)不联网、不需要 AI 服务;**让模型看图并回答**(图片理解)才需要配置 AI 服务。
|
||||
|
||||
## 9. 数据和隐私的最低须知
|
||||
|
||||
- 微信数据库、聊天解析和本地索引默认留在本机。
|
||||
- 微信数据库、聊天解析、本地索引和图片文字识别默认留在本机。
|
||||
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
|
||||
- 图片文字索引只在本机识别,原始图片不会因为本地识别而上传;识别出的文字会进入本地索引,供搜索和“问问微信”使用。
|
||||
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
|
||||
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
|
||||
|
||||
### 状态怎么读
|
||||
|
||||
| 状态 | 含义 |
|
||||
| ---- | ---- |
|
||||
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
|
||||
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
|
||||
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
|
||||
| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 |
|
||||
| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 |
|
||||
| 状态 | 含义 |
|
||||
| ------------------- | ------------------------------------------------ |
|
||||
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
|
||||
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
|
||||
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
|
||||
| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 |
|
||||
| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 |
|
||||
|
||||
只有确实追平、且没有待补齐内容时才会出现“已追至最新”。索引不可查询时不会显示“可用”。
|
||||
|
||||
@@ -38,6 +38,21 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
|
||||
|
||||
同步过程中可以点击 **取消同步**(点击后显示“正在取消…”)。取消只结束当前这一轮,不会删除已经建立的索引,也不会回滚已完成的部分;下次同步会从上次停下的位置继续,不需要从头重扫。中断过的索引仍然可以正常搜索。
|
||||
|
||||
## 图片文字索引(另一份索引)
|
||||
|
||||
本地索引其实有两份,彼此独立:
|
||||
|
||||
- **聊天记录索引**(也就是上面说的 Knowledge):索引文字消息,用于跨会话、跨时间查找;
|
||||
- **图片文字索引**:在本机识别微信图片里的文字(截图、公告、报价图等),把识别结果也变成可搜索的文字。
|
||||
|
||||
“独立”的意思是:聊天记录索引建好了,并不代表图片里的文字就搜得到。建立图片文字索引后,可以在“问问微信”里直接搜截图或公告图里写过的词。
|
||||
|
||||
图片文字索引只在本机识别,原始图片不会因为本地识别而上传。它**不等于“图片理解”**:识别文字不联网、不需要 AI 服务;而让模型看图并回答属于图片理解,需要配置 AI 服务,走的是另一条路径。
|
||||
|
||||
识别失败的图片可以单独重试,也有“只重建搜索索引、不重新识别图片”的修复入口——修索引不需要重跑几万张图。
|
||||
|
||||
两个索引都可以在“设置 → 本地索引”里集中查看状态、建立、同步和清理。
|
||||
|
||||
## 账号隔离
|
||||
|
||||
每个微信账号使用独立的本地索引。切换账号时,应用不会把一个账号的索引混入另一个账号的搜索结果。
|
||||
@@ -58,4 +73,3 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
|
||||
## 产品术语(可选)
|
||||
|
||||
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 TraceMemo 的前置知识。
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
|
||||
- 读取和解析微信数据库;
|
||||
- 聊天档案浏览和普通关键词搜索;
|
||||
- 本地 Knowledge 索引及其账号隔离;
|
||||
- 图片文字索引:识别图片中的文字在本机完成,原始图片不会因为本地识别而上传;
|
||||
- 离线语音转写;
|
||||
- 导出文件生成和本地日报历史。
|
||||
|
||||
@@ -16,7 +17,7 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
|
||||
|
||||
## 什么时候会请求外部服务
|
||||
|
||||
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
|
||||
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。图片文字索引、离线语音转写、档案浏览和普通搜索不会触发这一步。当前设置页给出的边界是:
|
||||
|
||||
- 当前用户问题;
|
||||
- 受控检索所需的有限上下文;
|
||||
@@ -28,7 +29,14 @@ Ollama 等本机 Provider 可以把模型请求留在本机,但本机服务的
|
||||
|
||||
## 语音和媒体
|
||||
|
||||
离线语音转写在本机进行。图片理解属于 AI 功能:只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。无法读取的媒体不会被自动“猜出来”。
|
||||
离线语音转写在本机进行。
|
||||
|
||||
图片有两条完全不同的路径,不要混为一谈:
|
||||
|
||||
- **图片文字索引**:在本机识别图片里的文字,产出的是本地索引数据;原始图片不会因为这一步被上传,也不需要配置 AI 服务。
|
||||
- **图片理解**:属于 AI 功能。只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。
|
||||
|
||||
无法读取的媒体不会被自动“猜出来”。
|
||||
|
||||
## Local HTTP API
|
||||
|
||||
|
||||
@@ -33,9 +33,11 @@
|
||||
|
||||
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
|
||||
|
||||
## 定时日报
|
||||
## 定时日报(在自动化里)
|
||||
|
||||
在“日报 → 定时日报”中可以创建每天运行的任务。选择群聊、执行时间、日报范围、消息类型和模板后,TraceMemo 会按计划执行:
|
||||
定时日报现在是「自动化」里的一种规则,不再单独占一个页面:打开一级导航的「自动化」,新建或编辑一条「定时日报」规则,选择群聊、执行时间、日报范围、消息类型和发送目标。日报页顶部的指引条也会直接跳到自动化。
|
||||
|
||||
TraceMemo 会按计划执行:
|
||||
|
||||
```text
|
||||
定时触发 → 读取群聊 → 生成报告 → 保存 Report History → 尝试发送
|
||||
@@ -45,6 +47,8 @@
|
||||
|
||||
执行记录支持查看已生成的日报。对“等待发送”或“发送失败”的记录,可以直接重试发送,重试会复用已经生成的 PNG,不会重新调用 AI 生成整份报告;完整执行状态和发送边界见[如何把聊天变成可用的信息](../concepts/how-it-works.md#动作执行与审计)。
|
||||
|
||||
发送目标当前支持**当前群聊、文件传输助手、自己、指定好友**——还不是任意群发。
|
||||
|
||||
## 让报告更可靠
|
||||
|
||||
- 先选正确的群和时间范围;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
extends: ./electron-builder.yml
|
||||
extraResources:
|
||||
- from: build/app-update.yml
|
||||
to: app-update.yml
|
||||
- from: resources
|
||||
to: resources
|
||||
filter:
|
||||
- '**/*'
|
||||
- from: docs/skill/tracememo-reader
|
||||
to: skill/tracememo-reader
|
||||
filter:
|
||||
- '**/*'
|
||||
mac:
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
@@ -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/**
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+20
-17
@@ -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",
|
||||
@@ -171,4 +174,4 @@
|
||||
"ffmpeg-static"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+52
-3
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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.
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 @@
|
||||
f1bbd88584075c0e6d3487357a06424949de26d7e6dcad803d210aab83b0cbda xkey_helper_4_1_13
|
||||
a35d3fa67387e049cc30bc073f9e65b077aa1db9c5c2b183125dec695fa013b9 xkey_helper_4_1_13
|
||||
|
||||
+327
-23
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
/*
|
||||
* wechat_chatter runtime integration
|
||||
*
|
||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||
* Runtime version: v0.0.18
|
||||
* Upstream license: GNU General Public License version 3 (GPL-3.0)
|
||||
*
|
||||
* This file applies local compatibility patches to the upstream
|
||||
* onebot/script.js. See docs/third-party/wechat-chatter/NOTICE.md.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const release = 'v0.0.18'
|
||||
const asset = 'onebot_mac_arm64.tar.gz'
|
||||
const url = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${release}/${asset}`
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const outputDir = path.join(
|
||||
projectRoot,
|
||||
'resources',
|
||||
'connectors',
|
||||
'wechat-personal',
|
||||
'darwin-arm64'
|
||||
)
|
||||
const archive = path.join(os.tmpdir(), `wechat-chatter-${release}-${asset}`)
|
||||
const appleSilicon =
|
||||
process.platform === 'darwin' &&
|
||||
execFileSync('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'], { encoding: 'utf8' }).trim() === '1'
|
||||
|
||||
if (!appleSilicon) {
|
||||
throw new Error('个人微信发送运行时当前仅支持 macOS arm64')
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
let archiveReady = false
|
||||
if (fs.existsSync(archive)) {
|
||||
try {
|
||||
execFileSync('/usr/bin/tar', ['-tzf', archive], { stdio: 'ignore' })
|
||||
archiveReady = true
|
||||
console.log(`[wechat-personal] 复用已下载归档:${archive}`)
|
||||
} catch {
|
||||
// The archive is partial or invalid; curl will resume it below.
|
||||
}
|
||||
}
|
||||
if (!archiveReady) {
|
||||
console.log(`[wechat-personal] 下载 ${release},支持断点续传:${archive}`)
|
||||
execFileSync(
|
||||
'/usr/bin/curl',
|
||||
['--http1.1', '-L', '--fail', '--retry', '3', '--continue-at', '-', '--output', archive, url],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
}
|
||||
|
||||
const extractionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-chatter-extract-'))
|
||||
console.log(`[wechat-personal] 解压并原子安装到 ${outputDir}`)
|
||||
try {
|
||||
execFileSync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDir], { stdio: 'inherit' })
|
||||
fs.mkdirSync(path.join(outputDir, 'onebot'), { recursive: true })
|
||||
fs.mkdirSync(path.join(outputDir, 'wechat_version'), { recursive: true })
|
||||
fs.copyFileSync(
|
||||
path.join(extractionDir, 'onebot', 'script.js'),
|
||||
path.join(outputDir, 'onebot', 'script.js')
|
||||
)
|
||||
fs.cpSync(path.join(extractionDir, 'wechat_version'), path.join(outputDir, 'wechat_version'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
const stagedExecutable = path.join(outputDir, 'onebot', `.onebot-${process.pid}.tmp`)
|
||||
fs.copyFileSync(path.join(extractionDir, 'onebot', 'onebot'), stagedExecutable)
|
||||
fs.chmodSync(stagedExecutable, 0o755)
|
||||
fs.renameSync(stagedExecutable, path.join(outputDir, 'onebot', 'onebot'))
|
||||
} finally {
|
||||
fs.rmSync(extractionDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const executable = path.join(outputDir, 'onebot', 'onebot')
|
||||
const script = path.join(outputDir, 'onebot', 'script.js')
|
||||
const config = path.join(outputDir, 'wechat_version', '4_1_11_53_mac.json')
|
||||
for (const required of [executable, script, config]) {
|
||||
if (!fs.existsSync(required)) throw new Error(`运行时文件缺失:${required}`)
|
||||
}
|
||||
|
||||
function patchPerSendPayload(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return
|
||||
|
||||
const activeSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||
activeTriggerX1Payload = Memory.alloc(payloadData.length);
|
||||
activeTriggerX1Payload.writeByteArray(payloadData);
|
||||
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
|
||||
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
|
||||
const upstreamSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||
triggerX1Payload.writeByteArray(payloadData);
|
||||
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
|
||||
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
|
||||
if (!source.includes(activeSend)) throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
|
||||
source = source
|
||||
.replace(
|
||||
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;',
|
||||
'var triggerX1Payload;\nvar triggerX0;'
|
||||
)
|
||||
.replace(activeSend, upstreamSend)
|
||||
.replace(
|
||||
' MMStartTask(triggerX0, activeTriggerX1Payload);',
|
||||
' MMStartTask(triggerX0, triggerX1Payload);'
|
||||
)
|
||||
.replace(
|
||||
' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
|
||||
' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
|
||||
)
|
||||
.replace(
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn',
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn'
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已恢复原生发送 payload 布局')
|
||||
}
|
||||
|
||||
function patchSendContextCapture(scriptPath, strict = true) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('function isLikelySendContext(')) return
|
||||
const original = `function AttachSendFunc() {
|
||||
Interceptor.attach(sendFuncAddr.add(0x10), {
|
||||
onEnter: function (args) {
|
||||
|
||||
if (triggerX1Payload) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerX0 = this.context.x0;
|
||||
triggerX1Payload = this.context.x1;
|
||||
console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
||||
}
|
||||
})
|
||||
}`
|
||||
const patched = `function isLikelySendContext(candidateX0, candidateX1) {
|
||||
try {
|
||||
if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false;
|
||||
var manager = readPointerIfReadable(candidateX0.add(0x18));
|
||||
var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18)));
|
||||
console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi);
|
||||
return !manager.equals(ptr(0));
|
||||
} catch (e) {
|
||||
console.error("[debug] StartTask candidate inspect failed: " + e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function AttachSendFunc() {
|
||||
Interceptor.attach(sendFuncAddr.add(0x10), {
|
||||
onEnter: function (args) {
|
||||
if (triggerX1Payload) return;
|
||||
var candidateX0 = this.context.x0;
|
||||
var candidateX1 = this.context.x1;
|
||||
if (!isLikelySendContext(candidateX0, candidateX1)) return;
|
||||
triggerX0 = candidateX0;
|
||||
triggerX1Payload = candidateX1;
|
||||
console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
||||
}
|
||||
})
|
||||
}`
|
||||
if (!source.includes(original)) {
|
||||
if (strict) throw new Error('无法定位 StartTask 上下文 Hook')
|
||||
return
|
||||
}
|
||||
source = source.replace(original, patched)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
function patchVoiceAudioBuffer(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
|
||||
|
||||
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
|
||||
if (!source.includes(staticAllocation)) {
|
||||
throw new Error('无法定位 wechat_chatter 语音缓冲区')
|
||||
}
|
||||
source = source.replace(
|
||||
staticAllocation,
|
||||
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
|
||||
)
|
||||
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
|
||||
if (!source.includes(audioLengthMarker)) {
|
||||
throw new Error('无法定位 wechat_chatter 语音上传逻辑')
|
||||
}
|
||||
source = source.replace(
|
||||
audioLengthMarker,
|
||||
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用按语音长度分配上传缓冲区补丁')
|
||||
}
|
||||
|
||||
function patchImageHookReadiness(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (
|
||||
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
|
||||
source.includes('图片上传 Hook Setup Complete')
|
||||
)
|
||||
return
|
||||
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
|
||||
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
|
||||
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
|
||||
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
|
||||
\t\t\t}
|
||||
\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||
if (!source.includes(original)) throw new Error('无法定位 wechat_chatter 图片 Hook 状态补丁位置')
|
||||
source = source.replace(original, patched)
|
||||
source = source.replace(
|
||||
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
|
||||
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用图片 Hook 状态补丁')
|
||||
}
|
||||
|
||||
// Backport of wechat_chatter PR #36 by @Leslielu:
|
||||
// https://github.com/yincongcyincong/wechat_chatter/pull/36
|
||||
// TraceMemo adds the verified macOS WeChat 4.1.11.53 addresses.
|
||||
// macOS WeChat 4.1.11.53, located and verified by TraceMemo
|
||||
function patchCdnColdStart(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('function resolveCdnManager()')) return
|
||||
|
||||
const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
||||
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});`
|
||||
const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
||||
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});
|
||||
// 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为)
|
||||
{{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}}
|
||||
{{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}`
|
||||
if (!source.includes(initAddresses)) throw new Error('下载的微信版本配置与当前应用不兼容')
|
||||
source = source.replace(initAddresses, patchedInitAddresses)
|
||||
|
||||
const downloadChunkEnd = `}
|
||||
|
||||
function fillUploadX1AndStart`
|
||||
const resolver = `}
|
||||
|
||||
// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器
|
||||
// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。
|
||||
function resolveCdnManager() {
|
||||
if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) {
|
||||
return ptr(0);
|
||||
}
|
||||
try {
|
||||
// libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。
|
||||
var strDefault = Memory.alloc(24);
|
||||
strDefault.writeUtf8String("default");
|
||||
strDefault.add(0x17).writeU8(7);
|
||||
|
||||
var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']);
|
||||
var svc = getService(strDefault);
|
||||
if (!isReadablePointer(svc)) {
|
||||
console.error("[!] GetService(\\"default\\") 返回不可读: " + svc);
|
||||
return ptr(0);
|
||||
}
|
||||
var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']);
|
||||
var ctx = getCtx(svc);
|
||||
if (!isReadablePointer(ctx)) {
|
||||
console.error("[!] CdnManager getter 返回不可读: " + ctx);
|
||||
return ptr(0);
|
||||
}
|
||||
var mgr = readPointerIfReadable(ctx.add(0x40));
|
||||
if (!isReadablePointer(mgr)) {
|
||||
console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx);
|
||||
return ptr(0);
|
||||
}
|
||||
return mgr;
|
||||
} catch (e) {
|
||||
console.error("[!] resolveCdnManager 异常: " + e);
|
||||
return ptr(0);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCdnManagerX0() {
|
||||
if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) {
|
||||
uploadGlobalX0 = downloadGlobalX0;
|
||||
console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0);
|
||||
}
|
||||
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) {
|
||||
downloadGlobalX0 = uploadGlobalX0;
|
||||
console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0);
|
||||
}
|
||||
if (uploadGlobalX0.equals(ptr(0))) {
|
||||
var mgr = resolveCdnManager();
|
||||
if (!mgr.equals(ptr(0))) {
|
||||
uploadGlobalX0 = mgr;
|
||||
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
||||
downloadGlobalX0 = mgr;
|
||||
}
|
||||
console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr);
|
||||
}
|
||||
}
|
||||
return !uploadGlobalX0.equals(ptr(0));
|
||||
}
|
||||
|
||||
function fillUploadX1AndStart`
|
||||
if (!source.includes(downloadChunkEnd)) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
|
||||
source = source.replace(downloadChunkEnd, resolver)
|
||||
|
||||
const declarations = 'var uploadImageAddr;\n'
|
||||
const patchedDeclarations =
|
||||
'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n'
|
||||
if (!source.includes(declarations)) throw new Error('无法定位 wechat_chatter 媒体地址声明')
|
||||
source = source.replace(declarations, patchedDeclarations)
|
||||
|
||||
const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {
|
||||
ensureCdnManagerX0();
|
||||
}
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
if (!source.includes(uploadGuard)) throw new Error('无法定位 wechat_chatter 媒体上传入口')
|
||||
source = source.replace(uploadGuard, patchedUploadGuard)
|
||||
|
||||
const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {
|
||||
ensureCdnManagerX0();
|
||||
}
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
if (!source.includes(voiceGuard)) throw new Error('无法定位 wechat_chatter 语音上传入口')
|
||||
source = source.replace(voiceGuard, patchedVoiceGuard)
|
||||
|
||||
const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||
const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;
|
||||
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) {
|
||||
downloadGlobalX0 = capturedUploadX0;
|
||||
console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0);
|
||||
}`
|
||||
if (!source.includes(uploadHook)) throw new Error('无法定位 wechat_chatter 图片 Hook')
|
||||
source = source.replace(uploadHook, patchedUploadHook)
|
||||
|
||||
const downloadHook = ` downloadGlobalX0 = this.context.x0;`
|
||||
const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0;
|
||||
if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) {
|
||||
uploadGlobalX0 = downloadGlobalX0;
|
||||
console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0);
|
||||
}`
|
||||
if (!source.includes(downloadHook)) throw new Error('无法定位 wechat_chatter 下载 Hook')
|
||||
source = source.replace(downloadHook, patchedDownloadHook)
|
||||
|
||||
const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
||||
if (!downloadGlobalX0) {`
|
||||
const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
||||
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
||||
ensureCdnManagerX0();
|
||||
}
|
||||
if (!downloadGlobalX0) {`
|
||||
if (!source.includes(downloadGuard)) throw new Error('无法定位 wechat_chatter 媒体下载入口')
|
||||
source = source.replace(downloadGuard, patchedDownloadGuard)
|
||||
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用 CdnManager 冷启动解析补丁')
|
||||
}
|
||||
|
||||
function patchCdnColdStartConfig(configPath) {
|
||||
const source = fs.readFileSync(configPath, 'utf8')
|
||||
let config
|
||||
try {
|
||||
config = JSON.parse(source)
|
||||
} catch {
|
||||
throw new Error('4.1.11.53 版本配置不是有效 JSON')
|
||||
}
|
||||
config.cdnGetServiceAddr = '0x50a15d0'
|
||||
config.cdnManagerGetterAddr = '0x5259290'
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
console.log('[wechat-personal] 已写入 4.1.11.53 CdnManager 地址')
|
||||
}
|
||||
|
||||
function patchWechatCoreModuleBase(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('WeChat core module base:')) return
|
||||
const initMarker = 'function initAddresses() {'
|
||||
const initIndex = source.indexOf(initMarker)
|
||||
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
|
||||
throw new Error('无法定位 wechat_chatter 基址扫描逻辑')
|
||||
}
|
||||
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
|
||||
var module = Process.enumerateModules().find(function(m) {
|
||||
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
|
||||
});
|
||||
if (!module) {
|
||||
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
|
||||
}
|
||||
var moduleBase = module.base;
|
||||
var baseAddr = moduleBase;
|
||||
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
|
||||
setImmediate(initAddresses);
|
||||
|
||||
`
|
||||
source = patchedHeader + source.slice(initIndex)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用微信核心模块基址补丁')
|
||||
}
|
||||
|
||||
function addModifiedWorkNotice(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
|
||||
|
||||
const notice = `/*
|
||||
* TraceMemo wechat_chatter compatibility modifications
|
||||
* Modified: 2026-08-17
|
||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||
* Runtime version: v0.0.18
|
||||
* License: GNU General Public License version 3 (GPL-3.0)
|
||||
* Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers,
|
||||
* CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging.
|
||||
* These modifications are not provided by the upstream author.
|
||||
*/
|
||||
|
||||
`
|
||||
source = notice + source
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已写入 GPL 修改声明')
|
||||
}
|
||||
|
||||
patchWechatCoreModuleBase(script)
|
||||
patchPerSendPayload(script)
|
||||
patchSendContextCapture(script)
|
||||
patchVoiceAudioBuffer(script)
|
||||
patchImageHookReadiness(script)
|
||||
patchCdnColdStartConfig(config)
|
||||
patchCdnColdStart(script)
|
||||
addModifiedWorkNotice(script)
|
||||
fs.chmodSync(executable, 0o755)
|
||||
console.log('[wechat-personal] 运行时准备完成')
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const REQUIRED_FILES = ['tm-wechat-host', 'libtmwechat.dylib', 'runtime-manifest.json']
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index]
|
||||
if (value === '--source' || value === '--target') {
|
||||
if (!argv[index + 1]) throw new Error(`${value} requires a path`)
|
||||
result[value.slice(2)] = argv[index + 1]
|
||||
index += 1
|
||||
} else {
|
||||
throw new Error(`Unknown option: ${value}`)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function sha256(filePath) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
|
||||
}
|
||||
|
||||
function listFiles(root) {
|
||||
const files = []
|
||||
function visit(directory) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolute = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) visit(absolute)
|
||||
else if (entry.isFile()) files.push(path.relative(root, absolute))
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
function assertMachOArm64(filePath, label) {
|
||||
const buffer = fs.readFileSync(filePath)
|
||||
if (buffer.length < 8 || buffer.readUInt32LE(0) !== 0xfeedfacf) {
|
||||
throw new Error(`${label} is not a 64-bit Mach-O binary: ${filePath}`)
|
||||
}
|
||||
if (buffer.readUInt32LE(4) !== 0x0100000c) {
|
||||
throw new Error(`${label} is not arm64: ${filePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
function readAndValidateArtifact(sourceDir) {
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
throw new Error('native runtime artifact not found: build the macOS runtime artifact first')
|
||||
}
|
||||
|
||||
const actualFiles = listFiles(sourceDir)
|
||||
const expectedFiles = [...REQUIRED_FILES].sort()
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||
throw new Error(`native runtime artifact has an unexpected tree: ${actualFiles.join(', ')}`)
|
||||
}
|
||||
|
||||
const manifestPath = path.join(sourceDir, 'runtime-manifest.json')
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
if (manifest.runtime !== 'tm-wechat-native') throw new Error('Unexpected runtime name')
|
||||
if (manifest.platform !== 'darwin-arm64') throw new Error('Runtime platform must be darwin-arm64')
|
||||
if (manifest.architecture !== 'arm64') throw new Error('Runtime architecture must be arm64')
|
||||
if (manifest.protocolVersion !== 1) throw new Error('Unsupported runtime protocolVersion')
|
||||
if (!/^[a-f0-9]{64}$/.test(manifest.tmSendSourceSha256 || '')) {
|
||||
throw new Error('Runtime manifest has no valid tmSendSourceSha256')
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/.test(manifest.addressProfileSha256 || '')) {
|
||||
throw new Error('Runtime manifest has no valid addressProfileSha256')
|
||||
}
|
||||
const capabilities = new Set(manifest.capabilities || [])
|
||||
for (const capability of ['text', 'image', 'voice']) {
|
||||
if (!capabilities.has(capability)) throw new Error(`Runtime capability missing: ${capability}`)
|
||||
}
|
||||
if (
|
||||
!Array.isArray(manifest.supportedWechatVersions) ||
|
||||
manifest.supportedWechatVersions.length === 0
|
||||
) {
|
||||
throw new Error('Runtime manifest has no supportedWechatVersions')
|
||||
}
|
||||
|
||||
for (const relative of REQUIRED_FILES) {
|
||||
const absolute = path.join(sourceDir, relative)
|
||||
if (!fs.statSync(absolute).isFile()) throw new Error(`Runtime file missing: ${relative}`)
|
||||
}
|
||||
const hostPath = path.join(sourceDir, 'tm-wechat-host')
|
||||
const dylibPath = path.join(sourceDir, 'libtmwechat.dylib')
|
||||
assertMachOArm64(hostPath, 'tm-wechat-host')
|
||||
assertMachOArm64(dylibPath, 'libtmwechat.dylib')
|
||||
if (!fs.readFileSync(dylibPath).includes(Buffer.from(manifest.tmSendSourceSha256, 'utf8'))) {
|
||||
throw new Error('Embedded agent hash is not present in libtmwechat.dylib')
|
||||
}
|
||||
if (!fs.readFileSync(dylibPath).includes(Buffer.from(manifest.addressProfileSha256, 'utf8'))) {
|
||||
throw new Error('Embedded address profile hash is not present in libtmwechat.dylib')
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
hashes: Object.fromEntries(
|
||||
REQUIRED_FILES.map((file) => [file, sha256(path.join(sourceDir, file))])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function syncArtifact(sourceDir, targetDir) {
|
||||
const source = readAndValidateArtifact(sourceDir)
|
||||
const parent = path.dirname(targetDir)
|
||||
const name = path.basename(targetDir)
|
||||
const staging = path.join(parent, `.${name}.prepare-${process.pid}`)
|
||||
const backup = path.join(parent, `.${name}.backup-${process.pid}`)
|
||||
|
||||
fs.mkdirSync(parent, { recursive: true })
|
||||
fs.rmSync(staging, { recursive: true, force: true })
|
||||
fs.rmSync(backup, { recursive: true, force: true })
|
||||
fs.mkdirSync(staging, { recursive: true })
|
||||
|
||||
try {
|
||||
for (const relative of REQUIRED_FILES) {
|
||||
const destination = path.join(staging, relative)
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true })
|
||||
fs.copyFileSync(path.join(sourceDir, relative), destination)
|
||||
}
|
||||
fs.chmodSync(path.join(staging, 'tm-wechat-host'), 0o755)
|
||||
|
||||
const staged = readAndValidateArtifact(staging)
|
||||
for (const relative of REQUIRED_FILES) {
|
||||
if (source.hashes[relative] !== staged.hashes[relative]) {
|
||||
throw new Error(`Packaged runtime hash mismatch: ${relative}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(targetDir)) fs.renameSync(targetDir, backup)
|
||||
try {
|
||||
fs.renameSync(staging, targetDir)
|
||||
} catch (error) {
|
||||
if (fs.existsSync(backup)) fs.renameSync(backup, targetDir)
|
||||
throw error
|
||||
}
|
||||
fs.rmSync(backup, { recursive: true, force: true })
|
||||
return staged
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSource(explicit) {
|
||||
if (explicit) return path.resolve(explicit)
|
||||
const fromEnv = String(process.env.TM_NATIVE_RUNTIME_DIR || '').trim()
|
||||
if (fromEnv) return path.resolve(fromEnv)
|
||||
// 本机路径写在这里即可,该文件不进仓库。
|
||||
const localConfig = path.join(__dirname, '..', '.native-runtime-source')
|
||||
if (fs.existsSync(localConfig)) {
|
||||
const configured = fs.readFileSync(localConfig, 'utf8').trim()
|
||||
if (configured) return path.resolve(configured)
|
||||
}
|
||||
throw new Error(
|
||||
'runtime artifact source is required: pass --source <dir>, set TM_NATIVE_RUNTIME_DIR, ' +
|
||||
'or write the path into .native-runtime-source'
|
||||
)
|
||||
}
|
||||
|
||||
function main() {
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const sourceDir = resolveSource(args.source)
|
||||
const targetDir = path.resolve(
|
||||
args.target || path.join(projectRoot, 'resources', 'runtime', 'darwin-arm64')
|
||||
)
|
||||
const result = syncArtifact(sourceDir, targetDir)
|
||||
console.log(`[prepare-wechat-native] source: ${sourceDir}`)
|
||||
console.log(`[prepare-wechat-native] target: ${targetDir}`)
|
||||
console.log(
|
||||
`[prepare-wechat-native] runtime: ${result.manifest.runtime}/${result.manifest.version}`
|
||||
)
|
||||
console.log(`[prepare-wechat-native] agent: ${result.manifest.tmSendSourceSha256}`)
|
||||
console.log(`[prepare-wechat-native] address profile: ${result.manifest.addressProfileSha256}`)
|
||||
console.log('[prepare-wechat-native] files: 3')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REQUIRED_FILES,
|
||||
assertMachOArm64,
|
||||
readAndValidateArtifact,
|
||||
syncArtifact
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[prepare-wechat-native] ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,17 @@ 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)
|
||||
? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
|
||||
: avatarSvg('图', '#dbeafe')
|
||||
/**
|
||||
* 可选的本地样例图(用于人工核对图片区块的排版)。
|
||||
*
|
||||
* 走环境变量传入,**不要在源码里写本机路径** —— 微信数据目录会连带暴露
|
||||
* 系统用户名与账号目录名。不传就退回内置的 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')
|
||||
|
||||
const avatars = {
|
||||
阿宇: avatarSvg('宇', '#dcfce7'),
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: 
|
||||
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  and "
|
||||
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 := ""
|
||||
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] + "..."
|
||||
}
|
||||
@@ -1246,10 +1246,15 @@ async function runSingleExport(
|
||||
const wavChannels =
|
||||
audioBuffer.length >= 44 ? audioBuffer.readUInt16LE(22) : 1
|
||||
const pcmBytes = Math.max(0, audioBuffer.length - 44)
|
||||
message.voiceDuration = Math.max(
|
||||
1,
|
||||
Math.round(pcmBytes / (wavSampleRate * wavChannels * 2))
|
||||
)
|
||||
// 口径统一:消息解析阶段已从 <voicemsg voicelength> 拿到微信的原始秒数(带小数),
|
||||
// 它是唯一权威来源,不要覆盖。只有拿不到时才退回用 PCM 字节数估算——
|
||||
// 那份估算是整秒、且下限 1 秒(WAV 缺失头部时的兜底),语义不同。
|
||||
if (message.voiceDuration == null) {
|
||||
message.voiceDuration = Math.max(
|
||||
1,
|
||||
Math.round(pcmBytes / (wavSampleRate * wavChannels * 2))
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
keepMediaError(
|
||||
request,
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
GroupReportRenderSnapshotExportRequest,
|
||||
ReportHeat,
|
||||
ReportSectionMeta,
|
||||
buildReportAvatarAliasIndex,
|
||||
mergeReportAvatars,
|
||||
selectHeroParticipantNames
|
||||
} from '../shared/group-report'
|
||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||
@@ -99,51 +101,155 @@ 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 }
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
try {
|
||||
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)
|
||||
})
|
||||
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 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)
|
||||
return {
|
||||
source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
|
||||
fallback: true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只认真实图片字节。
|
||||
*
|
||||
* `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 AVATAR_FETCH_TIMEOUT_MS = 8000
|
||||
/** 首次 + 一次重试:单次瞬时失败(限流 / 连接重置 / 超时)不该让一个人永久退回首字。 */
|
||||
const AVATAR_FETCH_ATTEMPTS = 2
|
||||
/** 同一 origin 的并发上限。几十个头像同时打一个 CDN 会显著抬高被限流的概率。 */
|
||||
const AVATAR_FETCH_CONCURRENCY = 6
|
||||
/** 进程内头像缓存条目上限(老报告重渲染 / 连续生成同一群时不必重复下载)。 */
|
||||
const AVATAR_CACHE_LIMIT = 256
|
||||
|
||||
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(AVATAR_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
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 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从群成员快照反推真头像,填进 metadata.avatars。
|
||||
* - 没传 talker → 跳过(向后兼容)
|
||||
* - 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,
|
||||
{
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
}
|
||||
)
|
||||
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,
|
||||
{
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
}
|
||||
)
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
})
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
|
||||
+10
-4
@@ -3,6 +3,7 @@ import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
||||
import {
|
||||
isReady,
|
||||
listContacts,
|
||||
listContactsAsync,
|
||||
listMessages,
|
||||
getGroupSnapshot,
|
||||
listRecentChat,
|
||||
@@ -230,21 +231,26 @@ const routes: Record<string, RouteHandler> = {
|
||||
})
|
||||
},
|
||||
|
||||
'/api/v1/contact': ({ res, url }) => {
|
||||
'/api/v1/contact': async ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
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': 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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+683
-130
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchResult,
|
||||
@@ -53,6 +55,13 @@ export class KnowledgeService {
|
||||
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
|
||||
}
|
||||
|
||||
/** 单个会话内「按发送者聚合」的发言统计(群员统计用)。 */
|
||||
memberStats(
|
||||
request: Omit<KnowledgeMemberStatsRequest, 'databaseRoot'>
|
||||
): Promise<KnowledgeMemberStatsResult> {
|
||||
return this.worker.memberStats({ ...request, databaseRoot: this.databaseRoot })
|
||||
}
|
||||
|
||||
/** 只中止正在跑的索引任务;查询请求不受影响。 */
|
||||
cancelIndex(): Promise<boolean> {
|
||||
return this.worker.cancelActiveIndex()
|
||||
|
||||
@@ -13,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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
KnowledgeCapacityPreflightRequest,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeStatusRequest,
|
||||
@@ -187,6 +189,38 @@ async function handleHighWater(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 群员统计的按发送者聚合。
|
||||
*
|
||||
* 与 `handleSearch` 同样先判库是否存在:**「还没建索引」是正常状态,不是故障**,
|
||||
* 返回空结果而不是抛错,让上层能稳定地区分「没人发言」与「索引不存在」。
|
||||
*/
|
||||
async function handleMemberStats(
|
||||
request: KnowledgeWorkerRequest,
|
||||
payload: KnowledgeMemberStatsRequest
|
||||
): Promise<void> {
|
||||
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
|
||||
if (!existsSync(path)) {
|
||||
const unavailable: KnowledgeMemberStatsResult = {
|
||||
conversationId: payload.conversationId,
|
||||
totalMessages: 0,
|
||||
senders: [],
|
||||
unattributedMessages: 0,
|
||||
excludedSystemMessages: 0,
|
||||
earliestMessageTime: null,
|
||||
indexLatestAt: null
|
||||
}
|
||||
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
|
||||
return
|
||||
}
|
||||
send({
|
||||
version: 1,
|
||||
type: 'result',
|
||||
requestId: request.requestId,
|
||||
payload: getStore(payload).memberStats(payload)
|
||||
})
|
||||
}
|
||||
|
||||
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
|
||||
try {
|
||||
if (request.type === 'cancel') {
|
||||
@@ -226,6 +260,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
|
||||
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
|
||||
return
|
||||
}
|
||||
if (request.type === 'memberStats') {
|
||||
await handleMemberStats(request, request.payload as KnowledgeMemberStatsRequest)
|
||||
return
|
||||
}
|
||||
if (request.type === 'index') {
|
||||
await handleIndex(request, request.payload as KnowledgeIndexRequest)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,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}`
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ''
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,652 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import {
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
createDefaultDailyReportRule,
|
||||
createDefaultLeaveNotificationRule,
|
||||
createDefaultScheduledReportRule,
|
||||
normalizeRuleDraft,
|
||||
type AutomationRule,
|
||||
type AutomationRuleType,
|
||||
type ScheduledReportAutomationConfig
|
||||
} from '../../shared/automation'
|
||||
import {
|
||||
planLeaveNotificationMigration,
|
||||
type LeaveNotificationMigrationPlan,
|
||||
type LegacyLeaveNotificationState
|
||||
} from './leave-notification-migration'
|
||||
import {
|
||||
planScheduledReportMigrationBatch,
|
||||
type LegacyScheduledReportTask,
|
||||
type ScheduledReportMigrationSummary
|
||||
} from './scheduled-report-migration'
|
||||
import {
|
||||
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
|
||||
insertGroupNamePlaceholder
|
||||
} from '../../shared/group-exit-monitor'
|
||||
|
||||
/**
|
||||
* AutomationRuleStore —— 自动化规则的持久化与增删改查。
|
||||
*
|
||||
* 存储形态刻意做到最简:一个 JSON 文件(`{userData}/automation/rules.json`)。
|
||||
* 规则总量是个位数到几十条,引入数据库只会增加迁移负担。
|
||||
*
|
||||
* **一次性标记全部单独存**(`builtinSeeded` / `leaveNotificationMigrated` /
|
||||
* `notificationTemplateUpgraded` / `scheduledReportMigrated`):
|
||||
* 如果靠"文件里有没有那条内置规则"来判断是否播种,用户一旦删掉它,
|
||||
* 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。迁移同理。
|
||||
*/
|
||||
|
||||
const STORAGE_DIR = 'automation'
|
||||
const RULES_FILE = 'rules.json'
|
||||
const MIGRATION_BACKUP_FILE = 'leave-notification-migration-backup.json'
|
||||
/** 旧退群监控的状态文件(迁移时读一次,之后运行期不再读)。 */
|
||||
const LEGACY_MONITOR_FILE = 'group-exit-monitor.json'
|
||||
/** 旧定时日报的**任务**文件(迁移时读一次,之后运行期不再读)。 */
|
||||
const LEGACY_SCHEDULED_DIR = 'scheduled-reports'
|
||||
const LEGACY_SCHEDULED_TASKS_FILE = 'tasks.json'
|
||||
const SCHEDULED_MIGRATION_BACKUP_FILE = 'scheduled-report-migration-backup.json'
|
||||
const CURRENT_VERSION = 4
|
||||
|
||||
interface StoredRules {
|
||||
version: number
|
||||
/** 内置「@我生成日报」是否已经播种过。**即使随后被删除也保持 true**。 */
|
||||
builtinSeeded: boolean
|
||||
/** 旧退群通知配置是否已经迁移过。**即使随后被删除也保持 true**。 */
|
||||
leaveNotificationMigrated: boolean
|
||||
/**
|
||||
* 是否已经替用户往模板里补过 `{groupName}`。
|
||||
*
|
||||
* **必须有这个标记**:没有它就得靠"模板里有没有 `{groupName}`"来判断,
|
||||
* 于是用户主动删掉那一行后、下次启动又会被补回来 —— 删不掉的东西最烦人。
|
||||
*/
|
||||
notificationTemplateUpgraded: boolean
|
||||
/**
|
||||
* 旧「定时日报」任务是否已经迁移过。
|
||||
*
|
||||
* ⚠️ 与其它标记有一点不同:迁移需要**把旧群标识解析成稳定会话 id**,
|
||||
* 而那需要数据库。数据库没就绪时这一位**保持 false**,等解析器注入后再补跑 ——
|
||||
* 否则会把每一条规则都误判成"目标无法确认"。
|
||||
*/
|
||||
scheduledReportMigrated: boolean
|
||||
rules: AutomationRule[]
|
||||
}
|
||||
|
||||
export interface AutomationRuleStoreDependencies {
|
||||
userDataPath?: () => string
|
||||
now?: () => number
|
||||
/**
|
||||
* 迁移旧定时日报时,把 legacy 群标识(会话 md5 / 群名 / roomId)解析成
|
||||
* **稳定会话 id**(`xxx@chatroom`)。解析不到返回 `undefined`。
|
||||
*
|
||||
* 不注入 ⇒ 迁移**推迟**(不写任何规则、不置标记)。
|
||||
*/
|
||||
resolveLegacyConversationId?: (raw: string) => string | undefined
|
||||
}
|
||||
|
||||
function emptyState(): StoredRules {
|
||||
return {
|
||||
version: CURRENT_VERSION,
|
||||
builtinSeeded: false,
|
||||
leaveNotificationMigrated: false,
|
||||
notificationTemplateUpgraded: false,
|
||||
scheduledReportMigrated: false,
|
||||
rules: []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuleType(value: unknown): AutomationRuleType {
|
||||
return value === 'leave_notification'
|
||||
? 'leave_notification'
|
||||
: value === 'scheduled_report'
|
||||
? 'scheduled_report'
|
||||
: 'daily_report'
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条规则的读盘归一化。
|
||||
*
|
||||
* 走 `normalizeRuleDraft` —— 它覆盖了 `AutomationRule` 除 id / 时间戳之外的**全部**字段,
|
||||
* 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / 各类 config。
|
||||
*/
|
||||
function normalizeStoredRule(value: unknown): AutomationRule | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const raw = value as Partial<AutomationRule>
|
||||
const id = String(raw.id || '').trim()
|
||||
if (!id) return null
|
||||
const ruleType = normalizeRuleType(raw.ruleType)
|
||||
const normalized = normalizeRuleDraft(
|
||||
{
|
||||
...raw,
|
||||
ruleType,
|
||||
// 历史规则没有这些字段;反过来也要避免脏字段落盘。
|
||||
...(ruleType === 'leave_notification' ? { leaveNotification: raw.leaveNotification } : {}),
|
||||
...(ruleType === 'scheduled_report' ? { scheduledReport: raw.scheduledReport } : {})
|
||||
},
|
||||
String(raw.name || '').trim() || '未命名自动化'
|
||||
)
|
||||
const createdAt = Number(raw.createdAt) || 0
|
||||
const updatedAt = Number(raw.updatedAt) || createdAt
|
||||
return {
|
||||
...normalized,
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
...(normalized.ruleType === 'leave_notification' && normalized.leaveNotification
|
||||
? { leaveNotification: normalized.leaveNotification }
|
||||
: {}),
|
||||
...(normalized.ruleType === 'scheduled_report' && normalized.scheduledReport
|
||||
? { scheduledReport: normalized.scheduledReport }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性替用户把 `{groupName}` 补进退群通知模板。
|
||||
*
|
||||
* **为什么替他填**:模板里原先根本没有群名变量(`{groupRemark}` 是成员在本群的昵称)。
|
||||
* 目标一旦不是「当前群聊」,通知就等于「张三退群了」—— 收件人不知道是哪个群。
|
||||
*
|
||||
* **为什么只跑一次**:靠"模板里有没有 `{groupName}`"判断会导致用户删掉后被反复补回来。
|
||||
* 所以由 `notificationTemplateUpgraded` 标记保证一次性;用户之后删掉不会回来。
|
||||
*/
|
||||
function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): {
|
||||
rules: AutomationRule[]
|
||||
count: number
|
||||
} {
|
||||
let count = 0
|
||||
const next = rules.map((rule) => {
|
||||
if (rule.ruleType !== 'leave_notification' || !rule.leaveNotification) return rule
|
||||
const current = rule.leaveNotification.template
|
||||
const upgraded = insertGroupNamePlaceholder(current)
|
||||
// 补完不能超长:超了下次读盘会被校验拦下、整条模板被重置成默认,反而更糟。
|
||||
if (upgraded === current || upgraded.length > GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH) {
|
||||
return rule
|
||||
}
|
||||
count += 1
|
||||
return { ...rule, leaveNotification: { ...rule.leaveNotification, template: upgraded } }
|
||||
})
|
||||
return { rules: next, count }
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度游标(`lastRunAt` / `lastScheduledSlot`)**由 scheduler 拥有**,草稿改不动它们。
|
||||
*
|
||||
* 保存规则时一律从 `current` 取,而不是从草稿取 —— 否则用户进一次编辑页保存,
|
||||
* 就会把"已消费的槽位"抹掉,导致当天重复补跑一次日报。
|
||||
*/
|
||||
function pickScheduledReportRuntime(
|
||||
current: ScheduledReportAutomationConfig | undefined
|
||||
): Partial<ScheduledReportAutomationConfig> {
|
||||
if (!current) return {}
|
||||
return {
|
||||
...(current.lastRunAt ? { lastRunAt: current.lastRunAt } : {}),
|
||||
...(current.lastScheduledSlot ? { lastScheduledSlot: current.lastScheduledSlot } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */
|
||||
function normalizeStored(value: unknown): StoredRules {
|
||||
if (!value || typeof value !== 'object') return emptyState()
|
||||
const input = value as Partial<StoredRules>
|
||||
const rules = Array.isArray(input.rules)
|
||||
? input.rules
|
||||
.map((rule) => normalizeStoredRule(rule))
|
||||
.filter((rule): rule is AutomationRule => rule !== null)
|
||||
: []
|
||||
return {
|
||||
version: Number(input.version) || 1,
|
||||
builtinSeeded: input.builtinSeeded === true,
|
||||
leaveNotificationMigrated: input.leaveNotificationMigrated === true,
|
||||
notificationTemplateUpgraded: input.notificationTemplateUpgraded === true,
|
||||
scheduledReportMigrated: input.scheduledReportMigrated === true,
|
||||
rules
|
||||
}
|
||||
}
|
||||
|
||||
export class AutomationRuleStore {
|
||||
private readonly userDataPath: () => string
|
||||
private readonly now: () => number
|
||||
private state: StoredRules = emptyState()
|
||||
private loaded = false
|
||||
/** 迁移结果,供启动日志/报告读取(不含任何 id)。 */
|
||||
private lastMigrationPlan: LeaveNotificationMigrationPlan | null = null
|
||||
private lastScheduledMigrationSummary: ScheduledReportMigrationSummary | null = null
|
||||
private legacyConversationResolver: ((raw: string) => string | undefined) | null
|
||||
|
||||
constructor(dependencies: AutomationRuleStoreDependencies = {}) {
|
||||
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
|
||||
this.now = dependencies.now ?? (() => Date.now())
|
||||
this.legacyConversationResolver = dependencies.resolveLegacyConversationId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入 / 替换旧定时日报迁移所需的会话解析器(数据库就绪后由 main 调用)。
|
||||
*
|
||||
* 若之前因为"解析器不可用"推迟了迁移,这里会**立刻补跑**并落盘。
|
||||
*/
|
||||
setLegacyConversationResolver(resolver: (raw: string) => string | undefined): void {
|
||||
this.legacyConversationResolver = resolver
|
||||
if (!this.loaded) return
|
||||
if (this.state.scheduledReportMigrated) return
|
||||
if (this.runScheduledReportMigration(this.state)) this.persist()
|
||||
}
|
||||
|
||||
/** 旧定时日报迁移是否仍在等待解析器(仅用于启动日志与测试断言)。 */
|
||||
hasPendingScheduledReportMigration(): boolean {
|
||||
this.ensureLoaded()
|
||||
return !this.state.scheduledReportMigrated && this.readLegacyScheduledTasks().length > 0
|
||||
}
|
||||
|
||||
listRules(): AutomationRule[] {
|
||||
this.ensureLoaded()
|
||||
return this.state.rules.map((rule) => structuredClone(rule))
|
||||
}
|
||||
|
||||
getRule(id: string): AutomationRule | undefined {
|
||||
const key = String(id || '').trim()
|
||||
if (!key) return undefined
|
||||
const found = this.listRules().find((rule) => rule.id === key)
|
||||
return found
|
||||
}
|
||||
|
||||
/** 上一次迁移的判定结果(未迁移时为 null)。 */
|
||||
getLastLeaveNotificationMigration(): LeaveNotificationMigrationPlan | null {
|
||||
this.ensureLoaded()
|
||||
return this.lastMigrationPlan ? { ...this.lastMigrationPlan } : null
|
||||
}
|
||||
|
||||
/** 上一次旧定时日报迁移的统计(未迁移时为 null)。 */
|
||||
getLastScheduledReportMigration(): ScheduledReportMigrationSummary | null {
|
||||
this.ensureLoaded()
|
||||
return this.lastScheduledMigrationSummary ? { ...this.lastScheduledMigrationSummary } : null
|
||||
}
|
||||
|
||||
createRule(draft: unknown): AutomationRule {
|
||||
this.ensureLoaded()
|
||||
const timestamp = this.now()
|
||||
const normalized = normalizeRuleDraft(draft)
|
||||
const rule: AutomationRule = {
|
||||
...normalized,
|
||||
id: randomUUID(),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
}
|
||||
this.state.rules = [...this.state.rules, rule]
|
||||
this.persist()
|
||||
return structuredClone(rule)
|
||||
}
|
||||
|
||||
updateRule(id: string, draft: unknown): AutomationRule | undefined {
|
||||
this.ensureLoaded()
|
||||
const key = String(id || '').trim()
|
||||
const index = this.state.rules.findIndex((rule) => rule.id === key)
|
||||
if (index < 0) return undefined
|
||||
const current = this.state.rules[index]
|
||||
// 名字留空时沿用原名,而不是变成「未命名自动化」—— 编辑页只改开关时不该改名。
|
||||
const normalized = normalizeRuleDraft(draft, current.name)
|
||||
const rule: AutomationRule = {
|
||||
...current,
|
||||
...normalized,
|
||||
id: current.id,
|
||||
// ruleType 不允许被草稿改掉:它是规则的**身份**,不是可编辑字段。
|
||||
ruleType: current.ruleType,
|
||||
...(current.ruleType === 'leave_notification' && normalized.leaveNotification
|
||||
? { leaveNotification: normalized.leaveNotification }
|
||||
: {}),
|
||||
...(current.ruleType === 'scheduled_report' && normalized.scheduledReport
|
||||
? {
|
||||
scheduledReport: {
|
||||
...normalized.scheduledReport,
|
||||
...pickScheduledReportRuntime(current.scheduledReport)
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
createdAt: current.createdAt,
|
||||
updatedAt: this.now()
|
||||
}
|
||||
// 切类型时不能留下另一种类型才认识的字段。
|
||||
if (rule.ruleType === 'daily_report') {
|
||||
delete rule.leaveNotification
|
||||
delete rule.scheduledReport
|
||||
} else if (rule.ruleType === 'leave_notification') {
|
||||
delete rule.scheduledReport
|
||||
} else {
|
||||
delete rule.leaveNotification
|
||||
}
|
||||
this.state.rules = this.state.rules.map((item, at) => (at === index ? rule : item))
|
||||
this.persist()
|
||||
return structuredClone(rule)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存「退群通知」规则(**singleton upsert**)。
|
||||
*
|
||||
* 不存在则创建(固定 id),存在则更新 —— 所以点多少次保存都只有一条规则。
|
||||
*/
|
||||
saveLeaveNotificationRule(draft: unknown): AutomationRule {
|
||||
this.ensureLoaded()
|
||||
const existing = this.state.rules.find(
|
||||
(rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID
|
||||
)
|
||||
if (existing) {
|
||||
const updated = this.updateRule(existing.id, { ...(draft as object), ruleType: 'leave_notification' })
|
||||
if (updated) return updated
|
||||
}
|
||||
const timestamp = this.now()
|
||||
const normalized = normalizeRuleDraft({ ...(draft as object), ruleType: 'leave_notification' })
|
||||
const rule: AutomationRule = {
|
||||
...createDefaultLeaveNotificationRule(timestamp),
|
||||
...normalized,
|
||||
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
ruleType: 'leave_notification',
|
||||
name: normalized.name || '退群通知',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
}
|
||||
this.state.rules = [...this.state.rules, rule]
|
||||
this.persist()
|
||||
return structuredClone(rule)
|
||||
}
|
||||
|
||||
deleteRule(id: string): boolean {
|
||||
this.ensureLoaded()
|
||||
const key = String(id || '').trim()
|
||||
const before = this.state.rules.length
|
||||
this.state.rules = this.state.rules.filter((rule) => rule.id !== key)
|
||||
if (this.state.rules.length === before) return false
|
||||
this.persist()
|
||||
return true
|
||||
}
|
||||
|
||||
setRuleEnabled(id: string, enabled: boolean): AutomationRule | undefined {
|
||||
const rule = this.getRule(id)
|
||||
if (!rule) return undefined
|
||||
return this.updateRule(id, { ...rule, enabled: enabled === true })
|
||||
}
|
||||
|
||||
/**
|
||||
* 写**调度游标**(`lastRunAt` / `lastScheduledSlot`)。
|
||||
*
|
||||
* 刻意不走 `updateRule`:这不是用户配置编辑,走草稿归一化会顺带触碰别的字段,
|
||||
* 也可能被"草稿里没这个字段"给抹掉。这里只允许改这两个键。
|
||||
*/
|
||||
setScheduledReportRuntime(
|
||||
id: string,
|
||||
patch: { lastRunAt?: string; lastScheduledSlot?: string }
|
||||
): AutomationRule | undefined {
|
||||
this.ensureLoaded()
|
||||
const key = String(id || '').trim()
|
||||
const index = this.state.rules.findIndex((rule) => rule.id === key)
|
||||
if (index < 0) return undefined
|
||||
const current = this.state.rules[index]
|
||||
if (current.ruleType !== 'scheduled_report' || !current.scheduledReport) return undefined
|
||||
const next: AutomationRule = {
|
||||
...current,
|
||||
scheduledReport: {
|
||||
...current.scheduledReport,
|
||||
...(patch.lastRunAt !== undefined ? { lastRunAt: patch.lastRunAt } : {}),
|
||||
...(patch.lastScheduledSlot !== undefined
|
||||
? { lastScheduledSlot: patch.lastScheduledSlot }
|
||||
: {})
|
||||
},
|
||||
updatedAt: this.now()
|
||||
}
|
||||
this.state.rules = this.state.rules.map((item, at) => (at === index ? next : item))
|
||||
this.persist()
|
||||
return structuredClone(next)
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
let stored: StoredRules
|
||||
try {
|
||||
stored = normalizeStored(fs.readJsonSync(this.rulesFilePath()) as unknown)
|
||||
} catch {
|
||||
stored = emptyState()
|
||||
}
|
||||
if (!stored.builtinSeeded) {
|
||||
stored.builtinSeeded = true
|
||||
stored.rules = [...stored.rules, createDefaultDailyReportRule(this.now())]
|
||||
}
|
||||
if (!stored.leaveNotificationMigrated) {
|
||||
stored.leaveNotificationMigrated = true
|
||||
stored.rules = this.migrateLeaveNotification(stored.rules)
|
||||
}
|
||||
if (!stored.notificationTemplateUpgraded) {
|
||||
stored.notificationTemplateUpgraded = true
|
||||
const upgraded = upgradeLeaveNotificationTemplate(stored.rules)
|
||||
stored.rules = upgraded.rules
|
||||
// 只记条数,**不记模板正文**。
|
||||
if (upgraded.count > 0) {
|
||||
console.log(`[Automation] 退群通知模板已补上群名变量(${upgraded.count} 条)`)
|
||||
}
|
||||
}
|
||||
this.runScheduledReportMigration(stored)
|
||||
stored.version = CURRENT_VERSION
|
||||
this.state = stored
|
||||
this.persist()
|
||||
}
|
||||
|
||||
/** 旧退群通知配置 → 内置退群通知规则。**只跑一次**,且先备份旧配置。 */
|
||||
private migrateLeaveNotification(rules: AutomationRule[]): AutomationRule[] {
|
||||
if (rules.some((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)) return rules
|
||||
const timestamp = this.now()
|
||||
const legacy = this.readLegacyLeaveNotificationState()
|
||||
if (!legacy) {
|
||||
// 全新安装:没有历史可迁,直接建默认规则(默认目标:文件传输助手)。
|
||||
this.logMigration('fresh_install', null)
|
||||
return [...rules, createDefaultLeaveNotificationRule(timestamp)]
|
||||
}
|
||||
const plan = planLeaveNotificationMigration(legacy)
|
||||
this.lastMigrationPlan = plan
|
||||
this.writeMigrationBackup(legacy, plan)
|
||||
this.logMigration(plan.outcome, plan)
|
||||
return [
|
||||
...rules,
|
||||
createDefaultLeaveNotificationRule(timestamp, {
|
||||
template: plan.template,
|
||||
target: plan.target,
|
||||
enabled: plan.enabled,
|
||||
// 二次勾选(旧「通知群聊」)逐字保留,迁移不再是有损的。
|
||||
notifyScope: plan.notifyScope,
|
||||
notifyRoomIds: plan.notifyRoomIds
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧「定时日报」任务 → `scheduled_report` 规则。**只跑一次**。
|
||||
*
|
||||
* 返回 `true` 表示本次真的做了迁移并需要落盘。
|
||||
* **解析器不可用时返回 `false` 且不置标记** —— 迁移推迟到数据库就绪后补跑。
|
||||
*/
|
||||
private runScheduledReportMigration(stored: StoredRules): boolean {
|
||||
if (stored.scheduledReportMigrated) return false
|
||||
const tasks = this.readLegacyScheduledTasks()
|
||||
if (!tasks.length) {
|
||||
// 全新安装 / 从没用过旧定时日报:没有历史可迁,直接置位。
|
||||
stored.scheduledReportMigrated = true
|
||||
console.log('[Automation] 定时日报迁移 outcome=fresh_install')
|
||||
return true
|
||||
}
|
||||
const resolver = this.legacyConversationResolver
|
||||
if (!resolver) {
|
||||
console.log(
|
||||
`[Automation] 定时日报迁移已推迟:数据库尚未就绪,无法解析旧群标识(待迁 ${tasks.length} 条)`
|
||||
)
|
||||
return false
|
||||
}
|
||||
const { plans, summary } = planScheduledReportMigrationBatch(tasks, resolver, this.now())
|
||||
this.lastScheduledMigrationSummary = summary
|
||||
this.writeScheduledMigrationBackup(tasks, summary)
|
||||
const existing = new Set(stored.rules.map((rule) => rule.id))
|
||||
const migrated: AutomationRule[] = []
|
||||
for (const plan of plans) {
|
||||
// 幂等:同一 id 已存在就不再插入(重复执行迁移也不会多出规则)。
|
||||
if (existing.has(plan.ruleId)) continue
|
||||
existing.add(plan.ruleId)
|
||||
migrated.push({
|
||||
...createDefaultScheduledReportRule(plan.createdAt, {
|
||||
id: plan.ruleId,
|
||||
name: plan.name,
|
||||
enabled: plan.enabled,
|
||||
config: plan.config
|
||||
}),
|
||||
createdAt: plan.createdAt,
|
||||
updatedAt: plan.updatedAt
|
||||
})
|
||||
}
|
||||
stored.rules = [...stored.rules, ...migrated]
|
||||
stored.scheduledReportMigrated = true
|
||||
// 只记数量与判定分布,**禁止**出现群名 / roomId / 群主昵称。
|
||||
console.log(
|
||||
`[Automation] 定时日报迁移 outcome=done total=${summary.total} migrated=${migrated.length}` +
|
||||
` lossless=${summary.lossless} needsReview=${summary.needsReview}` +
|
||||
` duplicatesSkipped=${summary.duplicatesSkipped}`
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 读旧定时日报**任务**文件。
|
||||
*
|
||||
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
|
||||
* 读失败视为"没有历史任务",不抛。
|
||||
*/
|
||||
private readLegacyScheduledTasks(): LegacyScheduledReportTask[] {
|
||||
try {
|
||||
const value = fs.readJsonSync(
|
||||
path.join(this.userDataPath(), LEGACY_SCHEDULED_DIR, LEGACY_SCHEDULED_TASKS_FILE)
|
||||
) as unknown
|
||||
return Array.isArray(value) ? (value as LegacyScheduledReportTask[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 迁移前把旧任务**原样**落一份 backup(不做不可逆覆盖)。 */
|
||||
private writeScheduledMigrationBackup(
|
||||
tasks: LegacyScheduledReportTask[],
|
||||
summary: ScheduledReportMigrationSummary
|
||||
): void {
|
||||
try {
|
||||
const filePath = path.join(
|
||||
this.userDataPath(),
|
||||
STORAGE_DIR,
|
||||
SCHEDULED_MIGRATION_BACKUP_FILE
|
||||
)
|
||||
fs.ensureDirSync(path.dirname(filePath))
|
||||
fs.writeJsonSync(
|
||||
filePath,
|
||||
{
|
||||
migratedAt: this.now(),
|
||||
summary,
|
||||
// 备份里保留原始字段:这是**用户数据**,不是日志,不进任何用户可见界面。
|
||||
legacyTasks: tasks
|
||||
},
|
||||
{ spaces: 2 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(`[Automation] 写入定时日报迁移备份失败: ${errorText(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读旧退群监控状态文件里的通知相关字段。
|
||||
*
|
||||
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
|
||||
* 读失败视为"没有历史配置",不抛。
|
||||
*/
|
||||
private readLegacyLeaveNotificationState(): LegacyLeaveNotificationState | null {
|
||||
try {
|
||||
const raw = fs.readJsonSync(
|
||||
path.join(this.userDataPath(), LEGACY_MONITOR_FILE)
|
||||
) as Partial<LegacyLeaveNotificationState>
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
return {
|
||||
monitoredRoomIds: Array.isArray(raw.monitoredRoomIds) ? raw.monitoredRoomIds : [],
|
||||
notificationRoomIds: Array.isArray(raw.notificationRoomIds) ? raw.notificationRoomIds : [],
|
||||
...(raw.notificationTemplate !== undefined
|
||||
? { notificationTemplate: raw.notificationTemplate }
|
||||
: {})
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移前把旧配置原样落一份 backup。
|
||||
*
|
||||
* 不做不可逆覆盖:旧状态文件本身也**不删**,只是不再被运行时代码读取。
|
||||
*/
|
||||
private writeMigrationBackup(
|
||||
legacy: LegacyLeaveNotificationState,
|
||||
plan: LeaveNotificationMigrationPlan
|
||||
): void {
|
||||
try {
|
||||
const filePath = path.join(this.userDataPath(), STORAGE_DIR, MIGRATION_BACKUP_FILE)
|
||||
fs.ensureDirSync(path.dirname(filePath))
|
||||
fs.writeJsonSync(
|
||||
filePath,
|
||||
{
|
||||
migratedAt: this.now(),
|
||||
outcome: plan.outcome,
|
||||
// 备份里保留原始 roomId:这是**用户数据**,不是日志,不进任何用户可见界面。
|
||||
legacy: {
|
||||
monitoredRoomIds: legacy.monitoredRoomIds,
|
||||
notificationRoomIds: legacy.notificationRoomIds,
|
||||
notificationTemplate: legacy.notificationTemplate
|
||||
},
|
||||
applied: {
|
||||
enabled: plan.enabled,
|
||||
targetType: plan.target.type,
|
||||
notifyScope: plan.notifyScope,
|
||||
notifyRoomIds: plan.notifyRoomIds
|
||||
}
|
||||
},
|
||||
{ spaces: 2 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(`[Automation] 写入退群通知迁移备份失败: ${errorText(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 迁移日志:只允许出现判定结果与布尔量,**禁止** roomId / 模板正文。 */
|
||||
private logMigration(outcome: string, plan: LeaveNotificationMigrationPlan | null): void {
|
||||
if (!plan) {
|
||||
console.log(`[Automation] 退群通知迁移 outcome=${outcome}`)
|
||||
return
|
||||
}
|
||||
console.log(
|
||||
`[Automation] 退群通知迁移 outcome=${outcome} enabled=${plan.enabled}` +
|
||||
` targetType=${plan.target.type} needsReview=${plan.targetNeedsReview}`
|
||||
)
|
||||
}
|
||||
|
||||
private rulesFilePath(): string {
|
||||
return path.join(this.userDataPath(), STORAGE_DIR, RULES_FILE)
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
const filePath = this.rulesFilePath()
|
||||
fs.ensureDirSync(path.dirname(filePath))
|
||||
fs.writeJsonSync(filePath, this.state, { spaces: 2 })
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Automation] 保存规则失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
export const automationRuleStore = new AutomationRuleStore()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||
SCHEDULED_REPORT_TARGET_OPTIONS,
|
||||
leaveNotificationTargetLabel,
|
||||
scheduledReportTargetLabel
|
||||
} from '../../shared/automation'
|
||||
import { WECHAT_FILE_HELPER_USERNAME, isFileHelperUsername } from '../../shared/wechat-identities'
|
||||
|
||||
/**
|
||||
* **中性的微信发送目标解析**。
|
||||
*
|
||||
* 退群通知与定时日报的目标语义完全一致(来源会话 / 自己 / 文件传输助手 / 指定好友),
|
||||
* 差的只是"来源会话"从哪来:
|
||||
* - 退群通知 → 事件所在群(`event.conversationId`);
|
||||
* - 定时日报 → 该规则配置的日报来源群(`config.report.sourceConversationId`)。
|
||||
*
|
||||
* ⇒ 所以**只允许有一份实现**(只保留一套 resolve)。
|
||||
* 两个业务各自只有一层薄适配(把参数摊平后交给这里),不重复任何判定逻辑。
|
||||
*
|
||||
* 五条规则(两边共用):
|
||||
* 1. 目标解析失败 → **报错**,绝不 fallback 到别的目标(尤其不许偷偷发文件助手 / 来源群);
|
||||
* 2. 只允许出现在选项表里的目标类型;
|
||||
* 3. 显示名不带 wxid / roomId:日志与界面只出现用户能看懂的称呼;
|
||||
* 4. 联系人必须**可发送**(排除群聊 / 公众号 / 文件传输助手 / 自己);
|
||||
* 5. 只有 `source_chat` 用得到来源会话,其余目标完全不看它。
|
||||
*/
|
||||
|
||||
/** 解析所需的最小联系人结构(main 侧 `FormattedContact` 结构上可赋值给它)。 */
|
||||
export interface AutomationTargetContact {
|
||||
m_nsUsrName: string
|
||||
m_nsNickName?: string
|
||||
md5?: string
|
||||
type: 'user' | 'group'
|
||||
isOfficialAccount?: boolean
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
/** 目标类型(两个业务共用同一组字面量)。 */
|
||||
export type AutomationTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact'
|
||||
|
||||
export interface ResolvedAutomationTarget {
|
||||
recipient: { type: 'group' | 'contact'; id: string; name: string }
|
||||
/** 用户可读的目标名,直接进执行日志的 `detail`。 */
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export type AutomationTargetResolution =
|
||||
| { ok: true; target: ResolvedAutomationTarget }
|
||||
| { ok: false; error: string }
|
||||
|
||||
/** 联系人的展示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
|
||||
export function automationContactDisplayName(contact: AutomationTargetContact): string {
|
||||
return (
|
||||
contact.remark?.trim() ||
|
||||
contact.wechatNickname?.trim() ||
|
||||
contact.m_nsNickName?.trim() ||
|
||||
'指定好友'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个联系人能不能作为「指定好友」的发送目标。
|
||||
*
|
||||
* 排除:群聊、公众号(`gh_`)、文件传输助手、自己。
|
||||
* UI 的联系人选择器与运行时的目标解析**共用这一条判定**,
|
||||
* 否则会出现"能选但发不出去"。
|
||||
*/
|
||||
export function isSendableFriendContact(
|
||||
contact: AutomationTargetContact,
|
||||
selfWxid?: string
|
||||
): boolean {
|
||||
if (contact.type !== 'user') return false
|
||||
if (contact.isOfficialAccount) return false
|
||||
const username = String(contact.m_nsUsrName || '').trim()
|
||||
if (!username) return false
|
||||
if (isFileHelperUsername(username)) return false
|
||||
const self = String(selfWxid || '').trim()
|
||||
if (self && username === self) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
|
||||
export function filterSendableFriendContacts<T extends AutomationTargetContact>(
|
||||
contacts: T[],
|
||||
selfWxid?: string
|
||||
): T[] {
|
||||
return contacts.filter((contact) => isSendableFriendContact(contact, selfWxid))
|
||||
}
|
||||
|
||||
/** 目标类型是否在(该业务的)选项表里。 */
|
||||
function isKnownTargetType(
|
||||
type: string,
|
||||
options: ReadonlyArray<{ type: string }>
|
||||
): boolean {
|
||||
return options.some((option) => option.type === type)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把群标识解析成**稳定会话 id**(`xxx@chatroom`)。
|
||||
*
|
||||
* 兼容三种历史形态:roomId 本身 / 会话 md5 / 群名。
|
||||
* 解析不出来返回 `undefined` —— 调用方必须如实报错,**不许回落**。
|
||||
*/
|
||||
export function resolveGroupConversationId(
|
||||
raw: string,
|
||||
contacts: AutomationTargetContact[]
|
||||
): string | undefined {
|
||||
const value = String(raw || '').trim()
|
||||
if (!value) return undefined
|
||||
if (value.endsWith('@chatroom')) return value
|
||||
const matched = contacts.find(
|
||||
(contact) =>
|
||||
(contact.type === 'group' || contact.m_nsUsrName.endsWith('@chatroom')) &&
|
||||
(contact.md5 === value ||
|
||||
contact.m_nsUsrName === value ||
|
||||
contact.m_nsNickName?.trim() === value)
|
||||
)
|
||||
const conversationId = String(matched?.m_nsUsrName || '').trim()
|
||||
return conversationId.endsWith('@chatroom') ? conversationId : undefined
|
||||
}
|
||||
|
||||
/** 群标识 → 可读群名(解析不到时返回空串,由调用方决定兜底文案)。 */
|
||||
export function resolveGroupDisplayName(
|
||||
raw: string,
|
||||
contacts: AutomationTargetContact[]
|
||||
): string {
|
||||
const value = String(raw || '').trim()
|
||||
if (!value) return ''
|
||||
const matched = contacts.find(
|
||||
(contact) =>
|
||||
contact.m_nsUsrName === value ||
|
||||
contact.md5 === value ||
|
||||
contact.m_nsNickName?.trim() === value
|
||||
)
|
||||
return String(matched?.m_nsNickName || '').trim()
|
||||
}
|
||||
|
||||
export interface ResolveAutomationTargetInput {
|
||||
targetType: AutomationTargetType
|
||||
/**
|
||||
* `source_chat` 时使用:来源会话(退群事件所在群 / 日报来源群)。
|
||||
* 允许是 roomId / 会话 md5 / 群名 —— 一律经 `resolveGroupConversationId` 收敛。
|
||||
*/
|
||||
sourceConversationId?: string
|
||||
/** 来源会话的可读名(拿不到时留空,由这里给兜底称呼)。 */
|
||||
sourceDisplayName?: string
|
||||
/** `contact` 时使用:稳定 id(wxid / username)。 */
|
||||
contactId?: string
|
||||
contacts: AutomationTargetContact[]
|
||||
/** 当前登录账号的 wxid;拿不到时传空串。 */
|
||||
selfWxid?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标解析的**文案与类型表**(按业务注入)。
|
||||
*
|
||||
* 为什么把文案参数化而不是各写一份 switch:两个业务的**判定逻辑必须一模一样**,
|
||||
* 只有"主语"不同。文案集中放在这里,判定仍然只有一份。
|
||||
*/
|
||||
export interface AutomationTargetMessages {
|
||||
/** 目标类型表的单一来源(与 UI 选项同源)。 */
|
||||
options: ReadonlyArray<{ type: string }>
|
||||
/** 类型非法(伪造值 / 未知值)。 */
|
||||
invalidTarget: string
|
||||
/** `source_chat` 但来源会话拿不到。 */
|
||||
sourceMissing: string
|
||||
/** `source_chat` 且来源会话没有可读名时,`recipient.name` 的兜底。 */
|
||||
sourceRecipientName: string
|
||||
/** `source_chat` 且来源会话没有可读名时,`displayName` 的兜底。 */
|
||||
sourceDisplayName: string
|
||||
/** `self` 但拿不到自身身份。 */
|
||||
selfMissing: string
|
||||
/** `contact` 但没选联系人。 */
|
||||
contactNotChosen: string
|
||||
/** `contact` 但联系人失效 / 不可发送。 */
|
||||
contactUnavailable: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 中性目标解析。**唯一的实现**。
|
||||
*/
|
||||
export function resolveAutomationTarget(
|
||||
input: ResolveAutomationTargetInput,
|
||||
messages: AutomationTargetMessages
|
||||
): AutomationTargetResolution {
|
||||
const type = input.targetType
|
||||
if (!type || !isKnownTargetType(type, messages.options)) {
|
||||
return { ok: false, error: messages.invalidTarget }
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'source_chat': {
|
||||
const conversationId = resolveGroupConversationId(
|
||||
String(input.sourceConversationId || ''),
|
||||
input.contacts
|
||||
)
|
||||
if (!conversationId) return { ok: false, error: messages.sourceMissing }
|
||||
const explicitName = String(input.sourceDisplayName || '').trim()
|
||||
const resolvedName = resolveGroupDisplayName(conversationId, input.contacts)
|
||||
const name = explicitName || resolvedName || messages.sourceRecipientName
|
||||
const displayName = explicitName || resolvedName || messages.sourceDisplayName
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: { type: 'group', id: conversationId, name },
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'self': {
|
||||
const selfWxid = String(input.selfWxid || '').trim()
|
||||
// 明确报错,不 fallback。
|
||||
if (!selfWxid) return { ok: false, error: messages.selfMissing }
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: { type: 'contact', id: selfWxid, name: '我' },
|
||||
displayName: '我'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'file_transfer':
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: {
|
||||
type: 'contact',
|
||||
id: WECHAT_FILE_HELPER_USERNAME,
|
||||
name: '文件传输助手'
|
||||
},
|
||||
displayName: '文件传输助手'
|
||||
}
|
||||
}
|
||||
|
||||
case 'contact': {
|
||||
const contactId = String(input.contactId || '').trim()
|
||||
if (!contactId) return { ok: false, error: messages.contactNotChosen }
|
||||
const contact = input.contacts.find(
|
||||
(item) => String(item.m_nsUsrName || '').trim() === contactId
|
||||
)
|
||||
if (!contact || !isSendableFriendContact(contact, input.selfWxid)) {
|
||||
// 之前选择的联系人被删除 / 不可发送 / 找不到时,规则不偷偷改发别处。
|
||||
return { ok: false, error: messages.contactUnavailable }
|
||||
}
|
||||
const displayName = automationContactDisplayName(contact)
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: { type: 'contact', id: contactId, name: displayName },
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 退群通知的已知目标类型表(与文案同源)。 */
|
||||
export const LEAVE_NOTIFICATION_TARGET_TYPES = LEAVE_NOTIFICATION_TARGET_OPTIONS
|
||||
|
||||
/** 定时日报的已知目标类型表(与文案同源)。 */
|
||||
export const SCHEDULED_REPORT_TARGET_TYPES = SCHEDULED_REPORT_TARGET_OPTIONS
|
||||
|
||||
export { leaveNotificationTargetLabel, scheduledReportTargetLabel }
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 +740,15 @@ 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
|
||||
}
|
||||
|
||||
@@ -643,13 +756,38 @@ export function listMessages(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number }
|
||||
options?: { limit?: number },
|
||||
caller: ListMessagesCaller = 'unknown'
|
||||
): 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)
|
||||
const perf = emptyPerf(caller, nextListMessagesRequestId())
|
||||
const totalStartedAt = Date.now()
|
||||
try {
|
||||
const sourceMessages = listSourceMessages(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
options,
|
||||
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,
|
||||
sourceMessages,
|
||||
startTime,
|
||||
endTime,
|
||||
options?.limit
|
||||
)
|
||||
perf.sortMs += Date.now() - recallStartedAt
|
||||
return result
|
||||
} finally {
|
||||
perf.totalMs = Date.now() - totalStartedAt
|
||||
logListMessagesPerf(perf)
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMessagesAsync(
|
||||
@@ -657,42 +795,128 @@ export async function listMessagesAsync(
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number },
|
||||
requestId = 'NO-REQUEST'
|
||||
requestId = '',
|
||||
caller: ListMessagesCaller = 'unknown'
|
||||
): 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`
|
||||
)
|
||||
const sourceMessages = listSourceMessages(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
options,
|
||||
rawMessages,
|
||||
requestId
|
||||
)
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
const result = mergeRecallArchiveMessages(
|
||||
userMd5,
|
||||
sourceMessages,
|
||||
startTime,
|
||||
endTime,
|
||||
options?.limit
|
||||
)
|
||||
wcdbDebugLog(
|
||||
`[${requestId}] ChatService listMessagesAsync end formatted=${result.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return result
|
||||
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(
|
||||
`[${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 rawMessages = await dbRef.getUserMessagesForExport(userMd5, startTime, endTime)
|
||||
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, undefined, rawMessages)
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
const mergedMessages = mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime)
|
||||
console.log(
|
||||
`[ChatService] listMessagesForExport end md5=${userMd5} source=${sourceMessages.length} merged=${mergedMessages.length}`
|
||||
)
|
||||
return mergedMessages
|
||||
const perf = emptyPerf('archive', nextListMessagesRequestId())
|
||||
const totalStartedAt = Date.now()
|
||||
try {
|
||||
const rawReadStartedAt = Date.now()
|
||||
const rawMessages = await dbRef.getUserMessagesForExport(userMd5, startTime, endTime)
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -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,10 +230,13 @@ class GroupExitMonitorService {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
async setMonitoredRoomIds(
|
||||
roomIds: string[],
|
||||
notificationRoomIds: string[] = []
|
||||
): Promise<GroupExitMonitorState> {
|
||||
/**
|
||||
* 保存**监控范围**。
|
||||
*
|
||||
* 参数只剩监控目标 —— 旧版第二个参数(通知群聊)已经迁到自动化规则里,
|
||||
* 本服务不再持有任何通知配置。
|
||||
*/
|
||||
async setMonitoredRoomIds(roomIds: string[]): Promise<GroupExitMonitorState> {
|
||||
this.ensureLoaded()
|
||||
this.eventSequence += 1
|
||||
this.scopeGeneration += 1
|
||||
@@ -225,10 +250,6 @@ 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()
|
||||
@@ -261,22 +282,41 @@ class GroupExitMonitorService {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
setNotificationTemplate(value: unknown): GroupExitMonitorState {
|
||||
/**
|
||||
* 按群 / 时间范围查退群事件(档案合并展示用)。
|
||||
*
|
||||
* 与 `getState()` 的分工:后者只带回最近 `MAX_EVENTS` 条、且是**给管理页**看的概览;
|
||||
* 档案要的是「某个群在这段时间里的全部事件」,所以单独开一个查询入口,
|
||||
* 直接打在内存里的完整历史上(事件是永久保留的)。
|
||||
*
|
||||
* 返回**按时间升序**(旧 → 新),与档案消息流的顺序一致。
|
||||
*/
|
||||
listEvents(
|
||||
query: { roomId?: string; sinceMs?: number; untilMs?: number; limit?: number } = {}
|
||||
): GroupExitMonitorEvent[] {
|
||||
this.ensureLoaded()
|
||||
const result = validateGroupExitNotificationTemplate(value)
|
||||
if (!result.valid || !result.template) {
|
||||
throw new Error(result.error || '退群监测模板无效')
|
||||
const roomId = String(query.roomId || '').trim()
|
||||
const since = Number(query.sinceMs)
|
||||
const until = Number(query.untilMs)
|
||||
const limit = Number(query.limit)
|
||||
|
||||
// this.events 是倒序(新在前)。
|
||||
let result = [...this.events].reverse()
|
||||
if (roomId) result = result.filter((event) => event.roomId === roomId)
|
||||
if (Number.isFinite(since)) result = result.filter((event) => event.detectedAt >= since)
|
||||
if (Number.isFinite(until)) result = result.filter((event) => event.detectedAt <= until)
|
||||
// 超量时保留**最近**的一批(尾部即最新)。
|
||||
if (Number.isFinite(limit) && limit > 0 && result.length > limit) {
|
||||
result = result.slice(-limit)
|
||||
}
|
||||
this.notificationTemplate = result.template
|
||||
this.save()
|
||||
this.broadcast()
|
||||
return this.getState()
|
||||
return result
|
||||
}
|
||||
|
||||
clearEvents(): GroupExitMonitorState {
|
||||
this.ensureLoaded()
|
||||
this.events = []
|
||||
this.actionGateway.clearMemberEvents?.()
|
||||
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
|
||||
this.rewriteEventsToDisk([])
|
||||
this.lastReadAt = Date.now()
|
||||
this.save()
|
||||
this.broadcast()
|
||||
@@ -399,7 +439,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 +470,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 +487,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 +627,145 @@ class GroupExitMonitorService {
|
||||
currentCount,
|
||||
delta: currentCount - previousCount,
|
||||
message,
|
||||
detectedAt,
|
||||
notificationStatus: 'not_requested'
|
||||
detectedAt
|
||||
}
|
||||
this.actionGateway.registerMemberEvent?.(event)
|
||||
this.events = [event, ...this.events].slice(0, MAX_EVENTS)
|
||||
// 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。
|
||||
// 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。
|
||||
this.events = [event, ...this.events]
|
||||
this.appendEventsToDisk([event])
|
||||
console.log(
|
||||
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
|
||||
)
|
||||
return event
|
||||
}
|
||||
|
||||
private async notifyGroup(
|
||||
group: GroupSnapshotRecord,
|
||||
event: GroupExitMonitorEvent,
|
||||
idempotencyKey = `member_left_notification:${event.id}`
|
||||
): Promise<void> {
|
||||
event.notificationStatus = 'pending'
|
||||
event.notification = { status: 'pending' }
|
||||
this.save()
|
||||
this.broadcast()
|
||||
/**
|
||||
* 把退群事件交给自动化 —— **不等待**。
|
||||
*
|
||||
* 三条约束:
|
||||
* 1. **绝不 await**:快照扫描与成员 diff 不能被微信发送耗时拖住;
|
||||
* 2. **异常必须被捕获**:`void promise` 漏掉 `.catch` 会变成 unhandled rejection;
|
||||
* 3. **不影响退群事实**:事件与快照在同一检查点已经先落盘,通知失败不回滚记录。
|
||||
*/
|
||||
private emitGroupExit(event: GroupExitMonitorEvent): void {
|
||||
const handler = this.onGroupExit
|
||||
if (!handler) return
|
||||
try {
|
||||
const result = await this.actionGateway.execute({
|
||||
idempotencyKey,
|
||||
origin: 'member_monitor',
|
||||
purpose: 'member_left_notification',
|
||||
triggerType: 'automation',
|
||||
sourceId: event.id,
|
||||
recipient: {
|
||||
type: 'group',
|
||||
id: group.roomId,
|
||||
name: group.groupName
|
||||
},
|
||||
content: {
|
||||
type: 'text',
|
||||
text: buildMemberLeftNotification(event, this.notificationTemplate)
|
||||
},
|
||||
metadata: {
|
||||
memberId: event.memberWxid,
|
||||
memberName: event.memberName,
|
||||
detectedAt: event.detectedAt,
|
||||
eventType: 'member_left',
|
||||
eventRoomId: event.roomId
|
||||
}
|
||||
})
|
||||
const notification: GroupExitNotificationState = {
|
||||
status: result.status,
|
||||
actionId: result.actionId,
|
||||
decision: result.decision,
|
||||
...(result.errorCode ? { errorCode: result.errorCode } : {}),
|
||||
...(result.reason ? { reason: result.reason } : {}),
|
||||
startedAt: result.startedAt,
|
||||
finishedAt: result.finishedAt
|
||||
}
|
||||
event.notificationStatus = result.status
|
||||
event.notification = notification
|
||||
if (result.status !== 'sent') {
|
||||
void Promise.resolve(
|
||||
handler(toGroupMemberExitedEvent(event))
|
||||
).catch((error) => {
|
||||
console.warn(
|
||||
`[GroupMonitor] 群聊通知未发送 roomId=${group.roomId} status=${result.status} code=${result.errorCode || ''}`
|
||||
`[GroupMonitor] 退群通知处理失败 eventId=${event.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
event.notificationStatus = 'failed'
|
||||
event.notification = {
|
||||
status: 'failed',
|
||||
errorCode: 'UNKNOWN',
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
// handler 同步抛出的情况(`handleGroupExit` 本身不抛,这里是防御性兜底)。
|
||||
console.warn(
|
||||
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
`[GroupMonitor] 退群通知处理异常 eventId=${event.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
} finally {
|
||||
this.save()
|
||||
this.broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
async resendEvent(eventId: string): Promise<GroupExitMonitorState> {
|
||||
const event = this.events.find((item) => item.id === eventId)
|
||||
if (!event) throw new Error('退群动态不存在')
|
||||
await this.notifyGroup(
|
||||
{ roomId: event.roomId, groupName: event.groupName } as GroupSnapshotRecord,
|
||||
event,
|
||||
`member_left_notification:${event.id}:retry:${Date.now()}`
|
||||
)
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
private filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群事件的 append-only 存储(每行一条 JSON)。
|
||||
*
|
||||
* 与状态文件分开,因为两者的写入模式完全不同:
|
||||
* - **状态**(开关 / 监控范围 / 快照 / 模板)小、且总是整体重写;
|
||||
* - **事件**只增不改,且要求**永久保留**。
|
||||
* 混在一个文件里时,每来一条事件都要把整部历史重新序列化写一遍 —— 越写越慢。
|
||||
*/
|
||||
private eventsPath(): string {
|
||||
return path.join(app.getPath('userData'), 'group-exit-monitor-events.jsonl')
|
||||
}
|
||||
|
||||
/** 读全量历史事件。单行损坏只跳过该行,不让整部历史读不出来。 */
|
||||
private readEventsFromDisk(): GroupExitMonitorEvent[] {
|
||||
let raw = ''
|
||||
try {
|
||||
raw = fs.readFileSync(this.eventsPath(), 'utf8')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const events: GroupExitMonitorEvent[] = []
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
try {
|
||||
events.push(JSON.parse(trimmed) as GroupExitMonitorEvent)
|
||||
} catch {
|
||||
// 跳过坏行
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** 只追加新增的那几行,不重写历史。 */
|
||||
private appendEventsToDisk(events: GroupExitMonitorEvent[]): void {
|
||||
if (!events.length) return
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.eventsPath()))
|
||||
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
|
||||
fs.appendFileSync(this.eventsPath(), payload, 'utf8')
|
||||
} catch (error) {
|
||||
console.warn('[GroupMonitor] 追加退群事件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 整体重写事件文件(清空、切换账号、老数据迁移时使用)。 */
|
||||
private rewriteEventsToDisk(events: GroupExitMonitorEvent[]): void {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.eventsPath()))
|
||||
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
|
||||
fs.writeFileSync(this.eventsPath(), payload, 'utf8')
|
||||
} catch (error) {
|
||||
console.warn('[GroupMonitor] 重写退群事件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
try {
|
||||
const stored = fs.readJsonSync(this.filePath()) as StoredState
|
||||
this.enabled = stored.enabled !== false
|
||||
this.events = normalizeEvents(stored.events)
|
||||
this.actionGateway.registerMemberEvents?.(this.events)
|
||||
// 事件从 append-only 文件读;状态文件不再承载它们。
|
||||
const fromDisk = this.readEventsFromDisk()
|
||||
const legacy = normalizeEvents(stored.events)
|
||||
if (legacy.length && !fromDisk.length) {
|
||||
// 老版本把事件塞在状态文件里 —— 一次性迁移过去,避免这批历史丢失。
|
||||
// 落盘按时间**升序**(旧 → 新),与之后 append 的方向一致,避免在
|
||||
// append-only 文件开头留下一段方向相反的旧历史(历史行序错乱的来源)。
|
||||
this.rewriteEventsToDisk(
|
||||
[...legacy].sort((left, right) => left.detectedAt - right.detectedAt)
|
||||
)
|
||||
this.events = sortEventsNewestFirst(legacy)
|
||||
} else {
|
||||
// 磁盘行序不保证时间有序(迁移段与追加段方向相反),读回后必须显式重建
|
||||
// 「新在前」这个内存不变量,否则列表顶部会恒为最旧的一批。
|
||||
this.events = sortEventsNewestFirst(normalizeEvents(fromDisk))
|
||||
}
|
||||
this.lastReadAt = Number(stored.lastReadAt) || 0
|
||||
this.accountRoot = String(stored.accountRoot || '')
|
||||
// 没有显式范围时按空范围处理,保留已有选择。
|
||||
this.monitorSelectionConfigured = true
|
||||
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
|
||||
this.notificationRoomIds = new Set(
|
||||
Array.from(normalizeRoomIds(stored.notificationRoomIds || [])).filter((roomId) =>
|
||||
this.monitoredRoomIds.has(roomId)
|
||||
)
|
||||
)
|
||||
this.notificationTemplate = normalizeGroupExitNotificationTemplate(
|
||||
stored.notificationTemplate
|
||||
)
|
||||
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
|
||||
} catch {
|
||||
// 首次启动或文件损坏时从空记录开始。
|
||||
this.events = []
|
||||
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
|
||||
// 不该被状态文件的问题连累,仍然读回来。
|
||||
this.events = sortEventsNewestFirst(normalizeEvents(this.readEventsFromDisk()))
|
||||
this.enabled = true
|
||||
this.lastReadAt = 0
|
||||
this.monitorSelectionConfigured = true
|
||||
this.monitoredRoomIds.clear()
|
||||
this.notificationRoomIds.clear()
|
||||
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
||||
this.snapshots.clear()
|
||||
}
|
||||
}
|
||||
@@ -725,12 +779,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 +980,28 @@ function normalizeEvents(
|
||||
? Number(value.delta)
|
||||
: currentCount - previousCount,
|
||||
message: String(value.message || `${memberName}退出了${groupName}`),
|
||||
detectedAt,
|
||||
...(value.notificationStatus
|
||||
? { notificationStatus: normalizeNotificationStatus(value.notificationStatus) }
|
||||
: {}),
|
||||
...(value.notification && typeof value.notification === 'object'
|
||||
? { notification: normalizeNotification(value.notification) }
|
||||
: {})
|
||||
detectedAt
|
||||
})
|
||||
if (normalized.length >= MAX_EVENTS) break
|
||||
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
|
||||
// 历史上的 `notificationStatus` / `notification` 字段被**丢弃**:
|
||||
// 通知状态已归 Automation 执行日志,退群监控不再持有它。
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeNotificationStatus(value: unknown): GroupExitNotificationStatus {
|
||||
const status = String(value || '').trim()
|
||||
return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed'
|
||||
? status
|
||||
: 'not_requested'
|
||||
}
|
||||
|
||||
function normalizeNotification(value: object): GroupExitNotificationState {
|
||||
const input = value as Partial<GroupExitNotificationState>
|
||||
return {
|
||||
status: normalizeNotificationStatus(input.status),
|
||||
...(input.actionId ? { actionId: String(input.actionId) } : {}),
|
||||
...(input.decision === 'allow' || input.decision === 'block'
|
||||
? { decision: input.decision }
|
||||
: {}),
|
||||
...(input.errorCode ? { errorCode: String(input.errorCode) } : {}),
|
||||
...(input.reason ? { reason: String(input.reason) } : {}),
|
||||
...(input.startedAt ? { startedAt: String(input.startedAt) } : {}),
|
||||
...(input.finishedAt ? { finishedAt: String(input.finishedAt) } : {})
|
||||
}
|
||||
/**
|
||||
* 事件在内存里恒定保持「**新在前**」。
|
||||
*
|
||||
* 这个不变量有三个依赖方:`recordExit` 的 `[event, ...this.events]` 写入方向、
|
||||
* `listEvents()` 的 `.reverse()`(它假定内存是倒序,反转后得到升序)、
|
||||
* 以及 `getState()` 的 `slice(0, MAX_EVENTS)`(要求取到的是**最新**的一批)。
|
||||
*
|
||||
* 必须显式重建它:磁盘是 append-only,行序由「迁移写入的历史 + 之后追加的新事件」
|
||||
* 决定,两段方向相反,整体不保证时间有序。直接信任文件行序会让列表顶部恒为最旧的
|
||||
* 一批,并让 `slice(0, MAX_EVENTS)` 恰好把最新的事件截掉。
|
||||
*/
|
||||
function sortEventsNewestFirst(events: GroupExitMonitorEvent[]): GroupExitMonitorEvent[] {
|
||||
return [...events].sort((left, right) => right.detectedAt - left.detectedAt)
|
||||
}
|
||||
|
||||
function isContactEvent(rawPayload: string): boolean {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { isKnowledgeFresh } from '../../shared/knowledge'
|
||||
import {
|
||||
GROUP_STATS_LIMITATION,
|
||||
GROUP_STATS_STALE_LIMITATION,
|
||||
type GroupMemberStatsActiveMember,
|
||||
type GroupMemberStatsQuery,
|
||||
type GroupMemberStatsResult,
|
||||
type GroupMemberStatsSilentMember,
|
||||
type GroupStatsFreshness
|
||||
} from '../../shared/group-stats'
|
||||
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
|
||||
import * as chat from './chat-service'
|
||||
|
||||
/**
|
||||
* 触发一次追赶同步的最小间隔。
|
||||
*
|
||||
* 面板是可以反复开关的交互入口,而追赶同步跑的是真实增量 pass(会读 WCDB)。
|
||||
* 没有这个节流,连续点击就等于连续触发索引。
|
||||
*/
|
||||
const CATCH_UP_MIN_INTERVAL_MS = 30_000
|
||||
|
||||
/**
|
||||
* 追赶的有界等待预算。
|
||||
*
|
||||
* 与 Query 侧同一取舍:全量追赶可能以分钟计,交互查询绝不能无限等。
|
||||
* 预算用完之后**如实**把结果标成不完整,而不是假装完整。
|
||||
*/
|
||||
const FRESHNESS_WAIT_BUDGET_MS = 2_000
|
||||
|
||||
/**
|
||||
* 群员统计。
|
||||
*
|
||||
* 数据两路来源,职责严格分开:
|
||||
* - **当前成员名单** 走 WCDB 的轻路径(只取 wxid + 显示名,**不** hydrate 头像);
|
||||
* - **发言聚合** 走 Knowledge 派生库的 `GROUP BY sender_id`,不读 WCDB 原始消息。
|
||||
*
|
||||
* 「未发言」= 当前成员 ∖ 窗口内有发言的 sender。这个定义**不能**简化成
|
||||
* 「整段窗口都没说话」—— 入群/退群时间在源头就不存在(整个 DB 没有该字段),
|
||||
* 所以结论必须带上 limitation。
|
||||
*/
|
||||
export class GroupStatsService {
|
||||
constructor(private readonly knowledge: KnowledgeSearchService) {}
|
||||
|
||||
async getMemberStats(query: GroupMemberStatsQuery): Promise<GroupMemberStatsResult> {
|
||||
const { userMd5, startTime, endTime } = query
|
||||
const limitations = [GROUP_STATS_LIMITATION]
|
||||
|
||||
const roomId = chat.resolveGroupRoomId(userMd5)
|
||||
if (!roomId) {
|
||||
// 不是群 / md5 解析不到:如实返回空,不编造统计。
|
||||
return this.build({
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
freshness: 'unknown',
|
||||
limitations,
|
||||
members: [],
|
||||
activeMembers: [],
|
||||
silentMembers: [],
|
||||
unattributedMessages: 0,
|
||||
excludedSystemMessages: 0,
|
||||
firstMessageTime: null
|
||||
})
|
||||
}
|
||||
|
||||
// 1) 当前群成员 —— 轻路径。只取 wxid,再按需解析显示名;绝不走
|
||||
// `getGroupSnapshotAsync`(它会 materialize 整群并 hydrate 头像,实测 8 群 ≈ 42s)。
|
||||
const membership = await chat.getGroupMemberIdsAsync(roomId)
|
||||
const memberIds = membership?.memberIds ?? []
|
||||
const members = await chat.getGroupMemberNamesAsync(userMd5, memberIds)
|
||||
|
||||
// 2) 发言聚合(只统计当前群;系统消息在 SQL 层就被排除)
|
||||
let { result, sourceLatestAt } = await this.knowledge.memberStats({
|
||||
conversationId: userMd5,
|
||||
startTime,
|
||||
endTime
|
||||
})
|
||||
let freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
|
||||
|
||||
// 3) 索引没追平 → 有界追赶一次再判。追不上就如实标 stale,
|
||||
// **不允许**把落后索引算出来的「未发言」包装成完整结论。
|
||||
if (freshness !== 'fresh') {
|
||||
this.knowledge.requestCatchUp(CATCH_UP_MIN_INTERVAL_MS)
|
||||
const settled = await this.knowledge.waitForIndexingComplete(FRESHNESS_WAIT_BUDGET_MS)
|
||||
if (settled) {
|
||||
const retried = await this.knowledge.memberStats({
|
||||
conversationId: userMd5,
|
||||
startTime,
|
||||
endTime
|
||||
})
|
||||
result = retried.result
|
||||
sourceLatestAt = retried.sourceLatestAt
|
||||
freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 差集:当前成员 × 窗口内发言
|
||||
const statBySender = new Map<string, { messageCount: number; lastMessageTime: number }>()
|
||||
for (const row of result?.senders ?? []) {
|
||||
if (row.senderId) statBySender.set(row.senderId, row)
|
||||
}
|
||||
|
||||
const knownSenderIds = new Set<string>()
|
||||
const activeMembers: GroupMemberStatsActiveMember[] = []
|
||||
const silentMembers: GroupMemberStatsSilentMember[] = []
|
||||
|
||||
for (const member of members) {
|
||||
const wxid = String(member.wxid || '').trim()
|
||||
if (!wxid) continue
|
||||
knownSenderIds.add(wxid)
|
||||
// 与 Knowledge 的 Join key 必须是 wxid,不是昵称 —— 昵称会变。
|
||||
// 显示名沿用项目既有优先级(nickname 已内含 `wechatNickname || groupNickname || username`)。
|
||||
const displayName = member.nickname || member.remark || wxid
|
||||
const groupNickname = member.groupNickname || ''
|
||||
const stat = statBySender.get(wxid)
|
||||
if (stat && stat.messageCount > 0) {
|
||||
activeMembers.push({
|
||||
senderId: wxid,
|
||||
displayName,
|
||||
groupNickname,
|
||||
messageCount: stat.messageCount,
|
||||
lastMessageTime: stat.lastMessageTime
|
||||
})
|
||||
} else {
|
||||
silentMembers.push({ senderId: wxid, displayName, groupNickname })
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口内有发言、但已不在当前成员名单里的人(退群者)。
|
||||
// 默认**不**进活跃榜:那份名单读作「当前群成员」,混入已退群的人会误导。
|
||||
let formerSenderCount = 0
|
||||
for (const senderId of statBySender.keys()) {
|
||||
if (!knownSenderIds.has(senderId)) formerSenderCount += 1
|
||||
}
|
||||
|
||||
activeMembers.sort(
|
||||
(a, b) => b.messageCount - a.messageCount || b.lastMessageTime - a.lastMessageTime
|
||||
)
|
||||
silentMembers.sort((a, b) => a.displayName.localeCompare(b.displayName, 'zh-Hans-CN'))
|
||||
|
||||
if (freshness !== 'fresh') limitations.push(GROUP_STATS_STALE_LIMITATION)
|
||||
if (formerSenderCount > 0) {
|
||||
limitations.push(
|
||||
`另有 ${formerSenderCount} 位窗口内发言者已不在当前群成员名单中,未计入活跃榜。`
|
||||
)
|
||||
}
|
||||
|
||||
return this.build({
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
freshness,
|
||||
limitations,
|
||||
members,
|
||||
activeMembers,
|
||||
silentMembers,
|
||||
unattributedMessages: result?.unattributedMessages ?? 0,
|
||||
excludedSystemMessages: result?.excludedSystemMessages ?? 0,
|
||||
firstMessageTime: result?.earliestMessageTime ?? null
|
||||
})
|
||||
}
|
||||
|
||||
private build(input: {
|
||||
userMd5: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
freshness: GroupStatsFreshness
|
||||
limitations: string[]
|
||||
members: Array<{ wxid: string }>
|
||||
activeMembers: GroupMemberStatsActiveMember[]
|
||||
silentMembers: GroupMemberStatsSilentMember[]
|
||||
unattributedMessages: number
|
||||
excludedSystemMessages: number
|
||||
firstMessageTime: number | null
|
||||
}): GroupMemberStatsResult {
|
||||
return {
|
||||
conversationId: input.userMd5,
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime,
|
||||
freshness: input.freshness,
|
||||
// 只有索引确实追平,才允许调用方把「未发言」当作完整结论。
|
||||
complete: input.freshness === 'fresh',
|
||||
memberCount: input.members.length,
|
||||
activeMemberCount: input.activeMembers.length,
|
||||
silentMemberCount: input.silentMembers.length,
|
||||
activeMembers: input.activeMembers,
|
||||
silentMembers: input.silentMembers,
|
||||
unattributedMessages: input.unattributedMessages,
|
||||
excludedSystemMessages: input.excludedSystemMessages,
|
||||
firstMessageTime: input.firstMessageTime,
|
||||
limitations: input.limitations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `isKnowledgeFresh` 返回 `boolean | null`(任一侧口径缺失即 null)。
|
||||
* 三态都要能表达:`null` 绝不能当成「新鲜」。
|
||||
*/
|
||||
function judgeFreshness(
|
||||
indexLatestAt: number | null,
|
||||
sourceLatestAt: number | null
|
||||
): GroupStatsFreshness {
|
||||
const fresh = isKnowledgeFresh({ indexLatestAt, sourceLatestAt })
|
||||
if (fresh === true) return 'fresh'
|
||||
if (fresh === false) return 'stale'
|
||||
return 'unknown'
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* 图片文字索引的**派生存储**(与 Knowledge 派生库物理分离)。
|
||||
*
|
||||
* 为什么单独一个库而不是往 knowledge.sqlite 里加表:
|
||||
* - 清理语义干净:整个能力 = 三个文件(.sqlite/-wal/-shm),删掉即可,不留残渣。
|
||||
* - 零迁移风险:不动已发布的 knowledge schema(升级不得破坏既有派生库)。
|
||||
* - 去重语义天然:artifact 按「图片内容 + OCR 运行时指纹」唯一,binding 承担多来源。
|
||||
*
|
||||
* 账号隔离与 Knowledge 一致:路径按 accountId 摘要分目录 + 库内 account_id 自证。
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, rmSync, statSync, existsSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import {
|
||||
IMAGE_TEXT_BACKFILL_TIER_ORDER,
|
||||
IMAGE_TEXT_INDEX_SCHEMA_VERSION,
|
||||
type ImageOcrArtifact,
|
||||
type ImageOcrBinding,
|
||||
type ImageOcrPersistedState,
|
||||
type ImageTextBackfillTier,
|
||||
type ImageTextIndexStorageStats,
|
||||
type ImageTextTierRunState
|
||||
} from '../../shared/image-text-index'
|
||||
|
||||
const MAX_SAFE_ACCOUNT_SEGMENT = /^[a-f0-9]{32}$/
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
/** 派生库目录名不直接暴露 accountId。 */
|
||||
export function imageTextIndexAccountKey(accountId: string): string {
|
||||
return sha256Hex(`image-text-index-account-v1:${accountId}`).slice(0, 32)
|
||||
}
|
||||
|
||||
export function getImageTextIndexDatabasePath(databaseRoot: string, accountId: string): string {
|
||||
const accountKey = imageTextIndexAccountKey(accountId)
|
||||
if (!MAX_SAFE_ACCOUNT_SEGMENT.test(accountKey)) {
|
||||
throw new Error('Invalid image text index account key')
|
||||
}
|
||||
return join(resolve(databaseRoot), accountKey, 'image-text-index.sqlite')
|
||||
}
|
||||
|
||||
/**
|
||||
* 精确删除三件套(与 Knowledge 的 removeKnowledgeDatabase 同构)。
|
||||
*
|
||||
* **删除后必须回验**:Windows 上只要还有句柄(WAL/SHM 未关干净、别的进程打开了库),
|
||||
* `rmSync` 可能不报错却没真的删掉 —— 那就是"看似清理成功,实际没删"。
|
||||
* 这里把没删掉的路径返回给调用方,让上层能如实报告失败,而不是假装成功。
|
||||
*/
|
||||
export function removeImageTextIndexDatabase(databasePath: string): {
|
||||
removed: boolean
|
||||
leftovers: string[]
|
||||
} {
|
||||
const leftovers: string[] = []
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
const target = `${databasePath}${suffix}`
|
||||
if (!existsSync(target)) continue
|
||||
try {
|
||||
rmSync(target, { force: true })
|
||||
} catch {
|
||||
// 删除失败(典型原因是文件仍被占用)→ 由下面的回验兜住。
|
||||
}
|
||||
if (existsSync(target)) leftovers.push(target)
|
||||
}
|
||||
return { removed: leftovers.length === 0, leftovers }
|
||||
}
|
||||
|
||||
function asRows(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? (value as Record<string, unknown>[]) : []
|
||||
}
|
||||
|
||||
function artifactFromRow(row: Record<string, unknown>): ImageOcrArtifact {
|
||||
return {
|
||||
accountId: String(row.account_id),
|
||||
artifactKey: String(row.artifact_key),
|
||||
imageIdentity: String(row.image_identity),
|
||||
state: String(row.state) as ImageOcrPersistedState,
|
||||
text: String(row.text ?? ''),
|
||||
charCount: Number(row.char_count ?? 0),
|
||||
engine: String(row.engine),
|
||||
platform: String(row.platform),
|
||||
runtimeVersion: row.runtime_version ? String(row.runtime_version) : null,
|
||||
language: row.language ? String(row.language) : null,
|
||||
...(row.error_code ? { errorCode: String(row.error_code) } : {}),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
/** 会话内「消息 → OCR 文本」,供 Knowledge 索引时解析(与语音 resolver 同构)。 */
|
||||
export interface ConversationImageOcrEntry {
|
||||
state: ImageOcrPersistedState
|
||||
text: string
|
||||
}
|
||||
|
||||
export class ImageTextIndexStore {
|
||||
private readonly database: DatabaseSync
|
||||
|
||||
constructor(
|
||||
private readonly databasePath: string,
|
||||
private readonly accountId: string
|
||||
) {
|
||||
mkdirSync(dirname(databasePath), { recursive: true })
|
||||
this.database = new DatabaseSync(databasePath)
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
this.database.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
CREATE TABLE IF NOT EXISTS image_ocr_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS image_ocr_artifacts (
|
||||
artifact_key TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
image_identity TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
char_count INTEGER NOT NULL DEFAULT 0,
|
||||
engine TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
runtime_version TEXT,
|
||||
language TEXT,
|
||||
error_code TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS image_ocr_artifacts_identity
|
||||
ON image_ocr_artifacts (image_identity);
|
||||
CREATE TABLE IF NOT EXISTS image_ocr_bindings (
|
||||
conversation_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL,
|
||||
create_time INTEGER NOT NULL,
|
||||
sender_id TEXT,
|
||||
sender_name TEXT,
|
||||
image_identity TEXT NOT NULL,
|
||||
artifact_key TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (conversation_id, message_id)
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS image_ocr_bindings_identity
|
||||
ON image_ocr_bindings (image_identity);
|
||||
CREATE INDEX IF NOT EXISTS image_ocr_bindings_state
|
||||
ON image_ocr_bindings (state);
|
||||
-- 每个会话的扫描 checkpoint:重启后据此跳过已完成的会话。
|
||||
CREATE TABLE IF NOT EXISTS image_ocr_scan_state (
|
||||
conversation_id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
image_total INTEGER NOT NULL DEFAULT 0,
|
||||
image_processed INTEGER NOT NULL DEFAULT 0,
|
||||
image_max_local_id INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
`)
|
||||
|
||||
// 探测式加列(与 Knowledge 一致):旧库缺列时补上,不做版本号比较。
|
||||
const bindingColumns = new Set(
|
||||
asRows(this.database.prepare('PRAGMA table_info(image_ocr_bindings)').all()).map((row) =>
|
||||
String(row.name)
|
||||
)
|
||||
)
|
||||
if (!bindingColumns.has('artifact_key')) {
|
||||
this.database.exec(
|
||||
"ALTER TABLE image_ocr_bindings ADD COLUMN artifact_key TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
}
|
||||
|
||||
const scanColumns = new Set(
|
||||
asRows(this.database.prepare('PRAGMA table_info(image_ocr_scan_state)').all()).map((row) =>
|
||||
String(row.name)
|
||||
)
|
||||
)
|
||||
if (!scanColumns.has('image_max_local_id')) {
|
||||
// 旧库补列后默认 0:等于「水位未知」,下一次 pass 会重扫该会话并写入真实水位。
|
||||
this.database.exec(
|
||||
'ALTER TABLE image_ocr_scan_state ADD COLUMN image_max_local_id INTEGER NOT NULL DEFAULT 0'
|
||||
)
|
||||
}
|
||||
|
||||
const storedAccount = this.readMeta('account_id')
|
||||
if (storedAccount && storedAccount !== this.accountId) {
|
||||
throw new Error('Image text index account isolation check failed')
|
||||
}
|
||||
if (!storedAccount) this.writeMeta('account_id', this.accountId)
|
||||
if (!this.readMeta('schema_version')) {
|
||||
this.writeMeta('schema_version', String(IMAGE_TEXT_INDEX_SCHEMA_VERSION))
|
||||
}
|
||||
}
|
||||
|
||||
private readMeta(key: string): string | null {
|
||||
const row = this.database
|
||||
.prepare('SELECT value FROM image_ocr_meta WHERE key = ?')
|
||||
.get(key) as Record<string, unknown> | undefined
|
||||
return row ? String(row.value) : null
|
||||
}
|
||||
|
||||
private writeMeta(key: string, value: string): void {
|
||||
this.database
|
||||
.prepare(
|
||||
'INSERT INTO image_ocr_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
)
|
||||
.run(key, value)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- artifacts
|
||||
|
||||
getArtifact(artifactKey: string): ImageOcrArtifact | null {
|
||||
const row = this.database
|
||||
.prepare('SELECT * FROM image_ocr_artifacts WHERE artifact_key = ?')
|
||||
.get(artifactKey) as Record<string, unknown> | undefined
|
||||
return row ? artifactFromRow(row) : null
|
||||
}
|
||||
|
||||
putArtifact(artifact: ImageOcrArtifact): void {
|
||||
if (artifact.accountId !== this.accountId) {
|
||||
throw new Error('Image OCR artifact account does not match database')
|
||||
}
|
||||
const existing = this.getArtifact(artifact.artifactKey)
|
||||
this.database
|
||||
.prepare(
|
||||
`INSERT INTO image_ocr_artifacts (
|
||||
artifact_key, account_id, image_identity, state, text, char_count,
|
||||
engine, platform, runtime_version, language, error_code, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(artifact_key) DO UPDATE SET
|
||||
state = excluded.state,
|
||||
text = excluded.text,
|
||||
char_count = excluded.char_count,
|
||||
runtime_version = excluded.runtime_version,
|
||||
language = excluded.language,
|
||||
error_code = excluded.error_code,
|
||||
updated_at = excluded.updated_at`
|
||||
)
|
||||
.run(
|
||||
artifact.artifactKey,
|
||||
artifact.accountId,
|
||||
artifact.imageIdentity,
|
||||
artifact.state,
|
||||
artifact.text,
|
||||
artifact.charCount,
|
||||
artifact.engine,
|
||||
artifact.platform,
|
||||
artifact.runtimeVersion,
|
||||
artifact.language,
|
||||
artifact.errorCode ?? null,
|
||||
existing?.createdAt ?? artifact.createdAt,
|
||||
artifact.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- bindings
|
||||
|
||||
/** 写入绑定;同一 OCR 结果可被多个会话/消息引用。 */
|
||||
putBinding(binding: ImageOcrBinding): void {
|
||||
if (binding.accountId !== this.accountId) {
|
||||
throw new Error('Image OCR binding account does not match database')
|
||||
}
|
||||
this.database
|
||||
.prepare(
|
||||
`INSERT INTO image_ocr_bindings (
|
||||
conversation_id, message_id, account_id, create_time, sender_id, sender_name,
|
||||
image_identity, artifact_key, state, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(conversation_id, message_id) DO UPDATE SET
|
||||
create_time = excluded.create_time,
|
||||
sender_id = excluded.sender_id,
|
||||
sender_name = excluded.sender_name,
|
||||
image_identity = excluded.image_identity,
|
||||
artifact_key = excluded.artifact_key,
|
||||
state = excluded.state,
|
||||
updated_at = excluded.updated_at`
|
||||
)
|
||||
.run(
|
||||
binding.conversationId,
|
||||
binding.messageId,
|
||||
binding.accountId,
|
||||
binding.createTime,
|
||||
binding.senderId ?? null,
|
||||
binding.senderName ?? null,
|
||||
binding.imageIdentity,
|
||||
binding.artifactKey,
|
||||
binding.state,
|
||||
binding.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 某会话的「消息 → OCR 文本」映射。
|
||||
*
|
||||
* 与语音的 `withVoiceTranscript` 同构:在**主进程**把派生文本贴到消息上,
|
||||
* 再交给 Knowledge 索引,派生库不需要被 worker 打开。
|
||||
*/
|
||||
getConversationOcr(
|
||||
conversationId: string
|
||||
): Map<string, ConversationImageOcrEntry> {
|
||||
const rows = asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
`SELECT b.message_id AS message_id, b.state AS binding_state,
|
||||
a.state AS artifact_state, a.text AS text
|
||||
FROM image_ocr_bindings b
|
||||
LEFT JOIN image_ocr_artifacts a ON a.artifact_key = b.artifact_key
|
||||
WHERE b.conversation_id = ?`
|
||||
)
|
||||
.all(conversationId)
|
||||
)
|
||||
const result = new Map<string, ConversationImageOcrEntry>()
|
||||
for (const row of rows) {
|
||||
result.set(String(row.message_id), {
|
||||
state: String(row.artifact_state || row.binding_state) as ImageOcrPersistedState,
|
||||
text: String(row.text ?? '')
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- checkpoint
|
||||
|
||||
/**
|
||||
* 已持久化的图片消息总数统计。
|
||||
*
|
||||
* 必须落盘:派生库只知道自己**处理过**什么,不知道源数据里**一共**有多少图片。
|
||||
* 一旦把这个 total 只放在内存里,应用重启后 coverage 就会退化成
|
||||
* 「processed / processed」→ 把 30% 的部分索引谎报成 100% 完整覆盖。
|
||||
*/
|
||||
readCountedTotal(): { total: number; countedAt: number; complete: boolean } | null {
|
||||
const total = this.readMeta('total_image_messages')
|
||||
const countedAt = this.readMeta('total_image_counted_at')
|
||||
if (total === null || countedAt === null) return null
|
||||
const parsedTotal = Number(total)
|
||||
const parsedCountedAt = Number(countedAt)
|
||||
if (!Number.isFinite(parsedTotal) || !Number.isFinite(parsedCountedAt)) return null
|
||||
return {
|
||||
total: parsedTotal,
|
||||
countedAt: parsedCountedAt,
|
||||
// 统计时若有会话没数上(数据库不支持该统计),分母就是偏小的 →
|
||||
// 绝不能据此声称"已覆盖全部",否则少数的那些会话会被静默算进"已覆盖"。
|
||||
complete: this.readMeta('total_image_messages_complete') === '1'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描进度:所有会话累计「应扫多少张图片消息」与「实际扫过多少张」。
|
||||
*
|
||||
* 与上面的 `readCountedTotal()` 分工必须分清:
|
||||
* - `readCountedTotal()` 是 `countImageMessages()` 给出的**预估**分母(遍历消息表数出来的,
|
||||
* 会随新消息变动,且与「归档合并后流水线真正拿到的消息集合」并不完全一致);
|
||||
* - 这里是流水线**真实走过**的集合。
|
||||
*
|
||||
* 进度必须用后者。拿预估值当分母,进度会永远差最后几个百分点,
|
||||
* 让已经跑完的索引一直显示成"部分完成"。
|
||||
*/
|
||||
readScanProgress(): { total: number; processed: number } {
|
||||
const row = this.database
|
||||
.prepare(
|
||||
'SELECT SUM(image_total) AS total, SUM(image_processed) AS processed FROM image_ocr_scan_state'
|
||||
)
|
||||
.get() as Record<string, unknown> | undefined
|
||||
return {
|
||||
total: Number(row?.total ?? 0) || 0,
|
||||
processed: Number(row?.processed ?? 0) || 0
|
||||
}
|
||||
}
|
||||
|
||||
writeCountedTotal(input: { total: number; countedAt: number; complete: boolean }): void {
|
||||
this.writeMeta('total_image_messages', String(input.total))
|
||||
this.writeMeta('total_image_counted_at', String(input.countedAt))
|
||||
this.writeMeta('total_image_messages_complete', input.complete ? '1' : '0')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- recent-first 回填状态
|
||||
//
|
||||
// 全部走 `image_ocr_meta`(key/value),**不加表、不加列** —— 这是 old checkpoint
|
||||
// 兼容性的来源:旧库没有这些 key 时读出来就是"没有计划",于是下一次 pass
|
||||
// 以当前时刻为锚点重新建计划。已经处理过的图片由 terminal binding 兜住,
|
||||
// 不会因为"计划是新的"而重新 OCR。
|
||||
|
||||
/**
|
||||
* 读取回填计划状态。
|
||||
*
|
||||
* `anchorMs === null` = 从未规划过(新用户,或从旧版本升级且没有这些 key)。
|
||||
* 调用方此时必须**新建**计划,而不是假设"已完成"。
|
||||
*/
|
||||
readBackfillState(): {
|
||||
anchorMs: number | null
|
||||
tierStates: Partial<Record<ImageTextBackfillTier, ImageTextTierRunState>>
|
||||
coveredToMs: number | null
|
||||
} {
|
||||
const anchorRaw = this.readMeta('backfill_anchor_ms')
|
||||
const anchorMs = anchorRaw === null ? null : Number(anchorRaw)
|
||||
let tierStates: Partial<Record<ImageTextBackfillTier, ImageTextTierRunState>> = {}
|
||||
const statesRaw = this.readMeta('backfill_tier_states')
|
||||
if (statesRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(statesRaw) as Record<string, unknown>
|
||||
for (const tier of IMAGE_TEXT_BACKFILL_TIER_ORDER) {
|
||||
const value = parsed[tier]
|
||||
if (value === 'pending' || value === 'running' || value === 'complete') {
|
||||
tierStates[tier] = value
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 状态串损坏时按"没有分段状态"处理:最坏情况是重扫一遍,
|
||||
// 而 terminal binding 保证不会重复 OCR。
|
||||
tierStates = {}
|
||||
}
|
||||
}
|
||||
const coveredRaw = this.readMeta('backfill_covered_to_ms')
|
||||
const coveredToMs = coveredRaw === null ? null : Number(coveredRaw)
|
||||
return {
|
||||
anchorMs: anchorMs !== null && Number.isFinite(anchorMs) ? anchorMs : null,
|
||||
tierStates,
|
||||
coveredToMs: coveredToMs !== null && Number.isFinite(coveredToMs) ? coveredToMs : null
|
||||
}
|
||||
}
|
||||
|
||||
writeBackfillAnchor(anchorMs: number): void {
|
||||
this.writeMeta('backfill_anchor_ms', String(Math.floor(anchorMs)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入单个分段的运行态。
|
||||
*
|
||||
* 一个 pass 是单线程串行推进分段,所以"读-改-写"整体落在一条 meta 行里;
|
||||
* 这里额外做的只有"保留其它分段"。
|
||||
*/
|
||||
writeBackfillTierState(tier: ImageTextBackfillTier, state: ImageTextTierRunState): void {
|
||||
const current = this.readBackfillState().tierStates
|
||||
const next = { ...current, [tier]: state }
|
||||
this.writeMeta('backfill_tier_states', JSON.stringify(next))
|
||||
}
|
||||
|
||||
/** 增量补齐水位:处理到哪儿了(`create_time <= ms` 都已就绪)。只增不减。 */
|
||||
writeBackfillCoveredToMs(coveredToMs: number): void {
|
||||
const previous = this.readBackfillState().coveredToMs
|
||||
if (previous !== null && previous >= coveredToMs) return
|
||||
this.writeMeta('backfill_covered_to_ms', String(Math.floor(coveredToMs)))
|
||||
}
|
||||
|
||||
|
||||
readScanState(): Map<
|
||||
string,
|
||||
{ state: string; imageTotal: number; processed: number; maxLocalId: number }
|
||||
> {
|
||||
const rows = asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
'SELECT conversation_id, state, image_total, image_processed, image_max_local_id FROM image_ocr_scan_state'
|
||||
)
|
||||
.all()
|
||||
)
|
||||
const map = new Map<
|
||||
string,
|
||||
{ state: string; imageTotal: number; processed: number; maxLocalId: number }
|
||||
>()
|
||||
for (const row of rows) {
|
||||
map.set(String(row.conversation_id), {
|
||||
state: String(row.state),
|
||||
imageTotal: Number(row.image_total ?? 0),
|
||||
processed: Number(row.image_processed ?? 0),
|
||||
maxLocalId: Number(row.image_max_local_id ?? 0)
|
||||
})
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
writeScanState(input: {
|
||||
conversationId: string
|
||||
state: 'done' | 'partial'
|
||||
imageTotal: number
|
||||
imageProcessed: number
|
||||
/** 本会话图片消息的最大插入序(增量水位)。 */
|
||||
maxLocalId: number
|
||||
}): void {
|
||||
this.database
|
||||
.prepare(
|
||||
`INSERT INTO image_ocr_scan_state (
|
||||
conversation_id, account_id, state, image_total, image_processed,
|
||||
image_max_local_id, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(conversation_id) DO UPDATE SET
|
||||
state = excluded.state,
|
||||
image_total = excluded.image_total,
|
||||
image_processed = excluded.image_processed,
|
||||
image_max_local_id = excluded.image_max_local_id,
|
||||
updated_at = excluded.updated_at`
|
||||
)
|
||||
.run(
|
||||
input.conversationId,
|
||||
this.accountId,
|
||||
input.state,
|
||||
input.imageTotal,
|
||||
input.imageProcessed,
|
||||
input.maxLocalId,
|
||||
Date.now()
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 统计
|
||||
|
||||
/**
|
||||
* 有 OCR 派生绑定的会话集合。
|
||||
*
|
||||
* 清理时**必须**先拿到它:OCR 文本早已被灌进 Knowledge 的 chunks / FTS,
|
||||
* 只删派生病不会让那些派生文字失效 —— 用户仍会从旧索引里搜到图片里的文字。
|
||||
*/
|
||||
conversationIdsWithOcr(): string[] {
|
||||
const rows = asRows(
|
||||
this.database.prepare('SELECT DISTINCT conversation_id FROM image_ocr_bindings').all()
|
||||
)
|
||||
return rows.map((row) => String(row.conversation_id))
|
||||
}
|
||||
|
||||
/**
|
||||
* **有 OCR 派生文本**(artifact 里 char_count > 0)的会话集合。
|
||||
*
|
||||
* 派生索引修复只需要重建它们:只有这些会话的 Knowledge 里"应该"存在图片派生文字。
|
||||
* 全是 `empty` 的会话本来就没有派生文字可修,重建它们只是白读一遍 WCDB。
|
||||
*/
|
||||
conversationIdsWithIndexedOcr(): string[] {
|
||||
const rows = asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
`SELECT DISTINCT b.conversation_id AS conversation_id
|
||||
FROM image_ocr_bindings b
|
||||
JOIN image_ocr_artifacts a ON a.artifact_key = b.artifact_key
|
||||
WHERE a.char_count > 0`
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return rows.map((row) => String(row.conversation_id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置指定状态的失败记录,让它们可以被下一轮 pass 重新处理。
|
||||
*
|
||||
* 用途:**代码修好后**,把上一次 bug 造成的假失败(例如整批 `decrypt_failed`)
|
||||
* 变成可重试状态,而不是要求用户删掉整个派生库 —— 那会连已经成功的记录一起丢掉。
|
||||
*
|
||||
* 三件事一起做,缺一不可:
|
||||
* 1. 删掉这些失败绑定;
|
||||
* 2. 删掉它们所在会话的 checkpoint —— 否则 pass 会以"该会话已完成"直接跳过,
|
||||
* 表现为"点了重试但什么都没发生";
|
||||
* 3. 删掉因此变成孤儿的 artifact(**没有任何绑定再引用的**才删,成功记录一条不动)。
|
||||
*/
|
||||
resetFailures(states: ImageOcrPersistedState[]): number {
|
||||
if (!states.length) return 0
|
||||
const placeholders = states.map(() => '?').join(', ')
|
||||
const affected = asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
`SELECT DISTINCT conversation_id FROM image_ocr_bindings WHERE state IN (${placeholders})`
|
||||
)
|
||||
.all(...states)
|
||||
).map((row) => String(row.conversation_id))
|
||||
const artifactKeys = asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
`SELECT DISTINCT artifact_key FROM image_ocr_bindings WHERE state IN (${placeholders})`
|
||||
)
|
||||
.all(...states)
|
||||
).map((row) => String(row.artifact_key))
|
||||
|
||||
const info = this.database
|
||||
.prepare(`DELETE FROM image_ocr_bindings WHERE state IN (${placeholders})`)
|
||||
.run(...states)
|
||||
|
||||
const clearScan = this.database.prepare(
|
||||
'DELETE FROM image_ocr_scan_state WHERE conversation_id = ?'
|
||||
)
|
||||
for (const conversationId of affected) clearScan.run(conversationId)
|
||||
|
||||
const dropOrphan = this.database.prepare(
|
||||
`DELETE FROM image_ocr_artifacts
|
||||
WHERE artifact_key = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM image_ocr_bindings b WHERE b.artifact_key = ?)`
|
||||
)
|
||||
for (const artifactKey of artifactKeys) dropOrphan.run(artifactKey, artifactKey)
|
||||
|
||||
return Number(info.changes ?? 0)
|
||||
}
|
||||
|
||||
/** 按状态聚合绑定数 —— 覆盖度与进度都从这里取,保证与库内真实一致。 */
|
||||
countByState(): Record<string, number> { const rows = asRows(
|
||||
this.database
|
||||
.prepare('SELECT state, COUNT(*) AS total FROM image_ocr_bindings GROUP BY state')
|
||||
.all()
|
||||
)
|
||||
const counts: Record<string, number> = {}
|
||||
for (const row of rows) counts[String(row.state)] = Number(row.total ?? 0)
|
||||
return counts
|
||||
}
|
||||
|
||||
storageStats(): ImageTextIndexStorageStats {
|
||||
const counts = this.countByState()
|
||||
const textRow = this.database
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS total FROM image_ocr_artifacts WHERE state = 'indexed' AND length(text) > 0"
|
||||
)
|
||||
.get() as Record<string, unknown> | undefined
|
||||
const updatedRow = this.database
|
||||
.prepare('SELECT MAX(updated_at) AS latest FROM image_ocr_bindings')
|
||||
.get() as Record<string, unknown> | undefined
|
||||
let totalBytes = 0
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try {
|
||||
totalBytes += statSync(`${this.databasePath}${suffix}`).size
|
||||
} catch {
|
||||
// 文件可能尚未创建;忽略。
|
||||
}
|
||||
}
|
||||
const latest = updatedRow?.latest
|
||||
return {
|
||||
indexedImages: counts['indexed'] ?? 0,
|
||||
ocrTextCount: Number(textRow?.total ?? 0),
|
||||
totalBytes,
|
||||
updatedAt: latest === null || latest === undefined ? null : Number(latest)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空全部派生数据(表级清空;文件级删除由 service 负责)。 */
|
||||
clearAll(): void {
|
||||
this.database.exec(`
|
||||
DELETE FROM image_ocr_bindings;
|
||||
DELETE FROM image_ocr_artifacts;
|
||||
DELETE FROM image_ocr_scan_state;
|
||||
DELETE FROM image_ocr_meta WHERE key IN (
|
||||
'total_image_messages',
|
||||
'total_image_counted_at',
|
||||
'total_image_messages_complete',
|
||||
'backfill_anchor_ms',
|
||||
'backfill_tier_states',
|
||||
'backfill_covered_to_ms'
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空并重置检查点。
|
||||
*
|
||||
* 注意:必须同时清 `scan_state`,否则清理后再次索引会因为「会话已完成」
|
||||
* 而直接跳过 —— UI 会停在「未建立」但实际再也不跑。
|
||||
*/
|
||||
clearDerivedData(): void {
|
||||
this.clearAll()
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
// 先折叠 WAL 再关连接:否则 -wal / -shm 可能仍被持有,
|
||||
// Windows 上会导致后续 rmSync 静默失败("清理成功"但文件还在)。
|
||||
this.database.exec('PRAGMA wal_checkpoint(TRUNCATE);')
|
||||
} catch {
|
||||
// 库可能已经处于不可写状态;关闭仍然要做。
|
||||
}
|
||||
try {
|
||||
this.database.close()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
normalizeLeaveNotificationRoomIds,
|
||||
normalizeLeaveNotificationTemplate,
|
||||
type LeaveNotificationNotifyScope,
|
||||
type LeaveNotificationTarget
|
||||
} from '../../shared/automation'
|
||||
|
||||
/**
|
||||
* 旧「退群监控 → 通知群聊 / 模板」配置 → 新「自动化 → 退群通知」规则的**纯映射器**。
|
||||
*
|
||||
* 单独成文件是为了能直接单测:这是整轮迁移里**唯一会改变用户既有行为**的地方,
|
||||
* 不允许只靠"跑一遍看看"来验证。
|
||||
*
|
||||
* ## 旧语义(实测自 `group-exit-monitor-service.ts`)
|
||||
*
|
||||
* 旧版是**两层**配置,缺一不可:
|
||||
* 1. **管理群聊**(`monitoredRoomIds`)= 监测哪些群有人退出;
|
||||
* 2. **通知群聊**(`notificationRoomIds`)= 上面这批群里,**哪些要真的发通知**
|
||||
* —— 即对第 1 层的二次勾选。
|
||||
*
|
||||
* `notificationRoomIds` **不是**"统一通知接收目标列表",而是
|
||||
* **"哪些被监控群要在自己群里通报成员退出"**(per-group 多值):
|
||||
* - `applyCurrentMemberships()` 只在 `notificationRoomIds.has(roomId)` 时通知;
|
||||
* - `notifyGroup()` 的收件人**恒等于事件所在群**。
|
||||
*
|
||||
* ## 新语义
|
||||
*
|
||||
* 拆成**两个正交维度**,恰好一一对应旧版那两层:
|
||||
* - `target` ← 旧版第 2 层里"发回本群"这个事实 ⇒ 恒为 `source_chat`;
|
||||
* - `notifyScope` / `notifyRoomIds` ← 旧版第 2 层是"哪些群",逐字保留。
|
||||
*
|
||||
* ⇒ **迁移现在是无损的**。早期版本把目标压成单值、丢掉了第 2 层的"哪些群",
|
||||
* 于是部分勾选只能判成 `needs_review` 并强制停用;恢复 `notifyScope` 之后
|
||||
* 这个特例整体消失,用户不会再有"规则被无辜停用"的体验。
|
||||
*/
|
||||
|
||||
export interface LegacyLeaveNotificationState {
|
||||
monitoredRoomIds: string[]
|
||||
notificationRoomIds: string[]
|
||||
notificationTemplate?: unknown
|
||||
}
|
||||
|
||||
export type LeaveNotificationMigrationOutcome =
|
||||
/** 旧配置从不通知任何人 ⇒ 迁移成"关闭",行为完全一致。 */
|
||||
| 'lossless_off'
|
||||
/** 旧配置在**每个**被监控群里通报 ⇒ `notifyScope: 'all'`,行为完全一致。 */
|
||||
| 'lossless_all_groups'
|
||||
/** 旧配置只在**部分**群里通报 ⇒ `notifyScope: 'selected'` + 原样保留那份勾选。 */
|
||||
| 'lossless_selected_groups'
|
||||
|
||||
export interface LeaveNotificationMigrationPlan {
|
||||
outcome: LeaveNotificationMigrationOutcome
|
||||
enabled: boolean
|
||||
target: LeaveNotificationTarget
|
||||
notifyScope: LeaveNotificationNotifyScope
|
||||
notifyRoomIds: string[]
|
||||
targetNeedsReview: boolean
|
||||
template: string
|
||||
}
|
||||
|
||||
function uniqueRoomIds(values: unknown): string[] {
|
||||
return normalizeLeaveNotificationRoomIds(values)
|
||||
}
|
||||
|
||||
export function planLeaveNotificationMigration(
|
||||
legacy: LegacyLeaveNotificationState
|
||||
): LeaveNotificationMigrationPlan {
|
||||
const monitored = uniqueRoomIds(legacy.monitoredRoomIds)
|
||||
const monitoredSet = new Set(monitored)
|
||||
// 再夹一次:防止脏状态文件把范围外的群带进来。
|
||||
const notifications = uniqueRoomIds(legacy.notificationRoomIds).filter((roomId) =>
|
||||
monitoredSet.has(roomId)
|
||||
)
|
||||
// 模板永远是无损迁移的那一项:它就是用户写的那段文字。
|
||||
const template = normalizeLeaveNotificationTemplate(legacy.notificationTemplate)
|
||||
// 旧版收件人恒为事件所在群 ⇒ 无论勾了几个群,目标都是 source_chat。
|
||||
const target: LeaveNotificationTarget = { type: 'source_chat' }
|
||||
|
||||
if (!notifications.length) {
|
||||
// 旧语义是"从不通知" ⇒ 关掉,避免迁移后突然开始发消息。
|
||||
// 同时把范围写成 `selected` + 空集:配置本身也精确表达"一个都不通知"。
|
||||
return {
|
||||
outcome: 'lossless_off',
|
||||
enabled: false,
|
||||
target,
|
||||
notifyScope: 'selected',
|
||||
notifyRoomIds: [],
|
||||
targetNeedsReview: false,
|
||||
template
|
||||
}
|
||||
}
|
||||
|
||||
if (monitored.length > 0 && notifications.length === monitored.length) {
|
||||
// 每个被监控群都在自己群里通报 ⇒ 等价于"全部已监控群聊"。
|
||||
return {
|
||||
outcome: 'lossless_all_groups',
|
||||
enabled: true,
|
||||
target,
|
||||
notifyScope: 'all',
|
||||
notifyRoomIds: [],
|
||||
targetNeedsReview: false,
|
||||
template
|
||||
}
|
||||
}
|
||||
|
||||
// 部分勾选:**无损**保留那份子集,不再降级成 needs_review。
|
||||
return {
|
||||
outcome: 'lossless_selected_groups',
|
||||
enabled: true,
|
||||
target,
|
||||
notifyScope: 'selected',
|
||||
notifyRoomIds: notifications,
|
||||
targetNeedsReview: false,
|
||||
template
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||
leaveNotificationTargetLabel,
|
||||
type LeaveNotificationConfig
|
||||
} from '../../shared/automation'
|
||||
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
|
||||
import {
|
||||
automationContactDisplayName,
|
||||
filterSendableFriendContacts as filterSendable,
|
||||
isSendableFriendContact as isSendable,
|
||||
resolveAutomationTarget,
|
||||
type AutomationTargetContact,
|
||||
type AutomationTargetResolution,
|
||||
type ResolvedAutomationTarget
|
||||
} from './automation-wechat-target'
|
||||
|
||||
/**
|
||||
* 退群通知的**目标解析**(薄适配层)。
|
||||
*
|
||||
* 真正的判定在 `automation-wechat-target.ts` —— 那里才是**唯一实现**,
|
||||
* 定时日报与退群通知共用它。这里只有两件事:
|
||||
* 1. 把 `LeaveNotificationConfig` + 退群事件摊平成中性输入;
|
||||
* 2. 提供退群通知专属文案,保持既有调用方 / 测试不变。
|
||||
*
|
||||
* ⚠️ 这里**不允许**再出现一份 switch / 判定逻辑 —— 两套并存迟早分叉。
|
||||
*/
|
||||
|
||||
/** 解析所需的最小联系人结构(`FormattedContact` 结构上可赋值给它)。 */
|
||||
export type LeaveNotificationCandidateContact = AutomationTargetContact
|
||||
|
||||
export type ResolvedLeaveNotificationTarget = ResolvedAutomationTarget
|
||||
|
||||
export type LeaveNotificationTargetResolution = AutomationTargetResolution
|
||||
|
||||
/** 退群通知的目标文案(唯一一份)。 */
|
||||
export const LEAVE_NOTIFICATION_TARGET_MESSAGES = {
|
||||
options: LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||
invalidTarget: '退群通知的发送目标无效,请重新选择',
|
||||
sourceMissing: '无法确定发生退群的群聊,本次通知未发送',
|
||||
sourceRecipientName: '当前群聊',
|
||||
sourceDisplayName: '发生退群事件的群聊',
|
||||
selfMissing: '无法确定当前登录的微信账号,本次通知未发送',
|
||||
contactNotChosen: '还没有选择通知联系人,请重新选择',
|
||||
contactUnavailable: '通知联系人已不存在或当前无法发送,请重新选择'
|
||||
} as const
|
||||
|
||||
/** 联系人显示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
|
||||
export function leaveNotificationContactDisplayName(contact: AutomationTargetContact): string {
|
||||
return automationContactDisplayName(contact)
|
||||
}
|
||||
|
||||
/** 这个联系人能不能作为「指定好友」的发送目标(与定时日报共用同一条判定)。 */
|
||||
export function isSendableFriendContact(
|
||||
contact: AutomationTargetContact,
|
||||
selfWxid?: string
|
||||
): boolean {
|
||||
return isSendable(contact, selfWxid)
|
||||
}
|
||||
|
||||
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
|
||||
export function filterSendableFriendContacts<T extends AutomationTargetContact>(
|
||||
contacts: T[],
|
||||
selfWxid?: string
|
||||
): T[] {
|
||||
return filterSendable(contacts, selfWxid)
|
||||
}
|
||||
|
||||
export function resolveLeaveNotificationTarget(input: {
|
||||
config: LeaveNotificationConfig | undefined
|
||||
event: GroupMemberExitedEvent
|
||||
contacts: LeaveNotificationCandidateContact[]
|
||||
/** 当前登录账号的 wxid;拿不到时传空串。 */
|
||||
selfWxid?: string
|
||||
}): LeaveNotificationTargetResolution {
|
||||
const config = input.config
|
||||
if (!config) return { ok: false, error: '退群通知尚未配置发送目标' }
|
||||
return resolveAutomationTarget(
|
||||
{
|
||||
targetType: config.target?.type as never,
|
||||
sourceConversationId: String(input.event.conversationId || '').trim(),
|
||||
sourceDisplayName: String(input.event.groupName || '').trim(),
|
||||
...(config.target?.contactId ? { contactId: config.target.contactId } : {}),
|
||||
contacts: input.contacts,
|
||||
...(input.selfWxid ? { selfWxid: input.selfWxid } : {})
|
||||
},
|
||||
LEAVE_NOTIFICATION_TARGET_MESSAGES
|
||||
)
|
||||
}
|
||||
|
||||
/** 目标类型 → 界面短标签(保持既有导出面)。 */
|
||||
export { leaveNotificationTargetLabel }
|
||||
@@ -1,5 +1,6 @@
|
||||
import { listContactsAsync, listMessagesAsync, isReady, type FormattedContact, type FormattedMessage } from './chat-service'
|
||||
import { resolveContact } from './contact-resolution-service'
|
||||
import { sourceMessageId } from '../knowledge/message-identity'
|
||||
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
|
||||
import { inferAiSearchTimeRange } from '../../shared/ai-search'
|
||||
import { KNOWLEDGE_FRESHNESS_TOLERANCE_MS } from '../../shared/knowledge'
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
ResolvedCorpusScope,
|
||||
ResolvedTimeRange,
|
||||
QueryIndexCoverage,
|
||||
QueryImageTextCoverage,
|
||||
QuerySearchTimings,
|
||||
QueryMessagesRequest,
|
||||
SearchMessagesRequest,
|
||||
@@ -26,6 +28,14 @@ import {
|
||||
decodeMessageRef as fromRef,
|
||||
normalizeMessageIdentity
|
||||
} from '../../shared/local-query-api'
|
||||
import {
|
||||
IMAGE_TEXT_BACKFILL_TIER_LABEL,
|
||||
describeImageTextCoverage,
|
||||
describeImageTextCoveredRanges,
|
||||
imageTextCoverageState,
|
||||
type ImageTextIndexCoverage
|
||||
} from '../../shared/image-text-index'
|
||||
import { toEvidenceDisplayText } from '../../shared/knowledge'
|
||||
|
||||
const LIMIT_MAX = 200
|
||||
const CONTEXT_MAX = 50
|
||||
@@ -112,6 +122,59 @@ function buildIndexCoverage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片文字索引未完成时,必须附加的零结果诚实性约束。
|
||||
*
|
||||
* 图片 OCR 是**独立的**覆盖维度:它可能是"未建立"或"只做了 30%"。
|
||||
* 此时 0 条图片证据只是**索引缺口**,不是**事实空缺**。
|
||||
*/
|
||||
const IMAGE_OCR_ZERO_RESULT_CAUTION =
|
||||
'涉及图片、截图、海报里的文字的问题,当前不能因为没搜到就回答"没有"。'
|
||||
|
||||
/**
|
||||
* 图片文字索引覆盖度 → 可直接引用的结论句。
|
||||
*
|
||||
* 与 `buildIndexCoverage` 同思路:只给结构化数字,模型会自己换算、甚至反过来
|
||||
* 宣称"覆盖完整"。这里由 Engine 给出结论句,模型只需引用。
|
||||
*/
|
||||
export function buildImageOcrCoverage(
|
||||
coverage: ImageTextIndexCoverage | null
|
||||
): QueryImageTextCoverage | undefined {
|
||||
if (!coverage) return undefined
|
||||
const state = imageTextCoverageState(coverage)
|
||||
const countedNote = coverage.countedAt
|
||||
? `(图片数量统计于 ${formatLocalMinute(coverage.countedAt)})`
|
||||
: ''
|
||||
const base = describeImageTextCoverage(coverage)
|
||||
/**
|
||||
* recent-first 之后必须把"哪段时间能下确定性结论"一起给出。
|
||||
*
|
||||
* 否则模型只看一个总百分比:30% 时它会以为连最近一周都不可信(过度保守没坏处),
|
||||
* 但 99% 时它会以为"去年也能放心下结论"(这就把索引缺口说成了事实空缺)。
|
||||
*/
|
||||
const tiers = coverage.tiers ?? []
|
||||
const coveredRanges = tiers
|
||||
.filter((entry) => entry.state === 'complete')
|
||||
.map((entry) => IMAGE_TEXT_BACKFILL_TIER_LABEL[entry.tier])
|
||||
const rangeNote = tiers.length ? describeImageTextCoveredRanges(coverage) : ''
|
||||
return {
|
||||
state,
|
||||
totalImageMessages: coverage.totalImageMessages,
|
||||
processed: coverage.processed,
|
||||
indexed: coverage.indexed,
|
||||
empty: coverage.empty,
|
||||
missing: coverage.missing,
|
||||
failed: coverage.failed,
|
||||
pending: coverage.pending,
|
||||
...(coverage.countedAt ? { countedAtLabel: formatLocalMinute(coverage.countedAt) } : {}),
|
||||
...(coveredRanges.length ? { coveredRanges } : {}),
|
||||
summary:
|
||||
state === 'complete'
|
||||
? `${base}${countedNote}`
|
||||
: `${base}${countedNote}${rangeNote}${IMAGE_OCR_ZERO_RESULT_CAUTION}`
|
||||
}
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<QueryMessageType, string> = {
|
||||
text: '文本',
|
||||
image: '图片',
|
||||
@@ -149,7 +212,19 @@ function resolvedTimeRange(input: QueryTimeRange, now = new Date()): ResolvedTim
|
||||
const range = inferAiSearchTimeRange(phrase[input.kind], map[input.kind], now)
|
||||
return { kind: input.kind, startTime: range.startTime, endTime: range.endTime, label: range.label }
|
||||
}
|
||||
function toQueryMessage(conversationId: string, message: FormattedMessage, target: FormattedContact): QueryMessage {
|
||||
/**
|
||||
* 一条消息的展示形态。
|
||||
*
|
||||
* `imageOcr` 是可选的**派生文本**(来自本地图片文字索引,只读、不触发 OCR)。
|
||||
* 图片消息的正文永远是空的 —— 识别出的文字必须走独立字段,
|
||||
* 否则"图片里的文字"会被伪装成"群友发的文字消息"。
|
||||
*/
|
||||
function toQueryMessage(
|
||||
conversationId: string,
|
||||
message: FormattedMessage,
|
||||
target: FormattedContact,
|
||||
imageOcr?: { state: string; text: string }
|
||||
): QueryMessage {
|
||||
const kind = kindOf(message)
|
||||
const content = message.contentData
|
||||
const attachment =
|
||||
@@ -163,7 +238,16 @@ function toQueryMessage(conversationId: string, message: FormattedMessage, targe
|
||||
? { kind: 'file' as const, name: message.exportMediaName || (content?.type === 'share' ? content.title : undefined), url: content?.type === 'share' ? content.url : undefined }
|
||||
: undefined
|
||||
const text = message.content?.trim() || message.voiceTranscript?.trim() || undefined
|
||||
return { messageRef: toRef(conversationId, message.id), timestamp: (message.createTime || 0) * 1000, datetime: message.datetime, sender: message.isSender ? '我' : (message.name || target.m_nsNickName), direction: message.isSender ? 'to_target' : 'from_target', messageType: kind, sourceKind: kind, ...(attachment ? { attachment } : {}), ...(text ? { text } : {}) }
|
||||
const derived = kind === 'image' ? imageOcr : undefined
|
||||
const ocrText = derived && derived.state === 'indexed' ? derived.text.trim() : ''
|
||||
const imageTextState: QueryMessage['imageTextState'] = !derived
|
||||
? 'not_indexed'
|
||||
: ocrText
|
||||
? 'indexed'
|
||||
: derived.state === 'empty'
|
||||
? 'empty'
|
||||
: 'not_indexed'
|
||||
return { messageRef: toRef(conversationId, message.id), timestamp: (message.createTime || 0) * 1000, datetime: message.datetime, sender: message.isSender ? '我' : (message.name || target.m_nsNickName), direction: message.isSender ? 'to_target' : 'from_target', messageType: kind, sourceKind: kind, ...(attachment ? { attachment } : {}), ...(text ? { text } : {}), ...(ocrText ? { imageOcrText: ocrText, derivedSource: 'image_ocr' as const } : {}), ...(kind === 'image' ? { imageTextState } : {}) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,7 +300,49 @@ function outsideScopeError(scope: ResolvedCorpusScope, actual: string): { status
|
||||
}
|
||||
|
||||
export class LocalQueryApiService {
|
||||
constructor(private readonly knowledge?: KnowledgeSearchService, private readonly nowProvider: () => Date = () => new Date()) {}
|
||||
/**
|
||||
* 图片文字索引覆盖度提供者(同步、只读)。
|
||||
*
|
||||
* 刻意不在 Knowledge worker 里算:OCR 派生库(`image-text-index.sqlite`)与
|
||||
* `knowledge.sqlite` 物理分离,worker 不该为了一个覆盖度数字去开它。
|
||||
*/
|
||||
private imageTextCoverage: () => ImageTextIndexCoverage | null = () => null
|
||||
|
||||
/**
|
||||
* 单条图片消息的 OCR 派生文本提供者(同步、只读)。
|
||||
*
|
||||
* L4(查询层)**只读** L1(OCR artifact)—— 这里绝不允许触发 OCR、解密或读原图。
|
||||
* 之前 `query_messages` 缺这一环,导致"图片已经识别出文字"这件事在精确读消息
|
||||
* 这条路径上完全不可见:模型只拿到一个空的 `attachment`,于是把"索引缺口"
|
||||
* 说成"图片里没有文字",甚至反过来建议用户去建立已经建好的索引。
|
||||
*/
|
||||
private imageOcrEntry:
|
||||
| ((conversationId: string, messageId: string) => { state: string; text: string } | undefined)
|
||||
| undefined
|
||||
|
||||
constructor(
|
||||
private readonly knowledge?: KnowledgeSearchService,
|
||||
private readonly nowProvider: () => Date = () => new Date()
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 注入图片文字索引覆盖度提供者。
|
||||
*
|
||||
* 用 setter 而不是构造参数:避免给第二个带默认值的参数写 `undefined` 占位,
|
||||
* 也让测试可以直接注入假的覆盖度。
|
||||
*/
|
||||
setImageTextCoverageProvider(provider: () => ImageTextIndexCoverage | null): void {
|
||||
this.imageTextCoverage = provider
|
||||
}
|
||||
|
||||
/** 注入单条图片消息的 OCR 派生文本解析器(只读;见 `imageOcrEntry` 的约束)。 */
|
||||
setImageOcrEntryProvider(
|
||||
provider:
|
||||
| ((conversationId: string, messageId: string) => { state: string; text: string } | undefined)
|
||||
| undefined
|
||||
): void {
|
||||
this.imageOcrEntry = provider
|
||||
}
|
||||
capabilities(): QueryCapabilitiesResponse {
|
||||
return { version: 1, tools: { query_messages: { operation: '读取指定联系人的确定性消息', directions: ['any', 'from_target', 'to_target'], messageTypes: kinds, timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, search_messages: { operation: '受限 Knowledge 关键词检索', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, message_context: { operation: '读取消息前后文', timeRanges: ['all'], limitMax: CONTEXT_MAX }, conversation_overview: { operation: '按会话时间片提取概览证据', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX } } }
|
||||
}
|
||||
@@ -282,7 +408,18 @@ export class LocalQueryApiService {
|
||||
const raw = await listMessagesAsync(contact.md5, range.startTime, range.endTime)
|
||||
const direction = request.direction || 'any'; const allowed = new Set(request.messageTypes || kinds)
|
||||
const filtered = raw.filter((message) => !(request.excludeSystem !== false && kindOf(message) === 'system')).filter((message) => allowed.has(kindOf(message))).filter((message) => direction === 'any' || (direction === 'to_target' ? message.isSender : !message.isSender)).sort((a, b) => ((a.createTime || 0) - (b.createTime || 0)) * ((request.order || 'asc') === 'asc' ? 1 : -1)).slice(0, Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)))
|
||||
return { status: 'completed' as const, target: contactView(contact), query: { direction, messageTypes: request.messageTypes || [], order: request.order || 'asc', limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)), excludeSystem: request.excludeSystem !== false, resolvedTimeRange: range }, coverage: { state: 'complete' as const }, returnedCount: filtered.length, messages: filtered.map((message) => toQueryMessage(contact.md5, message, contact)), scope: corpus.scope }
|
||||
// 图片 OCR 派生文本:L4 只读 L1,**不触发 OCR / 解密 / 读原图**。
|
||||
// 键必须用 `sourceMessageId(message)`(binding 主键就是它),不能用裸 `message.id`,
|
||||
// 否则 `local:` 前缀会让查表静默失配 —— 与 Knowledge 用同一条身份规则。
|
||||
const messages = filtered.map((message) => {
|
||||
const ocr =
|
||||
kindOf(message) === 'image'
|
||||
? this.imageOcrEntry?.(contact.md5, sourceMessageId(message))
|
||||
: undefined
|
||||
return toQueryMessage(contact.md5, message, contact, ocr)
|
||||
})
|
||||
const imageOcrCoverage = buildImageOcrCoverage(this.imageTextCoverage())
|
||||
return { status: 'completed' as const, target: contactView(contact), query: { direction, messageTypes: request.messageTypes || [], order: request.order || 'asc', limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)), excludeSystem: request.excludeSystem !== false, resolvedTimeRange: range }, coverage: { state: 'complete' as const }, returnedCount: filtered.length, messages, scope: corpus.scope, ...(imageOcrCoverage ? { imageOcrCoverage } : {}) }
|
||||
}
|
||||
async search(request: SearchMessagesRequest) {
|
||||
const requestStartedAt = Date.now()
|
||||
@@ -359,6 +496,8 @@ export class LocalQueryApiService {
|
||||
|
||||
const covered = indexCovers(found.indexLatestAt, requestedEnd, found.sourceLatestAt)
|
||||
const indexCoverage = buildIndexCoverage(found.indexLatestAt, found.sourceLatestAt, covered)
|
||||
// 图片文字索引是**独立覆盖维度**:文字索引再完整也不代表图片里的文字搜得到。
|
||||
const imageOcrCoverage = buildImageOcrCoverage(this.imageTextCoverage())
|
||||
const timings: QuerySearchTimings = {
|
||||
totalMs: Date.now() - requestStartedAt,
|
||||
scopeMs,
|
||||
@@ -381,6 +520,7 @@ export class LocalQueryApiService {
|
||||
sourceLatestAt: found.sourceLatestAt,
|
||||
freshness: { catchUp: freshness.catchUp },
|
||||
...(indexCoverage ? { indexCoverage } : {}),
|
||||
...(imageOcrCoverage ? { imageOcrCoverage } : {}),
|
||||
timings
|
||||
}
|
||||
}
|
||||
@@ -454,7 +594,11 @@ export class LocalQueryApiService {
|
||||
timestamp: item.timestamp,
|
||||
sender: item.sender,
|
||||
sourceKind: item.sourceKind,
|
||||
text: item.text,
|
||||
// 兜底再剥一次:不管 Knowledge 侧哪条检索路径产出的文本,
|
||||
// 面向用户与模型的都不允许出现 `图片文字:` 这类引擎内部标签。
|
||||
text: toEvidenceDisplayText(item.text),
|
||||
...(item.derivedSource ? { derivedSource: item.derivedSource } : {}),
|
||||
...(item.imageOcrText ? { imageOcrText: item.imageOcrText } : {}),
|
||||
conversationName: owner ? contactView(owner).displayName : undefined,
|
||||
conversationType: owner?.type
|
||||
} satisfies QueryEvidenceItem
|
||||
@@ -592,6 +736,7 @@ export class LocalQueryApiService {
|
||||
const truncated = raw.length > OVERVIEW_SOURCE_CAP
|
||||
const evidence = selectTemporalCoverageEvidence(contact, messages, OVERVIEW_EVIDENCE_TARGET)
|
||||
const state: 'complete' | 'partial' = truncated ? 'partial' : 'complete'
|
||||
const imageOcrCoverage = buildImageOcrCoverage(this.imageTextCoverage())
|
||||
return {
|
||||
status: 'completed' as const,
|
||||
target: contactView(contact),
|
||||
@@ -603,6 +748,7 @@ export class LocalQueryApiService {
|
||||
selection: { mode: 'temporal_coverage' as const, selectedEvidenceCount: evidence.length, sampled: truncated || evidence.length < messages.length },
|
||||
evidence,
|
||||
scope: corpus.scope,
|
||||
...(imageOcrCoverage ? { imageOcrCoverage } : {}),
|
||||
origin: 'wcdb' as const
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user