Compare commits

...
27 Commits
Author SHA1 Message Date
电摇小子 97c74859c0 fix: 修复联系人 API 因昵称备注未补全导致搜索结果为空 #51
Fixes https://github.com/Wxw-Gu/TraceMemo/issues/51
2026-09-29 10:47:33 +07:00
电摇小子 4d5c6459fe feat: 定时日报并入自动化,统一规则管理与发送链路
发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友
日报页补「定时日报已并入自动化」指引条
2026-09-28 17:11:50 +07:00
Wxw-Gu c835d5c570 feat: 定时日报归到定制化功能下 2026-09-25 17:44:45 +08:00
Wxw-Gu 59872daf52 feat: 退群监控接入自动化能力统一管理, 支持发送个人/文件传输助手/指定群聊 2026-09-24 16:31:07 +08:00
Wxw-Gu 3bd3935042 fix: 修复mac发送 初始化卡在'正在检查 发送运行时…' 2026-09-24 10:49:34 +08:00
Wxw-Gu 77a6912ade fix: 修复退群监控列表错误, 界面左侧滚动条 2026-09-24 10:36:10 +08:00
Wxw-Gu 4b83aaed61 Merge branch 'develop' into develop_0920
# Conflicts:
#	src/main/index.ts
#	src/preload/index.d.ts
#	src/preload/index.ts
#	src/renderer/src/styles/index.scss
2026-09-24 09:41:53 +08:00
Wxw-Gu 7268188e40 feat: mac发送能力重构为libtmwechat
- 发送改走本地host, 在微信登录时触发
- 删除旧OneBot及对应UI控制面
2026-09-24 03:01:03 +08:00
Wxw-Gu 069d836dea feat: 精确会话自动化规则 2026-09-21 11:06:59 +08:00
Wxw-Gu 95384a4a92 feat: 自动化 2026-09-21 00:35:32 +08:00
Wxw-Gu f5633a29cb feat: 新增群员统计与退群事件,并把本地索引集中到设置页
- 群员统计:档案中按群统计发言排行与未发言成员,可复制纯文本、按平台能力发送
- 退群事件永久保存,并以本地推断条目并入档案消息流
- 设置新增本地索引页,集中管理聊天记录索引与图片文字索引
2026-09-20 21:47:06 +08:00
Wxw-Gu 766cd94788 feat: 新增群员统计与退群事件,并把本地索引集中到设置页
- 群员统计:档案中按群统计发言排行与未发言成员,可复制纯文本、按平台能力发送
- 退群事件永久保存,并以本地推断条目并入档案消息流
- 设置新增本地索引页,集中管理聊天记录索引与图片文字索引
2026-09-20 21:46:37 +08:00
wuyouMaster b6d287a299 fix: make packaging checks platform safe 2026-09-20 18:16:41 +08:00
wuyouMaster 4c25236070 fix: ad-hoc sign packaged macOS key helpers and app bundle
electron-builder 26 skips macOS signing entirely when no Developer ID identity is configured, so the packaged key helpers can ship unsigned or with a broken signature and macOS kills them even with SIP disabled. afterPack now verifies and ad-hoc re-signs the packaged xkey helpers and the outer app bundle, failing the build when a signature cannot be repaired.
2026-09-20 18:16:41 +08:00
qingmao c1224cb612 Merge pull request #45 from mmhh256/fix/query-agent-system-messages
fix: 合并 Query Agent 的连续 system messages
2026-09-20 16:11:08 +08:00
Wxw-Gu 5036639aa1 test: 测试用例 2026-09-20 15:56:27 +08:00
Wxw-Gu 9593ca0f54 fix: 修复语音消息取错、时长精度与转写不刷新
- 时长解析:改取 <voicemsg voicelength>(毫秒),不再误取 length(SILK 字节数)
- 时长显示:四舍五入对齐微信口径,有原生时长时不被解码时长覆盖
- 转写:重新识别改为强制重算,跳过身份级缓存短路(音频级缓存仍生效)
- 日报:语音累计秒数显示取整,不再出现小数
2026-09-20 15:40:01 +08:00
mmhh256 b441287d54 fix: 合并 Query Agent 的连续 system messages 2026-09-19 14:01:39 +08:00
Wxw-Gu 9b82d29037 fix: 兼容微信新版系统消息模板格式,入群通知不再显示成「撤销」 2026-09-18 14:41:50 +08:00
Wxw-Gu 0f270366aa feat: 图片文字索引按时间分段优先处理最近图片
- 首次索引先处理最近 7 天,再依次回溯 30 天 / 近一年 / 更早历史
 - 完成后新到的图片单独补齐,不受历史回填影响
 - 覆盖度增加时间维度,可区分「最近已完整」与「更早仍在补齐」
2026-09-18 14:39:05 +08:00
Wxw-Gu 12b8fb34c6 fix: 增加sip文案 2026-09-18 09:38:33 +08:00
Wxw-Gu 774346d503 fix: 日报头像补齐下载重试 2026-09-17 19:24:27 +08:00
Wxw-Gu b97e1f4bc6 fix: 问问微信恢复引用编号,日报修复成员头像与 hero 溢出
- 问问微信:Host 分配稳定 citationId 并注入模型上下文,正文 [E#] 与证据按钮同号
- 问问微信:回答返回前校验引用,幻觉编号移除并提示,不再出现不可信的引用按钮
- Agent Hub:收紧「最近会话」快捷路由,内容查询交回 Query Agent
2026-09-17 18:56:32 +08:00
Wxw-Gu 4b2395a8d2 feat: 重写agent hub连接器,新增对话记录和微信输入状态
- 新增 Agent Hub 对话记录面板,按会话回看机器人与微信用户的完整收发内容
- 新增微信原生正在输入状态,长任务维持 typing,异常路径强制收尾
2026-09-17 17:32:37 +08:00
Wxw-Gu 1337bcb7de feat: 新增mac ocr转文字,增加到问一问微信 图片索引优化速度
- 图片文字索引性能与进度诚实化
- 问问微信:证据卡区分「消息类型」与「派生来源」,派生命中内容自报来源
- 问问微信:回答规则禁止未真实执行的多轮承诺
- 本地图片文字识别:支持 macOS 系统 OCR(Apple Vision)
2026-09-17 13:11:20 +08:00
电摇小子 b8f08d54c8 feat: 新增微信图片文字索引与问问微信图片检索能力 2026-09-16 10:42:52 +08:00
电摇小子 24399f1d70 feat: 新增 Windows 本地图片文字识别能力 2026-09-16 00:23:23 +08:00
347 changed files with 54574 additions and 12138 deletions
-8
View File
@@ -25,11 +25,6 @@ jobs:
node-version: 22 node-version: 22
cache: pnpm 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 - name: Install dependencies
run: pnpm install run: pnpm install
@@ -51,9 +46,6 @@ jobs:
- name: Skill installation instruction tests - name: Skill installation instruction tests
run: pnpm test:skill-install run: pnpm test:skill-install
- name: WeChat connector tests
run: pnpm test:wechat-connector
- name: Build Electron test application - name: Build Electron test application
run: pnpm test:e2e:build run: pnpm test:e2e:build
+2 -2
View File
@@ -10,8 +10,8 @@ out
coverage/ coverage/
playwright-report/ playwright-report/
test-results/ test-results/
resources/connectors/wechat/ resources/runtime/darwin-arm64
resources/connectors/wechat-personal/ .native-runtime-source
.omc .omc
.codex/ .codex/
skills-lock.json skills-lock.json
+2 -1
View File
@@ -49,6 +49,8 @@ Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP A
## 开发文档 ## 开发文档
- [开发、测试与构建](./development/overview.md) - [开发、测试与构建](./development/overview.md)
- [界面开发规范:按钮与主题色](./development/ui-guidelines.md)
- [微信系统消息解析与格式兼容](./development/wechat-system-message-parsing.md)
- [Query Agent POC(开发测试入口)](./development/query-agent-poc.md) - [Query Agent POC(开发测试入口)](./development/query-agent-poc.md)
- [本地启动排障](./development/local-startup-troubleshooting.md) - [本地启动排障](./development/local-startup-troubleshooting.md)
- [macOS 数据访问说明](./platform/macos.md) - [macOS 数据访问说明](./platform/macos.md)
@@ -59,7 +61,6 @@ Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP A
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md) - [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md) - [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
- [TraceMemo Reader Skill 文件](./skill/tracememo-reader/SKILL.md) - [TraceMemo Reader Skill 文件](./skill/tracememo-reader/SKILL.md)
- [第三方组件说明](./third-party/wechat-chatter/NOTICE.md)
## 版本说明 ## 版本说明
@@ -6,7 +6,6 @@
执行 `pnpm dev` 后,以下状态同时满足,说明本地开发环境已经可用: 执行 `pnpm dev` 后,以下状态同时满足,说明本地开发环境已经可用:
- 控制台显示连接器已生成,例如 `resources/connectors/wechat/win32-x64/wechat-connector.exe`;
- Electron 窗口已打开,或 `http://localhost:5173/` 返回 HTTP `200`; - Electron 窗口已打开,或 `http://localhost:5173/` 返回 HTTP `200`;
- 控制台显示 Local HTTP API 正在监听 `http://127.0.0.1:6131`。 - 控制台显示 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` 即可关闭。 开启后会输出 `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 二进制缺失或下载失败
`electron-vite dev` 报 `Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 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、微信数据路径或聊天内容。
+2 -3
View File
@@ -6,7 +6,6 @@
- Electron + React + TypeScript; - Electron + React + TypeScript;
- pnpm 7+; - pnpm 7+;
- Go(构建微信连接器);
- 平台对应的 Electron/native 构建环境。 - 平台对应的 Electron/native 构建环境。
产品文档的事实来源优先级是:当前源码 → 当前 UI/Renderer → 测试 → package/config → README/docs → 历史资料。功能、API、版本、隐私和兼容性变更时,不要只改 README。 产品文档的事实来源优先级是:当前源码 → 当前 UI/Renderer → 测试 → package/config → README/docs → 历史资料。功能、API、版本、隐私和兼容性变更时,不要只改 README。
@@ -18,7 +17,7 @@ pnpm install
pnpm dev pnpm dev
``` ```
本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。 本地依赖安装与 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
常用检查: 常用检查:
@@ -30,7 +29,7 @@ pnpm test:integration
pnpm test:e2e:build pnpm test:e2e:build
``` ```
完整测试入口 `pnpm test` 还会运行 Skill 安装指令、微信连接器、构建和 Playwright 测试;需要对应平台环境。 完整测试入口 `pnpm test` 还会运行 Skill 安装指令、构建和 Playwright 测试;需要对应平台环境。
## 代码变更对应文档 ## 代码变更对应文档
+171
View File
@@ -0,0 +1,171 @@
# 界面开发规范:按钮与主题色
这份规范回答一件事:**为什么同一个产品里,有的按钮是主题色,有的还是浏览器默认的黑白方角。**
先看一个真实案例 —— 同一屏里的两组按钮:
```
主界面:「更新图片文字索引」 ← 主题色(正确)
弹窗里:「取消」「开始索引」 ← 浏览器默认样式(错误)
```
两者渲染出来完全不同,用户会以为是两个不同的产品。根因不是"设计没定颜色",
而是**组件在导出时把样式丢了**。下面写清楚怎么避免。
---
## 1. 永远不要写裸 `<button>`
任何可点的按钮都必须来自 `components/ui/button`:
```tsx
import { Button } from '../ui'
<Button variant="outline" onClick={handleCancel}>取消</Button>
```
**唯一的例外**:结构性控件(导航项、Tab、列表行、图标热区)——它们有自己
成套的布局样式,用原生 `<button>` 是合理的,但**必须**带 `className`,
且样式写在对应的 `.scss` 里,不要在 JSX 里临时拼颜色。
```tsx
// 可以:结构性控件,样式来自 .scss
<button type="button" role="tab" className={active ? 'active' : ''} onClick={...}>
今日日报
</button>
```
**判据**:如果这个按钮在别的界面也会以同样形态出现("取消"、"保存"、"删除"),
它就该是 `Button`;如果它只在某一个位置有意义(侧栏导航项),才考虑原生。
---
## 2. 三种角色,只有三个默认变体
`Button` 提供 6 个变体,但**日常只用其中 3 个**:
| 角色 | `variant` | 长什么样 | 用在哪 |
| --- | --- | --- | --- |
| 主要 | `default` | 主题色实底 | 这一步用户唯一该做的事 |
| 次要 | `outline` / `ghost` | 描边 / 无底色 | 取消、返回、并列的辅助操作 |
| 危险 | `destructive` | 红色实底 | 删除、清空、不可恢复的操作 |
另外两个(`secondary` / `link`)按需用;`link` 只用于正文里的行内跳转。
**一条硬约束:同一个界面(或同一个弹窗)里,`default` 最多出现一次。**
两个主题色实底按钮并排,等于没有主次。
---
## 3. 弹窗按钮:组件已经带样式了,不要再包一层
`AlertDialogCancel` 和 `AlertDialogAction` **自带**按钮样式(分别是 `outline`
和 `default`),直接写文字即可:
```tsx
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleStart}>开始索引</AlertDialogAction>
</AlertDialogFooter>
```
**不要**再套一层 `Button`:
```tsx
// 反面写法:外层已经有样式了,再包一层只会产生重复类名
<AlertDialogCancel asChild>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
```
需要危险动作时,用 `className` 覆盖(`cn` 走 tailwind-merge,同族类后者生效):
```tsx
<AlertDialogAction className="bg-destructive text-destructive-foreground">
删除
</AlertDialogAction>
```
---
## 4. 颜色只能用语义 token,禁止硬编码
颜色全部走 Tailwind 的语义类,它们背后是 `--tm-*` 变量,换主题时自动跟随:
```
背景 bg-primary / bg-surface / bg-accent / bg-destructive
文字 text-foreground / text-primary-foreground / text-muted-foreground
描边 border-border / border-border-subtle / border-disabled-border
```
```tsx
// 对
<Button className="bg-primary text-primary-foreground">保存</Button>
// 错 —— 换主题时这行不会跟着变
<Button className="bg-[#247a63] text-white">保存</Button>
```
**判据**:JSX 里出现 `#` 开头的颜色、`rgb(...)`、或 Tailwind 的调色板名
(`bg-green-600`、`text-slate-500`)—— 都是漏用 semantic token 的信号。
---
## 5. 「默认样式」的三个常见来源
排查界面里冒出来的黑白方角按钮时,按这个顺序找:
**① 组件导出时把样式丢了。** 最常见。把 Radix 的 primitive 原样导出:
```tsx
// 错:渲染出来就是浏览器默认按钮
const AlertDialogCancel = AlertDialogPrimitive.Cancel
```
正确做法是 `forwardRef` 包一层,挂上 `buttonVariants`:
```tsx
const AlertDialogCancel = React.forwardRef<...>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
))
```
**判据**:`components/ui/` 里凡是导出 Radix primitive 的地方,都要确认它是
"样式化的封装"还是"原样透传"。原样透传只对布局容器(`Root` / `Portal` /
`Group`)成立,对**可点元素**(`Close` / `Action` / `Cancel` / `Item`)不成立。
**② `asChild` 里重复包了一层。** 外层已经带样式、子元素又带一次,虽然因为
同族类后生效而不会出错,但会产生冗余类名。**能去掉一层就去掉。**
**③ 原生 `<button>` 忘写 `className`。** 见第 1 节的例外条款 —— 结构性控件也必须
有样式来源。
---
## 6. 提交前检查清单
- [ ] 新增的可点元素来自 `Button`,不是裸 `<button>`
- [ ] 同一界面里 `default` 变体不超过一个
- [ ] 危险操作走 `destructive`,不是红色硬编码
- [ ] 弹窗按钮没有重复包 `Button`
- [ ] JSX 里没有 `#` 开头的颜色、没有 Tailwind 调色板名
- [ ] `components/ui/` 里新导出的可点 primitive 已经挂上 `buttonVariants`
- [ ] 组件测试覆盖到按钮的可见性与点击行为(testid 用 `xxx-yyy` 连字符命名)
---
## 7. 一个反面案例的复盘
弹窗里的「取消 / 开始索引」显示成浏览器默认样式,原因就是第 5 节第 ① 条:
`alert-dialog.tsx` 把 `Cancel` / `Action` 两个 primitive 原样导出了。
修复是给它们各加一个 `forwardRef` 封装,挂上 `buttonVariants`。**组件本身没坏**,
所有调用方一行不用改,样式自动生效 —— 这正是把样式收在 `components/ui/` 里的价值:
**修一处,全产品对齐。**
如果你发现某个地方的按钮"没跟上主题",先别去改那个界面 ——
**先看它用的组件是不是漏了样式。**
@@ -0,0 +1,92 @@
# 微信系统消息(sysmsg)解析与格式兼容
微信的「系统消息」(入群、撤回、成员变动等)以 XML(`<sysmsg>`)存放在消息内容里,
但**同一类提示的 XML 结构会随客户端版本变化**。本文说明 TraceMemo 的解析方式,
以及在遇到新格式时应当怎么扩展。
## 两类格式
### 旧格式:正文直接放在 `<plain>`
```xml
<sysmsg type="delchatroommember">
<delchatroommember>
<plain><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></plain>
<text><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></text>
<link>
<scene>qrcode</scene>
<text><![CDATA[撤销]]></text>
</link>
</delchatroommember>
</sysmsg>
```
解析:命中 `delchatroommember`,直接取 `<plain>`。
### 新格式:正文在 `<template>`,用 `$名称$` 引用 link
```xml
<sysmsg type="sysmsgtemplate">
<sysmsgtemplate>
<content_template type="tmpl_type_profilewithrevokeqrcode">
<plain><![CDATA[]]></plain>
<template><![CDATA["$adder$"通过扫描你分享的二维码加入群聊 $revoke$]]></template>
<link_list>
<link name="adder" type="link_profile">
<memberlist><member>
<username><![CDATA[wxid_xxxxxxxx]]></username>
<nickname><![CDATA[成员昵称]]></nickname>
</member></memberlist>
</link>
<link name="revoke" type="link_revoke_qrcode" hidden="1">
<title><![CDATA[撤销]]></title>
</link>
</link_list>
</content_template>
</sysmsgtemplate>
</sysmsg>
```
三个要点:
- `<plain>` 变成**空 CDATA**,正文挪进 `<template>`;
- 正文里的 `$名称$` 是占位符,按 `<link_list>` 中 `link[name]` 回填;
- `hidden="1"` 的 link 在微信里是**可点击按钮**,纯文本展示时应省略其文案。
## 解析流程
`src/main/message-parser.ts` 的 `parseSystemMessage()` 按以下顺序尝试:
| 顺序 | 分支 | 处理对象 |
| --- | --- | --- |
| 1 | `extractRecallMessage` | `<revokemsg>` 撤回通知 |
| 2 | `extractSysmsgTemplateText` | `<sysmsgtemplate>` 模板消息 |
| 3 | `extractDelChatroomMemberText` | `<delchatroommember>` 成员变动 |
| 4 | 通用提取(`plain` → `text` → `title`),再退回 `fallbackSystemText` | 其余未覆盖类型 |
第 4 步之前会先调用 `stripSysmsgLinkList()` 剥掉 `<link_list>`。
## 为什么必须显式处理新格式
通用提取链只在第 1~3 步全部落空时才执行,而新格式恰好让它落空:
`<plain>` 是空 CDATA,又没有 `<text>`,于是取到 `<title>` ——
那是 `hidden="1"` 按钮的标题。**结果是整条系统消息只剩一个按钮文案**,
例如把「某某通过扫描你分享的二维码加入群聊」显示成「撤销」。
因此三处约束缺一不可:
1. 模板分支必须排在通用提取之前;
2. 占位符回填必须尊重 `hidden="1"`;
3. 通用提取前先剥 `<link_list>`,作为未知类型的防护。
## 新增一类系统消息时
1. 从真实消息中取出 `content`(`<sysmsg>` 原文),确认 `type` 与承载正文的标签;
2. 在 `parseSystemMessage()` 里加一个**早于通用提取**的分支;
3. 补 `tests/unit/message-parser.test.ts` 用例,**新旧两版各一条**,防止回归;
4. 文档与代码注释只写结构,不粘贴真实会话内容、昵称、wxid 或二维码链接。
## 相关位置
- 解析实现:`src/main/message-parser.ts`
- 单元测试:`tests/unit/message-parser.test.ts`
+60 -3
View File
@@ -1,5 +1,62 @@
# macOS 数据访问说明(兼容入口) # macOS 关闭 SIP 教程
完整内容已移到[macOS 数据访问与系统权限](./platform/macos.md)。 SIP(System Integrity Protection,系统完整性保护)是 macOS 的系统安全机制。关闭 SIP 会降低系统安全性,只建议在确实需要读取或调试本地微信数据时临时关闭;操作完成后,建议重新开启。
保留此文件是为了兼容应用内已经发布的帮助链接。请不要把“关闭 SIP”当作默认安装步骤;只有当当前连接页面明确要求时才处理,并在完成后恢复系统安全设置。 > 只在连接页面明确提示需要关闭 SIP 时才处理。首次连接失败时,先确认微信版本、账号目录和登录时机,再按本文操作。关闭 SIP 不是 TraceMemo 的常规安装步骤,也不应长期保持关闭。
## 准备
- 一台 Mac 电脑,Intel 芯片和 Apple Silicon 芯片均可。
- 需要进入 macOS 恢复模式。
- 请先保存正在编辑的文件,并预留一次重启时间。
## 关闭 SIP
### Intel Mac
1. 关机。
2. 按下开机键后,立刻按住 `Command + R`。
3. 保持按住,直到进入 macOS 恢复模式。
### Apple Silicon Mac(M1/M2/M3/M4/M5)
1. 关机。
2. 长按开机键不放。
3. 直到出现启动选项界面后松开。
4. 选择"选项",进入 macOS 恢复模式。
### 在恢复模式中执行命令
1. 进入恢复模式后,点击顶部菜单栏的 **Utilities(实用工具)**。
2. 选择 **Terminal(终端)**。
3. 在终端中输入:
```bash
csrutil disable
```
4. 按回车执行。
5. 看到关闭成功提示后,重启电脑。
## 确认是否生效
重启回到正常桌面后,打开"终端",执行:
```bash
csrutil status
```
看到 `System Integrity Protection status: disabled.` 才算关闭成功。
若仍显示 `enabled`,说明没有生效。常见原因是没在恢复模式里执行,或系统刚做过大版本更新——
macOS 大版本更新会把 SIP 重置回开启状态,此前关过也会失效,需要重新按上面的步骤操作。
## 重新开启 SIP
拿到数据库密钥后,建议重新进入恢复模式,在终端中执行:
```bash
csrutil enable
```
然后重启电脑,恢复系统安全设置。
+1 -1
View File
@@ -8,7 +8,7 @@ TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信
1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。 1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。
2. 确认微信数据目录指向当前账号。 2. 确认微信数据目录指向当前账号。
3. 只在页面明确要求时处理系统授权或 SIP;按页面提示完成密钥获取后,恢复你平时使用的安全设置。 3. 只在页面明确要求时处理系统授权或 SIP;关闭 SIP 的具体步骤见[关闭 SIP 教程](../mac-disable-sip.md),按页面提示完成密钥获取后,恢复你平时使用的安全设置。
4. 返回应用重新检测账号、数据库和图片资源状态。 4. 返回应用重新检测账号、数据库和图片资源状态。
不要直接复制网上针对其他微信版本的命令。系统授权失败时,记录 macOS 版本、微信版本和页面错误,再按[排障文档](../user-guide/troubleshooting.md#连接微信失败)处理。 不要直接复制网上针对其他微信版本的命令。系统授权失败时,记录 macOS 版本、微信版本和页面错误,再按[排障文档](../user-guide/troubleshooting.md#连接微信失败)处理。
-674
View File
@@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2026 yincongcyincong
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
-28
View File
@@ -1,28 +0,0 @@
# wechat_chatter / OneBot 第三方组件说明
TraceMemo 的 macOS 个人微信发送功能会按需使用以下第三方组件:
- 项目:`yincongcyincong/wechat_chatter`
- 上游仓库:https://github.com/yincongcyincong/wechat_chatter
- 当前运行时版本:`v0.0.18`
- 运行时文件:`onebot_mac_arm64.tar.gz`
- 许可证:GNU General Public License version 3(GPL-3.0)
- 上游版权:Copyright (C) 2026 yincongcyincong
- TraceMemo 修改日期:2026-08-17
## 集成方式
OneBot 运行时不会随 TraceMemo 安装包一起分发。用户启用该实验性功能时,TraceMemo 会从上述上游项目的 GitHub Release 按需下载运行时,并将其安装到应用的用户数据目录。
运行时作为独立进程启动,TraceMemo 通过本机 HTTP 接口与其通信。
## 本地修改
为适配连续发送、图片上传 Hook 状态检测以及微信核心模块基址定位,TraceMemo 会在用户设备上对上游 `onebot/script.js` 应用兼容性补丁。补丁逻辑位于:
- `scripts/prepare-wechat-chatter-runtime.cjs`
- `src/main/services/personal-wechat-runtime-manager.ts`
补丁中源自或修改自上游 `script.js` 的部分,以及补丁应用后产生的修改版 `script.js`,继续按照 GPL-3.0 提供。本说明只针对该第三方组件及相关修改,不用于声明 TraceMemo 仓库其他部分的许可证。
GPL-3.0 的完整文本见本目录下的 `LICENSE`。
+2 -1
View File
@@ -19,6 +19,8 @@ asarUnpack:
- node_modules/silk-wasm/** - node_modules/silk-wasm/**
- node_modules/sherpa-onnx-node/** - node_modules/sherpa-onnx-node/**
- node_modules/sherpa-onnx-*/** - node_modules/sherpa-onnx-*/**
- node_modules/@napi-rs/system-ocr/**
- node_modules/@napi-rs/system-ocr-*/**
extraResources: extraResources:
# Keep the updater provider in every packaged Windows app. electron-builder also # Keep the updater provider in every packaged Windows app. electron-builder also
# regenerates this file during publish, using the same release configuration. # regenerates this file during publish, using the same release configuration.
@@ -33,7 +35,6 @@ extraResources:
- mobile_daily_report.html - mobile_daily_report.html
- mobile_daily_report_v1.html - mobile_daily_report_v1.html
- mobile_daily_report_v2.html - mobile_daily_report_v2.html
- connectors/wechat/win32-x64/**
- key/win32/x64/** - key/win32/x64/**
- runtime/win32/** - runtime/win32/**
- wcdb/win32/x64/** - wcdb/win32/x64/**
+5 -1
View File
@@ -19,6 +19,11 @@ asarUnpack:
- node_modules/silk-wasm/** - node_modules/silk-wasm/**
- node_modules/sherpa-onnx-node/** - node_modules/sherpa-onnx-node/**
- node_modules/sherpa-onnx-*/** - 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: extraResources:
# Keep the updater provider in every packaged macOS app. electron-builder also # Keep the updater provider in every packaged macOS app. electron-builder also
# regenerates this file during publish, using the same release configuration. # regenerates this file during publish, using the same release configuration.
@@ -29,7 +34,6 @@ extraResources:
to: resources to: resources
filter: filter:
- '**/*' - '**/*'
- '!connectors/wechat-personal/**'
- from: docs/skill/tracememo-reader - from: docs/skill/tracememo-reader
to: skill/tracememo-reader to: skill/tracememo-reader
filter: filter:
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
output: { output: {
entryFileNames: '[name].js' entryFileNames: '[name].js'
}, },
external: ['koffi', 'sherpa-onnx-node'] external: ['koffi', 'sherpa-onnx-node', '@napi-rs/system-ocr']
} }
} }
}, },
+14 -16
View File
@@ -25,12 +25,13 @@
}, },
"main": "./out/main/index.js", "main": "./out/main/index.js",
"scripts": { "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 .", "format": "prettier --write .",
"lint": "eslint --cache .", "lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.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", "test:skill-install": "node scripts/test-skill-install-instruction.cjs",
"cp:env": "node scripts/ensure-env.cjs", "cp:env": "node scripts/ensure-env.cjs",
"prepare: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:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64",
"prepare:ffmpeg:mac:x64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch x64", "prepare:ffmpeg:mac:x64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch x64",
"prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win", "prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win",
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs", "prepare:wechat-native": "node scripts/prepare-wechat-native-runtime.cjs",
"start": "electron-vite preview", "start": "electron-vite preview",
"predev": "node scripts/ensure-electron-binary.cjs", "predev": "node scripts/ensure-electron-binary.cjs",
"dev": "node scripts/ensure-env.cjs && 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", "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": "electron-vite build && node scripts/run-query-agent-poc.cjs",
"poc:query-agent:run": "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:unit": "vitest run --config vitest.unit.config.ts",
"test:component": "vitest run --config vitest.component.config.ts", "test:component": "vitest run --config vitest.component.config.ts",
"test:integration": "vitest run --config vitest.integration.config.ts", "test:integration": "vitest run --config vitest.integration.config.ts",
@@ -56,21 +56,17 @@
"test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual", "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:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
"test:smoke": "node --test tests/smoke/native-environment.test.mjs", "test:smoke": "node --test tests/smoke/native-environment.test.mjs",
"build:wechat-connector": "node scripts/build-wechat-connector.cjs", "build": "npm run typecheck && electron-vite build",
"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",
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs && node scripts/ensure-electron-binary.cjs", "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: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: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 && 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:arm64": "npm run typecheck && 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:mac:x64": "npm run typecheck && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"release": "npm run release:mac && npm run release:win", "release": "npm run release:mac && npm run release:win",
"release:mac": "npm run typecheck && 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: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: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:beta": "cross-env EP_PRE_RELEASE=true npm run release",
"release:stable": "npm run release", "release:stable": "npm run release",
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux" "build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
@@ -79,6 +75,8 @@
"@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-win32-x64": "3.1.0", "@koromix/koffi-win32-x64": "3.1.0",
"@napi-rs/system-ocr": "1.2.0",
"@napi-rs/system-ocr-win32-x64-msvc": "1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-checkbox": "^1.3.11", "@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dialog": "^1.1.23",
@@ -171,4 +169,4 @@
"ffmpeg-static" "ffmpeg-static"
] ]
} }
} }
+42
View File
@@ -12,6 +12,8 @@ specifiers:
'@electron-toolkit/tsconfig': ^2.0.0 '@electron-toolkit/tsconfig': ^2.0.0
'@electron-toolkit/utils': ^4.0.0 '@electron-toolkit/utils': ^4.0.0
'@koromix/koffi-win32-x64': 3.1.0 '@koromix/koffi-win32-x64': 3.1.0
'@napi-rs/system-ocr': 1.2.0
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
'@playwright/test': ^1.62.1 '@playwright/test': ^1.62.1
'@radix-ui/react-alert-dialog': ^1.1.23 '@radix-ui/react-alert-dialog': ^1.1.23
'@radix-ui/react-checkbox': ^1.3.11 '@radix-ui/react-checkbox': ^1.3.11
@@ -86,6 +88,8 @@ dependencies:
'@electron-toolkit/preload': 3.0.2_electron@43.1.0 '@electron-toolkit/preload': 3.0.2_electron@43.1.0
'@electron-toolkit/utils': 4.0.0_electron@43.1.0 '@electron-toolkit/utils': 4.0.0_electron@43.1.0
'@koromix/koffi-win32-x64': 3.1.0 '@koromix/koffi-win32-x64': 3.1.0
'@napi-rs/system-ocr': 1.2.0
'@napi-rs/system-ocr-win32-x64-msvc': 1.2.0
'@radix-ui/react-alert-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu '@radix-ui/react-alert-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
'@radix-ui/react-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu '@radix-ui/react-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu
'@radix-ui/react-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu '@radix-ui/react-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
@@ -1685,6 +1689,44 @@ packages:
- supports-color - supports-color
dev: true dev: true
/@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
/@napi-rs/system-ocr-darwin-arm64/1.2.0:
resolution: {integrity: sha512-cK8dcDBEl3P4A04xmFJSHEJQxfDytaAIFyDCLqavTp92FVU5plESttWzZsqtTkS81/kzKiBfHyPQffSIndfWbQ==}
cpu: [arm64]
os: [darwin]
engines: {node: '>= 10'}
dev: false
/@napi-rs/system-ocr-darwin-x64/1.2.0:
resolution: {integrity: sha512-u3TBvBGrhmT5Os6AfaxbUEg6VHe8lvrFJNPgThJgshJHyRXUx/wCfTyOroJ22KdVCP5AE4GpwS5tFHMb6p6iaQ==}
cpu: [x64]
os: [darwin]
engines: {node: '>= 10'}
dev: false
/@napi-rs/system-ocr-win32-arm64-msvc/1.2.0:
resolution: {integrity: sha512-7ej8uMvmXomw3NXo5gZ5p2Nl6UKsHI+VRU3ELv0mhcxR0sJ6wFifYTu5bJrM1TGcz1/RsaX+TjWMmsDq8vriKQ==}
cpu: [arm64]
os: [win32]
engines: {node: '>= 10'}
dev: false
/@napi-rs/system-ocr-win32-x64-msvc/1.2.0:
resolution: {integrity: sha512-oOoCj3FPWDVctTxx98vMBiMI6m51U+w7SMmMefvmtpcpLelzZ/zYTqdwtWZFAjShaHO+RdaKkcpeVcQuBQiVbA==}
cpu: [x64]
os: [win32]
engines: {node: '>= 10'}
dev: false
/@nodelib/fs.scandir/2.1.5: /@nodelib/fs.scandir/2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
+17 -9
View File
@@ -68,21 +68,29 @@
.overview { .overview {
margin-top: 2px; 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 { .avatar-grid {
width: 58px;
height: 58px;
display: grid; 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; gap: 3px;
flex: 0 0 auto; flex: 0 0 auto;
} }
/* 单头像时不保留空列,簇宽恰好等于一个头像。 */
.avatar-grid.avatar-count-1 { .avatar-grid.avatar-count-1 {
width: 28px; grid-template-columns: var(--tm-avatar-hero-size, 40px);
height: 28px;
grid-template-columns: 1fr;
}
.avatar-grid.avatar-count-2 {
height: 28px;
} }
.avatar-grid.empty-section { .avatar-grid.empty-section {
display: none; display: none;
+17 -9
View File
@@ -61,21 +61,29 @@
font-size: 13px; font-size: 13px;
line-height: 1.55; 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 { .avatar-grid {
width: 58px;
height: 58px;
display: grid; 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; gap: 3px;
flex: 0 0 auto; flex: 0 0 auto;
} }
/* 单头像时不保留空列,簇宽恰好等于一个头像。 */
.avatar-grid.avatar-count-1 { .avatar-grid.avatar-count-1 {
width: 28px; grid-template-columns: var(--tm-avatar-hero-size, 40px);
height: 28px;
grid-template-columns: 1fr;
}
.avatar-grid.avatar-count-2 {
height: 28px;
} }
.avatar-grid.empty-section { .avatar-grid.empty-section {
display: none; 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
View File
@@ -1 +1 @@
f1bbd88584075c0e6d3487357a06424949de26d7e6dcad803d210aab83b0cbda xkey_helper_4_1_13 a35d3fa67387e049cc30bc073f9e65b077aa1db9c5c2b183125dec695fa013b9 xkey_helper_4_1_13
+130 -6
View File
@@ -16,6 +16,15 @@ const REQUIRED_RUNTIME_PACKAGES = [
'koffi' '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) { function getRuntimeResources(context) {
const productName = context.packager.appInfo.productFilename const productName = context.packager.appInfo.productFilename
return context.electronPlatformName === 'darwin' return context.electronPlatformName === 'darwin'
@@ -78,11 +87,106 @@ 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(', ')}`)
}
}
function normalizeBuilderArch(arch) { function normalizeBuilderArch(arch) {
if (typeof arch === 'string') return arch if (typeof arch === 'string') return arch
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch) return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
} }
function runCodesign(args) {
execFileSync('/usr/bin/codesign', args, { stdio: 'ignore' })
}
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
}
function signMacosAppBundle(appBundlePath, run = runCodesign) {
if (isMacosCodeValid(appBundlePath, run)) return appBundlePath
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 * A foreign-architecture binary only fails once the user touches the feature
* that needs it, so verify the ones whose filename is shared across * that needs it, so verify the ones whose filename is shared across
@@ -152,16 +256,24 @@ function validateReaderSkillRuntime(runtimeResources) {
* The loaders pick their package from process.platform/arch, so the siblings * The loaders pick their package from process.platform/arch, so the siblings
* are dead weight — drop them. * are dead weight — drop them.
*/ */
// 每个条目返回 platform package 的**完整后缀**(不含 package 前缀与连字符)。
const NATIVE_RUNTIME_PACKAGES = [ const NATIVE_RUNTIME_PACKAGES = [
{ {
modules: [], modules: [],
prefix: 'sherpa-onnx', prefix: 'sherpa-onnx',
platformName: (platform) => (platform === 'win32' ? 'win' : platform) platformName: (platform, arch) => `${platform === 'win32' ? 'win' : platform}-${arch}`
}, },
{ {
modules: ['@koromix'], modules: ['@koromix'],
prefix: 'koffi', 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 +285,16 @@ function pruneForeignArchNativeRuntimes(runtimeResources, platform, arch) {
for (const runtime of NATIVE_RUNTIME_PACKAGES) { for (const runtime of NATIVE_RUNTIME_PACKAGES) {
const modulesRoot = path.join(unpackedRoot, ...runtime.modules) const modulesRoot = path.join(unpackedRoot, ...runtime.modules)
if (!existsSync(modulesRoot)) continue if (!existsSync(modulesRoot)) continue
const expected = `${runtime.prefix}-${runtime.platformName(platform)}-${arch}` const expected = `${runtime.prefix}-${runtime.platformName(platform, arch)}`
const foreign = new RegExp(`^${runtime.prefix}-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)$`) const foreign =
runtime.foreignPattern ||
new RegExp(`^${runtime.prefix}-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)$`)
for (const entry of readdirSync(modulesRoot, { withFileTypes: true })) { for (const entry of readdirSync(modulesRoot, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === expected || !foreign.test(entry.name)) continue if (!entry.isDirectory() || entry.name === expected || !foreign.test(entry.name)) continue
rmSync(path.join(modulesRoot, entry.name), { recursive: true, force: true }) 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 return removed
@@ -223,6 +339,7 @@ exports.default = async function afterPack(context) {
'Bundled ffmpeg' 'Bundled ffmpeg'
) )
validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch) validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch)
validateSystemOcrRuntime(runtimeResources, context.electronPlatformName, arch)
pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch) pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch)
pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch) pruneForeignArchConnectors(runtimeResources, context.electronPlatformName, arch)
pruneForeignArchNativeRuntimes(runtimeResources, context.electronPlatformName, arch) pruneForeignArchNativeRuntimes(runtimeResources, context.electronPlatformName, arch)
@@ -231,6 +348,9 @@ exports.default = async function afterPack(context) {
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], { execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
stdio: 'ignore' stdio: 'ignore'
}) })
signMacosHelpers(runtimeResources)
const productName = context.packager.appInfo.productFilename
signMacosAppBundle(path.join(context.appOutDir, productName + '.app'))
} }
if (context.electronPlatformName === 'win32') { if (context.electronPlatformName === 'win32') {
@@ -249,7 +369,6 @@ exports.default = async function afterPack(context) {
} }
return return
} }
} }
exports.getRuntimeResources = getRuntimeResources exports.getRuntimeResources = getRuntimeResources
@@ -258,7 +377,12 @@ exports.validateReaderSkillRuntime = validateReaderSkillRuntime
exports.validateFfmpegRuntime = validateFfmpegRuntime exports.validateFfmpegRuntime = validateFfmpegRuntime
exports.validateSilkWasmRuntime = validateSilkWasmRuntime exports.validateSilkWasmRuntime = validateSilkWasmRuntime
exports.validateSherpaRuntime = validateSherpaRuntime exports.validateSherpaRuntime = validateSherpaRuntime
exports.validateSystemOcrRuntime = validateSystemOcrRuntime
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
exports.pruneForeignArchConnectors = pruneForeignArchConnectors exports.pruneForeignArchConnectors = pruneForeignArchConnectors
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture
exports.findMacosHelperPaths = findMacosHelperPaths
exports.isMacosCodeValid = isMacosCodeValid
exports.signMacosHelpers = signMacosHelpers
exports.signMacosAppBundle = signMacosAppBundle
-65
View File
@@ -1,65 +0,0 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const projectRoot = path.resolve(__dirname, '..')
const sourceDir = path.join(projectRoot, 'services', 'wechat-connector')
const outputRoot = path.join(projectRoot, 'resources', 'connectors', 'wechat')
function normalizePlatform(value) {
if (value === 'win32' || value === 'windows') return 'windows'
if (value === 'darwin' || value === 'macos') return 'darwin'
if (value === 'linux') return 'linux'
throw new Error(`Unsupported connector platform: ${value}`)
}
function normalizeArch(value) {
if (value === 'x64' || value === 'amd64') return 'amd64'
if (value === 'arm64') return 'arm64'
throw new Error(`Unsupported connector architecture: ${value}`)
}
function detectHostArch() {
if (process.platform !== 'darwin') return process.arch
try {
const arm64Supported = execFileSync('sysctl', ['-n', 'hw.optional.arm64'], {
encoding: 'utf8'
}).trim()
return arm64Supported === '1' ? 'arm64' : process.arch
} catch {
return process.arch
}
}
function parseTargets() {
const platformArg = process.argv.indexOf('--platform')
const archArg = process.argv.indexOf('--arch')
const platforms = platformArg >= 0 ? process.argv[platformArg + 1].split(',') : [process.platform]
const arches = archArg >= 0 ? process.argv[archArg + 1].split(',') : [detectHostArch()]
return platforms.flatMap((platform) =>
arches.map((arch) => ({ goos: normalizePlatform(platform), goarch: normalizeArch(arch) }))
)
}
if (!fs.existsSync(path.join(sourceDir, 'go.mod'))) {
throw new Error(`Repository-local WeChat connector source is missing: ${sourceDir}`)
}
for (const target of parseTargets()) {
const directoryName = `${target.goos === 'windows' ? 'win32' : target.goos}-${target.goarch === 'amd64' ? 'x64' : target.goarch}`
const outputDir = path.join(outputRoot, directoryName)
const outputPath = path.join(
outputDir,
target.goos === 'windows' ? 'wechat-connector.exe' : 'wechat-connector'
)
fs.rmSync(outputDir, { recursive: true, force: true })
fs.mkdirSync(outputDir, { recursive: true })
execFileSync('go', ['build', '-trimpath', '-o', outputPath, '.'], {
cwd: sourceDir,
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' },
stdio: 'inherit'
})
if (target.goos !== 'windows') fs.chmodSync(outputPath, 0o755)
console.log(`[build-wechat-connector] built ${directoryName}: ${outputPath}`)
}
-436
View File
@@ -1,436 +0,0 @@
/*
* wechat_chatter runtime integration
*
* Upstream: https://github.com/yincongcyincong/wechat_chatter
* Runtime version: v0.0.18
* Upstream license: GNU General Public License version 3 (GPL-3.0)
*
* This file applies local compatibility patches to the upstream
* onebot/script.js. See docs/third-party/wechat-chatter/NOTICE.md.
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const release = 'v0.0.18'
const asset = 'onebot_mac_arm64.tar.gz'
const url = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${release}/${asset}`
const projectRoot = path.resolve(__dirname, '..')
const outputDir = path.join(
projectRoot,
'resources',
'connectors',
'wechat-personal',
'darwin-arm64'
)
const archive = path.join(os.tmpdir(), `wechat-chatter-${release}-${asset}`)
const appleSilicon =
process.platform === 'darwin' &&
execFileSync('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'], { encoding: 'utf8' }).trim() === '1'
if (!appleSilicon) {
throw new Error('个人微信发送运行时当前仅支持 macOS arm64')
}
fs.mkdirSync(outputDir, { recursive: true })
let archiveReady = false
if (fs.existsSync(archive)) {
try {
execFileSync('/usr/bin/tar', ['-tzf', archive], { stdio: 'ignore' })
archiveReady = true
console.log(`[wechat-personal] 复用已下载归档:${archive}`)
} catch {
// The archive is partial or invalid; curl will resume it below.
}
}
if (!archiveReady) {
console.log(`[wechat-personal] 下载 ${release},支持断点续传:${archive}`)
execFileSync(
'/usr/bin/curl',
['--http1.1', '-L', '--fail', '--retry', '3', '--continue-at', '-', '--output', archive, url],
{ stdio: 'inherit' }
)
}
const extractionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-chatter-extract-'))
console.log(`[wechat-personal] 解压并原子安装到 ${outputDir}`)
try {
execFileSync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDir], { stdio: 'inherit' })
fs.mkdirSync(path.join(outputDir, 'onebot'), { recursive: true })
fs.mkdirSync(path.join(outputDir, 'wechat_version'), { recursive: true })
fs.copyFileSync(
path.join(extractionDir, 'onebot', 'script.js'),
path.join(outputDir, 'onebot', 'script.js')
)
fs.cpSync(path.join(extractionDir, 'wechat_version'), path.join(outputDir, 'wechat_version'), {
recursive: true,
force: true
})
const stagedExecutable = path.join(outputDir, 'onebot', `.onebot-${process.pid}.tmp`)
fs.copyFileSync(path.join(extractionDir, 'onebot', 'onebot'), stagedExecutable)
fs.chmodSync(stagedExecutable, 0o755)
fs.renameSync(stagedExecutable, path.join(outputDir, 'onebot', 'onebot'))
} finally {
fs.rmSync(extractionDir, { recursive: true, force: true })
}
const executable = path.join(outputDir, 'onebot', 'onebot')
const script = path.join(outputDir, 'onebot', 'script.js')
const config = path.join(outputDir, 'wechat_version', '4_1_11_53_mac.json')
for (const required of [executable, script, config]) {
if (!fs.existsSync(required)) throw new Error(`运行时文件缺失:${required}`)
}
function patchPerSendPayload(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return
const activeSend = ` const payloadData = hexToByteArray(payloadHex);
activeTriggerX1Payload = Memory.alloc(payloadData.length);
activeTriggerX1Payload.writeByteArray(payloadData);
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
const upstreamSend = ` const payloadData = hexToByteArray(payloadHex);
triggerX1Payload.writeByteArray(payloadData);
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
if (!source.includes(activeSend)) throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
source = source
.replace(
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;',
'var triggerX1Payload;\nvar triggerX0;'
)
.replace(activeSend, upstreamSend)
.replace(
' MMStartTask(triggerX0, activeTriggerX1Payload);',
' MMStartTask(triggerX0, triggerX1Payload);'
)
.replace(
' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
)
.replace(
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn',
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn'
)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已恢复原生发送 payload 布局')
}
function patchSendContextCapture(scriptPath, strict = true) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('function isLikelySendContext(')) return
const original = `function AttachSendFunc() {
Interceptor.attach(sendFuncAddr.add(0x10), {
onEnter: function (args) {
if (triggerX1Payload) {
return
}
triggerX0 = this.context.x0;
triggerX1Payload = this.context.x1;
console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
}
})
}`
const patched = `function isLikelySendContext(candidateX0, candidateX1) {
try {
if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false;
var manager = readPointerIfReadable(candidateX0.add(0x18));
var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18)));
console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi);
return !manager.equals(ptr(0));
} catch (e) {
console.error("[debug] StartTask candidate inspect failed: " + e);
return false;
}
}
function AttachSendFunc() {
Interceptor.attach(sendFuncAddr.add(0x10), {
onEnter: function (args) {
if (triggerX1Payload) return;
var candidateX0 = this.context.x0;
var candidateX1 = this.context.x1;
if (!isLikelySendContext(candidateX0, candidateX1)) return;
triggerX0 = candidateX0;
triggerX1Payload = candidateX1;
console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
}
})
}`
if (!source.includes(original)) {
if (strict) throw new Error('无法定位 StartTask 上下文 Hook')
return
}
source = source.replace(original, patched)
fs.writeFileSync(scriptPath, source)
}
function patchVoiceAudioBuffer(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
if (!source.includes(staticAllocation)) {
throw new Error('无法定位 wechat_chatter 语音缓冲区')
}
source = source.replace(
staticAllocation,
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
)
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
if (!source.includes(audioLengthMarker)) {
throw new Error('无法定位 wechat_chatter 语音上传逻辑')
}
source = source.replace(
audioLengthMarker,
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已应用按语音长度分配上传缓冲区补丁')
}
function patchImageHookReadiness(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
source.includes('图片上传 Hook Setup Complete')
)
return
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
\t\t\t}
\t\t\tuploadGlobalX0 = capturedUploadX0;`
if (!source.includes(original)) throw new Error('无法定位 wechat_chatter 图片 Hook 状态补丁位置')
source = source.replace(original, patched)
source = source.replace(
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已应用图片 Hook 状态补丁')
}
// Backport of wechat_chatter PR #36 by @Leslielu:
// https://github.com/yincongcyincong/wechat_chatter/pull/36
// TraceMemo adds the verified macOS WeChat 4.1.11.53 addresses.
// macOS WeChat 4.1.11.53, located and verified by TraceMemo
function patchCdnColdStart(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('function resolveCdnManager()')) return
const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});`
const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});
// 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为)
{{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}}
{{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}`
if (!source.includes(initAddresses)) throw new Error('下载的微信版本配置与当前应用不兼容')
source = source.replace(initAddresses, patchedInitAddresses)
const downloadChunkEnd = `}
function fillUploadX1AndStart`
const resolver = `}
// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器
// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。
function resolveCdnManager() {
if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) {
return ptr(0);
}
try {
// libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。
var strDefault = Memory.alloc(24);
strDefault.writeUtf8String("default");
strDefault.add(0x17).writeU8(7);
var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']);
var svc = getService(strDefault);
if (!isReadablePointer(svc)) {
console.error("[!] GetService(\\"default\\") 返回不可读: " + svc);
return ptr(0);
}
var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']);
var ctx = getCtx(svc);
if (!isReadablePointer(ctx)) {
console.error("[!] CdnManager getter 返回不可读: " + ctx);
return ptr(0);
}
var mgr = readPointerIfReadable(ctx.add(0x40));
if (!isReadablePointer(mgr)) {
console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx);
return ptr(0);
}
return mgr;
} catch (e) {
console.error("[!] resolveCdnManager 异常: " + e);
return ptr(0);
}
}
function ensureCdnManagerX0() {
if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) {
uploadGlobalX0 = downloadGlobalX0;
console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0);
}
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) {
downloadGlobalX0 = uploadGlobalX0;
console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0);
}
if (uploadGlobalX0.equals(ptr(0))) {
var mgr = resolveCdnManager();
if (!mgr.equals(ptr(0))) {
uploadGlobalX0 = mgr;
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
downloadGlobalX0 = mgr;
}
console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr);
}
}
return !uploadGlobalX0.equals(ptr(0));
}
function fillUploadX1AndStart`
if (!source.includes(downloadChunkEnd)) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
source = source.replace(downloadChunkEnd, resolver)
const declarations = 'var uploadImageAddr;\n'
const patchedDeclarations =
'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n'
if (!source.includes(declarations)) throw new Error('无法定位 wechat_chatter 媒体地址声明')
source = source.replace(declarations, patchedDeclarations)
const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
if (uploadGlobalX0.equals(ptr(0))) {`
const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
if (uploadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (uploadGlobalX0.equals(ptr(0))) {`
if (!source.includes(uploadGuard)) throw new Error('无法定位 wechat_chatter 媒体上传入口')
source = source.replace(uploadGuard, patchedUploadGuard)
const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
if (uploadGlobalX0.equals(ptr(0))) {`
const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
if (uploadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (uploadGlobalX0.equals(ptr(0))) {`
if (!source.includes(voiceGuard)) throw new Error('无法定位 wechat_chatter 语音上传入口')
source = source.replace(voiceGuard, patchedVoiceGuard)
const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;`
const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) {
downloadGlobalX0 = capturedUploadX0;
console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0);
}`
if (!source.includes(uploadHook)) throw new Error('无法定位 wechat_chatter 图片 Hook')
source = source.replace(uploadHook, patchedUploadHook)
const downloadHook = ` downloadGlobalX0 = this.context.x0;`
const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0;
if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) {
uploadGlobalX0 = downloadGlobalX0;
console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0);
}`
if (!source.includes(downloadHook)) throw new Error('无法定位 wechat_chatter 下载 Hook')
source = source.replace(downloadHook, patchedDownloadHook)
const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
if (!downloadGlobalX0) {`
const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (!downloadGlobalX0) {`
if (!source.includes(downloadGuard)) throw new Error('无法定位 wechat_chatter 媒体下载入口')
source = source.replace(downloadGuard, patchedDownloadGuard)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已应用 CdnManager 冷启动解析补丁')
}
function patchCdnColdStartConfig(configPath) {
const source = fs.readFileSync(configPath, 'utf8')
let config
try {
config = JSON.parse(source)
} catch {
throw new Error('4.1.11.53 版本配置不是有效 JSON')
}
config.cdnGetServiceAddr = '0x50a15d0'
config.cdnManagerGetterAddr = '0x5259290'
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
console.log('[wechat-personal] 已写入 4.1.11.53 CdnManager 地址')
}
function patchWechatCoreModuleBase(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('WeChat core module base:')) return
const initMarker = 'function initAddresses() {'
const initIndex = source.indexOf(initMarker)
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
throw new Error('无法定位 wechat_chatter 基址扫描逻辑')
}
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
var module = Process.enumerateModules().find(function(m) {
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
});
if (!module) {
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
}
var moduleBase = module.base;
var baseAddr = moduleBase;
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
setImmediate(initAddresses);
`
source = patchedHeader + source.slice(initIndex)
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已应用微信核心模块基址补丁')
}
function addModifiedWorkNotice(scriptPath) {
let source = fs.readFileSync(scriptPath, 'utf8')
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
const notice = `/*
* TraceMemo wechat_chatter compatibility modifications
* Modified: 2026-08-17
* Upstream: https://github.com/yincongcyincong/wechat_chatter
* Runtime version: v0.0.18
* License: GNU General Public License version 3 (GPL-3.0)
* Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers,
* CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging.
* These modifications are not provided by the upstream author.
*/
`
source = notice + source
fs.writeFileSync(scriptPath, source)
console.log('[wechat-personal] 已写入 GPL 修改声明')
}
patchWechatCoreModuleBase(script)
patchPerSendPayload(script)
patchSendContextCapture(script)
patchVoiceAudioBuffer(script)
patchImageHookReadiness(script)
patchCdnColdStartConfig(config)
patchCdnColdStart(script)
addModifiedWorkNotice(script)
fs.chmodSync(executable, 0o755)
console.log('[wechat-personal] 运行时准备完成')
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable @typescript-eslint/no-require-imports */
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const REQUIRED_FILES = ['tm-wechat-host', 'libtmwechat.dylib', 'runtime-manifest.json']
function parseArgs(argv) {
const result = {}
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index]
if (value === '--source' || value === '--target') {
if (!argv[index + 1]) throw new Error(`${value} requires a path`)
result[value.slice(2)] = argv[index + 1]
index += 1
} else {
throw new Error(`Unknown option: ${value}`)
}
}
return result
}
function sha256(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
}
function listFiles(root) {
const files = []
function visit(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name)
if (entry.isDirectory()) visit(absolute)
else if (entry.isFile()) files.push(path.relative(root, absolute))
}
}
visit(root)
return files.sort()
}
function assertMachOArm64(filePath, label) {
const buffer = fs.readFileSync(filePath)
if (buffer.length < 8 || buffer.readUInt32LE(0) !== 0xfeedfacf) {
throw new Error(`${label} is not a 64-bit Mach-O binary: ${filePath}`)
}
if (buffer.readUInt32LE(4) !== 0x0100000c) {
throw new Error(`${label} is not arm64: ${filePath}`)
}
}
function readAndValidateArtifact(sourceDir) {
if (!fs.existsSync(sourceDir)) {
throw new Error('native runtime artifact not found: build the macOS runtime artifact first')
}
const actualFiles = listFiles(sourceDir)
const expectedFiles = [...REQUIRED_FILES].sort()
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
throw new Error(`native runtime artifact has an unexpected tree: ${actualFiles.join(', ')}`)
}
const manifestPath = path.join(sourceDir, 'runtime-manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
if (manifest.runtime !== 'tm-wechat-native') throw new Error('Unexpected runtime name')
if (manifest.platform !== 'darwin-arm64') throw new Error('Runtime platform must be darwin-arm64')
if (manifest.architecture !== 'arm64') throw new Error('Runtime architecture must be arm64')
if (manifest.protocolVersion !== 1) throw new Error('Unsupported runtime protocolVersion')
if (!/^[a-f0-9]{64}$/.test(manifest.tmSendSourceSha256 || '')) {
throw new Error('Runtime manifest has no valid tmSendSourceSha256')
}
if (!/^[a-f0-9]{64}$/.test(manifest.addressProfileSha256 || '')) {
throw new Error('Runtime manifest has no valid addressProfileSha256')
}
const capabilities = new Set(manifest.capabilities || [])
for (const capability of ['text', 'image', 'voice']) {
if (!capabilities.has(capability)) throw new Error(`Runtime capability missing: ${capability}`)
}
if (
!Array.isArray(manifest.supportedWechatVersions) ||
manifest.supportedWechatVersions.length === 0
) {
throw new Error('Runtime manifest has no supportedWechatVersions')
}
for (const relative of REQUIRED_FILES) {
const absolute = path.join(sourceDir, relative)
if (!fs.statSync(absolute).isFile()) throw new Error(`Runtime file missing: ${relative}`)
}
const hostPath = path.join(sourceDir, 'tm-wechat-host')
const dylibPath = path.join(sourceDir, 'libtmwechat.dylib')
assertMachOArm64(hostPath, 'tm-wechat-host')
assertMachOArm64(dylibPath, 'libtmwechat.dylib')
if (!fs.readFileSync(dylibPath).includes(Buffer.from(manifest.tmSendSourceSha256, 'utf8'))) {
throw new Error('Embedded agent hash is not present in libtmwechat.dylib')
}
if (!fs.readFileSync(dylibPath).includes(Buffer.from(manifest.addressProfileSha256, 'utf8'))) {
throw new Error('Embedded address profile hash is not present in libtmwechat.dylib')
}
return {
manifest,
hashes: Object.fromEntries(
REQUIRED_FILES.map((file) => [file, sha256(path.join(sourceDir, file))])
)
}
}
function syncArtifact(sourceDir, targetDir) {
const source = readAndValidateArtifact(sourceDir)
const parent = path.dirname(targetDir)
const name = path.basename(targetDir)
const staging = path.join(parent, `.${name}.prepare-${process.pid}`)
const backup = path.join(parent, `.${name}.backup-${process.pid}`)
fs.mkdirSync(parent, { recursive: true })
fs.rmSync(staging, { recursive: true, force: true })
fs.rmSync(backup, { recursive: true, force: true })
fs.mkdirSync(staging, { recursive: true })
try {
for (const relative of REQUIRED_FILES) {
const destination = path.join(staging, relative)
fs.mkdirSync(path.dirname(destination), { recursive: true })
fs.copyFileSync(path.join(sourceDir, relative), destination)
}
fs.chmodSync(path.join(staging, 'tm-wechat-host'), 0o755)
const staged = readAndValidateArtifact(staging)
for (const relative of REQUIRED_FILES) {
if (source.hashes[relative] !== staged.hashes[relative]) {
throw new Error(`Packaged runtime hash mismatch: ${relative}`)
}
}
if (fs.existsSync(targetDir)) fs.renameSync(targetDir, backup)
try {
fs.renameSync(staging, targetDir)
} catch (error) {
if (fs.existsSync(backup)) fs.renameSync(backup, targetDir)
throw error
}
fs.rmSync(backup, { recursive: true, force: true })
return staged
} finally {
fs.rmSync(staging, { recursive: true, force: true })
}
}
function resolveSource(explicit) {
if (explicit) return path.resolve(explicit)
const fromEnv = String(process.env.TM_NATIVE_RUNTIME_DIR || '').trim()
if (fromEnv) return path.resolve(fromEnv)
// 本机路径写在这里即可,该文件不进仓库。
const localConfig = path.join(__dirname, '..', '.native-runtime-source')
if (fs.existsSync(localConfig)) {
const configured = fs.readFileSync(localConfig, 'utf8').trim()
if (configured) return path.resolve(configured)
}
throw new Error(
'runtime artifact source is required: pass --source <dir>, set TM_NATIVE_RUNTIME_DIR, ' +
'or write the path into .native-runtime-source'
)
}
function main() {
const projectRoot = path.resolve(__dirname, '..')
const args = parseArgs(process.argv.slice(2))
const sourceDir = resolveSource(args.source)
const targetDir = path.resolve(
args.target || path.join(projectRoot, 'resources', 'runtime', 'darwin-arm64')
)
const result = syncArtifact(sourceDir, targetDir)
console.log(`[prepare-wechat-native] source: ${sourceDir}`)
console.log(`[prepare-wechat-native] target: ${targetDir}`)
console.log(
`[prepare-wechat-native] runtime: ${result.manifest.runtime}/${result.manifest.version}`
)
console.log(`[prepare-wechat-native] agent: ${result.manifest.tmSendSourceSha256}`)
console.log(`[prepare-wechat-native] address profile: ${result.manifest.addressProfileSha256}`)
console.log('[prepare-wechat-native] files: 3')
}
module.exports = {
REQUIRED_FILES,
assertMachOArm64,
readAndValidateArtifact,
syncArtifact
}
if (require.main === module) {
try {
main()
} catch (error) {
console.error(
`[prepare-wechat-native] ${error instanceof Error ? error.message : String(error)}`
)
process.exitCode = 1
}
}
+11 -4
View File
@@ -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>` `<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')}` ).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 = { const avatars = {
阿宇: avatarSvg('宇', '#dcfce7'), 阿宇: avatarSvg('宇', '#dcfce7'),
+140
View File
@@ -0,0 +1,140 @@
/*
* 测试文件的类型检查棘轮(ratchet)。
*
* 背景:`tsconfig.node.json` / `tsconfig.web.json` 的 include 都不含 `tests/`,
* 所以测试里的类型错误对 `pnpm typecheck` 与 CI 完全不可见——已经积累了一批历史债。
*
* 策略:**不阻塞既有债,但禁止新增**。
* - 基线按「文件 → 错误数」记录,而不是只记总数:
* 否则在 A 文件修掉 1 条、同时在 B 文件新增 1 条会互相抵消,棘轮形同虚设。
* - 某个文件的错误数超过基线即失败;新增了带类型错误的文件同样失败。
* - 需要主动下调基线时用 `--update`(只在确实修好了错误之后)。
*
* 用法:
* node scripts/typecheck-tests.cjs # 校验
* node scripts/typecheck-tests.cjs --update # 用当前结果重写基线
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */
const { spawnSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const projectRoot = path.resolve(__dirname, '..')
const configPath = path.join(projectRoot, 'tsconfig.test.json')
const baselinePath = path.join(projectRoot, 'tests', 'typecheck-baseline.json')
const ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/
const MAX_REPORTED = 20
function runTypeScript() {
// 直接用本地 typescript 包,避免依赖 node_modules/.bin 在各平台的差异。
const tscPath = require.resolve('typescript/bin/tsc')
const result = spawnSync(
process.execPath,
[tscPath, '--noEmit', '--pretty', 'false', '-p', configPath],
{ cwd: projectRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
)
if (result.error) throw result.error
return `${result.stdout || ''}${result.stderr || ''}`
}
function relative(file) {
const rel = path.relative(projectRoot, path.resolve(projectRoot, file))
return rel.split(path.sep).join('/')
}
/** 解析出「文件 → 错误数」与「文件 → 错误信息列表」。 */
function collectErrors(output) {
const counts = new Map()
const details = new Map()
for (const line of output.split('\n')) {
const match = ERROR_LINE.exec(line.trim())
if (!match) continue
const file = relative(match[1])
counts.set(file, (counts.get(file) ?? 0) + 1)
if (!details.has(file)) details.set(file, [])
if (details.get(file).length < 3) details.get(file).push(`${match[2]}:${match[3]} ${match[4]}`)
}
return { counts, details }
}
function readBaseline() {
try {
const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8'))
if (parsed && typeof parsed.files === 'object' && parsed.files !== null) return parsed
} catch {
// 基线缺失或损坏时按「空基线」处理,会在下面明确报错提示。
}
return null
}
function total(counts) {
let sum = 0
for (const value of counts.values()) sum += value
return sum
}
function main() {
if (!fs.existsSync(configPath)) {
console.error(`[typecheck:test] 缺少 ${path.relative(projectRoot, configPath)}`)
process.exit(1)
}
const { counts, details } = collectErrors(runTypeScript())
if (process.argv.includes('--update')) {
const files = Object.fromEntries([...counts.entries()].sort(([a], [b]) => a.localeCompare(b)))
const payload = {
note: '测试文件类型检查基线:只允许下降,不允许上升。用 node scripts/typecheck-tests.cjs --update 下调。',
total: total(counts),
files
}
fs.mkdirSync(path.dirname(baselinePath), { recursive: true })
fs.writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
console.log(
`[typecheck:test] 基线已更新:${payload.total} 个错误 / ${Object.keys(files).length} 个文件`
)
return
}
const baseline = readBaseline()
if (!baseline) {
console.error(
`[typecheck:test] 找不到基线 ${path.relative(projectRoot, baselinePath)}。\n` +
' 首次启用请运行:node scripts/typecheck-tests.cjs --update'
)
process.exit(1)
}
const regressions = []
for (const [file, count] of counts) {
const allowed = baseline.files[file] ?? 0
if (count > allowed) regressions.push({ file, count, allowed })
}
const now = total(counts)
const baselineTotal = Number(baseline.total) || 0
const improved = baselineTotal - now
if (regressions.length > 0) {
console.error('[typecheck:test] 测试文件出现新的类型错误 ❌')
console.error(` 基线 ${baselineTotal} → 当前 ${now}(+${now - baselineTotal})\n`)
let printed = 0
for (const item of regressions) {
console.error(` ${item.file} ${item.allowed} → ${item.count}`)
for (const line of details.get(item.file) ?? []) {
if (printed >= MAX_REPORTED) break
console.error(` ${line}`)
printed += 1
}
}
console.error('\n 修好之后用 --update 下调基线(不要为了过检查而放宽它)。')
process.exit(1)
}
console.log(
`[typecheck:test] PASS ✅ 当前 ${now} 个既有类型错误 / ${counts.size} 个文件` +
(improved > 0 ? `(比基线少 ${improved} 个,可运行 --update 下调)` : '')
)
console.log(' 注意:这是棘轮,只保证「不新增」。修完历史债后可改为阻断式检查。')
}
main()
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 fastclaw-ai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-25
View File
@@ -1,25 +0,0 @@
# TraceMemo WeChat Connector
This repository-local service provides the minimal WeChat bridge required by TraceMemo:
- QR-code login with a single persisted credential
- account discovery
- inbound long polling and authenticated webhook delivery
- local HTTP health and send endpoints
- text and local/remote media sending
The executable is managed by the Electron main process. It is not a general-purpose agent runtime and does not load external AI command-line tools.
## Commands
```bash
go run . login --json
go run . accounts --json
go run . start --foreground --api-addr 127.0.0.1:18011 --account-id <account-id>
```
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. This legacy directory name is intentionally retained so upgrades can reuse existing accounts. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
## Attribution
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). TraceMemo-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding TraceMemo project.
-135
View File
@@ -1,135 +0,0 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
)
// Server provides an HTTP API for sending messages.
type Server struct {
clients []*ilink.Client
addr string
}
// NewServer creates an API server.
func NewServer(clients []*ilink.Client, addr string) *Server {
if addr == "" {
addr = "127.0.0.1:18011"
}
return &Server{clients: clients, addr: addr}
}
// SendRequest is the JSON body for POST /api/send.
type SendRequest struct {
AccountID string `json:"account_id,omitempty"`
To string `json:"to"`
Text string `json:"text,omitempty"`
MediaURL string `json:"media_url,omitempty"` // image/video/file URL
}
// Run starts the HTTP server. Blocks until ctx is cancelled.
func (s *Server) Run(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/api/send", s.handleSend)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
srv := &http.Server{Addr: s.addr, Handler: mux}
go func() {
<-ctx.Done()
srv.Shutdown(context.Background())
}()
log.Printf("[api] listening on %s", s.addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req SendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.To == "" {
http.Error(w, `"to" is required`, http.StatusBadRequest)
return
}
if req.Text == "" && req.MediaURL == "" {
http.Error(w, `"text" or "media_url" is required`, http.StatusBadRequest)
return
}
if len(s.clients) == 0 {
http.Error(w, "no accounts configured", http.StatusServiceUnavailable)
return
}
client := s.clientForAccount(req.AccountID)
if client == nil {
http.Error(w, "requested account is not available", http.StatusNotFound)
return
}
ctx := r.Context()
// Send text if provided
if req.Text != "" {
if err := messaging.SendTextReply(ctx, client, req.To, req.Text, "", ""); err != nil {
log.Printf("[api] send text failed: %v", err)
http.Error(w, "send text failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent text to %s: %q", req.To, req.Text)
// Extract and send any markdown images embedded in text
for _, imgURL := range messaging.ExtractImageURLs(req.Text) {
if err := messaging.SendMediaFromURL(ctx, client, req.To, imgURL, ""); err != nil {
log.Printf("[api] send extracted image failed: %v", err)
} else {
log.Printf("[api] sent extracted image to %s: %s", req.To, imgURL)
}
}
}
// Send media if provided
if req.MediaURL != "" {
if err := messaging.SendMediaFromURL(ctx, client, req.To, req.MediaURL, ""); err != nil {
log.Printf("[api] send media failed: %v", err)
http.Error(w, "send media failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent media to %s: %s", req.To, req.MediaURL)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) clientForAccount(accountID string) *ilink.Client {
if accountID == "" {
return s.clients[0]
}
for _, client := range s.clients {
if client.BotID() == accountID {
return client
}
}
return nil
}
@@ -1,20 +0,0 @@
package api
import (
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestClientForAccountSelectsMatchingBot(t *testing.T) {
oldClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-old"})
newClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-new"})
server := NewServer([]*ilink.Client{oldClient, newClient}, "")
if got := server.clientForAccount("bot-new"); got != newClient {
t.Fatal("clientForAccount did not select the requested account")
}
if got := server.clientForAccount("missing"); got != nil {
t.Fatal("clientForAccount should reject an unknown account")
}
}
-8
View File
@@ -1,8 +0,0 @@
module github.com/Wxw-Gu/WechatExplorer/services/wechat-connector
go 1.23.0
require (
github.com/google/uuid v1.6.0
rsc.io/qr v0.2.0
)
-4
View File
@@ -1,4 +0,0 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
-236
View File
@@ -1,236 +0,0 @@
package ilink
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const (
qrCodeURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3"
qrStatusURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode="
statusWait = "wait"
statusScanned = "scaned"
statusConfirmed = "confirmed"
statusExpired = "expired"
)
// FetchQRCode retrieves a new QR code for login.
func FetchQRCode(ctx context.Context) (*QRCodeResponse, error) {
c := NewUnauthenticatedClient()
var resp QRCodeResponse
if err := c.doGet(ctx, qrCodeURL, &resp); err != nil {
return nil, fmt.Errorf("fetch QR code: %w", err)
}
return &resp, nil
}
// PollQRStatus polls for QR code scan status until confirmed or expired.
// It calls onStatus for each status change so the caller can display progress.
func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status string)) (*Credentials, error) {
c := NewUnauthenticatedClient()
url := qrStatusURL + qrcode
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
pollCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
var resp QRStatusResponse
err := c.doGet(pollCtx, url, &resp)
cancel()
if err != nil {
// Timeout is normal for long-poll, retry
if ctx.Err() != nil {
return nil, ctx.Err()
}
continue
}
if onStatus != nil {
onStatus(resp.Status)
}
switch resp.Status {
case statusConfirmed:
creds := &Credentials{
BotToken: resp.BotToken,
ILinkBotID: resp.ILinkBotID,
BaseURL: resp.BaseURL,
ILinkUserID: resp.ILinkUserID,
}
return creds, nil
case statusExpired:
return nil, fmt.Errorf("QR code expired")
case statusWait, statusScanned:
// Continue polling
default:
// Unknown status, continue
}
}
}
func accountsDir(rootName string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, rootName, "wechat-connector", "accounts"), nil
}
// AccountsDir returns the TraceMemo directory where new credentials are stored.
func AccountsDir() (string, error) {
return accountsDir(".tracememo")
}
// LegacyAccountsDir is read-only compatibility for v2.1.9 and earlier.
func LegacyAccountsDir() (string, error) {
return accountsDir(".wechatexplorer")
}
func accountDirectoryForID(accountID string) (string, error) {
current, err := AccountsDir()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(current, accountID+".json")); err == nil {
return current, nil
}
legacy, err := LegacyAccountsDir()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(legacy, accountID+".json")); err == nil {
return legacy, nil
}
return current, nil
}
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
func NormalizeAccountID(raw string) string {
s := raw
for _, ch := range []string{"@", ".", ":"} {
s = filepath.Clean(s)
s = replaceAll(s, ch, "-")
}
return s
}
func replaceAll(s, old, new string) string {
for {
i := indexOf(s, old)
if i < 0 {
return s
}
s = s[:i] + new + s[i+len(old):]
}
}
func indexOf(s, sub string) int {
for i := range s {
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// SaveCredentials saves the latest credentials and removes older accounts.
// The new credential is written first so a failed login never destroys the
// previously working credential.
func SaveCredentials(creds *Credentials) error {
dir, err := AccountsDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create accounts dir: %w", err)
}
id := NormalizeAccountID(creds.ILinkBotID)
path := filepath.Join(dir, id+".json")
data, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write credentials: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("prune old credentials: %w", err)
}
keepPrefix := id + "."
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), keepPrefix) {
continue
}
if filepath.Ext(entry.Name()) != ".json" {
continue
}
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove old credential %s: %w", entry.Name(), err)
}
}
return nil
}
func loadCredentialsFromDir(dir string) ([]*Credentials, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read accounts dir: %w", err)
}
var result []*Credentials
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var creds Credentials
if json.Unmarshal(data, &creds) == nil && creds.BotToken != "" {
result = append(result, &creds)
}
}
return result, nil
}
// LoadAllCredentials loads TraceMemo credentials first and falls back to the
// untouched WechatExplorer directory for one-version upgrade compatibility.
func LoadAllCredentials() ([]*Credentials, error) {
current, err := AccountsDir()
if err != nil {
return nil, err
}
credentials, err := loadCredentialsFromDir(current)
if err != nil || len(credentials) > 0 {
return credentials, err
}
legacy, err := LegacyAccountsDir()
if err != nil {
return nil, err
}
return loadCredentialsFromDir(legacy)
}
// CredentialsPath returns the path for display purposes.
func CredentialsPath() (string, error) {
return AccountsDir()
}
@@ -1,86 +0,0 @@
package ilink
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func setTestHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
return home
}
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
setTestHome(t)
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
if err := SaveCredentials(old); err != nil {
t.Fatal(err)
}
dir, err := AccountsDir()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json"), []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
if err := SaveCredentials(latest); err != nil {
t.Fatal(err)
}
accounts, err := LoadAllCredentials()
if err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].ILinkBotID != latest.ILinkBotID {
t.Fatalf("accounts = %#v", accounts)
}
if _, err := os.Stat(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json")); !os.IsNotExist(err) {
t.Fatalf("old sync state still exists: %v", err)
}
}
func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
home := setTestHome(t)
dir, err := AccountsDir()
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".tracememo", "wechat-connector", "accounts")
if dir != want {
t.Fatalf("AccountsDir() = %q, want %q", dir, want)
}
}
func TestLoadAllCredentialsFallsBackToLegacyDirectory(t *testing.T) {
setTestHome(t)
legacyDir, err := LegacyAccountsDir()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
t.Fatal(err)
}
legacy := &Credentials{ILinkBotID: "legacy@im.bot", BotToken: "legacy-token"}
data, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(legacyDir, NormalizeAccountID(legacy.ILinkBotID)+".json"), data, 0o600); err != nil {
t.Fatal(err)
}
accounts, err := LoadAllCredentials()
if err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].BotToken != legacy.BotToken {
t.Fatalf("accounts = %#v", accounts)
}
if _, err := os.Stat(legacyDir); err != nil {
t.Fatalf("legacy directory changed or removed: %v", err)
}
}
-218
View File
@@ -1,218 +0,0 @@
package ilink
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
defaultBaseURL = "https://ilinkai.weixin.qq.com"
longPollTimeout = 35 * time.Second
sendTimeout = 15 * time.Second
)
// Client is an iLink HTTP API client.
type Client struct {
baseURL string
botToken string
botID string
httpClient *http.Client
wechatUIN string
}
// NewClient creates a new iLink API client.
func NewClient(creds *Credentials) *Client {
baseURL := creds.BaseURL
if baseURL == "" {
baseURL = defaultBaseURL
}
return &Client{
baseURL: baseURL,
botToken: creds.BotToken,
botID: creds.ILinkBotID,
httpClient: &http.Client{},
wechatUIN: generateWechatUIN(),
}
}
// NewUnauthenticatedClient creates a client without credentials for login flow.
func NewUnauthenticatedClient() *Client {
return &Client{
baseURL: defaultBaseURL,
httpClient: &http.Client{Timeout: 40 * time.Second},
wechatUIN: generateWechatUIN(),
}
}
// BotID returns the bot's user ID.
func (c *Client) BotID() string {
return c.botID
}
// GetUpdates performs a long-poll for new messages.
func (c *Client) GetUpdates(ctx context.Context, buf string) (*GetUpdatesResponse, error) {
reqBody := GetUpdatesRequest{
GetUpdatesBuf: buf,
BaseInfo: BaseInfo{ChannelVersion: "1.0.0"},
}
ctx, cancel := context.WithTimeout(ctx, longPollTimeout+5*time.Second)
defer cancel()
var resp GetUpdatesResponse
if err := c.doPost(ctx, "/ilink/bot/getupdates", reqBody, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendMessage sends a message through iLink.
func (c *Client) SendMessage(ctx context.Context, msg *SendMessageRequest) (*SendMessageResponse, error) {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
var resp SendMessageResponse
if err := c.doPost(ctx, "/ilink/bot/sendmessage", msg, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetConfig fetches bot config for a user (includes typing_ticket).
func (c *Client) GetConfig(ctx context.Context, userID, contextToken string) (*GetConfigResponse, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req := GetConfigRequest{
ILinkUserID: userID,
ContextToken: contextToken,
BaseInfo: BaseInfo{},
}
var resp GetConfigResponse
if err := c.doPost(ctx, "/ilink/bot/getconfig", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendTyping sends a typing indicator to a user.
func (c *Client) SendTyping(ctx context.Context, userID, typingTicket string, status int) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req := SendTypingRequest{
ILinkUserID: userID,
TypingTicket: typingTicket,
Status: status,
BaseInfo: BaseInfo{},
}
var resp SendTypingResponse
if err := c.doPost(ctx, "/ilink/bot/sendtyping", req, &resp); err != nil {
return err
}
if resp.Ret != 0 {
return fmt.Errorf("sendtyping failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
return nil
}
// GetUploadURL gets a pre-signed CDN upload URL for media files.
func (c *Client) GetUploadURL(ctx context.Context, req *GetUploadURLRequest) (*GetUploadURLResponse, error) {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
var resp GetUploadURLResponse
if err := c.doPost(ctx, "/ilink/bot/getuploadurl", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// BaseURL returns the base URL for CDN operations.
func (c *Client) BaseURL() string {
return c.baseURL
}
func (c *Client) doPost(ctx context.Context, path string, body interface{}, result interface{}) error {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
return nil
}
func (c *Client) doGet(ctx context.Context, url string, result interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
return nil
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
req.Header.Set("Authorization", "Bearer "+c.botToken)
req.Header.Set("X-WECHAT-UIN", c.wechatUIN)
}
func generateWechatUIN() string {
var n uint32
_ = binary.Read(rand.Reader, binary.LittleEndian, &n)
s := fmt.Sprintf("%d", n)
return base64.StdEncoding.EncodeToString([]byte(s))
}
-181
View File
@@ -1,181 +0,0 @@
package ilink
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
)
const (
maxConsecutiveFailures = 5
initialBackoff = 3 * time.Second
maxBackoff = 60 * time.Second
sessionExpiredBackoff = 5 * time.Second
errCodeSessionExpired = -14
)
// MessageHandler is called for each received message.
type MessageHandler func(ctx context.Context, client *Client, msg WeixinMessage)
// Monitor manages the long-poll loop for receiving messages.
type Monitor struct {
client *Client
handler MessageHandler
getUpdatesBuf string
bufPath string
failures int
lastActivity time.Time
}
// NewMonitor creates a new long-poll monitor.
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
accountID := NormalizeAccountID(client.BotID())
accountsRoot, err := accountDirectoryForID(accountID)
if err != nil {
return nil, err
}
bufPath := filepath.Join(accountsRoot, accountID+".sync.json")
m := &Monitor{
client: client,
handler: handler,
bufPath: bufPath,
lastActivity: time.Now(),
}
m.loadBuf()
return m, nil
}
// Run starts the long-poll loop. It blocks until ctx is cancelled.
// Automatically recovers from errors with exponential backoff.
func (m *Monitor) Run(ctx context.Context) error {
log.Println("[monitor] starting long-poll loop")
for {
select {
case <-ctx.Done():
log.Println("[monitor] shutting down")
return ctx.Err()
default:
}
resp, err := m.client.GetUpdates(ctx, m.getUpdatesBuf)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
m.failures++
backoff := m.calcBackoff()
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
m.failures, maxConsecutiveFailures, backoff, err)
if m.failures == maxConsecutiveFailures {
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from TraceMemo if this persists.", maxConsecutiveFailures)
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
// Reset failure counter on any successful response
m.failures = 0
m.lastActivity = time.Now()
// Session expired — reset sync buf and reconnect silently
if resp.ErrCode == errCodeSessionExpired {
if m.getUpdatesBuf != "" {
log.Printf("[monitor] session expired, resetting sync buf")
m.getUpdatesBuf = ""
m.saveBuf()
} else {
// Sync buf already empty but still getting session expired:
// the bot token itself has expired. The user needs to re-login.
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from TraceMemo.")
}
select {
case <-time.After(sessionExpiredBackoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
// Other server errors
if resp.Ret != 0 && resp.ErrCode != 0 {
log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg)
continue
}
// Update buf for next poll
if resp.GetUpdatesBuf != "" {
m.getUpdatesBuf = resp.GetUpdatesBuf
m.saveBuf()
}
// Process messages concurrently — don't block the poll loop
for _, msg := range resp.Msgs {
go m.handler(ctx, m.client, msg)
}
}
}
// calcBackoff returns an exponential backoff duration capped at maxBackoff.
func (m *Monitor) calcBackoff() time.Duration {
d := initialBackoff
for i := 1; i < m.failures; i++ {
d *= 2
if d > maxBackoff {
return maxBackoff
}
}
return d
}
type syncData struct {
GetUpdatesBuf string `json:"get_updates_buf"`
}
func (m *Monitor) loadBuf() {
data, err := os.ReadFile(m.bufPath)
if err != nil {
return
}
var s syncData
if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" {
m.getUpdatesBuf = s.GetUpdatesBuf
log.Printf("[monitor] loaded sync buf from %s", m.bufPath)
}
}
func (m *Monitor) saveBuf() {
dir := filepath.Dir(m.bufPath)
if err := os.MkdirAll(dir, 0o700); err != nil {
log.Printf("[monitor] failed to create buf dir: %v", err)
return
}
data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf})
if err := os.WriteFile(m.bufPath, data, 0o600); err != nil {
log.Printf("[monitor] failed to save buf: %v", err)
}
}
// FormatMessageSummary returns a short description of a message for logging.
func FormatMessageSummary(msg WeixinMessage) string {
text := ""
for _, item := range msg.ItemList {
if item.Type == ItemTypeText && item.TextItem != nil {
text = item.TextItem.Text
break
}
}
if len(text) > 50 {
text = text[:50] + "..."
}
return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text)
}
-219
View File
@@ -1,219 +0,0 @@
package ilink
// Message types
const (
MessageTypeNone = 0
MessageTypeUser = 1
MessageTypeBot = 2
)
// Message states
const (
MessageStateNew = 0
MessageStateGenerating = 1
MessageStateFinish = 2
)
// Item types
const (
ItemTypeNone = 0
ItemTypeText = 1
ItemTypeImage = 2
ItemTypeVoice = 3
ItemTypeFile = 4
ItemTypeVideo = 5
)
// QRCodeResponse is the response from get_bot_qrcode.
type QRCodeResponse struct {
QRCode string `json:"qrcode"`
QRCodeImgContent string `json:"qrcode_img_content"`
}
// QRStatusResponse is the response from get_qrcode_status.
type QRStatusResponse struct {
Status string `json:"status"`
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
BaseURL string `json:"baseurl"`
ILinkUserID string `json:"ilink_user_id"`
}
// Credentials stores login session data.
type Credentials struct {
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
BaseURL string `json:"baseurl"`
ILinkUserID string `json:"ilink_user_id"`
}
// BaseInfo is included in request bodies.
type BaseInfo struct {
ChannelVersion string `json:"channel_version,omitempty"`
}
// GetUpdatesRequest is the body for getupdates.
type GetUpdatesRequest struct {
GetUpdatesBuf string `json:"get_updates_buf"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetUpdatesResponse is the response from getupdates.
type GetUpdatesResponse struct {
Ret int `json:"ret"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
Msgs []WeixinMessage `json:"msgs"`
GetUpdatesBuf string `json:"get_updates_buf"`
LongPollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
}
// WeixinMessage represents a message from WeChat.
type WeixinMessage struct {
Seq int `json:"seq,omitempty"`
MessageID int64 `json:"message_id,omitempty"`
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// MessageItem is a single item in a message.
type MessageItem struct {
Type int `json:"type"`
TextItem *TextItem `json:"text_item,omitempty"`
ImageItem *ImageItem `json:"image_item,omitempty"`
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
VideoItem *VideoItem `json:"video_item,omitempty"`
FileItem *FileItem `json:"file_item,omitempty"`
}
// CDN media type constants.
const (
CDNMediaTypeImage = 1
CDNMediaTypeVideo = 2
CDNMediaTypeFile = 3
)
// GetUploadURLRequest is the body for getuploadurl.
type GetUploadURLRequest struct {
FileKey string `json:"filekey"`
MediaType int `json:"media_type"`
ToUserID string `json:"to_user_id"`
RawSize int `json:"rawsize"`
RawFileMD5 string `json:"rawfilemd5"`
FileSize int `json:"filesize"`
NoNeedThumb bool `json:"no_need_thumb"`
AESKey string `json:"aeskey"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetUploadURLResponse is the response from getuploadurl.
type GetUploadURLResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
UploadParam string `json:"upload_param"`
UploadFullURL string `json:"upload_full_url,omitempty"`
}
// TextItem holds text content.
type TextItem struct {
Text string `json:"text"`
}
// MediaInfo holds CDN media reference for uploaded files.
type MediaInfo struct {
EncryptQueryParam string `json:"encrypt_query_param"`
AESKey string `json:"aes_key"` // base64-encoded
EncryptType int `json:"encrypt_type"` // 1 = AES-128-ECB
}
// VoiceItem holds voice content.
type VoiceItem struct {
Media *MediaInfo `json:"media,omitempty"`
VoiceSize int `json:"voice_size,omitempty"`
EncodeType int `json:"encode_type,omitempty"` // 1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3
BitsPerSample int `json:"bits_per_sample,omitempty"`
SampleRate int `json:"sample_rate,omitempty"` // Hz
Playtime int `json:"playtime,omitempty"` // duration in milliseconds
Text string `json:"text,omitempty"` // speech-to-text transcription from WeChat
}
// ImageItem holds image content.
type ImageItem struct {
URL string `json:"url,omitempty"`
Media *MediaInfo `json:"media,omitempty"`
MidSize int `json:"mid_size,omitempty"` // ciphertext size
}
// VideoItem holds video content.
type VideoItem struct {
Media *MediaInfo `json:"media,omitempty"`
VideoSize int `json:"video_size,omitempty"`
}
// FileItem holds file content.
type FileItem struct {
Media *MediaInfo `json:"media,omitempty"`
FileName string `json:"file_name,omitempty"`
Len string `json:"len,omitempty"` // plaintext size as string
}
// SendMessageRequest is the body for sendmessage.
type SendMessageRequest struct {
Msg SendMsg `json:"msg"`
BaseInfo BaseInfo `json:"base_info"`
}
// SendMsg is the message payload for sending.
type SendMsg struct {
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
ClientID string `json:"client_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// SendMessageResponse is the response from sendmessage.
type SendMessageResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
}
// Typing status constants.
const (
TypingStatusTyping = 1
TypingStatusCancel = 2
)
// GetConfigRequest is the body for getconfig.
type GetConfigRequest struct {
ILinkUserID string `json:"ilink_user_id"`
ContextToken string `json:"context_token,omitempty"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetConfigResponse is the response from getconfig.
type GetConfigResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
TypingTicket string `json:"typing_ticket,omitempty"`
}
// SendTypingRequest is the body for sendtyping.
type SendTypingRequest struct {
ILinkUserID string `json:"ilink_user_id"`
TypingTicket string `json:"typing_ticket"`
Status int `json:"status"`
BaseInfo BaseInfo `json:"base_info"`
}
// SendTypingResponse is the response from sendtyping.
type SendTypingResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
}
-200
View File
@@ -1,200 +0,0 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/api"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
"rsc.io/qr"
)
type loginEvent struct {
Status string `json:"status"`
QRCodeDataURL string `json:"qr_code_data_url,omitempty"`
AccountID string `json:"account_id,omitempty"`
WeChatUserID string `json:"wechat_user_id,omitempty"`
}
type accountSummary struct {
AccountID string `json:"account_id"`
WeChatUserID string `json:"wechat_user_id"`
}
func main() {
if len(os.Args) < 2 {
fatal(errors.New("expected one of: login, accounts, start"))
}
var err error
switch os.Args[1] {
case "login":
err = runLogin(os.Args[2:])
case "accounts":
err = runAccounts(os.Args[2:])
case "start":
err = runStart(os.Args[2:])
default:
err = fmt.Errorf("unknown command %q", os.Args[1])
}
if err != nil {
fatal(err)
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
func signalContext() (context.Context, context.CancelFunc) {
return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
}
func runLogin(args []string) error {
flags := flag.NewFlagSet("login", flag.ContinueOnError)
jsonOutput := flags.Bool("json", false, "emit JSON Lines events")
if err := flags.Parse(args); err != nil {
return err
}
ctx, cancel := signalContext()
defer cancel()
creds, err := login(ctx, *jsonOutput)
if err != nil {
return err
}
if !*jsonOutput {
fmt.Printf("WeChat account %s connected.\n", creds.ILinkBotID)
}
return nil
}
func login(ctx context.Context, jsonOutput bool) (*ilink.Credentials, error) {
qrResponse, err := ilink.FetchQRCode(ctx)
if err != nil {
return nil, err
}
code, err := qr.Encode(qrResponse.QRCodeImgContent, qr.L)
if err != nil {
return nil, fmt.Errorf("encode QR image: %w", err)
}
emit := func(event loginEvent) {
if jsonOutput {
_ = json.NewEncoder(os.Stdout).Encode(event)
}
}
emit(loginEvent{Status: "qrcode", QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())})
lastStatus := ""
creds, err := ilink.PollQRStatus(ctx, qrResponse.QRCode, func(status string) {
if status != lastStatus {
lastStatus = status
emit(loginEvent{Status: status})
}
})
if err != nil {
return nil, err
}
if err := ilink.SaveCredentials(creds); err != nil {
return nil, fmt.Errorf("save credentials: %w", err)
}
emit(loginEvent{Status: "active", AccountID: creds.ILinkBotID, WeChatUserID: creds.ILinkUserID})
return creds, nil
}
func runAccounts(args []string) error {
flags := flag.NewFlagSet("accounts", flag.ContinueOnError)
jsonOutput := flags.Bool("json", false, "print JSON")
if err := flags.Parse(args); err != nil {
return err
}
accounts, err := ilink.LoadAllCredentials()
if err != nil {
return err
}
items := make([]accountSummary, 0, len(accounts))
for _, account := range accounts {
items = append(items, accountSummary{AccountID: account.ILinkBotID, WeChatUserID: account.ILinkUserID})
}
if *jsonOutput {
return json.NewEncoder(os.Stdout).Encode(map[string]any{"accounts": items})
}
for _, item := range items {
fmt.Printf("%s\t%s\n", item.AccountID, item.WeChatUserID)
}
return nil
}
func runStart(args []string) error {
flags := flag.NewFlagSet("start", flag.ContinueOnError)
_ = flags.Bool("foreground", false, "kept for host compatibility")
apiAddr := flags.String("api-addr", "127.0.0.1:18011", "local send API address")
accountID := flags.String("account-id", "", "account to start")
if err := flags.Parse(args); err != nil {
return err
}
accounts, err := ilink.LoadAllCredentials()
if err != nil {
return err
}
if len(accounts) == 0 {
return errors.New("no connected WeChat account; scan a QR code first")
}
selected := accounts[len(accounts)-1]
if *accountID != "" {
selected = nil
for _, account := range accounts {
if account.ILinkBotID == *accountID {
selected = account
break
}
}
if selected == nil {
return fmt.Errorf("account %q not found", *accountID)
}
}
ctx, cancel := signalContext()
defer cancel()
client := ilink.NewClient(selected)
server := api.NewServer([]*ilink.Client{client}, *apiAddr)
webhookURL := strings.TrimSpace(os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL"))
webhook := messaging.NewInboundWebhook(webhookURL, os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN"))
monitor, err := ilink.NewMonitor(client, func(messageContext context.Context, source *ilink.Client, message ilink.WeixinMessage) {
if webhookURL != "" {
webhook.Dispatch(messageContext, source, message)
}
})
if err != nil {
return err
}
var wait sync.WaitGroup
wait.Add(2)
go func() {
defer wait.Done()
if err := server.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[api] stopped: %v", err)
cancel()
}
}()
go func() {
defer wait.Done()
if err := monitor.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[monitor] stopped: %v", err)
cancel()
}
}()
wait.Wait()
return nil
}
-232
View File
@@ -1,232 +0,0 @@
package messaging
import (
"bytes"
"context"
"crypto/aes"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
const cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
// UploadedFile holds the result of a CDN upload.
type UploadedFile struct {
DownloadParam string // encrypted query param for download
AESKeyHex string // hex-encoded AES key
FileSize int // plaintext size
CipherSize int // ciphertext size
}
// UploadFileToCDN encrypts and uploads a file to the WeChat CDN.
func UploadFileToCDN(ctx context.Context, client *ilink.Client, data []byte, toUserID string, mediaType int) (*UploadedFile, error) {
// Generate random filekey and AES key
filekey := make([]byte, 16)
aeskey := make([]byte, 16)
if _, err := rand.Read(filekey); err != nil {
return nil, fmt.Errorf("generate filekey: %w", err)
}
if _, err := rand.Read(aeskey); err != nil {
return nil, fmt.Errorf("generate aeskey: %w", err)
}
filekeyHex := hex.EncodeToString(filekey)
aeskeyHex := hex.EncodeToString(aeskey)
// Calculate MD5 of plaintext
hash := md5.Sum(data)
rawMD5 := hex.EncodeToString(hash[:])
// Calculate ciphertext size (PKCS7 padding)
cipherSize := aesECBPaddedSize(len(data))
// Get upload URL from iLink API
uploadReq := &ilink.GetUploadURLRequest{
FileKey: filekeyHex,
MediaType: mediaType,
ToUserID: toUserID,
RawSize: len(data),
RawFileMD5: rawMD5,
FileSize: cipherSize,
NoNeedThumb: true,
AESKey: aeskeyHex,
BaseInfo: ilink.BaseInfo{},
}
uploadResp, err := client.GetUploadURL(ctx, uploadReq)
if err != nil {
return nil, fmt.Errorf("get upload URL: %w", err)
}
if uploadResp.Ret != 0 {
return nil, fmt.Errorf("get upload URL failed: ret=%d errmsg=%s", uploadResp.Ret, uploadResp.ErrMsg)
}
// Encrypt data with AES-128-ECB
encrypted, err := encryptAESECB(data, aeskey)
if err != nil {
return nil, fmt.Errorf("encrypt: %w", err)
}
// Upload to CDN: prefer server-provided full URL, fall back to param-based construction
cdnURL := strings.TrimSpace(uploadResp.UploadFullURL)
if cdnURL == "" {
if uploadResp.UploadParam == "" {
return nil, fmt.Errorf("getuploadurl returned no upload URL (need upload_full_url or upload_param)")
}
cdnURL = fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s",
cdnBaseURL, url.QueryEscape(uploadResp.UploadParam), url.QueryEscape(filekeyHex))
}
downloadParam, err := uploadToCDN(ctx, encrypted, cdnURL)
if err != nil {
return nil, fmt.Errorf("CDN upload: %w", err)
}
return &UploadedFile{
DownloadParam: downloadParam,
AESKeyHex: aeskeyHex,
FileSize: len(data),
CipherSize: cipherSize,
}, nil
}
// AESKeyToBase64 converts a hex AES key to base64 format for message items.
func AESKeyToBase64(hexKey string) string {
return base64.StdEncoding.EncodeToString([]byte(hexKey))
}
// DownloadFileFromCDN downloads and decrypts a file from the WeChat CDN.
func DownloadFileFromCDN(ctx context.Context, encryptQueryParam, aesKeyBase64 string) ([]byte, error) {
// Decode AES key: base64 -> hex string -> raw bytes
aesKeyHexBytes, err := base64.StdEncoding.DecodeString(aesKeyBase64)
if err != nil {
return nil, fmt.Errorf("decode AES key base64: %w", err)
}
aesKey, err := hex.DecodeString(string(aesKeyHexBytes))
if err != nil {
return nil, fmt.Errorf("decode AES key hex: %w", err)
}
// Download encrypted data from CDN
downloadURL := fmt.Sprintf("%s/download?encrypted_query_param=%s",
cdnBaseURL, url.QueryEscape(encryptQueryParam))
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download from CDN: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("CDN download HTTP %d: %s", resp.StatusCode, string(body))
}
encrypted, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read CDN response: %w", err)
}
// Decrypt AES-128-ECB
return decryptAESECB(encrypted, aesKey)
}
// decryptAESECB decrypts data encrypted with AES-128-ECB and removes PKCS7 padding.
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext is not a multiple of block size")
}
plaintext := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
// Remove PKCS7 padding
if len(plaintext) == 0 {
return plaintext, nil
}
padLen := int(plaintext[len(plaintext)-1])
if padLen > aes.BlockSize || padLen == 0 {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
return plaintext[:len(plaintext)-padLen], nil
}
func uploadToCDN(ctx context.Context, encrypted []byte, cdnURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(encrypted))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/octet-stream")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("CDN upload HTTP %d: %s", resp.StatusCode, string(body))
}
downloadParam := resp.Header.Get("X-Encrypted-Param")
if downloadParam == "" {
return "", fmt.Errorf("CDN upload: missing X-Encrypted-Param header")
}
return downloadParam, nil
}
// encryptAESECB encrypts data using AES-128-ECB with PKCS7 padding.
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// PKCS7 padding
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
padded := make([]byte, len(plaintext)+padLen)
copy(padded, plaintext)
for i := len(plaintext); i < len(padded); i++ {
padded[i] = byte(padLen)
}
// ECB mode: encrypt each block independently
encrypted := make([]byte, len(padded))
for i := 0; i < len(padded); i += aes.BlockSize {
block.Encrypt(encrypted[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
}
return encrypted, nil
}
func aesECBPaddedSize(plaintextSize int) int {
return (plaintextSize/aes.BlockSize + 1) * aes.BlockSize
}
@@ -1,121 +0,0 @@
package messaging
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
const (
webhookAttempts = 3
webhookTimeout = 5 * time.Second
)
type InboundWebhook struct {
url string
token string
client *http.Client
}
type inboundWebhookPayload struct {
AccountID string `json:"account_id"`
FromUserID string `json:"from_user_id"`
MessageID int64 `json:"message_id"`
MessageType int `json:"message_type"`
Items []inboundWebhookItem `json:"items"`
ReceivedAt time.Time `json:"received_at"`
}
type inboundWebhookItem struct {
Type int `json:"type"`
Text string `json:"text,omitempty"`
}
func NewInboundWebhook(url, token string) *InboundWebhook {
return &InboundWebhook{
url: strings.TrimSpace(url),
token: token,
client: &http.Client{Timeout: webhookTimeout},
}
}
// Dispatch is intentionally non-blocking so webhook failures never stall iLink polling.
func (w *InboundWebhook) Dispatch(ctx context.Context, client *ilink.Client, msg ilink.WeixinMessage) {
payload := normalizeInboundMessage(client.BotID(), msg)
go func() {
if err := w.deliver(ctx, payload); err != nil {
log.Printf("[webhook] inbound delivery failed for message %d: %v", msg.MessageID, err)
}
}()
}
func (w *InboundWebhook) deliver(ctx context.Context, payload inboundWebhookPayload) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encode payload: %w", err)
}
var lastErr error
for attempt := 1; attempt <= webhookAttempts; attempt++ {
if attempt > 1 {
timer := time.NewTimer(time.Duration(attempt-1) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body))
if reqErr != nil {
return fmt.Errorf("create request: %w", reqErr)
}
req.Header.Set("Content-Type", "application/json")
if w.token != "" {
req.Header.Set("Authorization", "Bearer "+w.token)
}
resp, doErr := w.client.Do(req)
if doErr != nil {
lastErr = doErr
continue
}
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
lastErr = fmt.Errorf("status %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
break
}
}
return lastErr
}
func normalizeInboundMessage(accountID string, msg ilink.WeixinMessage) inboundWebhookPayload {
items := make([]inboundWebhookItem, 0, len(msg.ItemList))
for _, item := range msg.ItemList {
normalized := inboundWebhookItem{Type: item.Type}
if item.TextItem != nil {
normalized.Text = item.TextItem.Text
} else if item.VoiceItem != nil {
normalized.Text = item.VoiceItem.Text
}
items = append(items, normalized)
}
return inboundWebhookPayload{
AccountID: accountID,
FromUserID: msg.FromUserID,
MessageID: msg.MessageID,
MessageType: msg.MessageType,
Items: items,
ReceivedAt: time.Now().UTC(),
}
}
@@ -1,75 +0,0 @@
package messaging
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestInboundWebhookDeliversNormalizedPayload(t *testing.T) {
var got inboundWebhookPayload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "secret")
err := webhook.deliver(context.Background(), normalizeInboundMessage("bot-new", ilink.WeixinMessage{
MessageID: 7, FromUserID: "user-1", MessageType: ilink.MessageTypeUser,
ItemList: []ilink.MessageItem{{Type: ilink.ItemTypeText, TextItem: &ilink.TextItem{Text: "最近5条消息"}}},
}))
if err != nil {
t.Fatalf("deliver: %v", err)
}
if got.AccountID != "bot-new" || got.MessageID != 7 || len(got.Items) != 1 || got.Items[0].Text != "最近5条消息" {
t.Fatalf("payload = %#v", got)
}
}
func TestInboundWebhookRetriesServerErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) < 3 {
http.Error(w, "temporary", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "")
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err != nil {
t.Fatalf("deliver: %v", err)
}
if calls.Load() != 3 {
t.Fatalf("calls = %d, want 3", calls.Load())
}
}
func TestInboundWebhookDoesNotRetryClientErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "")
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err == nil {
t.Fatal("deliver error = nil")
}
if calls.Load() != 1 {
t.Fatalf("calls = %d, want 1", calls.Load())
}
}
@@ -1,103 +0,0 @@
package messaging
import (
"regexp"
"strings"
)
var (
// Code blocks: strip fences, keep code content
reCodeBlock = regexp.MustCompile("(?s)```[^\n]*\n?(.*?)```")
// Inline code: strip backticks, keep content
reInlineCode = regexp.MustCompile("`([^`]+)`")
// Images: remove entirely
reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`)
// Links: keep display text only
reLink = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)
// Table separator rows: remove
reTableSep = regexp.MustCompile(`(?m)^\|[\s:|\-]+\|$`)
// Table rows: convert pipe-delimited to space-delimited
reTableRow = regexp.MustCompile(`(?m)^\|(.+)\|$`)
// Headers: remove # prefix
reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+`)
// Bold: **text** or __text__
reBold = regexp.MustCompile(`\*\*(.+?)\*\*|__(.+?)__`)
// Italic: *text* or _text_
reItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)|(?:^|[^_])_([^_]+)_(?:[^_]|$)`)
// Strikethrough: ~~text~~
reStrike = regexp.MustCompile(`~~(.+?)~~`)
// Blockquote: > prefix
reBlockquote = regexp.MustCompile(`(?m)^>\s?`)
// Horizontal rule
reHR = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`)
// Unordered list markers: -, *, +
reUL = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
)
// MarkdownToPlainText converts markdown to readable plain text for WeChat.
func MarkdownToPlainText(text string) string {
result := text
// Code blocks: strip fences, keep code content
result = reCodeBlock.ReplaceAllStringFunc(result, func(match string) string {
parts := reCodeBlock.FindStringSubmatch(match)
if len(parts) > 1 {
return strings.TrimSpace(parts[1])
}
return match
})
// Images: remove entirely
result = reImage.ReplaceAllString(result, "")
// Links: keep display text only
result = reLink.ReplaceAllString(result, "$1")
// Table separator rows: remove
result = reTableSep.ReplaceAllString(result, "")
// Table rows: pipe-delimited to space-delimited
result = reTableRow.ReplaceAllStringFunc(result, func(match string) string {
parts := reTableRow.FindStringSubmatch(match)
if len(parts) > 1 {
cells := strings.Split(parts[1], "|")
for i := range cells {
cells[i] = strings.TrimSpace(cells[i])
}
return strings.Join(cells, " ")
}
return match
})
// Headers: remove # prefix
result = reHeader.ReplaceAllString(result, "")
// Bold
result = reBold.ReplaceAllStringFunc(result, func(match string) string {
parts := reBold.FindStringSubmatch(match)
if parts[1] != "" {
return parts[1]
}
return parts[2]
})
// Strikethrough
result = reStrike.ReplaceAllString(result, "$1")
// Blockquote
result = reBlockquote.ReplaceAllString(result, "")
// Horizontal rule -> empty line
result = reHR.ReplaceAllString(result, "")
// Unordered list: replace markers with "• "
result = reUL.ReplaceAllString(result, "${1}• ")
// Inline code: strip backticks (do after code blocks)
result = reInlineCode.ReplaceAllString(result, "$1")
// Clean up excessive blank lines
result = regexp.MustCompile(`\n{3,}`).ReplaceAllString(result, "\n\n")
return strings.TrimSpace(result)
}
@@ -1,221 +0,0 @@
package messaging
import (
"context"
"fmt"
"io"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
// reMarkdownImage matches markdown image syntax: ![alt](url)
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
// ExtractImageURLs extracts image URLs from markdown text.
func ExtractImageURLs(text string) []string {
matches := reMarkdownImage.FindAllStringSubmatch(text, -1)
var urls []string
for _, m := range matches {
url := strings.TrimSpace(m[1])
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
urls = append(urls, url)
}
}
return urls
}
// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message.
func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error {
// Check if it's a local file
if _, err := os.Stat(mediaURL); err == nil {
return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken)
}
// Must be a valid HTTP URL to download
if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") {
return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL)
}
data, contentType, err := downloadFile(ctx, mediaURL)
if err != nil {
return fmt.Errorf("download %s: %w", mediaURL, err)
}
return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken)
}
// SendMediaFromPath reads a local file and sends it as a media message.
func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken)
}
func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error {
if fileName == "" {
fileName = "file"
}
cdnMediaType, itemType := classifyMedia(contentType, source)
log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID)
uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType)
if err != nil {
return fmt.Errorf("upload to CDN: %w", err)
}
media := &ilink.MediaInfo{
EncryptQueryParam: uploaded.DownloadParam,
AESKey: AESKeyToBase64(uploaded.AESKeyHex),
EncryptType: 1,
}
var item ilink.MessageItem
switch itemType {
case ilink.ItemTypeImage:
item = ilink.MessageItem{
Type: ilink.ItemTypeImage,
ImageItem: &ilink.ImageItem{
Media: media,
MidSize: uploaded.CipherSize,
},
}
case ilink.ItemTypeVideo:
item = ilink.MessageItem{
Type: ilink.ItemTypeVideo,
VideoItem: &ilink.VideoItem{
Media: media,
VideoSize: uploaded.CipherSize,
},
}
default:
item = ilink.MessageItem{
Type: ilink.ItemTypeFile,
FileItem: &ilink.FileItem{
Media: media,
FileName: fileName,
Len: fmt.Sprintf("%d", uploaded.FileSize),
},
}
}
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: NewClientID(),
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{item},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send media message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source)
return nil
}
func downloadFile(ctx context.Context, url string) ([]byte, string, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = inferContentType(url)
}
return data, contentType, nil
}
func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) {
ct := strings.ToLower(contentType)
if strings.HasPrefix(ct, "image/") || isImageExt(url) {
return ilink.CDNMediaTypeImage, ilink.ItemTypeImage
}
if strings.HasPrefix(ct, "video/") || isVideoExt(url) {
return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo
}
return ilink.CDNMediaTypeFile, ilink.ItemTypeFile
}
func isImageExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
return true
}
return false
}
func isVideoExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".mp4", ".mov", ".webm", ".mkv", ".avi":
return true
}
return false
}
func inferContentType(url string) string {
ext := filepath.Ext(stripQuery(url))
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
func filenameFromURL(rawURL string) string {
u := stripQuery(rawURL)
name := filepath.Base(u)
if name == "" || name == "." || name == "/" {
return "file"
}
return name
}
func stripQuery(rawURL string) string {
if i := strings.IndexByte(rawURL, '?'); i >= 0 {
return rawURL[:i]
}
return rawURL
}
@@ -1,73 +0,0 @@
package messaging
import "testing"
func TestExtractImageURLs(t *testing.T) {
text := "check ![img](https://example.com/a.png) and ![](https://example.com/b.jpg)"
urls := ExtractImageURLs(text)
if len(urls) != 2 {
t.Fatalf("expected 2 urls, got %d", len(urls))
}
if urls[0] != "https://example.com/a.png" {
t.Errorf("urls[0] = %q", urls[0])
}
if urls[1] != "https://example.com/b.jpg" {
t.Errorf("urls[1] = %q", urls[1])
}
}
func TestExtractImageURLs_NoImages(t *testing.T) {
urls := ExtractImageURLs("just plain text")
if len(urls) != 0 {
t.Errorf("expected 0 urls, got %d", len(urls))
}
}
func TestExtractImageURLs_RelativeURL(t *testing.T) {
text := "![img](./local.png)"
urls := ExtractImageURLs(text)
if len(urls) != 0 {
t.Errorf("expected 0 urls for relative path, got %d", len(urls))
}
}
func TestFilenameFromURL(t *testing.T) {
tests := []struct {
url string
want string
}{
{"https://example.com/photo.png", "photo.png"},
{"https://example.com/path/to/report.pdf", "report.pdf"},
{"https://example.com/file", "file"},
}
for _, tt := range tests {
got := filenameFromURL(tt.url)
if got != tt.want {
t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want)
}
}
}
func TestFilenameFromURL_WithQuery(t *testing.T) {
got := filenameFromURL("https://example.com/photo.png?token=abc")
if got != "photo.png" {
t.Errorf("got %q, want %q", got, "photo.png")
}
}
func TestStripQuery(t *testing.T) {
tests := []struct {
input string
want string
}{
{"https://example.com/a?b=c", "https://example.com/a"},
{"https://example.com/a", "https://example.com/a"},
{"https://example.com/?x=1&y=2", "https://example.com/"},
}
for _, tt := range tests {
got := stripQuery(tt.input)
if got != tt.want {
t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
@@ -1,86 +0,0 @@
package messaging
import (
"context"
"fmt"
"log"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/google/uuid"
)
// NewClientID generates a new unique client ID for message correlation.
func NewClientID() string {
return uuid.New().String()
}
// SendTypingState sends a typing indicator to a user via the iLink sendtyping API.
// It first fetches a typing_ticket via getconfig, then sends the typing status.
func SendTypingState(ctx context.Context, client *ilink.Client, userID, contextToken string) error {
// Get typing ticket
configResp, err := client.GetConfig(ctx, userID, contextToken)
if err != nil {
return fmt.Errorf("get config for typing: %w", err)
}
if configResp.TypingTicket == "" {
return fmt.Errorf("no typing_ticket returned from getconfig")
}
// Send typing
if err := client.SendTyping(ctx, userID, configResp.TypingTicket, ilink.TypingStatusTyping); err != nil {
return fmt.Errorf("send typing: %w", err)
}
log.Printf("[sender] sent typing indicator to %s", userID)
return nil
}
// SendTextReply sends a text reply to a user through the iLink API.
// If clientID is empty, a new one is generated.
func SendTextReply(ctx context.Context, client *ilink.Client, toUserID, text, contextToken, clientID string) error {
if clientID == "" {
clientID = NewClientID()
}
// Convert markdown to plain text for WeChat display
plainText := MarkdownToPlainText(text)
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: clientID,
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{
{
Type: ilink.ItemTypeText,
TextItem: &ilink.TextItem{
Text: plainText,
},
},
},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send message failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[sender] sent reply to %s: %q", toUserID, truncate(text, 50))
return nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+9 -4
View File
@@ -1246,10 +1246,15 @@ async function runSingleExport(
const wavChannels = const wavChannels =
audioBuffer.length >= 44 ? audioBuffer.readUInt16LE(22) : 1 audioBuffer.length >= 44 ? audioBuffer.readUInt16LE(22) : 1
const pcmBytes = Math.max(0, audioBuffer.length - 44) const pcmBytes = Math.max(0, audioBuffer.length - 44)
message.voiceDuration = Math.max( // 口径统一:消息解析阶段已从 <voicemsg voicelength> 拿到微信的原始秒数(带小数),
1, // 它是唯一权威来源,不要覆盖。只有拿不到时才退回用 PCM 字节数估算——
Math.round(pcmBytes / (wavSampleRate * wavChannels * 2)) // 那份估算是整秒、且下限 1 秒(WAV 缺失头部时的兜底),语义不同。
) if (message.voiceDuration == null) {
message.voiceDuration = Math.max(
1,
Math.round(pcmBytes / (wavSampleRate * wavChannels * 2))
)
}
} catch (error) { } catch (error) {
keepMediaError( keepMediaError(
request, request,
+180 -76
View File
@@ -13,6 +13,8 @@ import {
GroupReportRenderSnapshotExportRequest, GroupReportRenderSnapshotExportRequest,
ReportHeat, ReportHeat,
ReportSectionMeta, ReportSectionMeta,
buildReportAvatarAliasIndex,
mergeReportAvatars,
selectHeroParticipantNames selectHeroParticipantNames
} from '../shared/group-report' } from '../shared/group-report'
import { resolveMd5, getGroupSnapshot } from './services/chat-service' import { resolveMd5, getGroupSnapshot } from './services/chat-service'
@@ -99,51 +101,155 @@ const fallbackAvatar = (name: string): RenderedAvatar => {
const hue = hashName(name) % 360 const hue = hashName(name) % 360
const initial = escapeHtml(Array.from(name.trim())[0] || '?') 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>` const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
return { source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, fallback: true } return {
} source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
fallback: true
const imageMimeType = (contentType: string | null, source: string): string => {
if (contentType?.startsWith('image/')) return contentType.split(';')[0]
const extension = path.extname(source).toLowerCase()
if (extension === '.png') return 'image/png'
if (extension === '.webp') return 'image/webp'
if (extension === '.gif') return 'image/gif'
return 'image/jpeg'
}
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)
} }
} }
/**
* 只认真实图片字节。
*
* `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。 * 从群成员快照反推真头像,填进 metadata.avatars。
* - 没传 talker → 跳过(向后兼容) * - 没传 talker → 跳过(向后兼容)
* - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback * - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback
* - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补 * - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补
* - 同名取首条(P2 风险:群里两人同名) * - 同名取首条(P2 风险:群里两人同名)
*
* **必须按多个别名建索引**:报告里的显示名取决于 `memberNameMode`
* (默认 `groupNickname` = 群昵称),而快照的 `nickname` 字段是
* `wechatNickname || groupNickname || username`(见 `normalizeGroupMembers`)。
* 一个成员同时有微信昵称与群昵称且两者不同时,只按 `nickname` 建索引就会**全部对不上**,
* 于是头像 enrichment 静默失效 —— 这正是原先只用单一索引键时的问题。
*/ */
const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<void> => { const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<void> => {
if (!metadata.talker) return if (!metadata.talker) return
@@ -162,22 +268,16 @@ const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<vo
return return
} }
const index = new Map<string, string>() // 每个成员的所有可用显示名都指向同一个头像 URL;先到先得,避免同名互相覆盖。
for (const member of snapshot.members) { // 只按 `member.nickname` 建索引会在"微信昵称 ≠ 群昵称"时全部对不上 —— 见 shared 里的注释。
if (member.nickname && member.avatar && !index.has(member.nickname)) { const index = buildReportAvatarAliasIndex(snapshot.members)
index.set(member.nickname, member.avatar)
}
}
metadata.avatars = metadata.avatars ?? {} metadata.avatars = metadata.avatars ?? {}
for (const [name, url] of index) { const filled = mergeReportAvatars(metadata.avatars, index)
if (metadata.avatars[name]) continue
metadata.avatars[name] = url
}
metadata.warnings = metadata.warnings ?? [] metadata.warnings = metadata.warnings ?? []
metadata.warnings.push( 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?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner)) report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
const avatars = new Map<string, RenderedAvatar>() // 有界并发 + 单条重试:几十个头像同时打同一个 CDN 会被限流,瞬时失败会让一个人
await Promise.all( // 在整份报告里永久退化成首字占位(实测同一天三次生成:0% / 0% / 32% 失败)。
Array.from(avatarNames).map(async (name) => { const renderedAvatars = await mapWithConcurrency(
avatars.set(name, await embedAvatar(metadata.avatars[name], name)) 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 avatar = (name: string): RenderedAvatar => avatars.get(name) || fallbackAvatar(name)
const renderAvatar = ( const renderAvatar = (
name: string, name: string,
@@ -295,9 +405,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
} }
const heroNames = selectHeroParticipantNames(metadata.heroParticipants) const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
const heroAvatars = heroNames const heroAvatars = heroNames.map((name) => renderAvatar(name, 'hero', '', name)).join('')
.map((name) => renderAvatar(name, 'hero', '', name))
.join('')
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section' const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
const topicCards = report.topics 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"> (item, index) => `<div class="rank tm-fragment tm-ranking-item">
${renderAvatar(item.sender, 'ranking')} ${renderAvatar(item.sender, 'ranking')}
<b>${index + 1}. ${escapeHtml(item.sender)}</b> <b>${index + 1}. ${escapeHtml(item.sender)}</b>
<span>${item.count} 条 · ${item.durationSec} 秒</span> <span>${item.count} 条 · ${Math.round(item.durationSec)} 秒</span>
</div>` </div>`
) )
.join('') .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) for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键) // 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
html = html.replace(/\{\{[A-Z_]+\}\}/g, '') html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
return addReportCsp(injectReportTemplateFragmentContract(html), resolvedTemplate.source === 'builtin') return addReportCsp(
injectReportTemplateFragmentContract(html),
resolvedTemplate.source === 'builtin'
)
} }
const renderReportSnapshotHtml = async ( const renderReportSnapshotHtml = async (
@@ -679,7 +790,10 @@ const renderReportSnapshotHtml = async (
REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate) REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate)
} }
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value) 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 ( export const extractGroupReportRenderSnapshot = async (
@@ -1021,15 +1135,10 @@ export const exportGroupReport = async (
await fs.writeFile(htmlPath, html, 'utf8') await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date() const htmlEndedAt = new Date()
const pngStartedAt = new Date() const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage( const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
htmlPath, ...resolvedTemplate.definition,
pngPath, maxCaptureHeight: resolvedTemplate.captureMaxHeight
request.templateId, })
{
...resolvedTemplate.definition,
maxCaptureHeight: resolvedTemplate.captureMaxHeight
}
)
const pngEndedAt = new Date() const pngEndedAt = new Date()
return { return {
success: true, success: true,
@@ -1074,15 +1183,10 @@ export const exportGroupReportSnapshot = async (
await fs.writeFile(htmlPath, html, 'utf8') await fs.writeFile(htmlPath, html, 'utf8')
const htmlEndedAt = new Date() const htmlEndedAt = new Date()
const pngStartedAt = new Date() const pngStartedAt = new Date()
const imageDataUrl = await captureFullPage( const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
htmlPath, ...resolvedTemplate.definition,
pngPath, maxCaptureHeight: resolvedTemplate.captureMaxHeight
request.templateId, })
{
...resolvedTemplate.definition,
maxCaptureHeight: resolvedTemplate.captureMaxHeight
}
)
const pngEndedAt = new Date() const pngEndedAt = new Date()
return { return {
success: true, success: true,
+10 -4
View File
@@ -3,6 +3,7 @@ import http, { IncomingMessage, ServerResponse, Server } from 'http'
import { import {
isReady, isReady,
listContacts, listContacts,
listContactsAsync,
listMessages, listMessages,
getGroupSnapshot, getGroupSnapshot,
listRecentChat, listRecentChat,
@@ -230,21 +231,26 @@ const routes: Record<string, RouteHandler> = {
}) })
}, },
'/api/v1/contact': ({ res, url }) => { '/api/v1/contact': async ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化') if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const filter = url.searchParams.get('filter') || undefined const filter = url.searchParams.get('filter') || undefined
const type = url.searchParams.get('type') || undefined const type = url.searchParams.get('type') || undefined
let contacts = listContacts(filter) // Use the hydrated data source: on macOS the session cache only carries raw
// ids until display names / contact identities are hydrated, so the sync
// `listContacts` would miss nickname and remark matches (Issue #51).
let contacts = await listContactsAsync(filter)
if (type === 'user' || type === 'group') { if (type === 'user' || type === 'group') {
contacts = contacts.filter((c) => c.type === type) contacts = contacts.filter((c) => c.type === type)
} }
sendJson(res, 200, { count: contacts.length, contacts }) sendJson(res, 200, { count: contacts.length, contacts })
}, },
'/api/v1/chatroom': ({ res, url }) => { '/api/v1/chatroom': async ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化') if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
const keyword = url.searchParams.get('keyword') || '' const keyword = url.searchParams.get('keyword') || ''
let groups = listContacts().filter((c) => c.type === 'group') // Same hydration requirement as /api/v1/contact: group display names are
// exactly the fields that stay un-hydrated on macOS.
let groups = (await listContactsAsync()).filter((c) => c.type === 'group')
if (keyword) { if (keyword) {
const lower = keyword.toLowerCase() const lower = keyword.toLowerCase()
groups = groups.filter( groups = groups.filter(
+5 -1
View File
@@ -114,7 +114,11 @@ function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCa
) )
} }
function resolveFfmpegExecutable(): string { /**
* 解析可用的 ffmpeg 可执行文件。除图片解密自身使用外,也供 System OCR 的
* 图片归一化(GIF/BMP/WebP/TIFF → PNG)复用,避免重复一套路径探测逻辑。
*/
export function resolveFfmpegExecutable(): string {
for (const candidate of getFfmpegCandidates()) { for (const candidate of getFfmpegCandidates()) {
const pathLike = candidate.executable.includes('/') || candidate.executable.includes('\\') const pathLike = candidate.executable.includes('/') || candidate.executable.includes('\\')
if (pathLike) { if (pathLike) {
+683 -130
View File
File diff suppressed because it is too large Load Diff
+11 -5
View File
@@ -134,6 +134,15 @@ export function parseXkeyHelperOutput(output: string): DatabaseKeyResult {
return mapXkeyHelperFailure(rawError) 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 { export class KeyServiceMac {
private getMacKeyRuntimeDir(): string { private getMacKeyRuntimeDir(): string {
return path.join(app.getPath('userData'), 'key-runtime') return path.join(app.getPath('userData'), 'key-runtime')
@@ -306,7 +315,7 @@ export class KeyServiceMac {
private async isSipEnabled(): Promise<boolean> { private async isSipEnabled(): Promise<boolean> {
try { try {
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status']) const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
return stdout.toLowerCase().includes('enabled') return parseSipEnabled(stdout)
} catch { } catch {
return false return false
} }
@@ -346,10 +355,7 @@ export class KeyServiceMac {
return { success: false, error: '自动获取密钥目前仅支持 macOS' } return { success: false, error: '自动获取密钥目前仅支持 macOS' }
} }
if (await this.isSipEnabled()) { if (await this.isSipEnabled()) {
return { return { success: false, code: 'SIP_ENABLED', error: SIP_ENABLED_ERROR }
success: false,
error: '当前系统还未完成连接环境准备,请按页面提示完成设置。'
}
} }
try { try {
+113 -11
View File
@@ -1,8 +1,10 @@
import { monitorEventLoopDelay } from 'perf_hooks' import { monitorEventLoopDelay } from 'perf_hooks'
import * as chat from '../services/chat-service' import * as chat from '../services/chat-service'
import type { import type {
KnowledgeImageOcrState,
KnowledgeAttachmentMetadata, KnowledgeAttachmentMetadata,
KnowledgeEvidence, KnowledgeEvidence,
KnowledgeMemberStatsResult,
KnowledgeMessageKind, KnowledgeMessageKind,
KnowledgePassProgress, KnowledgePassProgress,
KnowledgeRuntimeState, KnowledgeRuntimeState,
@@ -24,6 +26,7 @@ import {
emptyKnowledgeSearchTimings emptyKnowledgeSearchTimings
} from '../../shared/knowledge' } from '../../shared/knowledge'
import { KnowledgeService } from './knowledge-service' import { KnowledgeService } from './knowledge-service'
import { sourceMessageId } from './message-identity'
import { import {
voiceAccountIdentity, voiceAccountIdentity,
voiceMessageIdentity 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 { function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
if (message.voiceTranscript || message.type === '语音') return 'voice' if (message.voiceTranscript || message.type === '语音') return 'voice'
@@ -130,6 +128,16 @@ function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
return message.exportMediaType return message.exportMediaType
} }
if (message.exportMediaType === 'file') return 'file' 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') { if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
return message.contentData.type === 'share' && message.contentData.typeVal === '6' return message.contentData.type === 'share' && message.contentData.typeVal === '6'
? 'file' ? 'file'
@@ -201,12 +209,16 @@ function toSourceMessage(
accountId: string, accountId: string,
conversationId: string, conversationId: string,
message: chat.FormattedMessage, message: chat.FormattedMessage,
transcriptOverride?: string transcriptOverride?: string,
imageOcr?: { state: KnowledgeImageOcrState; text: string }
): KnowledgeSourceMessage | null { ): KnowledgeSourceMessage | null {
if (!message.createTime) return null if (!message.createTime) return null
const extracted = sourceTextAndAttachment(message) const extracted = sourceTextAndAttachment(message)
const voiceTranscript = transcriptOverride?.trim() || message.voiceTranscript?.trim() || undefined 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 { return {
accountId, accountId,
conversationId, conversationId,
@@ -218,7 +230,9 @@ function toSourceMessage(
kind: sourceKind(message), kind: sourceKind(message),
text: extracted.text, text: extracted.text,
attachment: extracted.attachment, 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 interactiveIdleResolve: (() => void) | null = null
private wcdbQueueMsTotal = 0 private wcdbQueueMsTotal = 0
private wcdbExecutionMsTotal = 0 private wcdbExecutionMsTotal = 0
/**
* 图片 OCR 文本解析器(由 main 注入)。
*
* 与语音同构:派生文本在**主进程**解析后贴到消息上,派生库不进 worker。
*/
private imageOcrResolver:
| ((conversationId: string, messageId: string) => { state: KnowledgeImageOcrState; text: string } | undefined)
| undefined
private voiceTranscriptResolver: private voiceTranscriptResolver:
| ((reference: VoiceMessageReference) => VoiceTranscriptSnapshot) | ((reference: VoiceMessageReference) => VoiceTranscriptSnapshot)
| undefined | undefined
@@ -372,6 +394,15 @@ export class KnowledgeSearchService {
this.voiceTranscriptResolver = resolver 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 * A successful recognition updates its source conversation. Consecutive
* updates for the same conversation are coalesced because a complete * 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` 组成。 * 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。
*/ */
@@ -1051,7 +1113,7 @@ export class KnowledgeSearchService {
lane: WcdbReadLane = 'interactive' lane: WcdbReadLane = 'interactive'
): ReturnType<typeof chat.listMessagesAsync> { ): ReturnType<typeof chat.listMessagesAsync> {
return this.enqueueWcdbRead( return this.enqueueWcdbRead(
() => chat.listMessagesAsync(conversationId, startTime, endTime), () => chat.listMessagesAsync(conversationId, startTime, endTime, undefined, undefined, 'knowledge'),
lane lane
) )
} }
@@ -1074,8 +1136,16 @@ export class KnowledgeSearchService {
const reference = this.voiceReferenceFromMessage(message) const reference = this.voiceReferenceFromMessage(message)
const snapshot = reference ? this.voiceTranscriptResolver?.(reference) : undefined const snapshot = reference ? this.voiceTranscriptResolver?.(reference) : undefined
const hydrated = this.withVoiceTranscript(message) const hydrated = this.withVoiceTranscript(message)
const source = toSourceMessage(accountId, conversationId, hydrated, transcriptOverride) const imageOcr = this.imageOcrResolver?.(conversationId, sourceMessageId(message))
if (!source || source.kind !== 'voice') return source const source = toSourceMessage(
accountId,
conversationId,
hydrated,
transcriptOverride,
imageOcr
)
if (!source) return source
if (source.kind !== 'image' && source.kind !== 'voice') return source
return { return {
...source, ...source,
voiceTranscriptState: 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> { private async indexVoiceTranscriptNow(update: VoiceTranscriptUpdate): Promise<void> {
if (!chat.isReady()) return if (!chat.isReady()) return
if (update.state === 'transcribed' && !update.transcript?.trim()) return if (update.state === 'transcribed' && !update.transcript?.trim()) return
+9
View File
@@ -5,6 +5,8 @@ import type {
KnowledgeIndexProgress, KnowledgeIndexProgress,
KnowledgeIndexRequest, KnowledgeIndexRequest,
KnowledgeIndexResult, KnowledgeIndexResult,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus, KnowledgeRuntimeStatus,
KnowledgeSearchRequest, KnowledgeSearchRequest,
KnowledgeSearchResult, KnowledgeSearchResult,
@@ -53,6 +55,13 @@ export class KnowledgeService {
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot }) return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
} }
/** 单个会话内「按发送者聚合」的发言统计(群员统计用)。 */
memberStats(
request: Omit<KnowledgeMemberStatsRequest, 'databaseRoot'>
): Promise<KnowledgeMemberStatsResult> {
return this.worker.memberStats({ ...request, databaseRoot: this.databaseRoot })
}
/** 只中止正在跑的索引任务;查询请求不受影响。 */ /** 只中止正在跑的索引任务;查询请求不受影响。 */
cancelIndex(): Promise<boolean> { cancelIndex(): Promise<boolean> {
return this.worker.cancelActiveIndex() return this.worker.cancelActiveIndex()
+127 -7
View File
@@ -13,13 +13,18 @@ import type {
KnowledgeIndexProgress, KnowledgeIndexProgress,
KnowledgeIndexRequest, KnowledgeIndexRequest,
KnowledgeIndexResult, KnowledgeIndexResult,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus, KnowledgeRuntimeStatus,
KnowledgeNormalizedMessage, KnowledgeNormalizedMessage,
KnowledgeQuery, KnowledgeQuery,
KnowledgeSearchTimings, KnowledgeSearchTimings,
KnowledgeSearchResult KnowledgeSearchResult
} from '../../shared/knowledge' } 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 { chunkConversation } from './chunker'
import { normalizeKnowledgeMessage } from './normalizer' 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)——「索引更新到哪」的权威口径。 * 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。
* *
@@ -761,7 +853,8 @@ export class KnowledgeStore {
evidence: asRows( evidence: asRows(
this.database this.database
.prepare( .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 FROM knowledge_messages m
WHERE ${clauses.join(' AND ')} WHERE ${clauses.join(' AND ')}
ORDER BY m.create_time DESC ORDER BY m.create_time DESC
@@ -789,7 +882,22 @@ export class KnowledgeStore {
timestamp: Number(row.create_time), timestamp: Number(row.create_time),
messageIds: chunk ? chunk.map((item) => String(item.message_id)) : [messageId], messageIds: chunk ? chunk.map((item) => String(item.message_id)) : [messageId],
sourceKind: String(row.kind) as KnowledgeEvidence['sourceKind'], 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 score: String(row.kind) === 'system' ? 1 : 0
} }
} }
@@ -899,6 +1007,7 @@ export class KnowledgeStore {
attachment_json TEXT, attachment_json TEXT,
voice_transcript TEXT, voice_transcript TEXT,
voice_transcript_state TEXT, voice_transcript_state TEXT,
image_ocr_text TEXT,
PRIMARY KEY (conversation_id, message_id) PRIMARY KEY (conversation_id, message_id)
) STRICT; ) STRICT;
CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time
@@ -957,6 +1066,14 @@ export class KnowledgeStore {
if (!messageColumns.has('voice_transcript_state')) { if (!messageColumns.has('voice_transcript_state')) {
this.database.exec('ALTER TABLE knowledge_messages ADD COLUMN voice_transcript_state TEXT') 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)) this.writeMetaIfMissing('schema_version', String(KNOWLEDGE_SCHEMA_VERSION))
const storedAccount = this.readMeta('account_id') const storedAccount = this.readMeta('account_id')
if (storedAccount && storedAccount !== this.accountId) { if (storedAccount && storedAccount !== this.accountId) {
@@ -1137,8 +1254,9 @@ export class KnowledgeStore {
const upsert = this.database.prepare( const upsert = this.database.prepare(
`INSERT INTO knowledge_messages ( `INSERT INTO knowledge_messages (
account_id, conversation_id, message_id, create_time, content_hash, searchable_text, account_id, conversation_id, message_id, create_time, content_hash, searchable_text,
kind, sender_id, sender_name, attachment_json, voice_transcript, voice_transcript_state kind, sender_id, sender_name, attachment_json, voice_transcript, voice_transcript_state,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) image_ocr_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(conversation_id, message_id) DO UPDATE SET ON CONFLICT(conversation_id, message_id) DO UPDATE SET
create_time = excluded.create_time, create_time = excluded.create_time,
content_hash = excluded.content_hash, content_hash = excluded.content_hash,
@@ -1148,7 +1266,8 @@ export class KnowledgeStore {
sender_name = excluded.sender_name, sender_name = excluded.sender_name,
attachment_json = excluded.attachment_json, attachment_json = excluded.attachment_json,
voice_transcript = excluded.voice_transcript, 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) { for (let index = 0; index < messages.length; index += 1) {
this.assertNotAborted(signal) this.assertNotAborted(signal)
@@ -1165,7 +1284,8 @@ export class KnowledgeStore {
message.senderName ?? null, message.senderName ?? null,
message.attachment ? encodedJson(message.attachment) : null, message.attachment ? encodedJson(message.attachment) : null,
message.voiceTranscript ?? null, message.voiceTranscript ?? null,
message.voiceTranscriptState ?? null message.voiceTranscriptState ?? null,
message.imageOcrText ?? null
) )
if (index % YIELD_EVERY === 0) { if (index % YIELD_EVERY === 0) {
onProgress(index + 1, 0) onProgress(index + 1, 0)
@@ -6,6 +6,8 @@ import type {
KnowledgeIndexProgress, KnowledgeIndexProgress,
KnowledgeIndexRequest, KnowledgeIndexRequest,
KnowledgeIndexResult, KnowledgeIndexResult,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus, KnowledgeRuntimeStatus,
KnowledgeSearchRequest, KnowledgeSearchRequest,
KnowledgeSearchResult, KnowledgeSearchResult,
@@ -19,6 +21,7 @@ type WorkerResult =
| KnowledgeCapacityPreflight | KnowledgeCapacityPreflight
| KnowledgeSearchResult | KnowledgeSearchResult
| KnowledgeRuntimeStatus | KnowledgeRuntimeStatus
| KnowledgeMemberStatsResult
| { marks: Record<string, number> } | { marks: Record<string, number> }
| { removed: true } | { removed: true }
type PendingRequest = { type PendingRequest = {
@@ -76,6 +79,11 @@ export class KnowledgeWorkerHost {
) )
} }
/** 单个会话内「按发送者聚合」的发言统计(群员统计用,只回聚合不回正文)。 */
memberStats(payload: KnowledgeMemberStatsRequest): Promise<KnowledgeMemberStatsResult> {
return this.request('memberStats', payload) as Promise<KnowledgeMemberStatsResult>
}
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */ /** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
async cancelActiveIndex(): Promise<boolean> { async cancelActiveIndex(): Promise<boolean> {
const target = this.activeIndexRequestId const target = this.activeIndexRequestId
+38
View File
@@ -1,6 +1,8 @@
import type { import type {
KnowledgeCapacityPreflightRequest, KnowledgeCapacityPreflightRequest,
KnowledgeIndexRequest, KnowledgeIndexRequest,
KnowledgeMemberStatsRequest,
KnowledgeMemberStatsResult,
KnowledgeRuntimeStatus, KnowledgeRuntimeStatus,
KnowledgeSearchRequest, KnowledgeSearchRequest,
KnowledgeStatusRequest, KnowledgeStatusRequest,
@@ -187,6 +189,38 @@ async function handleHighWater(
}) })
} }
/**
* 群员统计的按发送者聚合。
*
* 与 `handleSearch` 同样先判库是否存在:**「还没建索引」是正常状态,不是故障**,
* 返回空结果而不是抛错,让上层能稳定地区分「没人发言」与「索引不存在」。
*/
async function handleMemberStats(
request: KnowledgeWorkerRequest,
payload: KnowledgeMemberStatsRequest
): Promise<void> {
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
if (!existsSync(path)) {
const unavailable: KnowledgeMemberStatsResult = {
conversationId: payload.conversationId,
totalMessages: 0,
senders: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
earliestMessageTime: null,
indexLatestAt: null
}
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
return
}
send({
version: 1,
type: 'result',
requestId: request.requestId,
payload: getStore(payload).memberStats(payload)
})
}
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> { async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
try { try {
if (request.type === 'cancel') { if (request.type === 'cancel') {
@@ -226,6 +260,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
await handleHighWater(request, request.payload as KnowledgeStatusRequest) await handleHighWater(request, request.payload as KnowledgeStatusRequest)
return return
} }
if (request.type === 'memberStats') {
await handleMemberStats(request, request.payload as KnowledgeMemberStatsRequest)
return
}
if (request.type === 'index') { if (request.type === 'index') {
await handleIndex(request, request.payload as KnowledgeIndexRequest) await handleIndex(request, request.payload as KnowledgeIndexRequest)
return return
+24
View File
@@ -0,0 +1,24 @@
/**
* 消息身份的**唯一真源**。
*
* 这个规则同时被三处需要:
* - Knowledge 索引写入 `knowledge_messages.message_id`
* - 图片文字索引的 binding(必须与 Knowledge 里的 message_id 完全一致,否则 OCR 文本贴不到消息上)
* - Evidence → 档案跳转的 messageRef
*
* 任何一处各自复制一份,都会在 `local:` 前缀上静默失配(项目里已经有这个坑的历史注释),
* 所以抽成一个模块,谁都不许再抄。
*/
import type * as chat from '../services/chat-service'
/**
* 源消息 → 稳定消息 id。
*
* 降级顺序刻意保守:`localId` 是 WCDB 行内最稳的本地 id;其次用消息自带 id;
* 最后才退化成「时间 + 服务端 id / 内容」的组合(仅在极端缺字段时命中)。
*/
export function sourceMessageId(message: chat.FormattedMessage): string {
if (message.localId) return `local:${message.localId}`
if (message.id) return String(message.id)
return `${message.createTime || 0}:${message.serverId || message.content}`
}
+5
View File
@@ -24,6 +24,10 @@ export function normalizeKnowledgeMessage(
const transcript = compact(source.voiceTranscript) const transcript = compact(source.voiceTranscript)
if (transcript) sections.push(`语音转写:${transcript}`) if (transcript) sections.push(`语音转写:${transcript}`)
// 图片 OCR 文本:与语音同样的"固定前缀"约定,让检索与展示都能识别这是派生内容。
const imageText = compact(source.imageOcrText)
if (imageText) sections.push(`图片文字:${imageText}`)
const attachmentName = compact(source.attachment?.name) const attachmentName = compact(source.attachment?.name)
if (attachmentName) { if (attachmentName) {
const label = source.attachment?.kind === 'link' ? '链接' : '附件' const label = source.attachment?.kind === 'link' ? '链接' : '附件'
@@ -45,6 +49,7 @@ export function normalizeKnowledgeMessage(
senderId: source.senderId || '', senderId: source.senderId || '',
kind: source.kind, kind: source.kind,
voiceTranscriptState: source.voiceTranscriptState || '', voiceTranscriptState: source.voiceTranscriptState || '',
imageOcrState: source.imageOcrState || '',
searchableText searchableText
}) })
) )
+119 -8
View File
@@ -125,7 +125,10 @@ export type ParsedContent =
export function parseMessageContent(content: string, messageType: number): ParsedContent { export function parseMessageContent(content: string, messageType: number): ParsedContent {
// Voice rows may keep their binary payload outside msgContent, so an empty // Voice rows may keep their binary payload outside msgContent, so an empty
// content string is still a valid voice message. // content string is still a valid voice message.
if (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') { if (!content || typeof content !== 'string') {
return { type: 'unknown', raw: content || '' } 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 { function parseVideoMessage(content: string): ParsedContent {
const decoded = decodeXmlEntities(stripChatroomPrefix(content)) const decoded = decodeXmlEntities(stripChatroomPrefix(content))
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5')) const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
@@ -181,6 +211,14 @@ function parseSystemMessage(content: string): ParsedContent {
recall recall
} }
} }
const templateText = extractSysmsgTemplateText(decoded)
if (templateText) {
return {
type: 'system',
content: templateText,
raw: content
}
}
const delChatroomMemberText = extractDelChatroomMemberText(decoded) const delChatroomMemberText = extractDelChatroomMemberText(decoded)
if (delChatroomMemberText) { if (delChatroomMemberText) {
return { return {
@@ -189,16 +227,19 @@ function parseSystemMessage(content: string): ParsedContent {
raw: content raw: content
} }
} }
// <link_list> 里放的是富文本片段(可能含 hidden="1" 的可点击按钮),
// 不是消息正文;先剥掉再走通用提取,避免把按钮文案当成整条系统消息。
const withoutLinkList = stripSysmsgLinkList(decoded)
const plainText = const plainText =
extractXmlNodeText(decoded, 'plain') || extractXmlNodeText(withoutLinkList, 'plain') ||
extractXmlNodeText(decoded, 'text') || extractXmlNodeText(withoutLinkList, 'text') ||
extractXmlNodeText(decoded, 'title') || extractXmlNodeText(withoutLinkList, 'title') ||
extractXmlValue(decoded, 'plain') || extractXmlValue(withoutLinkList, 'plain') ||
extractXmlValue(decoded, 'text') || extractXmlValue(withoutLinkList, 'text') ||
extractXmlValue(decoded, 'title') || extractXmlValue(withoutLinkList, 'title') ||
'' ''
const normalized = normalizeSystemText(plainText || fallbackSystemText(decoded)) const normalized = normalizeSystemText(plainText || fallbackSystemText(withoutLinkList))
return { return {
type: 'system', type: 'system',
content: normalized || '[系统消息]', content: normalized || '[系统消息]',
@@ -838,6 +879,76 @@ function extractDelChatroomMemberText(xml: string): string {
return '' 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 { function normalizeMd5(value: unknown): string | undefined {
const md5 = String(value || '') const md5 = String(value || '')
.trim() .trim()
+43 -15
View File
@@ -23,6 +23,45 @@ import {
const aiProvider = new AIProviderService() 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 { export interface AgentGroupReportRequest {
group: string group: string
range?: SummaryDateRange | 'recent24h' range?: SummaryDateRange | 'recent24h'
@@ -101,22 +140,11 @@ export async function generateAgentGroupReport(
const snapshot = getGroupSnapshot(contact.md5) const snapshot = getGroupSnapshot(contact.md5)
if (snapshot) { if (snapshot) {
const members = new Map( messages = hydrateGroupMemberIdentity(
snapshot.members.map((member) => [ messages,
member.wxid, snapshot.members,
{ request.memberNameMode || 'groupNickname'
name: resolveMemberName(member, request.memberNameMode || 'groupNickname'),
avatar: member.avatar
}
])
) )
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') const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
@@ -0,0 +1,193 @@
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { randomUUID } from 'node:crypto'
import {
agentHubKindPlaceholder,
type AgentHubConversation,
type AgentHubConversationMessage,
type AgentHubConversationSummary,
type AgentHubMessageKind,
type AgentHubMessageStatus
} from '../../shared/agent-hub-conversation'
/**
* Agent Hub 对话记录存储。
*
* 设计取向:
* - 一个 JSON 文件装全部会话,按「最近活跃」排序,避免为几十个会话开目录;
* - 每个会话最多保留 N 条(默认 500),超出丢最旧的,文件不会无限增长;
* - 会话数也有上限(默认 50),只保留最近活跃的;
* - 原子写(tmp + rename)+ 0600,任何一个环节失败都不能影响真实收发。
*
* 与 `WechatInboundInbox` 的区别:收件箱是**待处理的在途消息**(处理完即删),
* 这里是**供人回看的历史**(按上限长期保留)。
*/
const DEFAULT_MAX_MESSAGES_PER_CONVERSATION = 500
const DEFAULT_MAX_CONVERSATIONS = 50
interface ConversationFile {
version: 1
conversations: AgentHubConversation[]
}
export interface AgentHubConversationStoreOptions {
filePath: () => string
maxMessagesPerConversation?: number
maxConversations?: number
now?: () => number
createId?: () => string
}
export interface AppendInput {
userId: string
accountId?: string
direction: 'in' | 'out'
kind: AgentHubMessageKind
text: string
messageId?: string
status?: AgentHubMessageStatus
errorCode?: string
createdAt?: number
}
export class AgentHubConversationStore {
private readonly options: AgentHubConversationStoreOptions
private readonly maxMessages: number
private readonly maxConversations: number
private cache: AgentHubConversation[] | null = null
constructor(options: AgentHubConversationStoreOptions) {
this.options = options
this.maxMessages = options.maxMessagesPerConversation ?? DEFAULT_MAX_MESSAGES_PER_CONVERSATION
this.maxConversations = options.maxConversations ?? DEFAULT_MAX_CONVERSATIONS
}
/** 追加一条消息,返回受影响会话的摘要与这条消息(供 UI 增量刷新)。 */
append(
input: AppendInput
): { summary: AgentHubConversationSummary; message: AgentHubConversationMessage } | null {
const userId = String(input.userId || '').trim()
if (!userId) return null
const createdAt = input.createdAt ?? this.options.now?.() ?? Date.now()
const message: AgentHubConversationMessage = {
id: this.options.createId?.() ?? randomUUID(),
direction: input.direction,
kind: input.kind,
text: String(input.text ?? ''),
createdAt,
...(input.status ? { status: input.status } : {}),
...(input.errorCode ? { errorCode: input.errorCode } : {}),
...(input.messageId ? { messageId: input.messageId } : {})
}
try {
const conversations = this.load()
const index = conversations.findIndex((item) => item.userId === userId)
if (index >= 0) {
const existing = conversations[index]
const messages = [...existing.messages, message].slice(-this.maxMessages)
conversations[index] = {
...existing,
...(input.accountId ? { accountId: input.accountId } : {}),
lastAt: createdAt,
messages
}
} else {
conversations.push({
userId,
...(input.accountId ? { accountId: input.accountId } : {}),
firstAt: createdAt,
lastAt: createdAt,
messages: [message]
})
}
// 只保留最近活跃的 maxConversations 个会话。
const trimmed = conversations
.sort((left, right) => right.lastAt - left.lastAt)
.slice(0, this.maxConversations)
this.persist(trimmed)
this.cache = trimmed
const summary = this.toSummary(trimmed.find((item) => item.userId === userId) ?? null)
return summary ? { summary, message } : null
} catch (error) {
// 记录历史失败绝不能影响真实收发。
console.warn('[AgentHubConversation] 写入对话记录失败:', error)
return null
}
}
listSummaries(): AgentHubConversationSummary[] {
return this.load()
.slice()
.sort((left, right) => right.lastAt - left.lastAt)
.map((conversation) => this.toSummary(conversation))
.filter((summary): summary is AgentHubConversationSummary => summary !== null)
}
get(userId: string): AgentHubConversation | null {
const normalized = String(userId || '').trim()
if (!normalized) return null
const found = this.load().find((item) => item.userId === normalized)
if (!found) return null
return { ...found, messages: found.messages.map((message) => ({ ...message })) }
}
clear(): void {
this.persist([])
this.cache = []
}
private toSummary(conversation: AgentHubConversation | null): AgentHubConversationSummary | null {
if (!conversation) return null
const last = conversation.messages[conversation.messages.length - 1]
const preview = last ? last.text.trim() || agentHubKindPlaceholder(last.kind) : ''
return {
userId: conversation.userId,
...(conversation.accountId ? { accountId: conversation.accountId } : {}),
firstAt: conversation.firstAt,
lastAt: conversation.lastAt,
messageCount: conversation.messages.length,
lastPreview: preview,
lastDirection: last?.direction ?? 'in'
}
}
private load(): AgentHubConversation[] {
if (this.cache) return this.cache
let parsed: ConversationFile | null = null
try {
parsed = JSON.parse(readFileSync(this.options.filePath(), 'utf8')) as ConversationFile
} catch {
parsed = null
}
const conversations = Array.isArray(parsed?.conversations)
? parsed!.conversations.filter((item): item is AgentHubConversation =>
Boolean(item && typeof item === 'object' && String(item.userId || '').trim())
)
: []
this.cache = conversations
return conversations
}
private persist(conversations: AgentHubConversation[]): void {
const path = this.options.filePath()
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`
try {
writeFileSync(
tempPath,
JSON.stringify({ version: 1, conversations } satisfies ConversationFile),
{ encoding: 'utf8', mode: 0o600 }
)
chmodSync(tempPath, 0o600)
renameSync(tempPath, path)
chmodSync(path, 0o600)
} catch (error) {
rmSync(tempPath, { force: true })
throw error
}
}
}
+36 -1
View File
@@ -87,15 +87,50 @@ export function matchGroupMemberChatIntent(text: string): GroupMemberChatIntent
return null 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 无法表达, * 这是**确定性能力**(列出会话),不是消息内容查询 —— Query Agent 无法表达,
* 因此保留为不经过模型的无 LLM 快捷路径。 * 因此保留为不经过模型的无 LLM 快捷路径。
*
* 判定必须**正向**:只有用户确实在要一份"会话 / 联系人名单"时才算 recent_list。
* 早先的实现只要求「最近」+「消息|会话|聊天」同时出现,于是
* 「最近群里聊的消息里有没有提到报价?」这类**内容查询**会被截走,
* 直接回一串会话名,用户永远得不到答案。
*/ */
export function matchRecentChatIntent(text: string): number | null { export function matchRecentChatIntent(text: string): number | null {
const normalized = text.replace(/\s+/g, '') 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) const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5)
return Math.max(1, Math.min(20, limit)) return Math.max(1, Math.min(20, limit))
} }
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -249,16 +249,33 @@ function selectCoverage(
} }
} }
/**
* Citation sanitize 的允许集合。
*
* 两个入口都只能引用「Host 侧已分配」的编号,但它们的形状不同:
* - Query Agent:`citationId` 字符串集合(`EvidenceCollector` 分配的 `E1`…`En`);
* - Legacy AI Search:Final Evidence 列表(取其中的 `id`)。
*
* 故意不接受裸 `string`:那会被当成字符序列迭代,静默退化成单字符白名单。
*/
export type CitationAllowList =
| ReadonlyArray<string | Pick<AiSearchFinalEvidence, 'id'>>
| ReadonlySet<string>
/** Do not expose citations that cannot resolve to program-owned Final Evidence. */ /** Do not expose citations that cannot resolve to program-owned Final Evidence. */
export function sanitizeAnswerCitations( export function sanitizeAnswerCitations(
answer: string, answer: string,
evidence: Array<Pick<AiSearchFinalEvidence, 'id'>> allowed: CitationAllowList
): CitationValidationResult { ): 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 invalidCitationIds = new Set<string>()
const sanitized = answer.replace(/\[E(\d+)\]/g, (citation, number: string) => { const sanitized = answer.replace(/\[E(\d+)\]/g, (citation, number: string) => {
const id = `E${number}` const id = `E${number}`
if (allowed.has(id as AiSearchFinalEvidence['id'])) return citation if (allowedIds.has(id)) return citation
invalidCitationIds.add(id) invalidCitationIds.add(id)
return '' return ''
}) })
+40 -13
View File
@@ -86,7 +86,8 @@ export class AskWechatService {
const diagnostics = this.diagnostics( const diagnostics = this.diagnostics(
{ provider: '', model: '', modelCallCount: 0, toolCallCount: 0, traces: [] }, { provider: '', model: '', modelCallCount: 0, toolCallCount: 0, traces: [] },
startedAt, startedAt,
'runtime_error' 'runtime_error',
{ question }
) )
this.writeLog('error', `Query Agent Runtime 异常(${this.options.entry})`, diagnostics) this.writeLog('error', `Query Agent Runtime 异常(${this.options.entry})`, diagnostics)
return this.fallback(request, 'runtime_error', diagnostics) return this.fallback(request, 'runtime_error', diagnostics)
@@ -97,13 +98,13 @@ export class AskWechatService {
engine: 'query-agent', engine: 'query-agent',
status: 'error', status: 'error',
message: EMPTY_QUESTION_MESSAGE, 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') { if (result.errorKind === 'provider_unavailable' || result.errorKind === 'provider_failure') {
const outcome: AskWechatOutcome = result.errorKind 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) this.writeLog('warn', `查询 Provider 不可用(${this.options.entry})`, diagnostics)
return { return {
engine: 'query-agent', engine: 'query-agent',
@@ -114,13 +115,13 @@ export class AskWechatService {
} }
if (result.errorKind === 'tool_limit') { 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) this.writeLog('warn', `查询超出工具调用上限(${this.options.entry})`, diagnostics)
return this.fallback(request, 'runtime_error', diagnostics) return this.fallback(request, 'runtime_error', diagnostics)
} }
if (!result.answer?.trim()) { 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) this.writeLog('warn', `Query Agent 未返回回答(${this.options.entry})`, diagnostics)
return this.fallback(request, 'runtime_error', diagnostics) return this.fallback(request, 'runtime_error', diagnostics)
} }
@@ -128,14 +129,19 @@ export class AskWechatService {
const answer = result.answer.trim() const answer = result.answer.trim()
// 澄清回答也记录:下一句("是 BOBO")需要接得上上文。 // 澄清回答也记录:下一句("是 BOBO")需要接得上上文。
this.memory.record(conversationKey, question, answer) 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) this.writeLog('info', `Query Agent 回答完成(${this.options.entry})`, diagnostics)
return { return {
engine: 'query-agent', engine: 'query-agent',
status: 'answered', status: 'answered',
answer, answer,
// 直接透传 Runtime 收集的真实证据:UI 不允许从 answer 文本反解析。 // 直接透传 Runtime 收集的真实证据:UI 不允许从 answer 文本反解析。
// citationId 由 Runtime 分配,Adapter 不改写、不重编号。
evidence: (result.evidence || []) as AskWechatEvidenceItem[], evidence: (result.evidence || []) as AskWechatEvidenceItem[],
// Host 侧 citation 校验中被移除的非法编号(非空 = 模型引用过不存在的 E#)。
...(result.invalidCitationIds?.length
? { invalidCitationIds: result.invalidCitationIds }
: {}),
stats: buildAskWechatStats(result, request.scope, startedAt), stats: buildAskWechatStats(result, request.scope, startedAt),
diagnostics diagnostics
} }
@@ -167,7 +173,12 @@ export class AskWechatService {
requestId: request.requestId, requestId: request.requestId,
text: request.text 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 } return { engine: 'legacy', status: 'legacy', reason, result: legacyResult }
} catch { } catch {
this.writeLog('error', `Legacy fallback 也失败(${this.options.entry})`, diagnostics, reason) this.writeLog('error', `Legacy fallback 也失败(${this.options.entry})`, diagnostics, reason)
@@ -187,17 +198,35 @@ export class AskWechatService {
> & > &
Partial<Pick<QueryAgentResult, 'totalMs'>>, Partial<Pick<QueryAgentResult, 'totalMs'>>,
startedAt: number, startedAt: number,
outcome: AskWechatOutcome outcome: AskWechatOutcome,
/**
* 问答原文(可选)。只在本地应用日志里用,不上传、不进遥测。
*
* 排查这类"同一问题时对时错"的故障,光有工具名与次数是不够的 ——
* 必须能对着"问题 + 模型回答"回放,否则无法判断是理解错了、链路断了,还是索引没建。
*/
content?: { question?: string; answer?: string }
): QueryAgentDiagnostics { ): 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 { return {
entry: this.options.entry, entry: this.options.entry,
provider: result.provider, provider: result.provider,
model: result.model, model: result.model,
modelCallCount: result.modelCallCount, modelCallCount: result.modelCallCount,
toolCallCount: result.toolCallCount, toolCallCount: result.toolCallCount,
tools: (result.traces || []).map((trace) => trace.toolName), tools: traces.map((trace) => trace.toolName),
totalMs: result.totalMs || Date.now() - startedAt, 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 const totalMs = result.totalMs || Date.now() - startedAt
// 真实拆解:模型总耗时直接来自每次模型调用的测量;本地查询 = 所有 Tool 的 durationMs 之和。 // 真实拆解:模型总耗时直接来自每次模型调用的测量;本地查询 = 所有 Tool 的 durationMs 之和。
// 两者不互相推算(用 total - model 反推会把"框架开销"混进"本地查询",那是另一种谎)。 // 两者不互相推算(用 total - model 反推会把"框架开销"混进"本地查询",那是另一种谎)。
const modelDurationsMs = (result.modelDurationsMs || []).filter((value) => const modelDurationsMs = (result.modelDurationsMs || []).filter((value) => Number.isFinite(value))
Number.isFinite(value)
)
const toolDurationsMs = (result.traces || []) const toolDurationsMs = (result.traces || [])
.map((trace) => trace.durationMs) .map((trace) => trace.durationMs)
.filter((value) => Number.isFinite(value)) .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()
+652
View File
@@ -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 }
+16
View File
@@ -8,9 +8,12 @@ export type { CacheClearScope } from '../../shared/cache'
const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap') const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap')
const KNOWLEDGE_CACHE_DIR = path.join(app.getPath('userData'), 'knowledge') 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 { export interface CacheClearOptions {
beforeClearKnowledge?: () => Promise<void> beforeClearKnowledge?: () => Promise<void>
/** 清理图片文字索引前调用:停任务 + 关闭派生库句柄。 */
beforeClearImageTextIndex?: () => Promise<void>
} }
function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } { function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } {
@@ -46,6 +49,7 @@ export function getCacheSummary(): CacheSummary {
const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR) const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR)
const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache')) const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache'))
const knowledge = inspectDirectory(KNOWLEDGE_CACHE_DIR) const knowledge = inspectDirectory(KNOWLEDGE_CACHE_DIR)
const imageTextIndex = inspectDirectory(IMAGE_TEXT_INDEX_CACHE_DIR)
const items: CacheSummaryItem[] = [ const items: CacheSummaryItem[] = [
{ {
id: 'bootstrap', id: 'bootstrap',
@@ -65,6 +69,13 @@ export function getCacheSummary(): CacheSummary {
description: description:
'为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。', '为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。',
...knowledge ...knowledge
},
{
id: 'image-text-index',
label: '图片文字索引',
description:
'本机从微信图片里识别出的文字及其检索索引。清理后无法搜索图片中的文字,可重新建立;不影响微信原始图片与聊天记录。',
...imageTextIndex
} }
] ]
return { return {
@@ -89,6 +100,11 @@ export async function clearCache(
await options.beforeClearKnowledge?.() await options.beforeClearKnowledge?.()
await fs.remove(KNOWLEDGE_CACHE_DIR) 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() return getCacheSummary()
} }
+343 -66
View File
@@ -16,6 +16,8 @@ import {
} from '../../shared/windows-runtime' } from '../../shared/windows-runtime'
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service' import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
import type { ExportImageQuality } from '../../shared/image-quality' 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 { wcdbDebugLog } from '../wcdb-debug'
import { import {
buildContactSearchIndex, buildContactSearchIndex,
@@ -23,6 +25,87 @@ import {
type ContactSearchIndex type ContactSearchIndex
} from '../../shared/contact-search' } 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 { export function getCurrentKey(): string {
if (!dbRef) return '' if (!dbRef) return ''
try { try {
@@ -198,7 +281,8 @@ export function isReady(): boolean {
/** Session 行的时间字段可能是秒,也可能是毫秒;1e11 以下按秒换算。 */ /** Session 行的时间字段可能是秒,也可能是毫秒;1e11 以下按秒换算。 */
function sessionTimeToEpochMs(value: unknown): number | null { 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 if (!Number.isFinite(numeric) || numeric <= 0) return null
return Math.round(numeric < 1e11 ? numeric * 1000 : numeric) return Math.round(numeric < 1e11 ? numeric * 1000 : numeric)
} }
@@ -378,28 +462,52 @@ function listSourceMessages(
endTime?: number, endTime?: number,
options?: { limit?: number }, options?: { limit?: number },
rawMessagesOverride?: WechatMessage[], rawMessagesOverride?: WechatMessage[],
requestId = 'NO-REQUEST' requestId = 'NO-REQUEST',
perf?: ListMessagesPerf
): FormattedMessage[] { ): FormattedMessage[] {
if (!dbRef) return [] if (!dbRef) return []
const startedAt = Date.now()
const wcdb4Client = dbRef.getWcdb4Client() const wcdb4Client = dbRef.getWcdb4Client()
const username = wcdb4Client.getUsernameByMd5(userMd5) const username = wcdb4Client.getUsernameByMd5(userMd5)
const isGroupChat = Boolean(username?.endsWith('@chatroom')) const isGroupChat = Boolean(username?.endsWith('@chatroom'))
wcdbDebugLog( 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 = const rawMessages =
rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options) rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options)
if (perf) {
perf.rawReadMs += Date.now() - rawReadStartedAt
perf.rawRows += rawMessages.length
}
wcdbDebugLog( 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 formatted = rawMessages.map((msg: WechatMessage) => {
const rawMsgType = parseInt(msg.messageType) const rawMsgType = parseInt(msg.messageType)
const msgType = normalizeMsgType(msg.messageType) const msgType = normalizeMsgType(msg.messageType)
const createTime = parseInt(msg.msgCreateTime) const createTime = parseInt(msg.msgCreateTime)
const date = new Date(createTime * 1000) 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 isMine = msg.mesDes !== 1
const localId = parseInt(msg.mesLocalID) || 0 const localId = parseInt(msg.mesLocalID) || 0
@@ -433,7 +541,7 @@ function listSourceMessages(
/<patinfo\b|<type>\s*62\s*<\/type>/i.test(rawContent) || /<patinfo\b|<type>\s*62\s*<\/type>/i.test(rawContent) ||
([10000, 10002].includes(msgType) && /拍了拍/i.test(rawContent)) ([10000, 10002].includes(msgType) && /拍了拍/i.test(rawContent))
if (isPatMessage) { if (isPatMessage) {
const system = parseMessageContent(content, 10000) const system = timedParse(() => parseMessageContent(content, 10000))
const patContent = const patContent =
system.type === 'system' system.type === 'system'
? { ...system, pat: true } ? { ...system, pat: true }
@@ -460,9 +568,9 @@ function listSourceMessages(
/<(?:emoji|sticker|emoticon)\b/i.test(content) || /<type>\s*47\s*<\/type>/i.test(content) /<(?:emoji|sticker|emoticon)\b/i.test(content) || /<type>\s*47\s*<\/type>/i.test(content)
const rowSticker = const rowSticker =
inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerPayload) inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
? parseStickerMessageFromRow(msg, content) ? timedParse(() => parseStickerMessageFromRow(msg, content))
: undefined : undefined
const parsedContent = parseMessageContent(content, inferredMsgType) const parsedContent = timedParse(() => parseMessageContent(content, inferredMsgType))
const rowStickerUrl = rowSticker?.type === 'sticker' ? String(rowSticker.url || '') : '' const rowStickerUrl = rowSticker?.type === 'sticker' ? String(rowSticker.url || '') : ''
const parsedShareUrl = parsedContent.type === 'share' ? parsedContent.url : '' const parsedShareUrl = parsedContent.type === 'share' ? parsedContent.url : ''
const redPacketUrl = rowStickerUrl || parsedShareUrl const redPacketUrl = rowStickerUrl || parsedShareUrl
@@ -534,7 +642,7 @@ function listSourceMessages(
} }
if (!contentData && typeof content === 'string' && /^[0-9a-fA-F]{64,}$/.test(content.trim())) { 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.type === 'sticker') {
if (!parsed.url && parsed.md5) { if (!parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5) parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
@@ -561,6 +669,9 @@ function listSourceMessages(
: msg.mesLocalID || Math.random().toString() : msg.mesLocalID || Math.random().toString()
) )
const imageContent = contentData?.type === 'image' ? contentData : undefined 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 // Local ids repeat across conversations. Scope media handles to this database
// connection and image without changing the message id used by other clients. // connection and image without changing the message id used by other clients.
const mediaId = imageContent const mediaId = imageContent
@@ -615,7 +726,7 @@ function listSourceMessages(
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user', from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
isSender: isMine, isSender: isMine,
type: displayType, type: displayType,
datetime: date.toLocaleString('zh-CN', { hour12: false }), datetime: datetimeText,
content, content,
img, img,
name, name,
@@ -629,13 +740,15 @@ function listSourceMessages(
createTime, createTime,
recoveredFromRecallJournal, recoveredFromRecallJournal,
contentData, contentData,
media media,
voiceDuration
} }
}) })
console.log( if (perf) {
`[ChatService] listMessages end md5=${userMd5} formatted=${formatted.length} cost=${Date.now() - startedAt}ms` perf.formatMs += Date.now() - formatStartedAt
) perf.formattedRows += formatted.length
}
return formatted return formatted
} }
@@ -643,13 +756,38 @@ export function listMessages(
userMd5: string, userMd5: string,
startTime?: number, startTime?: number,
endTime?: number, endTime?: number,
options?: { limit?: number } options?: { limit?: number },
caller: ListMessagesCaller = 'unknown'
): FormattedMessage[] { ): FormattedMessage[] {
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options) const perf = emptyPerf(caller, nextListMessagesRequestId())
if (!dbRef) return sourceMessages const totalStartedAt = Date.now()
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || '' try {
recordRecallArchiveMessages(userMd5, username, sourceMessages) const sourceMessages = listSourceMessages(
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit) 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( export async function listMessagesAsync(
@@ -657,42 +795,128 @@ export async function listMessagesAsync(
startTime?: number, startTime?: number,
endTime?: number, endTime?: number,
options?: { limit?: number }, options?: { limit?: number },
requestId = 'NO-REQUEST' requestId = '',
caller: ListMessagesCaller = 'unknown'
): Promise<FormattedMessage[]> { ): Promise<FormattedMessage[]> {
if (!dbRef) return [] if (!dbRef) return []
const startedAt = Date.now() const perf = emptyPerf(caller, requestId || nextListMessagesRequestId())
wcdbDebugLog(`[${requestId}] ChatService listMessagesAsync start md5=${userMd5}`) const totalStartedAt = Date.now()
const rawMessages = await dbRef.getUserMessagesAsync( try {
userMd5, wcdbDebugLog(`[${perf.requestId}] ChatService listMessagesAsync start`)
startTime, const rawReadStartedAt = Date.now()
endTime, const rawMessages = await dbRef.getUserMessagesAsync(
options, userMd5,
requestId startTime,
) endTime,
wcdbDebugLog( options,
`[${requestId}] ChatService getUserMessagesAsync end raw=${rawMessages.length} cost=${Date.now() - startedAt}ms` perf.requestId
) )
const sourceMessages = listSourceMessages( perf.rawReadMs += Date.now() - rawReadStartedAt
userMd5, wcdbDebugLog(
startTime, `[${perf.requestId}] ChatService getUserMessagesAsync end raw=${rawMessages.length} cost=${Date.now() - rawReadStartedAt}ms`
endTime, )
options, const sourceMessages = listSourceMessages(
rawMessages, userMd5,
requestId startTime,
) endTime,
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || '' options,
recordRecallArchiveMessages(userMd5, username, sourceMessages) rawMessages,
const result = mergeRecallArchiveMessages( perf.requestId,
userMd5, perf
sourceMessages, )
startTime, const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
endTime, const recallStartedAt = Date.now()
options?.limit recordRecallArchiveMessages(userMd5, username, sourceMessages)
) const result = mergeRecallArchiveMessages(
wcdbDebugLog( userMd5,
`[${requestId}] ChatService listMessagesAsync end formatted=${result.length} cost=${Date.now() - startedAt}ms` sourceMessages,
) startTime,
return result 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( export async function listMessagesForExport(
@@ -701,22 +925,61 @@ export async function listMessagesForExport(
endTime?: number endTime?: number
): Promise<FormattedMessage[]> { ): Promise<FormattedMessage[]> {
if (!dbRef) return [] if (!dbRef) return []
const rawMessages = await dbRef.getUserMessagesForExport(userMd5, startTime, endTime) const perf = emptyPerf('archive', nextListMessagesRequestId())
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, undefined, rawMessages) const totalStartedAt = Date.now()
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || '' try {
recordRecallArchiveMessages(userMd5, username, sourceMessages) const rawReadStartedAt = Date.now()
const mergedMessages = mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime) const rawMessages = await dbRef.getUserMessagesForExport(userMd5, startTime, endTime)
console.log( perf.rawReadMs += Date.now() - rawReadStartedAt
`[ChatService] listMessagesForExport end md5=${userMd5} source=${sourceMessages.length} merged=${mergedMessages.length}` const sourceMessages = listSourceMessages(
) userMd5,
return mergedMessages 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 * 图片消息计数(SQL 统计,不解密)。
* batch-selection view, where loading every conversation would make opening *
* Settings noticeably slow. * 返回 `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( export async function countVoiceMessagesAsync(
userMd5: string, userMd5: string,
startTime?: number, 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。 */ /** 退群检测专用轻量读取,不执行成员名称或头像 hydration。 */
export async function getGroupMemberIdsAsync( export async function getGroupMemberIdsAsync(
roomId: string roomId: string
+207 -165
View File
@@ -2,19 +2,17 @@ import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra' import fs from 'fs-extra'
import path from 'path' import path from 'path'
import * as chat from './chat-service' import * as chat from './chat-service'
import { wechatActionGateway, type WechatActionGateway } from './wechat-action-gateway'
import { import {
buildMemberLeftNotification,
findRemovedGroupMembers, findRemovedGroupMembers,
groupExitMemberName, groupExitMemberName,
normalizeGroupExitNotificationTemplate,
validateGroupExitNotificationTemplate,
type GroupExitMonitorEvent, type GroupExitMonitorEvent,
type GroupExitMonitorMember, type GroupExitMonitorMember,
type GroupExitNotificationStatus,
type GroupExitNotificationState,
type GroupExitMonitorState type GroupExitMonitorState
} from '../../shared/group-exit-monitor' } from '../../shared/group-exit-monitor'
import {
toGroupMemberExitedEvent,
type GroupMemberExitedEvent
} from '../../shared/group-exit-event'
type StoredState = { type StoredState = {
enabled?: boolean enabled?: boolean
@@ -23,9 +21,14 @@ type StoredState = {
lastReadAt?: number lastReadAt?: number
monitorSelectionConfigured?: boolean monitorSelectionConfigured?: boolean
monitoredRoomIds?: string[] monitoredRoomIds?: string[]
notificationRoomIds?: string[]
notificationTemplate?: unknown
snapshots?: Partial<StoredGroupSnapshot>[] snapshots?: Partial<StoredGroupSnapshot>[]
/*
* 历史遗留字段(`notificationRoomIds` / `notificationTemplate`)**刻意不再声明**。
*
* 它们已经迁到「自动化 → 退群通知」规则里,本服务运行期不再读、不再写。
* 旧状态文件原样保留在磁盘上(迁移时另有一份 backup),只是没有任何读取方 ——
* 这是"不双读"的落地方式。迁移逻辑在 `automation-rule-store.ts`。
*/
} }
type GroupSnapshotRecord = { type GroupSnapshotRecord = {
@@ -57,19 +60,30 @@ type StoredGroupSnapshot = Pick<
const DB_CHANGE_DEBOUNCE_MS = 350 const DB_CHANGE_DEBOUNCE_MS = 350
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000 const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
/**
* **只约束「一次状态回传带多少条」**,不是存储上限。
*
* 事件现在写在 append-only 的 JSONL 里(`eventsPath()`),**永久保留、不做截断**。
* 之所以必须把「存储」和「回传」分开:状态文件是整体重写的(`save()`),
* 事件留在里面时每来一条新事件都要重写整个文件 —— 越写越慢。
* 而 `getState()` 每次都把结果推过 IPC,也不能无上限。
*/
const MAX_EVENTS = 500 const MAX_EVENTS = 500
/**
* 退群事件处理器。
*
* 由主进程注入成 `automationService.handleGroupExit` —— 本服务**不再自己发送**。
* 契约:**必须自己吞掉异常**(实现方负责),本服务也会再兜一层。
*/
export type GroupExitEventHandler = (event: GroupMemberExitedEvent) => void | Promise<void>
export interface GroupExitMonitorServiceDependencies { export interface GroupExitMonitorServiceDependencies {
actionGateway?: GroupExitActionGateway onGroupExit?: GroupExitEventHandler
} }
type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
Partial<
Pick<WechatActionGateway, 'registerMemberEvent' | 'registerMemberEvents' | 'clearMemberEvents'>
>
class GroupExitMonitorService { class GroupExitMonitorService {
private readonly actionGateway: GroupExitActionGateway private onGroupExit: GroupExitEventHandler | undefined
private active = false private active = false
private enabled = true private enabled = true
private nativeMonitorActive = false private nativeMonitorActive = false
@@ -87,7 +101,6 @@ class GroupExitMonitorService {
private events: GroupExitMonitorEvent[] = [] private events: GroupExitMonitorEvent[] = []
private monitorSelectionConfigured = true private monitorSelectionConfigured = true
private monitoredRoomIds = new Set<string>() private monitoredRoomIds = new Set<string>()
private notificationRoomIds = new Set<string>()
private loaded = false private loaded = false
private accountRoot = '' private accountRoot = ''
private groupNamesByRoomId = new Map<string, string>() private groupNamesByRoomId = new Map<string, string>()
@@ -95,24 +108,34 @@ class GroupExitMonitorService {
private eventSequence = 0 private eventSequence = 0
private scopeGeneration = 0 private scopeGeneration = 0
private legacyFallbackLogged = false private legacyFallbackLogged = false
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
constructor(deps: GroupExitMonitorServiceDependencies = {}) { constructor(deps: GroupExitMonitorServiceDependencies = {}) {
this.actionGateway = deps.actionGateway || wechatActionGateway this.onGroupExit = deps.onGroupExit
}
/**
* 注入退群事件处理器(主进程在 AutomationService 初始化后调用)。
*
* 用 setter 而不是构造参数:AutomationService 依赖 `Wcdb4Client`,
* 要等数据库解锁后才存在,而本服务是模块级单例。
*/
setGroupExitHandler(handler: GroupExitEventHandler | undefined): void {
this.onGroupExit = handler
} }
getState(): GroupExitMonitorState { getState(): GroupExitMonitorState {
this.ensureLoaded() this.ensureLoaded()
return { return {
events: [...this.events], // this.events 恒为「新在前」,所以这里取到的就是**最新**的 MAX_EVENTS 条。
events: this.events.slice(0, MAX_EVENTS),
/** 永久保留的事件总数(`events` 只是最新的一批)。 */
totalEventCount: this.events.length,
enabled: this.enabled, enabled: this.enabled,
running: this.enabled && this.active && chat.isReady(), running: this.enabled && this.active && chat.isReady(),
nativeMonitorActive: this.nativeMonitorActive, nativeMonitorActive: this.nativeMonitorActive,
monitoredGroupCount: this.snapshots.size, monitoredGroupCount: this.snapshots.size,
monitorSelectionConfigured: this.monitorSelectionConfigured, monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds), monitoredRoomIds: Array.from(this.monitoredRoomIds),
notificationRoomIds: Array.from(this.notificationRoomIds),
notificationTemplate: this.notificationTemplate,
lastCheckedAt: this.lastCheckedAt, lastCheckedAt: this.lastCheckedAt,
lastReadAt: this.lastReadAt, lastReadAt: this.lastReadAt,
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
@@ -132,12 +155,11 @@ class GroupExitMonitorService {
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) || (this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
(!this.accountRoot && this.snapshots.size > 0) (!this.accountRoot && this.snapshots.size > 0)
) { ) {
this.actionGateway.clearMemberEvents?.()
this.events = [] this.events = []
this.rewriteEventsToDisk([])
this.lastReadAt = 0 this.lastReadAt = 0
this.monitorSelectionConfigured = true this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear() this.monitoredRoomIds.clear()
this.notificationRoomIds.clear()
this.snapshots.clear() this.snapshots.clear()
this.groupNamesByRoomId.clear() this.groupNamesByRoomId.clear()
} }
@@ -208,10 +230,13 @@ class GroupExitMonitorService {
return this.getState() return this.getState()
} }
async setMonitoredRoomIds( /**
roomIds: string[], * 保存**监控范围**。
notificationRoomIds: string[] = [] *
): Promise<GroupExitMonitorState> { * 参数只剩监控目标 —— 旧版第二个参数(通知群聊)已经迁到自动化规则里,
* 本服务不再持有任何通知配置。
*/
async setMonitoredRoomIds(roomIds: string[]): Promise<GroupExitMonitorState> {
this.ensureLoaded() this.ensureLoaded()
this.eventSequence += 1 this.eventSequence += 1
this.scopeGeneration += 1 this.scopeGeneration += 1
@@ -225,10 +250,6 @@ class GroupExitMonitorService {
} }
this.monitoredRoomIds = nextMonitoredRoomIds this.monitoredRoomIds = nextMonitoredRoomIds
this.groupNamesRefreshPending = true this.groupNamesRefreshPending = true
const requestedNotifications = normalizeRoomIds(notificationRoomIds)
this.notificationRoomIds = new Set(
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
)
this.lastCheckedAt = undefined this.lastCheckedAt = undefined
this.save() this.save()
this.broadcast() this.broadcast()
@@ -261,22 +282,41 @@ class GroupExitMonitorService {
return this.getState() return this.getState()
} }
setNotificationTemplate(value: unknown): GroupExitMonitorState { /**
* 按群 / 时间范围查退群事件(档案合并展示用)。
*
* 与 `getState()` 的分工:后者只带回最近 `MAX_EVENTS` 条、且是**给管理页**看的概览;
* 档案要的是「某个群在这段时间里的全部事件」,所以单独开一个查询入口,
* 直接打在内存里的完整历史上(事件是永久保留的)。
*
* 返回**按时间升序**(旧 → 新),与档案消息流的顺序一致。
*/
listEvents(
query: { roomId?: string; sinceMs?: number; untilMs?: number; limit?: number } = {}
): GroupExitMonitorEvent[] {
this.ensureLoaded() this.ensureLoaded()
const result = validateGroupExitNotificationTemplate(value) const roomId = String(query.roomId || '').trim()
if (!result.valid || !result.template) { const since = Number(query.sinceMs)
throw new Error(result.error || '退群监测模板无效') const until = Number(query.untilMs)
const limit = Number(query.limit)
// this.events 是倒序(新在前)。
let result = [...this.events].reverse()
if (roomId) result = result.filter((event) => event.roomId === roomId)
if (Number.isFinite(since)) result = result.filter((event) => event.detectedAt >= since)
if (Number.isFinite(until)) result = result.filter((event) => event.detectedAt <= until)
// 超量时保留**最近**的一批(尾部即最新)。
if (Number.isFinite(limit) && limit > 0 && result.length > limit) {
result = result.slice(-limit)
} }
this.notificationTemplate = result.template return result
this.save()
this.broadcast()
return this.getState()
} }
clearEvents(): GroupExitMonitorState { clearEvents(): GroupExitMonitorState {
this.ensureLoaded() this.ensureLoaded()
this.events = [] this.events = []
this.actionGateway.clearMemberEvents?.() // 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
this.rewriteEventsToDisk([])
this.lastReadAt = Date.now() this.lastReadAt = Date.now()
this.save() this.save()
this.broadcast() this.broadcast()
@@ -399,7 +439,7 @@ class GroupExitMonitorService {
groups: GroupMembershipRecord[], groups: GroupMembershipRecord[],
scopeGeneration: number scopeGeneration: number
): Promise<number> { ): Promise<number> {
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = [] const exits: GroupExitMonitorEvent[] = []
let changedGroups = 0 let changedGroups = 0
for (const membership of groups) { for (const membership of groups) {
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) { if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
@@ -430,10 +470,9 @@ class GroupExitMonitorService {
if (previous && next.members.length < previous.members.length) { if (previous && next.members.length < previous.members.length) {
const removed = findRemovedGroupMembers(previous.members, next.members) const removed = findRemovedGroupMembers(previous.members, next.members)
for (const member of removed) { for (const member of removed) {
// 一人一条事件、一条通知 —— 保持既有产品语义,不聚合。
const event = this.recordExit(next, member, previous.members.length, next.members.length) const event = this.recordExit(next, member, previous.members.length, next.members.length)
if (event && this.notificationRoomIds.has(next.roomId)) { if (event) exits.push(event)
notifications.push({ group: next, event })
}
} }
} }
@@ -448,12 +487,11 @@ class GroupExitMonitorService {
return changedGroups return changedGroups
} }
this.lastCheckedAt = Date.now() this.lastCheckedAt = Date.now()
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。 // 先把事件和新基线作为同一检查点落盘,再交给自动化。
// 「退群事实已记录」与「通知发送成功」是两件独立的事。
this.save() this.save()
this.broadcast() this.broadcast()
if (notifications.length) { for (const event of exits) this.emitGroupExit(event)
await Promise.all(notifications.map(({ group, event }) => this.notifyGroup(group, event)))
}
return changedGroups return changedGroups
} }
@@ -589,129 +627,145 @@ class GroupExitMonitorService {
currentCount, currentCount,
delta: currentCount - previousCount, delta: currentCount - previousCount,
message, message,
detectedAt, detectedAt
notificationStatus: 'not_requested'
} }
this.actionGateway.registerMemberEvent?.(event) // 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。
this.events = [event, ...this.events].slice(0, MAX_EVENTS) // 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。
this.events = [event, ...this.events]
this.appendEventsToDisk([event])
console.log( console.log(
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}` `[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
) )
return event return event
} }
private async notifyGroup( /**
group: GroupSnapshotRecord, * 把退群事件交给自动化 —— **不等待**。
event: GroupExitMonitorEvent, *
idempotencyKey = `member_left_notification:${event.id}` * 三条约束:
): Promise<void> { * 1. **绝不 await**:快照扫描与成员 diff 不能被微信发送耗时拖住;
event.notificationStatus = 'pending' * 2. **异常必须被捕获**:`void promise` 漏掉 `.catch` 会变成 unhandled rejection;
event.notification = { status: 'pending' } * 3. **不影响退群事实**:事件与快照在同一检查点已经先落盘,通知失败不回滚记录。
this.save() */
this.broadcast() private emitGroupExit(event: GroupExitMonitorEvent): void {
const handler = this.onGroupExit
if (!handler) return
try { try {
const result = await this.actionGateway.execute({ void Promise.resolve(
idempotencyKey, handler(toGroupMemberExitedEvent(event))
origin: 'member_monitor', ).catch((error) => {
purpose: 'member_left_notification',
triggerType: 'automation',
sourceId: event.id,
recipient: {
type: 'group',
id: group.roomId,
name: group.groupName
},
content: {
type: 'text',
text: buildMemberLeftNotification(event, this.notificationTemplate)
},
metadata: {
memberId: event.memberWxid,
memberName: event.memberName,
detectedAt: event.detectedAt,
eventType: 'member_left',
eventRoomId: event.roomId
}
})
const notification: GroupExitNotificationState = {
status: result.status,
actionId: result.actionId,
decision: result.decision,
...(result.errorCode ? { errorCode: result.errorCode } : {}),
...(result.reason ? { reason: result.reason } : {}),
startedAt: result.startedAt,
finishedAt: result.finishedAt
}
event.notificationStatus = result.status
event.notification = notification
if (result.status !== 'sent') {
console.warn( console.warn(
`[GroupMonitor] 群聊通知未发送 roomId=${group.roomId} status=${result.status} code=${result.errorCode || ''}` `[GroupMonitor] 退群通知处理失败 eventId=${event.id}: ${
error instanceof Error ? error.message : String(error)
}`
) )
} })
} catch (error) { } catch (error) {
event.notificationStatus = 'failed' // handler 同步抛出的情况(`handleGroupExit` 本身不抛,这里是防御性兜底)。
event.notification = {
status: 'failed',
errorCode: 'UNKNOWN',
reason: error instanceof Error ? error.message : String(error)
}
console.warn( console.warn(
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`, `[GroupMonitor] 退群通知处理异常 eventId=${event.id}: ${
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}`
) )
} finally {
this.save()
this.broadcast()
} }
} }
async resendEvent(eventId: string): Promise<GroupExitMonitorState> {
const event = this.events.find((item) => item.id === eventId)
if (!event) throw new Error('退群动态不存在')
await this.notifyGroup(
{ roomId: event.roomId, groupName: event.groupName } as GroupSnapshotRecord,
event,
`member_left_notification:${event.id}:retry:${Date.now()}`
)
return this.getState()
}
private filePath(): string { private filePath(): string {
return path.join(app.getPath('userData'), 'group-exit-monitor.json') return path.join(app.getPath('userData'), 'group-exit-monitor.json')
} }
/**
* 退群事件的 append-only 存储(每行一条 JSON)。
*
* 与状态文件分开,因为两者的写入模式完全不同:
* - **状态**(开关 / 监控范围 / 快照 / 模板)小、且总是整体重写;
* - **事件**只增不改,且要求**永久保留**。
* 混在一个文件里时,每来一条事件都要把整部历史重新序列化写一遍 —— 越写越慢。
*/
private eventsPath(): string {
return path.join(app.getPath('userData'), 'group-exit-monitor-events.jsonl')
}
/** 读全量历史事件。单行损坏只跳过该行,不让整部历史读不出来。 */
private readEventsFromDisk(): GroupExitMonitorEvent[] {
let raw = ''
try {
raw = fs.readFileSync(this.eventsPath(), 'utf8')
} catch {
return []
}
const events: GroupExitMonitorEvent[] = []
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
try {
events.push(JSON.parse(trimmed) as GroupExitMonitorEvent)
} catch {
// 跳过坏行
}
}
return events
}
/** 只追加新增的那几行,不重写历史。 */
private appendEventsToDisk(events: GroupExitMonitorEvent[]): void {
if (!events.length) return
try {
fs.ensureDirSync(path.dirname(this.eventsPath()))
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
fs.appendFileSync(this.eventsPath(), payload, 'utf8')
} catch (error) {
console.warn('[GroupMonitor] 追加退群事件失败:', error)
}
}
/** 整体重写事件文件(清空、切换账号、老数据迁移时使用)。 */
private rewriteEventsToDisk(events: GroupExitMonitorEvent[]): void {
try {
fs.ensureDirSync(path.dirname(this.eventsPath()))
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
fs.writeFileSync(this.eventsPath(), payload, 'utf8')
} catch (error) {
console.warn('[GroupMonitor] 重写退群事件失败:', error)
}
}
private ensureLoaded(): void { private ensureLoaded(): void {
if (this.loaded) return if (this.loaded) return
this.loaded = true this.loaded = true
try { try {
const stored = fs.readJsonSync(this.filePath()) as StoredState const stored = fs.readJsonSync(this.filePath()) as StoredState
this.enabled = stored.enabled !== false this.enabled = stored.enabled !== false
this.events = normalizeEvents(stored.events) // 事件从 append-only 文件读;状态文件不再承载它们。
this.actionGateway.registerMemberEvents?.(this.events) const fromDisk = this.readEventsFromDisk()
const legacy = normalizeEvents(stored.events)
if (legacy.length && !fromDisk.length) {
// 老版本把事件塞在状态文件里 —— 一次性迁移过去,避免这批历史丢失。
// 落盘按时间**升序**(旧 → 新),与之后 append 的方向一致,避免在
// append-only 文件开头留下一段方向相反的旧历史(历史行序错乱的来源)。
this.rewriteEventsToDisk(
[...legacy].sort((left, right) => left.detectedAt - right.detectedAt)
)
this.events = sortEventsNewestFirst(legacy)
} else {
// 磁盘行序不保证时间有序(迁移段与追加段方向相反),读回后必须显式重建
// 「新在前」这个内存不变量,否则列表顶部会恒为最旧的一批。
this.events = sortEventsNewestFirst(normalizeEvents(fromDisk))
}
this.lastReadAt = Number(stored.lastReadAt) || 0 this.lastReadAt = Number(stored.lastReadAt) || 0
this.accountRoot = String(stored.accountRoot || '') this.accountRoot = String(stored.accountRoot || '')
// 没有显式范围时按空范围处理,保留已有选择。 // 没有显式范围时按空范围处理,保留已有选择。
this.monitorSelectionConfigured = true this.monitorSelectionConfigured = true
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || []) this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
this.notificationRoomIds = new Set(
Array.from(normalizeRoomIds(stored.notificationRoomIds || [])).filter((roomId) =>
this.monitoredRoomIds.has(roomId)
)
)
this.notificationTemplate = normalizeGroupExitNotificationTemplate(
stored.notificationTemplate
)
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds) this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
} catch { } catch {
// 首次启动或文件损坏时从空记录开始。 // 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
this.events = [] // 不该被状态文件的问题连累,仍然读回来。
this.events = sortEventsNewestFirst(normalizeEvents(this.readEventsFromDisk()))
this.enabled = true this.enabled = true
this.lastReadAt = 0 this.lastReadAt = 0
this.monitorSelectionConfigured = true this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear() this.monitoredRoomIds.clear()
this.notificationRoomIds.clear()
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
this.snapshots.clear() this.snapshots.clear()
} }
} }
@@ -725,12 +779,11 @@ class GroupExitMonitorService {
{ {
accountRoot: this.accountRoot, accountRoot: this.accountRoot,
enabled: this.enabled, enabled: this.enabled,
events: this.events, // 事件**不在这里**:它们走 append-only 的 JSONL(见 `eventsPath()`)。
// 放进状态文件会让每新增一条事件都把整部历史重写一遍。
lastReadAt: this.lastReadAt, lastReadAt: this.lastReadAt,
monitorSelectionConfigured: this.monitorSelectionConfigured, monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds), monitoredRoomIds: Array.from(this.monitoredRoomIds),
notificationRoomIds: Array.from(this.notificationRoomIds),
notificationTemplate: this.notificationTemplate,
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot) snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
}, },
{ spaces: 2 } { spaces: 2 }
@@ -927,39 +980,28 @@ function normalizeEvents(
? Number(value.delta) ? Number(value.delta)
: currentCount - previousCount, : currentCount - previousCount,
message: String(value.message || `${memberName}退出了${groupName}`), message: String(value.message || `${memberName}退出了${groupName}`),
detectedAt, detectedAt
...(value.notificationStatus
? { notificationStatus: normalizeNotificationStatus(value.notificationStatus) }
: {}),
...(value.notification && typeof value.notification === 'object'
? { notification: normalizeNotification(value.notification) }
: {})
}) })
if (normalized.length >= MAX_EVENTS) break // 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
// 历史上的 `notificationStatus` / `notification` 字段被**丢弃**:
// 通知状态已归 Automation 执行日志,退群监控不再持有它。
} }
return normalized return normalized
} }
function normalizeNotificationStatus(value: unknown): GroupExitNotificationStatus { /**
const status = String(value || '').trim() * 事件在内存里恒定保持「**新在前**」。
return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed' *
? status * 这个不变量有三个依赖方:`recordExit` 的 `[event, ...this.events]` 写入方向、
: 'not_requested' * `listEvents()` 的 `.reverse()`(它假定内存是倒序,反转后得到升序)、
} * 以及 `getState()` 的 `slice(0, MAX_EVENTS)`(要求取到的是**最新**的一批)。
*
function normalizeNotification(value: object): GroupExitNotificationState { * 必须显式重建它:磁盘是 append-only,行序由「迁移写入的历史 + 之后追加的新事件」
const input = value as Partial<GroupExitNotificationState> * 决定,两段方向相反,整体不保证时间有序。直接信任文件行序会让列表顶部恒为最旧的
return { * 一批,并让 `slice(0, MAX_EVENTS)` 恰好把最新的事件截掉。
status: normalizeNotificationStatus(input.status), */
...(input.actionId ? { actionId: String(input.actionId) } : {}), function sortEventsNewestFirst(events: GroupExitMonitorEvent[]): GroupExitMonitorEvent[] {
...(input.decision === 'allow' || input.decision === 'block' return [...events].sort((left, right) => right.detectedAt - left.detectedAt)
? { decision: input.decision }
: {}),
...(input.errorCode ? { errorCode: String(input.errorCode) } : {}),
...(input.reason ? { reason: String(input.reason) } : {}),
...(input.startedAt ? { startedAt: String(input.startedAt) } : {}),
...(input.finishedAt ? { finishedAt: String(input.finishedAt) } : {})
}
} }
function isContactEvent(rawPayload: string): boolean { function isContactEvent(rawPayload: string): boolean {
+208
View File
@@ -0,0 +1,208 @@
import { isKnowledgeFresh } from '../../shared/knowledge'
import {
GROUP_STATS_LIMITATION,
GROUP_STATS_STALE_LIMITATION,
type GroupMemberStatsActiveMember,
type GroupMemberStatsQuery,
type GroupMemberStatsResult,
type GroupMemberStatsSilentMember,
type GroupStatsFreshness
} from '../../shared/group-stats'
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
import * as chat from './chat-service'
/**
* 触发一次追赶同步的最小间隔。
*
* 面板是可以反复开关的交互入口,而追赶同步跑的是真实增量 pass(会读 WCDB)。
* 没有这个节流,连续点击就等于连续触发索引。
*/
const CATCH_UP_MIN_INTERVAL_MS = 30_000
/**
* 追赶的有界等待预算。
*
* 与 Query 侧同一取舍:全量追赶可能以分钟计,交互查询绝不能无限等。
* 预算用完之后**如实**把结果标成不完整,而不是假装完整。
*/
const FRESHNESS_WAIT_BUDGET_MS = 2_000
/**
* 群员统计。
*
* 数据两路来源,职责严格分开:
* - **当前成员名单** 走 WCDB 的轻路径(只取 wxid + 显示名,**不** hydrate 头像);
* - **发言聚合** 走 Knowledge 派生库的 `GROUP BY sender_id`,不读 WCDB 原始消息。
*
* 「未发言」= 当前成员 ∖ 窗口内有发言的 sender。这个定义**不能**简化成
* 「整段窗口都没说话」—— 入群/退群时间在源头就不存在(整个 DB 没有该字段),
* 所以结论必须带上 limitation。
*/
export class GroupStatsService {
constructor(private readonly knowledge: KnowledgeSearchService) {}
async getMemberStats(query: GroupMemberStatsQuery): Promise<GroupMemberStatsResult> {
const { userMd5, startTime, endTime } = query
const limitations = [GROUP_STATS_LIMITATION]
const roomId = chat.resolveGroupRoomId(userMd5)
if (!roomId) {
// 不是群 / md5 解析不到:如实返回空,不编造统计。
return this.build({
userMd5,
startTime,
endTime,
freshness: 'unknown',
limitations,
members: [],
activeMembers: [],
silentMembers: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
firstMessageTime: null
})
}
// 1) 当前群成员 —— 轻路径。只取 wxid,再按需解析显示名;绝不走
// `getGroupSnapshotAsync`(它会 materialize 整群并 hydrate 头像,实测 8 群 ≈ 42s)。
const membership = await chat.getGroupMemberIdsAsync(roomId)
const memberIds = membership?.memberIds ?? []
const members = await chat.getGroupMemberNamesAsync(userMd5, memberIds)
// 2) 发言聚合(只统计当前群;系统消息在 SQL 层就被排除)
let { result, sourceLatestAt } = await this.knowledge.memberStats({
conversationId: userMd5,
startTime,
endTime
})
let freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
// 3) 索引没追平 → 有界追赶一次再判。追不上就如实标 stale,
// **不允许**把落后索引算出来的「未发言」包装成完整结论。
if (freshness !== 'fresh') {
this.knowledge.requestCatchUp(CATCH_UP_MIN_INTERVAL_MS)
const settled = await this.knowledge.waitForIndexingComplete(FRESHNESS_WAIT_BUDGET_MS)
if (settled) {
const retried = await this.knowledge.memberStats({
conversationId: userMd5,
startTime,
endTime
})
result = retried.result
sourceLatestAt = retried.sourceLatestAt
freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
}
}
// 4) 差集:当前成员 × 窗口内发言
const statBySender = new Map<string, { messageCount: number; lastMessageTime: number }>()
for (const row of result?.senders ?? []) {
if (row.senderId) statBySender.set(row.senderId, row)
}
const knownSenderIds = new Set<string>()
const activeMembers: GroupMemberStatsActiveMember[] = []
const silentMembers: GroupMemberStatsSilentMember[] = []
for (const member of members) {
const wxid = String(member.wxid || '').trim()
if (!wxid) continue
knownSenderIds.add(wxid)
// 与 Knowledge 的 Join key 必须是 wxid,不是昵称 —— 昵称会变。
// 显示名沿用项目既有优先级(nickname 已内含 `wechatNickname || groupNickname || username`)。
const displayName = member.nickname || member.remark || wxid
const groupNickname = member.groupNickname || ''
const stat = statBySender.get(wxid)
if (stat && stat.messageCount > 0) {
activeMembers.push({
senderId: wxid,
displayName,
groupNickname,
messageCount: stat.messageCount,
lastMessageTime: stat.lastMessageTime
})
} else {
silentMembers.push({ senderId: wxid, displayName, groupNickname })
}
}
// 窗口内有发言、但已不在当前成员名单里的人(退群者)。
// 默认**不**进活跃榜:那份名单读作「当前群成员」,混入已退群的人会误导。
let formerSenderCount = 0
for (const senderId of statBySender.keys()) {
if (!knownSenderIds.has(senderId)) formerSenderCount += 1
}
activeMembers.sort(
(a, b) => b.messageCount - a.messageCount || b.lastMessageTime - a.lastMessageTime
)
silentMembers.sort((a, b) => a.displayName.localeCompare(b.displayName, 'zh-Hans-CN'))
if (freshness !== 'fresh') limitations.push(GROUP_STATS_STALE_LIMITATION)
if (formerSenderCount > 0) {
limitations.push(
`另有 ${formerSenderCount} 位窗口内发言者已不在当前群成员名单中,未计入活跃榜。`
)
}
return this.build({
userMd5,
startTime,
endTime,
freshness,
limitations,
members,
activeMembers,
silentMembers,
unattributedMessages: result?.unattributedMessages ?? 0,
excludedSystemMessages: result?.excludedSystemMessages ?? 0,
firstMessageTime: result?.earliestMessageTime ?? null
})
}
private build(input: {
userMd5: string
startTime: number
endTime: number
freshness: GroupStatsFreshness
limitations: string[]
members: Array<{ wxid: string }>
activeMembers: GroupMemberStatsActiveMember[]
silentMembers: GroupMemberStatsSilentMember[]
unattributedMessages: number
excludedSystemMessages: number
firstMessageTime: number | null
}): GroupMemberStatsResult {
return {
conversationId: input.userMd5,
startTime: input.startTime,
endTime: input.endTime,
freshness: input.freshness,
// 只有索引确实追平,才允许调用方把「未发言」当作完整结论。
complete: input.freshness === 'fresh',
memberCount: input.members.length,
activeMemberCount: input.activeMembers.length,
silentMemberCount: input.silentMembers.length,
activeMembers: input.activeMembers,
silentMembers: input.silentMembers,
unattributedMessages: input.unattributedMessages,
excludedSystemMessages: input.excludedSystemMessages,
firstMessageTime: input.firstMessageTime,
limitations: input.limitations
}
}
}
/**
* `isKnowledgeFresh` 返回 `boolean | null`(任一侧口径缺失即 null)。
* 三态都要能表达:`null` 绝不能当成「新鲜」。
*/
function judgeFreshness(
indexLatestAt: number | null,
sourceLatestAt: number | null
): GroupStatsFreshness {
const fresh = isKnowledgeFresh({ indexLatestAt, sourceLatestAt })
if (fresh === true) return 'fresh'
if (fresh === false) return 'stale'
return 'unknown'
}
@@ -27,6 +27,8 @@ import {
isFreshImageInsight, isFreshImageInsight,
isHotImageCandidate isHotImageCandidate
} from '../../shared/image-insight' } from '../../shared/image-insight'
import type { SystemOcrCapability, SystemOcrRequest, SystemOcrResult } from '../../shared/system-ocr'
import { systemOcrService } from './system-ocr-service'
/** /**
* 单张图片的最小信息(由 renderer 从已加载的 messages 中提取并传入 main)。 * 单张图片的最小信息(由 renderer 从已加载的 messages 中提取并传入 main)。
@@ -312,6 +314,35 @@ class ImageInsightService {
listBySession(sessionId: string, limit?: number): ImageInsight[] { listBySession(sessionId: string, limit?: number): ImageInsight[] {
return imageInsightsStore.listBySession(sessionId, limit) 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() export const imageInsightService = new ImageInsightService()
File diff suppressed because it is too large Load Diff
+671
View File
@@ -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 }
+151 -5
View File
@@ -1,5 +1,6 @@
import { listContactsAsync, listMessagesAsync, isReady, type FormattedContact, type FormattedMessage } from './chat-service' import { listContactsAsync, listMessagesAsync, isReady, type FormattedContact, type FormattedMessage } from './chat-service'
import { resolveContact } from './contact-resolution-service' import { resolveContact } from './contact-resolution-service'
import { sourceMessageId } from '../knowledge/message-identity'
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service' import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
import { inferAiSearchTimeRange } from '../../shared/ai-search' import { inferAiSearchTimeRange } from '../../shared/ai-search'
import { KNOWLEDGE_FRESHNESS_TOLERANCE_MS } from '../../shared/knowledge' import { KNOWLEDGE_FRESHNESS_TOLERANCE_MS } from '../../shared/knowledge'
@@ -13,6 +14,7 @@ import type {
ResolvedCorpusScope, ResolvedCorpusScope,
ResolvedTimeRange, ResolvedTimeRange,
QueryIndexCoverage, QueryIndexCoverage,
QueryImageTextCoverage,
QuerySearchTimings, QuerySearchTimings,
QueryMessagesRequest, QueryMessagesRequest,
SearchMessagesRequest, SearchMessagesRequest,
@@ -26,6 +28,14 @@ import {
decodeMessageRef as fromRef, decodeMessageRef as fromRef,
normalizeMessageIdentity normalizeMessageIdentity
} from '../../shared/local-query-api' } 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 LIMIT_MAX = 200
const CONTEXT_MAX = 50 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> = { const KIND_LABELS: Record<QueryMessageType, string> = {
text: '文本', text: '文本',
image: '图片', image: '图片',
@@ -149,7 +212,19 @@ function resolvedTimeRange(input: QueryTimeRange, now = new Date()): ResolvedTim
const range = inferAiSearchTimeRange(phrase[input.kind], map[input.kind], now) const range = inferAiSearchTimeRange(phrase[input.kind], map[input.kind], now)
return { kind: input.kind, startTime: range.startTime, endTime: range.endTime, label: range.label } 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 kind = kindOf(message)
const content = message.contentData const content = message.contentData
const attachment = 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 } ? { kind: 'file' as const, name: message.exportMediaName || (content?.type === 'share' ? content.title : undefined), url: content?.type === 'share' ? content.url : undefined }
: undefined : undefined
const text = message.content?.trim() || message.voiceTranscript?.trim() || 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 { 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 { 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 } } } 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 raw = await listMessagesAsync(contact.md5, range.startTime, range.endTime)
const direction = request.direction || 'any'; const allowed = new Set(request.messageTypes || kinds) 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))) 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) { async search(request: SearchMessagesRequest) {
const requestStartedAt = Date.now() const requestStartedAt = Date.now()
@@ -359,6 +496,8 @@ export class LocalQueryApiService {
const covered = indexCovers(found.indexLatestAt, requestedEnd, found.sourceLatestAt) const covered = indexCovers(found.indexLatestAt, requestedEnd, found.sourceLatestAt)
const indexCoverage = buildIndexCoverage(found.indexLatestAt, found.sourceLatestAt, covered) const indexCoverage = buildIndexCoverage(found.indexLatestAt, found.sourceLatestAt, covered)
// 图片文字索引是**独立覆盖维度**:文字索引再完整也不代表图片里的文字搜得到。
const imageOcrCoverage = buildImageOcrCoverage(this.imageTextCoverage())
const timings: QuerySearchTimings = { const timings: QuerySearchTimings = {
totalMs: Date.now() - requestStartedAt, totalMs: Date.now() - requestStartedAt,
scopeMs, scopeMs,
@@ -381,6 +520,7 @@ export class LocalQueryApiService {
sourceLatestAt: found.sourceLatestAt, sourceLatestAt: found.sourceLatestAt,
freshness: { catchUp: freshness.catchUp }, freshness: { catchUp: freshness.catchUp },
...(indexCoverage ? { indexCoverage } : {}), ...(indexCoverage ? { indexCoverage } : {}),
...(imageOcrCoverage ? { imageOcrCoverage } : {}),
timings timings
} }
} }
@@ -454,7 +594,11 @@ export class LocalQueryApiService {
timestamp: item.timestamp, timestamp: item.timestamp,
sender: item.sender, sender: item.sender,
sourceKind: item.sourceKind, 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, conversationName: owner ? contactView(owner).displayName : undefined,
conversationType: owner?.type conversationType: owner?.type
} satisfies QueryEvidenceItem } satisfies QueryEvidenceItem
@@ -592,6 +736,7 @@ export class LocalQueryApiService {
const truncated = raw.length > OVERVIEW_SOURCE_CAP const truncated = raw.length > OVERVIEW_SOURCE_CAP
const evidence = selectTemporalCoverageEvidence(contact, messages, OVERVIEW_EVIDENCE_TARGET) const evidence = selectTemporalCoverageEvidence(contact, messages, OVERVIEW_EVIDENCE_TARGET)
const state: 'complete' | 'partial' = truncated ? 'partial' : 'complete' const state: 'complete' | 'partial' = truncated ? 'partial' : 'complete'
const imageOcrCoverage = buildImageOcrCoverage(this.imageTextCoverage())
return { return {
status: 'completed' as const, status: 'completed' as const,
target: contactView(contact), 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 }, selection: { mode: 'temporal_coverage' as const, selectedEvidenceCount: evidence.length, sampled: truncated || evidence.length < messages.length },
evidence, evidence,
scope: corpus.scope, scope: corpus.scope,
...(imageOcrCoverage ? { imageOcrCoverage } : {}),
origin: 'wcdb' as const origin: 'wcdb' as const
} }
} }
+49
View File
@@ -0,0 +1,49 @@
import { createHash } from 'node:crypto'
/**
* 统一日志脱敏。
*
* 微信连接器与发送日志共用同一套规则:任何 secret 都不能以原值进入普通日志,
* 包括 bot_token、context_token、typing_ticket、AES key、二维码凭据与 Authorization 头。
*
* 只用于自由文本日志;结构化字段(如 sha256 摘要)不要经过这里,否则会被误伤。
*/
const REDACTION_RULES: ReadonlyArray<readonly [RegExp, string]> = [
// Authorization 头
[/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [已隐藏]'],
// 二维码图片内容(base64 data URL 可能长达数百 KB)
[/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/gi, 'data:image/[二维码已隐藏]'],
// JSON / query 形态的 secret 字段
[
/("?(?:bot_token|context_token|typing_ticket|aeskey|aes_key|access_token|refresh_token|authorization)"?\s*[:=]\s*)"?[^\s",}&]+"?/gi,
'$1"[已隐藏]"'
],
// 兼容历史写法:token=xxx / token: xxx
[/(\btoken\s*[:=]\s*)[^\s,}]+/gi, '$1[已隐藏]'],
// iLink 上传参数
[/(encrypted_query_param=)[^\s&"]+/gi, '$1[已隐藏]']
]
/** 对任意自由文本日志做脱敏。 */
export function redactSecrets(message: string): string {
let result = String(message ?? '')
for (const [pattern, replacement] of REDACTION_RULES) {
result = result.replace(pattern, replacement)
}
return result
}
/** 日志中只允许出现 secret 是否存在,不允许出现原值。 */
export function describeSecretPresence(value: string | undefined | null): string {
return value ? 'present' : 'absent'
}
/**
* 生成不可逆的短指纹,用于需要关联同一 secret 又不能落盘的诊断场景。
* 只保留 sha256 前 8 位十六进制,无法反推原值。
*/
export function secretFingerprint(value: string | undefined | null): string {
if (!value) return 'absent'
return createHash('sha256').update(String(value)).digest('hex').slice(0, 8)
}
@@ -0,0 +1,101 @@
import { existsSync, readFileSync, statSync } from 'fs'
import { join } from 'path'
import type { MacWechatRuntimeManifest } from '../../shared/personal-wechat-mac-runtime'
const REQUIRED_CAPABILITIES = ['text', 'image', 'voice'] as const
function assertFile(path: string, label: string): void {
if (!existsSync(path) || !statSync(path).isFile()) {
throw new Error(`${label} missing: ${path}`)
}
}
function assertMachOArm64(path: string, label: string): void {
const binary = readFileSync(path)
if (binary.length < 8 || binary.readUInt32LE(0) !== 0xfeedfacf) {
throw new Error(`${label} is not a 64-bit Mach-O binary`)
}
if (binary.readUInt32LE(4) !== 0x0100000c) {
throw new Error(`${label} is not arm64`)
}
}
export function readMacWechatRuntimeManifest(
runtimeDir: string,
architecture = process.arch
): MacWechatRuntimeManifest {
const manifestPath = join(runtimeDir, 'runtime-manifest.json')
const hostPath = join(runtimeDir, 'tm-wechat-host')
const dylibPath = join(runtimeDir, 'libtmwechat.dylib')
assertFile(manifestPath, 'runtime manifest')
assertFile(hostPath, 'runtime host')
assertFile(dylibPath, 'runtime dylib')
let manifest: MacWechatRuntimeManifest
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as MacWechatRuntimeManifest
} catch (error) {
throw new Error(`runtime manifest is unreadable: ${String(error)}`)
}
if (manifest.runtime !== 'tm-wechat-native') {
throw new Error(`unexpected runtime name: ${String(manifest.runtime)}`)
}
if (manifest.platform !== 'darwin-arm64') {
throw new Error(`runtime platform must be darwin-arm64: ${String(manifest.platform)}`)
}
if (manifest.architecture !== 'arm64' || architecture !== 'arm64') {
throw new Error(
`runtime architecture mismatch: artifact=${String(manifest.architecture)} process=${architecture}`
)
}
if (manifest.protocolVersion !== 1) {
throw new Error(`unsupported runtime protocolVersion: ${String(manifest.protocolVersion)}`)
}
if (!/^[a-f0-9]{64}$/.test(manifest.tmSendSourceSha256 || '')) {
throw new Error('runtime manifest has no valid tmSendSourceSha256')
}
if (!/^[a-f0-9]{64}$/.test(manifest.addressProfileSha256 || '')) {
throw new Error('runtime manifest has no valid addressProfileSha256')
}
if (
!Array.isArray(manifest.supportedWechatVersions) ||
manifest.supportedWechatVersions.length === 0
) {
throw new Error('runtime manifest has no supported WeChat versions')
}
const capabilities = new Set(manifest.capabilities || [])
for (const capability of REQUIRED_CAPABILITIES) {
if (!capabilities.has(capability)) {
throw new Error(`runtime manifest capability missing: ${capability}`)
}
}
assertMachOArm64(hostPath, 'runtime host')
assertMachOArm64(dylibPath, 'runtime dylib')
if (!readFileSync(dylibPath).includes(Buffer.from(manifest.tmSendSourceSha256, 'utf8'))) {
throw new Error('runtime dylib embedded agent hash does not match manifest')
}
if (!readFileSync(dylibPath).includes(Buffer.from(manifest.addressProfileSha256, 'utf8'))) {
throw new Error('runtime dylib embedded address profile hash does not match manifest')
}
return manifest
}
export function bindingMatchesRuntimeManifest(
binding: {
hostPid?: number
runtimeVersion?: string
embeddedAgentSha256?: string
embeddedAddressProfileSha256?: string
},
manifest: MacWechatRuntimeManifest
): boolean {
return (
Number.isInteger(binding.hostPid) &&
Number(binding.hostPid) > 0 &&
binding.runtimeVersion === `tm-wechat-runtime/${manifest.version}` &&
binding.embeddedAgentSha256 === manifest.tmSendSourceSha256 &&
binding.embeddedAddressProfileSha256 === manifest.addressProfileSha256
)
}
@@ -0,0 +1,724 @@
import { spawn, type ChildProcess } from 'child_process'
import { chmodSync, existsSync } from 'fs'
import { get } from 'http'
import { join } from 'path'
import { app } from 'electron'
import { appLogger } from '../app-logger'
import { isPackagedRuntime } from '../runtime-mode'
import type {
MacWechatBindResult,
MacWechatBindingStatus,
MacWechatRuntimeManifest,
MacWechatRuntimeLifecycle,
MacWechatRuntimeSnapshot
} from '../../shared/personal-wechat-mac-runtime'
import type { PersonalWechatSenderStatus } from '../../shared/personal-wechat'
import {
bindingMatchesRuntimeManifest,
readMacWechatRuntimeManifest
} from './mac-wechat-runtime-artifact'
/*
* MacWechatRuntimeManager — lifecycle owner of the built-in macOS native
* WeChat runtime (tm-wechat-host + libtmwechat.dylib).
*
* Process isolation is a hard requirement: Electron NEVER dlopens the dylib.
* It spawns tm-wechat-host, which is the ONLY binding owner, and talks to it
* over 127.0.0.1 HTTP. A native crash therefore cannot take Electron down.
*
* Binding lifecycle is "bind once per WeChat process":
* - starting the host NEVER attaches to WeChat (the host is lazy)
* - bind happens only on an explicit user action
* - a host crash while bound leaves an orphaned frida agent inside WeChat
* that frida cannot remove from the outside; the only recovery is
* restarting WeChat. Surfaced as wechatRestartRequired. This was verified
* experimentally (2026-09-21) — do not "fix" it by auto-restarting and
* auto-binding, that path dead-ends in a 20s attach timeout every time.
*/
const PORT = 4290
const HOST = '127.0.0.1'
const START_TIMEOUT_MS = 15_000
const REQUEST_TIMEOUT_MS = 30_000
const CRASH_RESTART_DELAY_MS = 1_500
interface MacWechatSenderStatusInput {
snapshot: MacWechatRuntimeSnapshot
binding: MacWechatBindingStatus | null
manifest: MacWechatRuntimeManifest
arch?: string
}
export function shouldClearWechatRestartRequired(
orphanWechatPid: number,
currentWechatPid: number
): boolean {
return orphanWechatPid > 0 && currentWechatPid > 0 && orphanWechatPid !== currentWechatPid
}
export function deriveMacWechatSenderStatus({
snapshot,
binding,
manifest,
arch = process.arch
}: MacWechatSenderStatusInput): PersonalWechatSenderStatus {
const identityMatches = binding !== null && bindingMatchesRuntimeManifest(binding, manifest)
const stale = binding?.bindingState === 'stale'
let state: PersonalWechatSenderStatus['state'] = 'stopped'
let message = '正在初始化微信发送能力'
if (snapshot.wechatRestartRequired) {
state = 'error'
message = '微信发送组件异常退出。由于当前微信进程中可能残留上一发送会话,请重启微信后重新绑定。'
} else if (binding && !identityMatches) {
state = 'error'
message = '运行中的微信发送 host 与当前 Native Runtime 不一致,请退出旧 host 后重启 TraceMemo。'
} else if (binding && !binding.wechatRunning) {
state = 'wechat_not_running'
message = '微信未运行'
} else if (binding?.bindingState === 'ready') {
state = 'online'
message = '个人微信已准备好发送日报'
} else if (binding?.bindingState === 'bound' || binding?.bindingState === 'binding') {
state = 'hook_not_ready'
message = '已绑定,等待捕获微信发送上下文'
} else if (stale) {
state = 'stopped'
message = '检测到微信已重启,请重新绑定'
} else if (binding?.bindingState === 'failed') {
state = 'error'
message = '微信绑定失败'
} else {
state = 'stopped'
message = '尚未绑定当前微信,可点击"绑定微信"'
}
const runtimeCapabilities = new Set(manifest.capabilities)
const bindingReady = identityMatches && state === 'online' && binding?.sendContextReady === true
const canSendText =
bindingReady && runtimeCapabilities.has('text') && binding?.canSendText === true
const canSendImage =
bindingReady && runtimeCapabilities.has('image') && binding?.canSendImage === true
const canSendVoice =
bindingReady && runtimeCapabilities.has('voice') && binding?.canSendVoice === true
const sessionAttached = identityMatches && (binding?.sessionAttached ?? false)
const scriptLoaded = identityMatches && (binding?.scriptLoaded ?? false)
return {
state,
platform: 'darwin',
arch,
// 当前 gadget runtime 不依赖宿主 SIP 状态;该字段仅用于兼容旧 sender contract。
sipDisabled: true,
wechatRunning: binding?.wechatRunning ?? false,
wechatPid: binding?.wechatPid ?? 0,
boundWechatPid: binding?.boundWechatPid ?? 0,
endpoint: snapshot.endpoint,
endpointReady: snapshot.lifecycle === 'online',
wechatVersion: binding?.wechatVersion,
runtimeReady: snapshot.lifecycle === 'online' && identityMatches,
attachReady: sessionAttached,
baseAddressReady: sessionAttached,
textHookInstalled: scriptLoaded,
textHookReady: identityMatches && (binding?.sendContextReady ?? false),
imageHookInstalled: identityMatches && (binding?.canSendImage ?? false),
imageHookReady: identityMatches && (binding?.canSendImage ?? false),
messageListenerReady: scriptLoaded,
canSend: canSendText || canSendImage || canSendVoice,
canSendText,
canSendImage,
canSendVoice,
message,
error: state === 'error' ? message : undefined
}
}
class MacWechatRuntimeManager {
private child: ChildProcess | null = null
private lifecycle: MacWechatRuntimeLifecycle = 'stopped'
private wechatRestartRequired = false
/* The WeChat pid that was bound when the host died / bind hit the orphan
* wall. The orphan agent lives INSIDE that process; once WeChat is restarted
* the pid changes and the orphan is gone, so the flag must auto-clear. */
private orphanWechatPid = 0
private lastBinding: MacWechatBindingStatus | null = null
private manifest: MacWechatRuntimeManifest | null = null
private lastError: string | null = null
private restartTimer: NodeJS.Timeout | null = null
getPort(): number {
return PORT
}
getSnapshot(): MacWechatRuntimeSnapshot {
return {
lifecycle: this.lifecycle,
wechatRestartRequired: this.wechatRestartRequired,
endpoint: `http://${HOST}:${PORT}`,
port: PORT,
runtimeDir: this.resolveRuntimeDir(),
manifest: this.manifest ?? undefined,
error: this.lastError ?? undefined,
binding: this.lastBinding ?? undefined
}
}
private resolveRuntimeDir(): string {
const base = isPackagedRuntime() ? process.resourcesPath : app.getAppPath()
return join(base, 'resources', 'runtime', 'darwin-arm64')
}
private resolveHostPath(): string {
return join(this.resolveRuntimeDir(), 'tm-wechat-host')
}
/** Runtime binaries present and executable? Cheap check for the settings UI. */
isRuntimePresent(): boolean {
try {
this.manifest = readMacWechatRuntimeManifest(this.resolveRuntimeDir())
return true
} catch {
return false
}
}
/**
* Start the host if it is not already running. Deliberately lazy: the host
* comes up WITHOUT touching WeChat (lazy init inside the host), so "runtime
* online" and "WeChat bound" stay two independent states.
*/
async ensureStarted(): Promise<MacWechatRuntimeSnapshot> {
if (this.lifecycle === 'starting') {
return this.getSnapshot()
}
const runtimeDir = this.resolveRuntimeDir()
try {
this.manifest = readMacWechatRuntimeManifest(runtimeDir)
appLogger.write({
level: 'info',
scope: 'mac-wechat-runtime',
message: 'runtime_artifact_validated',
details: {
runtime: this.manifest.runtime,
version: this.manifest.version,
protocolVersion: this.manifest.protocolVersion,
agentSha256: this.manifest.tmSendSourceSha256,
addressProfileSha256: this.manifest.addressProfileSha256
}
})
} catch (error) {
this.manifest = null
const detail = error instanceof Error ? error.message : String(error)
appLogger.write({
level: 'warn',
scope: 'mac-wechat-runtime',
message: 'runtime_artifact_unavailable',
details: { detail }
})
this.lastError = '暂无发送能力'
this.lifecycle = 'stopped'
return this.getSnapshot()
}
if (this.lifecycle === 'online') {
await this.refreshBinding().catch(() => undefined)
if (
this.lastBinding &&
this.manifest &&
bindingMatchesRuntimeManifest(this.lastBinding, this.manifest)
) {
return this.getSnapshot()
}
this.lifecycle = 'stopped'
this.lastError = '运行中的 host 与 runtime manifest 不一致'
return this.getSnapshot()
}
/*
* Adopt-first: the host is designed to OUTLIVE TraceMemo ("keep the send
* capability process"), so a previous app session may have left it running
* with a live, bound session. If something is already answering on our
* port, adopt it instead of spawning a second one (which would just die on
* the occupied port). Adopting is pure HTTP — the running host keeps its
* frida session, so the app is immediately ready with NO re-bind, exactly
* the "keep the send capability process" behaviour the user asked to carry over.
*/
if (await this.ping()) {
this.lifecycle = 'online'
this.child = null
this.wechatRestartRequired = false
await this.refreshBinding().catch(() => undefined)
if (
!this.lastBinding ||
!this.manifest ||
!bindingMatchesRuntimeManifest(this.lastBinding, this.manifest)
) {
this.lifecycle = 'stopped'
this.lastError =
'检测到无法确认身份的微信发送 host。请先退出旧 host,再重新启动 TraceMemo。'
return this.getSnapshot()
}
return this.getSnapshot()
}
if (this.lifecycle === 'crashed_bound' || this.wechatRestartRequired) {
// Restarting the host is safe (it does not touch WeChat), but binding is
// not: the orphaned agent from the previous session is still inside the
// current WeChat process. Start anyway so the UI can show live status.
appLogger.write({
level: 'warn',
scope: 'mac-wechat-runtime',
message: 'restart_host_after_bound_crash',
details: { note: 'bind stays blocked until WeChat restarts' }
})
}
const hostPath = this.resolveHostPath()
if (!existsSync(hostPath)) {
this.lastError = `runtime host missing: ${hostPath}`
this.lifecycle = 'stopped'
return this.getSnapshot()
}
try {
chmodSync(hostPath, 0o755)
} catch {
/* best effort — dev checkouts usually keep the exec bit */
}
this.lifecycle = 'starting'
this.lastError = null
try {
/* Host stderr goes to /dev/null (stdio ignored, see note above), so the
* host writes its own log file via TM_WECHAT_LOG_FILE. Without this the
* agent's stage beacons (send-enter / cdn-enter / …) are invisible and a
* media/CDN stall is undiagnosable. info level so stage beacons show. */
const hostLogFile = join(app.getPath('logs'), 'tm-wechat-host.log')
this.child = spawn(
hostPath,
[
'--port',
String(PORT),
'--manifest',
join(runtimeDir, 'runtime-manifest.json'),
'--log-level',
'info'
],
{
cwd: runtimeDir,
stdio: 'ignore',
detached: false,
env: { ...process.env, TM_WECHAT_LOG_FILE: hostLogFile }
}
)
} catch (error) {
this.lifecycle = 'stopped'
this.lastError = String(error)
return this.getSnapshot()
}
this.child.on('exit', (code) => {
const wasBound =
this.lastBinding?.bound === true ||
this.lastBinding?.sessionAttached === true ||
this.lastBinding?.bindingState === 'binding'
const orphanPid = this.lastBinding?.boundWechatPid || this.lastBinding?.wechatPid || 0
this.child = null
this.lastBinding = null
if (this.lifecycle === 'stopped') return // deliberate shutdown
if (wasBound) {
this.lifecycle = 'crashed_bound'
this.wechatRestartRequired = true
this.orphanWechatPid = orphanPid
appLogger.write({
level: 'error',
scope: 'mac-wechat-runtime',
message: 'host_died_while_bound',
details: { code: code ?? null, orphanWechatPid: orphanPid }
})
} else {
this.lifecycle = 'crashed_unbound'
appLogger.write({
level: 'warn',
scope: 'mac-wechat-runtime',
message: 'host_died_while_unbound_auto_restart',
details: { code: code ?? null }
})
if (this.restartTimer) clearTimeout(this.restartTimer)
this.restartTimer = setTimeout(() => {
void this.ensureStarted()
}, CRASH_RESTART_DELAY_MS)
}
})
// Wait for /healthz
const deadline = Date.now() + START_TIMEOUT_MS
while (Date.now() < deadline) {
if (await this.ping()) {
this.lifecycle = 'online'
await this.refreshBinding().catch(() => undefined)
if (
!this.lastBinding ||
!this.manifest ||
!bindingMatchesRuntimeManifest(this.lastBinding, this.manifest)
) {
this.lifecycle = 'crashed_unbound'
this.lastError = '运行中的 host 与 runtime manifest 不一致'
return this.getSnapshot()
}
return this.getSnapshot()
}
if (this.child === null) break // died during startup
await new Promise((resolve) => setTimeout(resolve, 300))
}
if (this.lifecycle === 'starting') {
this.lifecycle = 'crashed_unbound'
this.lastError = 'host did not answer /healthz in time'
}
return this.getSnapshot()
}
private request(
method: string,
path: string,
timeoutMs = REQUEST_TIMEOUT_MS
): Promise<{ status: number | null; body: string }> {
return new Promise((resolve) => {
const request = get(
{ host: HOST, port: PORT, path, method, timeout: timeoutMs },
(response) => {
let body = ''
response.on('data', (chunk) => {
body += chunk
})
response.on('end', () => {
resolve({ status: response.statusCode ?? null, body })
})
}
)
request.on('timeout', () => {
request.destroy()
resolve({ status: null, body: '' })
})
request.on('error', () => {
resolve({ status: null, body: '' })
})
request.end()
})
}
async ping(): Promise<boolean> {
const { status } = await this.request('GET', '/healthz', 3_000)
if (status === 200) return true
/*
* Adopted hosts have no child handle, so their death is only observable
* through failed probes. Classify here: dying while bound means WeChat
* keeps an orphaned agent (restart required); dying while unbound is
* auto-recoverable.
*/
if (this.lifecycle === 'online') {
const wasBound =
this.lastBinding?.bound === true ||
this.lastBinding?.sessionAttached === true ||
this.lastBinding?.bindingState === 'binding'
const orphanPid = this.lastBinding?.boundWechatPid || this.lastBinding?.wechatPid || 0
this.lastBinding = null
if (wasBound) {
this.lifecycle = 'crashed_bound'
this.wechatRestartRequired = true
this.orphanWechatPid = orphanPid
appLogger.write({
level: 'error',
scope: 'mac-wechat-runtime',
message: 'adopted_host_died_while_bound',
details: { note: 'WeChat restart required', orphanWechatPid: orphanPid }
})
} else {
this.lifecycle = 'crashed_unbound'
}
}
return false
}
/** Pull /bindingStatus and cache it for crash classification. */
async refreshBinding(): Promise<MacWechatBindingStatus | null> {
const { status, body } = await this.request('GET', '/bindingStatus')
if (status !== 200) return null
try {
this.lastBinding = JSON.parse(body) as MacWechatBindingStatus
/*
* A 20s bind timeout is NOT proof of the orphan-agent wall: in the
* stale-rebind path the host worker waits up to 45s for the old session
* teardown before attaching, so the HTTP response times out while the
* attach quietly succeeds afterwards. When the binding is later observed
* alive and matching, the restart-required verdict was a false positive
* and must be withdrawn — otherwise the manager keeps rejecting every
* further bind locally while WeChat is actually bound and working.
*/
if (
this.wechatRestartRequired &&
(this.lastBinding.bound === true || this.lastBinding.bindingState === 'ready') &&
this.lastBinding.boundWechatPid === this.lastBinding.wechatPid
) {
this.wechatRestartRequired = false
appLogger.write({
level: 'warn',
scope: 'mac-wechat-runtime',
message: 'restart_required_withdrawn',
details: { note: 'binding observed alive after bind timeout' }
})
}
return this.lastBinding
} catch {
return null
}
}
async getBindingStatus(): Promise<MacWechatBindingStatus | null> {
if (this.lifecycle !== 'online') return this.lastBinding
await this.refreshBinding().catch(() => null)
return this.lastBinding
}
/*
* Build the legacy PersonalWechatSenderStatus contract from the live mac
* runtime state. Lives here (not in the capability service) so BOTH the
* capability mapping and the send path report the same facts without a
* circular import.
*/
async buildSenderStatus(): Promise<PersonalWechatSenderStatus> {
/*
* Lazy-start the host from status reads.
*
* Every UI surface discovers the runtime through this method, and the
* setup guide renders "正在检查 发送运行时…" while runtimeReady is false —
* where runtimeReady means lifecycle === 'online'. If nothing here brings a
* stopped host up, opening the app (with WeChat already logged in, so no
* send is ever attempted) leaves that step spinning forever: the check can
* never succeed because the check itself is what should start the host.
* Starting it never touches WeChat, so doing it from a read is safe.
*/
if (this.lifecycle === 'stopped') {
await this.ensureStarted().catch(() => undefined)
}
/* Self-heal the status card too: if WeChat was restarted, drop the stale
* restartRequired before it is read into the UI as an error. */
try {
this.manifest = readMacWechatRuntimeManifest(this.resolveRuntimeDir())
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
appLogger.write({
level: 'warn',
scope: 'mac-wechat-runtime',
message: 'runtime_artifact_unavailable',
details: { detail }
})
const message = '暂无发送能力'
this.manifest = null
this.lastError = message
return {
state: 'runtime_missing',
platform: 'darwin',
arch: process.arch,
sipDisabled: true,
wechatRunning: false,
endpoint: `http://${HOST}:${PORT}`,
endpointReady: false,
runtimeReady: false,
attachReady: false,
baseAddressReady: false,
textHookInstalled: false,
textHookReady: false,
imageHookInstalled: false,
imageHookReady: false,
messageListenerReady: false,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false,
message,
error: message
}
}
await this.clearRestartRequiredIfWechatRestarted()
const snapshot = this.getSnapshot()
const binding = await this.getBindingStatus()
return deriveMacWechatSenderStatus({ snapshot, binding, manifest: this.manifest })
}
/*
* The orphan agent lives inside ONE specific WeChat process. If the WeChat
* pid we recorded when restartRequired was raised no longer matches the
* WeChat pid the host currently sees, WeChat has been restarted and the
* orphan is gone — the flag must clear or every later bind is rejected
* forever even though the user DID restart WeChat (the reported bug).
*/
private async clearRestartRequiredIfWechatRestarted(): Promise<void> {
if (!this.wechatRestartRequired) return
await this.refreshBinding().catch(() => null)
const currentPid = this.lastBinding?.wechatPid ?? 0
if (shouldClearWechatRestartRequired(this.orphanWechatPid, currentPid)) {
this.wechatRestartRequired = false
this.orphanWechatPid = 0
if (this.lifecycle === 'crashed_bound') this.lifecycle = 'online'
appLogger.write({
level: 'info',
scope: 'mac-wechat-runtime',
message: 'restart_required_cleared_wechat_restarted',
details: { currentWechatPid: currentPid }
})
}
}
/**
* Explicit user action only. Idempotent on the host side: binding the same
* live WeChat pid again returns attached=false and never double-attaches.
*/
async bind(): Promise<MacWechatBindResult> {
if (this.lifecycle !== 'online') {
const snapshot = await this.ensureStarted()
if (snapshot.lifecycle !== 'online') {
return {
ok: false,
attached: false,
state: 'runtime_offline',
message: snapshot.error ?? 'Runtime 未在线,无法绑定'
}
}
}
/* Auto-clear if WeChat was restarted since the flag was raised —
* otherwise this branch rejects every bind forever (the reported bug). */
await this.clearRestartRequiredIfWechatRestarted()
if (this.wechatRestartRequired) {
return {
ok: false,
attached: false,
state: 'wechat_restart_required',
message:
'Previous WeChat send session was not fully released. ' + 'Restart WeChat and bind again.'
}
}
const { status, body } = await this.request('POST', '/bindWeChat')
await this.refreshBinding().catch(() => null)
if (status !== 200) {
return {
ok: false,
attached: false,
state: 'host_error',
message: `host 返回 HTTP ${status ?? '无响应'}`
}
}
try {
const parsed = JSON.parse(body) as MacWechatBindResult
if (!parsed.ok && parsed.message?.includes('not fully released')) {
this.wechatRestartRequired = true
this.orphanWechatPid = this.lastBinding?.boundWechatPid || this.lastBinding?.wechatPid || 0
appLogger.write({
level: 'error',
scope: 'mac-wechat-runtime',
message: 'bind_hit_orphan_agent_wall',
details: { note: 'WeChat restart required', orphanWechatPid: this.orphanWechatPid }
})
}
return parsed
} catch {
return { ok: false, attached: false, state: 'bad_response', message: body.slice(0, 200) }
}
}
/*
* 重新加载发送组件:优雅停掉当前 host 进程,再启动一个新实例。
* 用于:更新二进制后换新代码;或 host 卡死时的手动恢复。
* 注意:若当前 host 已绑定微信,此操作会让微信里留下孤儿 agent,
* 需要重启微信后重新绑定(UI 会提示)。
*/
async reload(): Promise<MacWechatRuntimeSnapshot> {
await this.shutdown().catch(() => undefined)
await new Promise((resolve) => setTimeout(resolve, 2_000))
return this.ensureStarted()
}
private isProcessAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
/**
* Graceful stop only. A bound host must never be force-killed: doing so can
* leave its injected agent inside WeChat and make the next attach hang.
*/
async shutdown(): Promise<void> {
if (this.restartTimer) {
clearTimeout(this.restartTimer)
this.restartTimer = null
}
if (this.lifecycle === 'stopped' && this.child === null) return
const reachable = await this.ping()
if (!reachable && this.child === null) {
this.lifecycle = 'stopped'
this.lastBinding = null
return
}
await this.refreshBinding().catch(() => null)
const child = this.child
const hostPid = this.lastBinding?.hostPid ?? 0
this.lifecycle = 'stopped'
this.lastError = null
const response = await this.request('POST', '/shutdown', 5_000)
if (response.status !== 200) {
this.lifecycle = 'online'
this.lastError = 'Native Runtime 未确认退出请求,已保留 host 以避免微信残留会话。'
throw new Error(this.lastError)
}
const deadline = Date.now() + 55_000
let transportClosedAt = 0
while (Date.now() < deadline) {
const childExited = child !== null && child.exitCode !== null
const pidExited = hostPid > 0 && !this.isProcessAlive(hostPid)
if (childExited || pidExited) {
this.child = null
this.lastBinding = null
return
}
if (child === null && hostPid === 0) {
const transportOpen = await this.ping()
if (!transportOpen) {
if (transportClosedAt === 0) transportClosedAt = Date.now()
/* Compatibility with a pre-hostPid runtime during one upgrade:
* its teardown waits at most 45s after closing the listen socket. */
if (Date.now() - transportClosedAt >= 46_000) {
this.lastBinding = null
return
}
} else {
transportClosedAt = 0
}
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
this.lastError = 'Native Runtime 安全退出超时;未强制结束绑定态 host,请先退出微信后再重试。'
throw new Error(this.lastError)
}
}
export const macWechatRuntimeManager = new MacWechatRuntimeManager()
@@ -0,0 +1,441 @@
import { decompress as zstdDecompress } from 'fzstd'
import type { Wcdb4Client, Wcdb4Message, Wcdb4MonitorEvent } from '../wcdb4-client'
/**
* MessageListener —— 实时消息回读底座。
*
* **职责边界(刻意很窄)**:
*
* ```
* WCDB native change
* ↓
* coalesce
* ↓
* bounded DB readback
* ↓
* dedup
* ↓
* NormalizedIncomingMessage → onMessage(callback)
* ```
*
* **不负责**(这些属于下一层,本模块不许碰):关键词匹配、@我业务判断、日报、
* 自动回复、AI 调用、Agent 调度、发送消息。
*
* 设计约束:一次写入会触发**一连串** native event(十几条),
* 所以**绝不能**一个事件触发一次业务动作。
*/
/** zstd 帧魔数(微信 `source` 列是 zstd 压缩)。 */
const ZSTD_MAGIC = [0x28, 0xb5, 0x2f, 0xfd]
/**
* coalesce 窗口。
*
* 实测同一批 native event 会**同时**到达(同一毫秒内十几个),所以只需要一个极短的
* 合并窗口就能把它们收敛成一次回读。取 120ms:
* - 足够吃掉同一批事件(实测事件间隔 < 5ms);
* - 相对「event → 可读」本身就有 ~1.2s 的落库延迟,这点等待**不构成额外延迟**;
* - 与项目既有 `wcdb-change` 消费端的 350ms debounce 相比更短,不会叠加成明显卡顿。
*/
const DEFAULT_COALESCE_MS = 120
/** 回读窗口:只看最近这么久,绝不扫历史。 */
const DEFAULT_LOOKBACK_SEC = 30
/** 未来容忍(时钟漂移 + 秒级取整)。 */
const DEFAULT_LOOKAHEAD_SEC = 2
/** 单次回读条数上限。 */
const DEFAULT_READ_LIMIT = 50
/** dedup 条目存活时间:超过它就可以被淘汰(同一条消息不会在窗口外再被读到)。 */
const DEFAULT_DEDUP_TTL_MS = 5 * 60 * 1000
/** dedup 容量上限,防止长时间运行后无限增长。 */
const DEFAULT_DEDUP_MAX_ENTRIES = 5_000
/**
* 规范化后的入站消息。
*
* 刻意**克制**:只包含 Trigger 后续真正需要的字段,不做「万能 Message DTO」。
* 原始行(`raw`)、未解压的 Buffer、头像等一律不往外传。
*/
export interface NormalizedIncomingMessage {
sessionId: string
/** 会话内序号。**单独不保证跨 shard 唯一**,去重必须带上 sessionId。 */
localId: string
/** 服务器侧全局标识。**可能缺失**,所以只作强标识、不作必需字段。 */
serverId?: string
/**
* Unix **epoch 秒** —— 与 WCDB `create_time` / `getMessages` 参数同口径。
*
* ⚠️ 本项目另有模块使用 epoch 毫秒。**换算只允许集中在本层**:
* 凡是需要毫秒的消费者,自己明确 `/1000` 的**唯一**位置就是这里之后的调用点,
* 不许在任意函数里散落 `* 1000` / `/ 1000`。
*/
createTime: number
messageType: number
/** `mesDes === 0` 表示自己发送(字段语义与直觉相反)。 */
isSelf: boolean
senderId?: string
senderNickname?: string
content?: string
/** **已解压**的 source XML;原始 zstd Buffer 不往外传。 */
source?: string
isGroup: boolean
/**
* 从 `source` 解析出的 @ 目标(**微信 username,不是昵称**)。
*
* 本轮只产出数据,**不做「是否 @ 我」的业务判断** —— 那需要与
* `getMyUsernameCandidates()` 求交集,属于下一层。
*/
mentionTargets: string[]
}
/** 监听统计(不含任何身份信息,可安全记录)。 */
export interface MessageListenerStats {
/** 收到的 native change event 数。 */
nativeEvents: number
/** 被 coalesce 合并掉的事件数(未单独触发回读)。 */
coalescedEvents: number
/** 实际执行的回读次数。 */
readbacks: number
/** 通过 dedup 并投递出去的消息数。 */
delivered: number
/** 因 dedup 被丢弃的重复投递数。 */
deduped: number
}
export interface MessageListenerOptions {
coalesceMs?: number
lookbackSec?: number
lookaheadSec?: number
readLimit?: number
dedupTtlMs?: number
dedupMaxEntries?: number
}
/**
* 从 `source`(**已解压的 XML 文本**)提取 @ 目标。
*
* 纯函数:不依赖任何运行时状态,便于单测。
*
* 实测两种形式都真实存在,**都必须支持**:
* ```xml
* <atuserlist><![CDATA[SELF_USERNAME]]></atuserlist>
* <atuserlist>SELF_USERNAME</atuserlist>
* ```
*
* ⚠️ 三条硬规则:
* 1. **CDATA 外壳必须剥掉**,否则拿到的是 `<![CDATA[xxx]]>` 字面量;
* 2. **值就是微信 username,不保证以 `wxid_` 开头** —— 绝不能用前缀做过滤/校验;
* 3. **不允许多人分隔符的猜测** —— 多人 @ 的格式尚未在真实样本中验证过,
* 这里按「单个值原样返回」处理;一旦拿到真实多人样本需补解析与测试
* (当前形状已经预留为 `string[]`)。
*/
export function extractMentionTargets(source: string | undefined): string[] {
const text = String(source ?? '')
if (!text) return []
const match = /<atuserlist>([\s\S]*?)<\/atuserlist>/i.exec(text)
if (!match) return []
const inner = match[1].trim()
const unwrapped = inner.replace(/^<!\[CDATA\[/, '').replace(/\]\]>$/, '').trim()
return unwrapped ? [unwrapped] : []
}
/** 把 WCDB 的 `source` 列(zstd 压缩的 Buffer)还原成 XML 文本。 */
export function decodeSourcePayload(value: unknown): string | undefined {
let buffer: Buffer | null = null
if (Buffer.isBuffer(value)) {
buffer = value
} else {
const candidate = value as { type?: string; data?: unknown } | null
if (candidate?.type === 'Buffer' && Array.isArray(candidate.data)) {
buffer = Buffer.from(candidate.data as number[])
}
}
if (!buffer || buffer.length === 0) return undefined
const isZstd = ZSTD_MAGIC.every((byte, index) => buffer![index] === byte)
if (!isZstd) return buffer.toString('utf8')
try {
return Buffer.from(zstdDecompress(buffer)).toString('utf8')
} catch {
return undefined
}
}
/**
* 实时消息监听。
*
* ⚠️ **已知 BLOCKER(必须如实标注)**:
* 本实现**无法从 native event 判断是哪个会话发生了变化** —— 事件 payload 只有
* `{db, table, action}`(native 侧 `MonitorEvent` 结构就这两个字段)。
* 因此当前策略是**回读「最近活跃会话」**(`sessions[0]`,按 `last_timestamp` 排序),
* 这在「刚刚收到消息的会话」这一场景下成立,但:
*
* - **不是**「全局监听所有微信会话」;
* - 若同一窗口内有**多个会话**同时来消息,只会回读到其中最近的那个;
* - 正式扩展需要解决「如何从 table event 推断需要回读哪些会话」。
*
* 详见 `NEXT_STEPS` 注释与 Spike 报告。
*/
export class MessageListenerService {
private readonly listeners = new Set<(message: NormalizedIncomingMessage) => void>()
/**
* dedup 缓存:`sessionId:localId` → 首次见到的时间戳。
*
* 必须有界 —— Spike 里用的是无上限 `Set`,长时间运行会持续吃内存。
*/
private readonly seen = new Map<string, number>()
/**
* 本批 coalesce 窗口内**出现过精确会话**的事件收集到的 session 集合。
*
* v2 事件(Native Monitor Event v2)会带上发生变化的 sessionId,这里用 **Set** 收集 ——
* 120ms 内收到 `A B A C B` 必须回读 A/B/C 三个,绝不能「最后一个 wins」。
*/
private readonly pendingSessions = new Set<string>()
private coalesceTimer: ReturnType<typeof setTimeout> | null = null
private readbackInFlight = false
private disposed = false
private readonly coalesceMs: number
private readonly lookbackSec: number
private readonly lookaheadSec: number
private readonly readLimit: number
private readonly dedupTtlMs: number
private readonly dedupMaxEntries: number
private counters: MessageListenerStats = {
nativeEvents: 0,
coalescedEvents: 0,
readbacks: 0,
delivered: 0,
deduped: 0
}
constructor(
private readonly client: Wcdb4Client,
options: MessageListenerOptions = {}
) {
this.coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS
this.lookbackSec = options.lookbackSec ?? DEFAULT_LOOKBACK_SEC
this.lookaheadSec = options.lookaheadSec ?? DEFAULT_LOOKAHEAD_SEC
this.readLimit = options.readLimit ?? DEFAULT_READ_LIMIT
this.dedupTtlMs = options.dedupTtlMs ?? DEFAULT_DEDUP_TTL_MS
this.dedupMaxEntries = options.dedupMaxEntries ?? DEFAULT_DEDUP_MAX_ENTRIES
}
/**
* 订阅新消息。返回取消订阅函数。
*
* 刻意**只提供回调**:当前规模不需要 EventEmitter / RxJS / 内部队列。
*/
onMessage(listener: (message: NormalizedIncomingMessage) => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
stats(): MessageListenerStats {
return { ...this.counters }
}
dispose(): void {
this.disposed = true
if (this.coalesceTimer) {
clearTimeout(this.coalesceTimer)
this.coalesceTimer = null
}
this.listeners.clear()
this.seen.clear()
}
/**
* 接 native change event。
*
* 只做计数、收集会话与 coalesce —— **绝不**在这里回读,更不触发任何业务。
*
* `event.protocol === 2` 时把 `sessionId` 收进本批的集合;legacy 事件(v1,不带会话)
* 走到回读时仍然只能退化成「最近活跃会话」。
*/
handleNativeChange(event?: Wcdb4MonitorEvent): void {
if (this.disposed) return
this.counters.nativeEvents += 1
if (event?.protocol === 2 && event.sessionId) {
this.pendingSessions.add(event.sessionId)
}
if (this.coalesceTimer) {
// 已经在同一个窗口里:直接合并掉,这就是 17:1 那个比值的收敛点。
this.counters.coalescedEvents += 1
return
}
this.coalesceTimer = setTimeout(() => {
this.coalesceTimer = null
void this.readback()
}, this.coalesceMs)
}
/**
* 有界回读 → normalize → dedup → 投递。
*
* 回读范围严格受限:**按会话** + **小时间窗口** + **条数上限**。不遍历会话、不扫全库。
*
* 目标会话的来源有两种:
* - **precise(protocol v2)**:本批事件收集到的 `sessionId` 集合,逐个回读
* (多个会话同时来消息时,A/B/C 都会被读到);
* - **legacy(protocol v1,旧 runtime)**:只能退回「最近活跃会话」`getSessions()[0]`。
*/
private async readback(): Promise<void> {
if (this.disposed || this.readbackInFlight) return
this.readbackInFlight = true
const startedAt = Date.now()
const preciseSessions = Array.from(this.pendingSessions)
this.pendingSessions.clear()
try {
let delivered = 0
let sessions = 0
if (preciseSessions.length > 0) {
for (const sessionId of preciseSessions) {
if (this.disposed) return
delivered += await this.readbackSession(sessionId)
sessions += 1
}
} else {
// legacy:旧 runtime 的 payload 不带会话,只能回读「最近活跃会话」。
const session = this.client.getSessions()[0]
if (!session?.username) return
delivered = await this.readbackSession(session.username)
sessions = 1
}
if (delivered > 0) {
// 日志只记录协议版本、数量与耗时 —— **不含** wxid / 群名 / 昵称 / 正文 / source / sessionId。
console.log(
`[MessageListener] protocol=${preciseSessions.length > 0 ? 'v2' : 'v1'}` +
` sessions=${sessions} delivered=${delivered} readbackMs=${Date.now() - startedAt}` +
` events=${this.counters.nativeEvents} coalesced=${this.counters.coalescedEvents}`
)
}
} catch (error) {
console.warn(
`[MessageListener] readback failed: ${error instanceof Error ? error.message : String(error)}`
)
} finally {
this.readbackInFlight = false
}
}
/** 回读**单个**会话并投递。返回本次真正投递出去的条数。 */
private async readbackSession(sessionId: string): Promise<number> {
const nowSec = Math.floor(Date.now() / 1000)
this.counters.readbacks += 1
const messages = await this.client.getMessagesAsync(
sessionId,
nowSec - this.lookbackSec,
nowSec + this.lookaheadSec,
{ limit: this.readLimit }
)
if (this.disposed) return 0
let delivered = 0
for (const message of messages) {
const normalized = this.normalize(sessionId, message)
if (!normalized) continue
// dedup:同一条消息可能在多个读回窗口中反复出现(native 事件本身也可能重复)。
if (!this.markSeen(normalized)) {
this.counters.deduped += 1
continue
}
this.counters.delivered += 1
delivered += 1
this.deliver(normalized)
}
return delivered
}
private deliver(message: NormalizedIncomingMessage): void {
for (const listener of this.listeners) {
try {
listener(message)
} catch (error) {
console.warn(
`[MessageListener] listener threw: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
}
/** 把 WCDB 行规范化为克制的 DTO。返回 null 表示这条行缺关键标识,无法投递。 */
private normalize(
sessionUsername: string,
message: Wcdb4Message
): NormalizedIncomingMessage | null {
const localId = message.mesLocalID === undefined ? '' : String(message.mesLocalID)
if (!localId) return null
const createTime = Number(message.msgCreateTime) || 0
if (!createTime) return null
const raw = (message.raw ?? {}) as Record<string, unknown>
const source = decodeSourcePayload(raw.source)
const senderId = message.sender ? String(message.sender) : undefined
return {
sessionId: sessionUsername,
localId,
serverId: message.serverId ? String(message.serverId) : undefined,
createTime,
messageType: Number(message.messageType) || 0,
// `mesDes === 0` 是自己发送。不用昵称 / sender 文本 / content / username 前缀判断。
isSelf: Number(message.mesDes) === 0,
senderId,
senderNickname: message.senderNickname ? String(message.senderNickname) : undefined,
content: message.msgContent ? String(message.msgContent) : undefined,
source,
isGroup: sessionUsername.endsWith('@chatroom'),
mentionTargets: extractMentionTargets(source)
}
}
/** 登记并返回「是否是第一次见到」。同时做 TTL + 容量淘汰。 */
private markSeen(message: NormalizedIncomingMessage): boolean {
const key = `${message.sessionId}:${message.localId}`
const now = Date.now()
if (this.seen.has(key)) return false
this.seen.set(key, now)
if (this.seen.size > this.dedupMaxEntries) this.evict(now)
return true
}
/**
* 淘汰策略:先按 TTL 清理过期条目;若仍然超限,再按插入顺序丢掉最旧的一批
* (Map 保持插入顺序,所以从头删就是删最旧)。
*/
private evict(now: number): void {
for (const [key, seenAt] of this.seen) {
if (now - seenAt > this.dedupTtlMs) this.seen.delete(key)
}
if (this.seen.size <= this.dedupMaxEntries) return
const overflow = this.seen.size - this.dedupMaxEntries
let removed = 0
for (const key of this.seen.keys()) {
this.seen.delete(key)
removed += 1
if (removed >= overflow) break
}
}
}
/**
* NEXT STEPS(不在本轮范围)
*
* 1. **会话定位(BLOCKER)**:目前只能回读「最近活跃会话」。要真正监听全部会话,
* 需要解决「从 table event 推断变更会话」。可能的路线:
* - 观察 native 侧能否在事件里补上 table 所属的会话标识(需改 dll);
* - 或改成按 `Session` 表的 `last_timestamp` 变化做「有界的多会话回读」。
* 2. **多人 @**:`extractMentionTargets` 目前按单值返回,多人格式**未验证**,不得猜分隔符。
* 3. **isMentionedMe**:`mentionTargets` ∩ `getMyUsernameCandidates()`,再接 Trigger。
* 4. 本轮**不做**:关键词 / @触发 / 自动回复 / 日报 / AI / Agent / 发送。
*/
@@ -7,15 +7,28 @@ import {
personalWechatSendService, personalWechatSendService,
type PersonalWechatSendService type PersonalWechatSendService
} from './personal-wechat-send-service' } from './personal-wechat-send-service'
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
/** /**
* Converts the detailed sender diagnostics into a small contract that other * Converts the detailed sender diagnostics into a small contract that other
* features can consume without knowing about OneBot, Hook or platform details. * features can consume without knowing about the macOS native runtime, the
* Windows hook transport, or platform details.
*
* On macOS the capability is derived only from MacWechatRuntimeManager and its
* validated runtime manifest. Windows continues to use the existing sender.
*/ */
export class PersonalWechatCapabilityService { export class PersonalWechatCapabilityService {
constructor(private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>) {} constructor(
private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>,
private readonly platform: NodeJS.Platform = process.platform,
private readonly getMacStatus: () => Promise<PersonalWechatSenderStatus> = () =>
macWechatRuntimeManager.buildSenderStatus()
) {}
async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> { async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> {
if (this.platform === 'darwin') {
return this.fromMacRuntime()
}
const senderStatus = await this.sender.getStatus() const senderStatus = await this.sender.getStatus()
return this.fromSenderStatus(senderStatus) return this.fromSenderStatus(senderStatus)
} }
@@ -49,6 +62,16 @@ export class PersonalWechatCapabilityService {
} }
} }
/*
* macOS capability from the native runtime. Builds a senderStatus snapshot
* aligned with the mac binding state and reuses the shared mapping, so the
* settings header and the binding card can never disagree.
*/
private async fromMacRuntime(): Promise<PersonalWechatSendCapability> {
const senderStatus = await this.getMacStatus()
return this.fromSenderStatus(senderStatus)
}
private mapState(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapabilityState { private mapState(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapabilityState {
if (senderStatus.platform === 'win32') { if (senderStatus.platform === 'win32') {
if (senderStatus.canSend) return 'ready' if (senderStatus.canSend) return 'ready'
@@ -60,6 +83,7 @@ export class PersonalWechatCapabilityService {
return 'unsupported' return 'unsupported'
} }
if (senderStatus.state === 'error') return 'error' if (senderStatus.state === 'error') return 'error'
if (senderStatus.state === 'runtime_missing') return 'unconfigured'
const hasCurrentBinding = Boolean( const hasCurrentBinding = Boolean(
senderStatus.endpointReady && senderStatus.endpointReady &&
senderStatus.attachReady && senderStatus.attachReady &&
@@ -1,603 +0,0 @@
import { createHash } from 'crypto'
import { execFile } from 'child_process'
import { app, net } from 'electron'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { chmod, copyFile, cp, mkdir, mkdtemp, open, rename, rm, stat } from 'fs/promises'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { promisify } from 'util'
import type {
PersonalWechatRuntimeDownloadResult,
PersonalWechatRuntimeStatus
} from '../../shared/personal-wechat-runtime'
import { findPersonalWechatRuntime } from './personal-wechat-send-service'
const execFileAsync = promisify(execFile)
const RUNTIME_VERSION = 'v0.0.18'
const ARCHIVE_NAME = 'onebot_mac_arm64.tar.gz'
const ARCHIVE_URL = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${RUNTIME_VERSION}/${ARCHIVE_NAME}`
const ARCHIVE_SIZE = 66_599_785
const ARCHIVE_SHA256 = 'ee1e11bccef7cec1cf944cd8b2ac3fadaadb9376ba24cd823e3409143e107dab'
function patchPerSendPayload(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return
const activeSend = ` const payloadData = hexToByteArray(payloadHex);
activeTriggerX1Payload = Memory.alloc(payloadData.length);
activeTriggerX1Payload.writeByteArray(payloadData);
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
const upstreamSend = ` const payloadData = hexToByteArray(payloadHex);
triggerX1Payload.writeByteArray(payloadData);
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
if (!source.includes(activeSend)) {
throw new Error('下载的发送组件与当前应用不兼容')
}
source = source.replace('var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;', 'var triggerX1Payload;\nvar triggerX0;')
.replace(activeSend, upstreamSend)
.replace(' MMStartTask(triggerX0, activeTriggerX1Payload);', ' MMStartTask(triggerX0, triggerX1Payload);')
.replace(' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);', ' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);')
.replace('\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn', '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn')
writeFileSync(scriptPath, source)
}
function patchSendContextCapture(scriptPath: string, strict = true): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('function isLikelySendContext(')) return
const original = `function AttachSendFunc() {
Interceptor.attach(sendFuncAddr.add(0x10), {
onEnter: function (args) {
if (triggerX1Payload) {
return
}
triggerX0 = this.context.x0;
triggerX1Payload = this.context.x1;
console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
}
})
}`
const patched = `function isLikelySendContext(candidateX0, candidateX1) {
try {
if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false;
var manager = readPointerIfReadable(candidateX0.add(0x18));
var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18)));
console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi);
return !manager.equals(ptr(0));
} catch (e) {
console.error("[debug] StartTask candidate inspect failed: " + e);
return false;
}
}
function AttachSendFunc() {
Interceptor.attach(sendFuncAddr.add(0x10), {
onEnter: function (args) {
if (triggerX1Payload) return;
var candidateX0 = this.context.x0;
var candidateX1 = this.context.x1;
if (!isLikelySendContext(candidateX0, candidateX1)) return;
triggerX0 = candidateX0;
triggerX1Payload = candidateX1;
console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
}
})
}`
if (!source.includes(original)) {
if (strict) throw new Error('下载的微信版本与当前应用不兼容')
return
}
source = source.replace(original, patched)
writeFileSync(scriptPath, source)
}
function patchVoiceAudioBuffer(scriptPath: string, strict = true): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
if (!source.includes(staticAllocation)) {
if (strict) throw new Error('下载的语音组件与当前应用不兼容')
return
}
source = source.replace(
staticAllocation,
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
)
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
if (!source.includes(audioLengthMarker)) {
if (strict) throw new Error('下载的语音组件与当前应用不兼容')
return
}
source = source.replace(
audioLengthMarker,
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
)
writeFileSync(scriptPath, source)
}
function patchImageHookReadiness(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
source.includes('图片上传 Hook Setup Complete')
) {
return
}
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
\t\t\t}
\t\t\tuploadGlobalX0 = capturedUploadX0;`
if (!source.includes(original)) throw new Error('下载的媒体组件与当前应用不兼容')
source = source.replace(original, patched)
source = source.replace(
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
)
writeFileSync(scriptPath, source)
}
function patchCdnColdStart(scriptPath: string, strict = true): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('function resolveCdnManager()')) return
const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});`
const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});
// 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为)
{{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}}
{{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}`
if (!source.includes(initAddresses)) {
if (strict) throw new Error('下载的微信版本配置与当前应用不兼容')
return
}
source = source.replace(initAddresses, patchedInitAddresses)
const downloadChunkEnd = `}
function fillUploadX1AndStart`
const resolver = `}
// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器
// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。
function resolveCdnManager() {
if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) {
return ptr(0);
}
try {
// libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。
var strDefault = Memory.alloc(24);
strDefault.writeUtf8String("default");
strDefault.add(0x17).writeU8(7);
var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']);
var svc = getService(strDefault);
if (!isReadablePointer(svc)) {
console.error("[!] GetService(\\"default\\") 返回不可读: " + svc);
return ptr(0);
}
var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']);
var ctx = getCtx(svc);
if (!isReadablePointer(ctx)) {
console.error("[!] CdnManager getter 返回不可读: " + ctx);
return ptr(0);
}
var mgr = readPointerIfReadable(ctx.add(0x40));
if (!isReadablePointer(mgr)) {
console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx);
return ptr(0);
}
return mgr;
} catch (e) {
console.error("[!] resolveCdnManager 异常: " + e);
return ptr(0);
}
}
function ensureCdnManagerX0() {
if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) {
uploadGlobalX0 = downloadGlobalX0;
console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0);
}
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) {
downloadGlobalX0 = uploadGlobalX0;
console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0);
}
if (uploadGlobalX0.equals(ptr(0))) {
var mgr = resolveCdnManager();
if (!mgr.equals(ptr(0))) {
uploadGlobalX0 = mgr;
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
downloadGlobalX0 = mgr;
}
console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr);
}
}
return !uploadGlobalX0.equals(ptr(0));
}
function fillUploadX1AndStart`
if (!source.includes(downloadChunkEnd)) {
if (strict) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
return
}
source = source.replace(downloadChunkEnd, resolver)
const declarations = 'var uploadImageAddr;\n'
const patchedDeclarations =
'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n'
if (!source.includes(declarations)) {
if (strict) throw new Error('无法定位 wechat_chatter 媒体地址声明')
return
}
source = source.replace(declarations, patchedDeclarations)
const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
if (uploadGlobalX0.equals(ptr(0))) {`
const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
if (uploadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (uploadGlobalX0.equals(ptr(0))) {`
if (!source.includes(uploadGuard)) {
if (strict) throw new Error('无法定位 wechat_chatter 媒体上传入口')
return
}
source = source.replace(uploadGuard, patchedUploadGuard)
const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
if (uploadGlobalX0.equals(ptr(0))) {`
const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
if (uploadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (uploadGlobalX0.equals(ptr(0))) {`
if (!source.includes(voiceGuard)) {
if (strict) throw new Error('无法定位 wechat_chatter 语音上传入口')
return
}
source = source.replace(voiceGuard, patchedVoiceGuard)
const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;`
const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) {
downloadGlobalX0 = capturedUploadX0;
console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0);
}`
if (!source.includes(uploadHook)) {
if (strict) throw new Error('无法定位 wechat_chatter 图片 Hook')
return
}
source = source.replace(uploadHook, patchedUploadHook)
const downloadHook = ` downloadGlobalX0 = this.context.x0;`
const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0;
if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) {
uploadGlobalX0 = downloadGlobalX0;
console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0);
}`
if (!source.includes(downloadHook)) {
if (strict) throw new Error('无法定位 wechat_chatter 下载 Hook')
return
}
source = source.replace(downloadHook, patchedDownloadHook)
const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
if (!downloadGlobalX0) {`
const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
ensureCdnManagerX0();
}
if (!downloadGlobalX0) {`
if (!source.includes(downloadGuard)) {
if (strict) throw new Error('无法定位 wechat_chatter 媒体下载入口')
return
}
source = source.replace(downloadGuard, patchedDownloadGuard)
writeFileSync(scriptPath, source)
}
function patchCdnColdStartConfig(configPath: string, strict = true): void {
let config: Record<string, unknown>
try {
config = JSON.parse(readFileSync(configPath, 'utf8')) as Record<string, unknown>
} catch {
if (strict) throw new Error('4.1.11.53 版本配置不是有效 JSON')
return
}
config.cdnGetServiceAddr = '0x50a15d0'
config.cdnManagerGetterAddr = '0x5259290'
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
}
function patchWechatCoreModuleBase(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('WeChat core module base:')) return
const initMarker = 'function initAddresses() {'
const initIndex = source.indexOf(initMarker)
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
throw new Error('下载的微信版本配置与当前应用不兼容')
}
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
var module = Process.enumerateModules().find(function(m) {
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
});
if (!module) {
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
}
var moduleBase = module.base;
var baseAddr = moduleBase;
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
setImmediate(initAddresses);
`
source = patchedHeader + source.slice(initIndex)
writeFileSync(scriptPath, source)
}
function addModifiedWorkNotice(scriptPath: string): void {
let source = readFileSync(scriptPath, 'utf8')
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
source = `/*
* TraceMemo wechat_chatter compatibility modifications
* Modified: 2026-08-17
* Upstream: https://github.com/yincongcyincong/wechat_chatter
* Runtime version: v0.0.18
* License: GNU General Public License version 3 (GPL-3.0)
* Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers,
* CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging.
* These modifications are not provided by the upstream author.
*/
${source}`
writeFileSync(scriptPath, source)
}
export class PersonalWechatRuntimeManager {
private downloadController: AbortController | null = null
private downloadPromise: Promise<PersonalWechatRuntimeDownloadResult> | null = null
private downloadedBytes = 0
private lastProgressAt = 0
private progressListener: ((status: PersonalWechatRuntimeStatus) => void) | null = null
get directory(): string {
return join(app.getPath('userData'), 'connectors', 'wechat-personal', 'darwin-arm64')
}
private get archivePath(): string {
return join(
app.getPath('userData'),
'downloads',
`wechat-chatter-${RUNTIME_VERSION}-${ARCHIVE_NAME}`
)
}
setProgressListener(listener: ((status: PersonalWechatRuntimeStatus) => void) | null): void {
this.progressListener = listener
}
async getStatus(): Promise<PersonalWechatRuntimeStatus> {
if (!this.isSupported()) {
return this.buildStatus(
'unsupported',
0,
process.platform === 'darwin'
? 'Intel Mac 不支持个人微信发送组件'
: process.platform === 'win32'
? 'Windows 暂不支持个人微信发送组件'
: '当前系统不支持个人微信发送组件'
)
}
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
const runtime = findPersonalWechatRuntime()
if (runtime) {
try {
// Apply compatibility fixes to runtimes installed before this version.
patchWechatCoreModuleBase(join(runtime.workingDirectory, 'script.js'))
patchPerSendPayload(join(runtime.workingDirectory, 'script.js'))
patchSendContextCapture(join(runtime.workingDirectory, 'script.js'), false)
patchVoiceAudioBuffer(join(runtime.workingDirectory, 'script.js'), false)
patchImageHookReadiness(join(runtime.workingDirectory, 'script.js'))
patchCdnColdStart(join(runtime.workingDirectory, 'script.js'), false)
addModifiedWorkNotice(join(runtime.workingDirectory, 'script.js'))
patchCdnColdStartConfig(join(runtime.root, 'wechat_version', '4_1_11_53_mac.json'), false)
} catch {
// Status discovery should remain available even if an old runtime is read-only.
}
return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root)
}
const hasPartialInstall = await this.hasPartialInstall()
return this.buildStatus(
hasPartialInstall ? 'invalid' : 'missing',
0,
hasPartialInstall ? '发送组件文件不完整,请重新下载' : undefined,
hasPartialInstall ? this.directory : undefined
)
}
download(): Promise<PersonalWechatRuntimeDownloadResult> {
if (!this.isSupported()) {
return this.getStatus().then((status) => ({ success: false, status, error: status.error }))
}
if (this.downloadPromise) return this.downloadPromise
this.downloadedBytes = 0
this.downloadController = new AbortController()
this.downloadPromise = this.runDownload(this.downloadController.signal).finally(() => {
this.downloadPromise = null
this.downloadController = null
})
return this.downloadPromise
}
cancelDownload(): boolean {
if (!this.downloadController) return false
this.downloadController.abort()
return true
}
async remove(): Promise<PersonalWechatRuntimeStatus> {
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
await Promise.all([
rm(this.directory, { recursive: true, force: true }),
rm(this.archivePath, { force: true }),
rm(`${this.archivePath}.partial`, { force: true })
])
return this.getStatus()
}
private async runDownload(signal: AbortSignal): Promise<PersonalWechatRuntimeDownloadResult> {
const archive = this.archivePath
const downloadsDirectory = dirname(archive)
const partial = `${archive}.partial`
let extractionDirectory = ''
let stagedDirectory = ''
try {
await mkdir(downloadsDirectory, { recursive: true })
await rm(partial, { force: true })
const response = await net.fetch(ARCHIVE_URL, { signal })
if (!response.ok || !response.body) {
throw new Error(`发送组件下载失败:HTTP ${response.status}`)
}
const handle = await open(partial, 'w')
const hash = createHash('sha256')
try {
const reader = response.body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
if (signal.aborted) throw new DOMException('Download cancelled', 'AbortError')
const chunk = Buffer.from(value)
await handle.write(chunk)
hash.update(chunk)
this.downloadedBytes += chunk.length
this.reportProgress(this.buildStatus('downloading', this.downloadedBytes))
}
} finally {
await handle.close()
}
if (this.downloadedBytes !== ARCHIVE_SIZE || hash.digest('hex') !== ARCHIVE_SHA256) {
throw new Error('发送组件校验失败,请重新下载')
}
await rm(archive, { force: true })
await rename(partial, archive)
extractionDirectory = await mkdtemp(join(tmpdir(), 'wechat-chatter-extract-'))
await execFileAsync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDirectory])
stagedDirectory = `${this.directory}.installing-${process.pid}`
await rm(stagedDirectory, { recursive: true, force: true })
await mkdir(dirname(stagedDirectory), { recursive: true })
await mkdir(join(stagedDirectory, 'onebot'), { recursive: true })
const sourceOneBot = join(extractionDirectory, 'onebot')
const sourceVersions = join(extractionDirectory, 'wechat_version')
await Promise.all([
cp(sourceVersions, join(stagedDirectory, 'wechat_version'), {
recursive: true,
force: true
}),
copyFile(join(sourceOneBot, 'onebot'), join(stagedDirectory, 'onebot', 'onebot')),
copyFile(join(sourceOneBot, 'script.js'), join(stagedDirectory, 'onebot', 'script.js'))
])
const executable = join(stagedDirectory, 'onebot', 'onebot')
const script = join(stagedDirectory, 'onebot', 'script.js')
const configPath = join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json')
patchCdnColdStartConfig(configPath)
patchWechatCoreModuleBase(script)
patchPerSendPayload(script)
patchSendContextCapture(script)
patchVoiceAudioBuffer(script)
patchImageHookReadiness(script)
patchCdnColdStart(script)
addModifiedWorkNotice(script)
await chmod(executable, 0o755)
for (const required of [
executable,
script,
join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json')
]) {
if (!existsSync(required)) throw new Error('发送组件解压后文件不完整')
}
await rm(this.directory, { recursive: true, force: true })
await rename(stagedDirectory, this.directory)
stagedDirectory = ''
const status = this.buildStatus('ready', ARCHIVE_SIZE, undefined, this.directory)
this.reportProgress(status, true)
return { success: true, status }
} catch (error) {
const cancelled = signal.aborted
const message = cancelled
? '发送组件下载已取消'
: error instanceof Error
? error.message
: String(error)
await rm(partial, { force: true })
const status = this.buildStatus(
cancelled ? 'missing' : 'error',
this.downloadedBytes,
message
)
this.reportProgress(status, true)
return { success: false, status, error: message }
} finally {
if (extractionDirectory) await rm(extractionDirectory, { recursive: true, force: true })
if (stagedDirectory) await rm(stagedDirectory, { recursive: true, force: true })
}
}
private async hasPartialInstall(): Promise<boolean> {
try {
await stat(this.directory)
return true
} catch {
return false
}
}
private isSupported(): boolean {
return process.platform === 'darwin' && process.arch === 'arm64'
}
private buildStatus(
state: PersonalWechatRuntimeStatus['state'],
downloadedBytes: number,
error?: string,
directory?: string
): PersonalWechatRuntimeStatus {
return {
version: RUNTIME_VERSION,
state,
downloadedBytes,
totalBytes: ARCHIVE_SIZE,
progress: ARCHIVE_SIZE ? Math.min(1, downloadedBytes / ARCHIVE_SIZE) : 0,
platform: process.platform,
architecture: process.arch,
supported: this.isSupported(),
removable: directory === this.directory,
...(directory ? { directory } : {}),
...(error ? { error } : {})
}
}
private reportProgress(status: PersonalWechatRuntimeStatus, force = false): void {
const now = Date.now()
if (!force && now - this.lastProgressAt < 100) return
this.lastProgressAt = now
this.progressListener?.(status)
}
}
File diff suppressed because it is too large Load Diff
@@ -7,11 +7,8 @@ import type {
PersonalWechatVoiceRuntimeComponent PersonalWechatVoiceRuntimeComponent
} from '../../shared/personal-wechat-voice-runtime' } from '../../shared/personal-wechat-voice-runtime'
import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime' import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime'
import { import { buildPersonalWechatRuntimeEnvironment } from './personal-wechat-send-service'
buildPersonalWechatRuntimeEnvironment, import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
findPersonalWechatRuntime
} from './personal-wechat-send-service'
import type { RuntimeLayout } from './personal-wechat-send-service'
import { appLogger } from '../app-logger' import { appLogger } from '../app-logger'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
@@ -37,8 +34,8 @@ type CommandRunner = (
interface PersonalWechatVoiceEnvironmentServiceOptions { interface PersonalWechatVoiceEnvironmentServiceOptions {
platform?: NodeJS.Platform platform?: NodeJS.Platform
architecture?: string architecture?: string
findRuntime?: () => RuntimeLayout | null isRuntimePresent?: () => boolean
buildEnvironment?: (runtimeRoot?: string) => NodeJS.ProcessEnv buildEnvironment?: () => NodeJS.ProcessEnv
runCommand?: CommandRunner runCommand?: CommandRunner
now?: () => Date now?: () => Date
} }
@@ -100,18 +97,26 @@ function unsupportedEnvironment(): PersonalWechatVoiceEncodingEnvironment {
} }
} }
function defaultIsRuntimePresent(): boolean {
try {
return macWechatRuntimeManager.isRuntimePresent()
} catch {
return false
}
}
export class PersonalWechatVoiceEnvironmentService { export class PersonalWechatVoiceEnvironmentService {
private readonly platform: NodeJS.Platform private readonly platform: NodeJS.Platform
private readonly architecture: string private readonly architecture: string
private readonly findRuntime: () => RuntimeLayout | null private readonly isRuntimePresent: () => boolean
private readonly buildEnvironment: (runtimeRoot?: string) => NodeJS.ProcessEnv private readonly buildEnvironment: () => NodeJS.ProcessEnv
private readonly runCommand: CommandRunner private readonly runCommand: CommandRunner
private readonly now: () => Date private readonly now: () => Date
constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) { constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) {
this.platform = options.platform || process.platform this.platform = options.platform || process.platform
this.architecture = options.architecture || process.arch this.architecture = options.architecture || process.arch
this.findRuntime = options.findRuntime || findPersonalWechatRuntime this.isRuntimePresent = options.isRuntimePresent || defaultIsRuntimePresent
this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment
this.runCommand = options.runCommand || runCommand this.runCommand = options.runCommand || runCommand
this.now = options.now || (() => new Date()) this.now = options.now || (() => new Date())
@@ -123,8 +128,8 @@ export class PersonalWechatVoiceEnvironmentService {
} }
logEnvironmentLine('Checking voice encoding environment') logEnvironmentLine('Checking voice encoding environment')
const runtime = this.findRuntime() const runtimeReady = this.isRuntimePresent()
const environment = this.buildEnvironment(runtime?.root) const environment = this.buildEnvironment()
const python = blankComponent() const python = blankComponent()
const pilk = blankComponent() const pilk = blankComponent()
const ffmpeg = blankComponent() const ffmpeg = blankComponent()
@@ -197,26 +202,25 @@ export class PersonalWechatVoiceEnvironmentService {
logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error }) logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error })
} }
const ready = Boolean(runtime && python.ready && pilk.ready && ffmpeg.ready) const ready = Boolean(runtimeReady && python.ready && pilk.ready && ffmpeg.ready)
const result: PersonalWechatVoiceEncodingEnvironment = { const result: PersonalWechatVoiceEncodingEnvironment = {
state: ready ? 'ready' : 'incomplete', state: ready ? 'ready' : 'incomplete',
ready, ready,
checkedAt: this.now().toISOString(), checkedAt: this.now().toISOString(),
runtimeReady: Boolean(runtime), runtimeReady,
...(runtime ? { runtimeRoot: runtime.root } : {}),
python, python,
pilk, pilk,
ffmpeg, ffmpeg,
encoder: pilk.ready encoder: pilk.ready
? 'pilk' ? 'pilk'
: runtime && python.ready && ffmpeg.ready : runtimeReady && python.ready && ffmpeg.ready
? 'go-silk' ? 'silk'
: 'unavailable', : 'unavailable',
message: ready message: ready
? '语音编码环境正常,可以使用 pilk 编码' ? '语音编码环境正常,可以使用 pilk 编码'
: runtime : runtimeReady
? '语音编码环境不完整,OneBot 可能回退到 go-silk' ? '语音编码环境不完整,语音将回退到内置 SILK 编码'
: '微信发送组件尚未安装,请先准备 OneBot 运行时' : '当前版本暂未提供微信消息发送功能'
} }
logEnvironmentLine( logEnvironmentLine(
ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready', ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready',
@@ -245,7 +249,7 @@ export class PersonalWechatVoiceEnvironmentService {
pythonExecutable, pythonExecutable,
['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`], ['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`],
{ {
env: this.buildEnvironment(before.runtimeRoot || undefined), env: this.buildEnvironment(),
timeout: INSTALL_TIMEOUT_MS timeout: INSTALL_TIMEOUT_MS
} }
) )
File diff suppressed because it is too large Load Diff
@@ -27,8 +27,7 @@ export interface ScheduledReportApiDependencies {
| 'deleteTask' | 'deleteTask'
| 'setTaskEnabled' | 'setTaskEnabled'
| 'runScheduledReportNow' | 'runScheduledReportNow'
> & >
Partial<Pick<ScheduledReportService, 'retryScheduledReportSend'>>
getCapability: () => Promise<PersonalWechatSendCapability> getCapability: () => Promise<PersonalWechatSendCapability>
listContacts: () => FormattedContact[] listContacts: () => FormattedContact[]
isDatabaseReady: () => boolean isDatabaseReady: () => boolean
@@ -250,16 +249,20 @@ export class ScheduledReportApiService {
return result.data return result.data
} }
async retrySend(executionId: string): Promise<ScheduledReportExecution> { /**
const retry = this.deps.service.retryScheduledReportSend * 「重新发送」不再支持。
if (!retry) { *
throw new ScheduledReportApiError(501, 'not_supported', '当前运行时不支持重新发送日报') * 日报图片归日报历史所有,发送目标由规则配置决定,「把某次执行记录里那张 PNG
} * 就地再发一遍」这个语义已经不存在 —— 与其在此伪造一个只对部分规则有效的重发
const result = await retry.call(this.deps.service, executionId) * 语义,不如**明确返回 501**。
if (!result.data) { * 用户的替代路径:在日报历史里手动转发,或对该规则点「立即执行」重跑一次。
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报执行记录') */
} async retrySend(_executionId: string): Promise<ScheduledReportExecution> {
return result.data throw new ScheduledReportApiError(
501,
'not_supported',
'当前运行时不支持重新发送日报;请在 TraceMemo 的自动化页面使用「立即执行」,或从日报历史手动转发。'
)
} }
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> { async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
@@ -0,0 +1,207 @@
import { createHash } from 'node:crypto'
import {
normalizeScheduledReportConfig,
type ScheduledReportAutomationConfig
} from '../../shared/automation'
import type {
ScheduledReportMemberNameMode,
ScheduledReportMessageType,
ScheduledReportRange
} from '../../shared/scheduled-report'
import type { SelectableReportTemplateId } from '../../shared/report-templates'
/**
* 旧「定时日报任务」→ 新「AutomationRule(ruleType='scheduled_report')」的**纯映射器**。
*
* 单独成文件是为了能直接单测:这是整轮迁移里**唯一会改变用户既有行为**的地方,
* 不允许只靠"跑一遍看看"来验证。
*
* ## 旧 target 的真实语义(实测)
*
* `ScheduledReportService.resolveTarget()`:
* - `target` 以 `@chatroom` 结尾 ⇒ **直接发到那个群**(可能是另一个群);
* - 否则 ⇒ **静默回落到来源群**。
*
* ⇒ 历史行为**事实上支持「生成 A 群日报 → 发 B 群」**,也存在一处静默 fallback。
* 新产品明确不支持"指定另一个群聊",所以:
* **能证明同群 → 无损映射成 `source_chat`;不能证明 → 停用 + 要求用户重选**,
* 绝不自动改写、绝不偷偷丢掉目标。
*/
/** 旧任务里迁移真正用得到的字段(其余字段不参与,也不需要读)。 */
export interface LegacyScheduledReportTask {
id?: unknown
name?: unknown
group?: unknown
scheduleTime?: unknown
reportRange?: unknown
messageTypes?: unknown
templateId?: unknown
memberNameMode?: unknown
timeoutSeconds?: unknown
target?: unknown
enabled?: unknown
createdAt?: unknown
updatedAt?: unknown
lastRunAt?: unknown
lastScheduledSlot?: unknown
}
export type ScheduledReportMigrationOutcome =
/** 目标与来源群是同一群 ⇒ 等价于 `source_chat`,行为完全一致。 */
| 'lossless_source_chat'
/** 目标是另一个群 / 无法确认是否同群 ⇒ 单值四选一表达不了,需用户重选。 */
| 'needs_review'
export interface ScheduledReportMigrationPlan {
outcome: ScheduledReportMigrationOutcome
/** 迁移后的规则 id(确定性,可重复执行且不产生新 id)。 */
ruleId: string
name: string
enabled: boolean
config: ScheduledReportAutomationConfig
createdAt: number
updatedAt: number
}
/** 把任意输入收敛成字符串。 */
function asText(value: unknown): string {
return typeof value === 'string' ? value.trim() : String(value ?? '').trim()
}
/**
* 确定性 ruleId。
*
* 优先**原样复用旧 taskId**(旧 id 形如 `scheduled_report_<uuid>`,与自动化现有
* id 空间 —— `randomUUID()` / `builtin-*` —— 不可能冲突)。
* 旧 id 缺失时(文件被手改坏),退化成一个**由稳定字段派生**的 id,
* 保证"重复迁移不会生成新规则"。
*/
export function scheduledReportMigrationRuleId(task: LegacyScheduledReportTask): string {
const id = asText(task.id)
if (id) return id
const digest = createHash('sha1')
.update(
[asText(task.name), asText(task.group), asText(task.scheduleTime), asText(task.target)].join(
'\u0001'
)
)
.digest('hex')
return `scheduled-report:${digest.slice(0, 32)}`
}
/** 旧 `createdAt` / `updatedAt` 是 ISO 字符串;非法时回落到迁移时刻。 */
function parseIso(value: unknown, fallback: number): number {
const text = asText(value)
if (!text) return fallback
const parsed = Date.parse(text)
return Number.isFinite(parsed) ? parsed : fallback
}
/**
* 单条旧任务 → 迁移计划。
*
* `resolveConversationId` 由调用方注入(main 侧是 `chat-service.resolveMd5`,
* 单测里是假的联系人表)。**它拿不到就说明主进程还没准备好**,
* 调用方必须**推迟整批迁移**,而不是用"解析不到"去误判成 needs_review。
*/
export function planScheduledReportMigration(
task: LegacyScheduledReportTask,
resolveConversationId: (raw: string) => string | undefined,
now: number
): ScheduledReportMigrationPlan {
const sourceRaw = asText(task.group)
const targetRaw = asText(task.target)
const sourceResolved = resolveConversationId(sourceRaw)
const targetResolved = resolveConversationId(targetRaw)
/*
* 三种"能证明是同一个群"的情况:
* 1. target 为空 —— 旧 `normalizeInput` 本就把空 target 填成 group;
* 2. 两者文本完全相等 —— 无论能否解析,用户表达的就是同一个东西;
* 3. 两者都能解析且解析结果相同 —— 旧 UI 的常态(group=会话 md5,target=roomId)。
*/
const sameGroup =
!targetRaw || targetRaw === sourceRaw || Boolean(sourceResolved && targetResolved && sourceResolved === targetResolved)
const config = normalizeScheduledReportConfig({
schedule: { time: asText(task.scheduleTime) },
report: {
// 解析得到就用稳定会话 id;解析不到**保留原值** —— 运行期会如实失败,
// 绝不在这里"猜一个群"。
sourceConversationId: sourceResolved ?? sourceRaw,
range: asText(task.reportRange) as ScheduledReportRange,
messageTypes: task.messageTypes as ScheduledReportMessageType[],
templateId: asText(task.templateId) as SelectableReportTemplateId,
memberNameMode: asText(task.memberNameMode) as ScheduledReportMemberNameMode,
timeoutSeconds: Number(task.timeoutSeconds)
},
target: { type: 'source_chat' },
...(sameGroup ? {} : { targetNeedsReview: true }),
// 旧目标原文只作展示;`sameGroup` 时不保留,避免留下看起来像"还生效"的残值。
...(sameGroup || !targetRaw ? {} : { legacyTarget: targetRaw }),
// 来源群被改写过(md5/群名 → roomId)时留下原文,便于用户核对。
...(sourceRaw && sourceResolved && sourceRaw !== sourceResolved
? { legacySourceGroup: sourceRaw }
: {}),
...(asText(task.lastRunAt) ? { lastRunAt: asText(task.lastRunAt) } : {}),
...(asText(task.lastScheduledSlot) ? { lastScheduledSlot: asText(task.lastScheduledSlot) } : {})
})
const name = asText(task.name) || '定时日报'
return {
outcome: sameGroup ? 'lossless_source_chat' : 'needs_review',
ruleId: scheduledReportMigrationRuleId(task),
name,
// 不自动改写、也不自动启用:无法确认目标的规则一律停用。
enabled: sameGroup ? task.enabled !== false : false,
config,
createdAt: parseIso(task.createdAt, now),
updatedAt: parseIso(task.updatedAt, now)
}
}
export interface ScheduledReportMigrationSummary {
total: number
migrated: number
lossless: number
needsReview: number
duplicatesSkipped: number
}
/**
* 整批任务的迁移结果:**按 id 去重**,保证幂等(同一份旧文件跑两次结果一致)。
*/
export function planScheduledReportMigrationBatch(
tasks: LegacyScheduledReportTask[],
resolveConversationId: (raw: string) => string | undefined,
now: number
): { plans: ScheduledReportMigrationPlan[]; summary: ScheduledReportMigrationSummary } {
const plans: ScheduledReportMigrationPlan[] = []
const seen = new Set<string>()
let duplicatesSkipped = 0
let lossless = 0
let needsReview = 0
for (const task of tasks) {
if (!task || typeof task !== 'object') continue
const plan = planScheduledReportMigration(task, resolveConversationId, now)
if (seen.has(plan.ruleId)) {
duplicatesSkipped += 1
continue
}
seen.add(plan.ruleId)
plans.push(plan)
if (plan.outcome === 'lossless_source_chat') lossless += 1
else needsReview += 1
}
return {
plans,
summary: {
total: plans.length + duplicatesSkipped,
migrated: plans.length,
lossless,
needsReview,
duplicatesSkipped
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
import {
SCHEDULED_REPORT_TARGET_OPTIONS,
scheduledReportTargetLabel,
type ScheduledReportAutomationConfig
} from '../../shared/automation'
import {
resolveAutomationTarget,
resolveGroupConversationId,
resolveGroupDisplayName,
type AutomationTargetContact,
type AutomationTargetResolution
} from './automation-wechat-target'
/**
* 定时日报的**目标解析**(薄适配层)。
*
* 判定逻辑在 `automation-wechat-target.ts`,与退群通知**共用同一份**。
* 差异只有一处:来源会话不是"事件所在群",而是**这条规则配置的日报来源群**。
*/
/** 定时日报的目标文案(唯一一份)。 */
export const SCHEDULED_REPORT_TARGET_MESSAGES = {
options: SCHEDULED_REPORT_TARGET_OPTIONS,
invalidTarget: '定时日报的发送目标无效,请重新选择',
sourceMissing: '无法确定日报来源群,本次日报未发送',
sourceRecipientName: '日报来源群',
sourceDisplayName: '日报来源群',
selfMissing: '无法确定当前登录的微信账号,本次日报未发送',
contactNotChosen: '还没有选择发送联系人,请重新选择',
contactUnavailable: '发送联系人已不存在或当前无法发送,请重新选择'
} as const
export function resolveScheduledReportTarget(input: {
config: ScheduledReportAutomationConfig | undefined
contacts: AutomationTargetContact[]
/** 当前登录账号的 wxid;拿不到时传空串。 */
selfWxid?: string
}): AutomationTargetResolution {
const config = input.config
if (!config) return { ok: false, error: '定时日报尚未配置发送目标' }
const sourceConversationId = String(config.report?.sourceConversationId || '').trim()
return resolveAutomationTarget(
{
targetType: config.target?.type as never,
sourceConversationId,
// 来源群的可读名从通讯录解析;解析不到时由中性层给兜底称呼。
sourceDisplayName: resolveGroupDisplayName(sourceConversationId, input.contacts),
...(config.target?.contactId ? { contactId: config.target.contactId } : {}),
contacts: input.contacts,
...(input.selfWxid ? { selfWxid: input.selfWxid } : {})
},
SCHEDULED_REPORT_TARGET_MESSAGES
)
}
/**
* 日报来源群的可读显示名(执行日志的 `sourceDisplayName` 用它)。
*
* **绝不回落到裸 id**:解析不到就说「日报来源群」。
*/
export function scheduledReportSourceDisplayName(
config: ScheduledReportAutomationConfig | undefined,
contacts: AutomationTargetContact[]
): string {
const raw = String(config?.report?.sourceConversationId || '').trim()
if (!raw) return '日报来源群'
return resolveGroupDisplayName(raw, contacts) || '日报来源群'
}
/** 日报来源群解析成稳定会话 id(执行前预检用;解析不到返回 undefined)。 */
export function scheduledReportSourceConversationId(
config: ScheduledReportAutomationConfig | undefined,
contacts: AutomationTargetContact[]
): string | undefined {
return resolveGroupConversationId(
String(config?.report?.sourceConversationId || ''),
contacts
)
}
export { scheduledReportTargetLabel }
+12 -6
View File
@@ -39,9 +39,8 @@ export interface AppSettings {
showStartupProgress: boolean showStartupProgress: boolean
ttsSelectedVoiceId: string ttsSelectedVoiceId: string
ttsModel: TextToSpeechModel ttsModel: TextToSpeechModel
/** Keep a running personal-WeChat OneBot process across app restarts. */
keepPersonalWechatProcess?: boolean
windowsWechatPort: string windowsWechatPort: string
reportImagePostfixText: string
/** /**
* Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。 * Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。
* 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关, * 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关,
@@ -130,8 +129,8 @@ const DEFAULT_SETTINGS: AppSettings = {
showStartupProgress: true, showStartupProgress: true,
ttsSelectedVoiceId: '', ttsSelectedVoiceId: '',
ttsModel: 's2.1-pro-free', ttsModel: 's2.1-pro-free',
keepPersonalWechatProcess: false,
windowsWechatPort: '', windowsWechatPort: '',
reportImagePostfixText: '今日日报',
queryAgentEnabled: true queryAgentEnabled: true
} }
@@ -150,7 +149,10 @@ export function loadSettings(): AppSettings {
if (cache) return cache if (cache) return cache
try { try {
if (fs.existsSync(SETTINGS_FILE)) { if (fs.existsSync(SETTINGS_FILE)) {
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings> const stored = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings> & {
keepPersonalWechatProcess?: unknown
}
const { keepPersonalWechatProcess: retiredKeepProcess, ...raw } = stored
cache = { ...DEFAULT_SETTINGS, ...raw } cache = { ...DEFAULT_SETTINGS, ...raw }
if (raw.autoLogin === undefined) { if (raw.autoLogin === undefined) {
const hasSavedDatabaseKey = fs.existsSync( const hasSavedDatabaseKey = fs.existsSync(
@@ -176,7 +178,7 @@ export function loadSettings(): AppSettings {
} }
// 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。 // 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。
// 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。 // 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。
if (cache.recallProtectionEnabled) { if (cache.recallProtectionEnabled || retiredKeepProcess !== undefined) {
cache.recallProtectionEnabled = false cache.recallProtectionEnabled = false
saveSettings(cache) saveSettings(cache)
} }
@@ -192,7 +194,11 @@ export function loadSettings(): AppSettings {
export function saveSettings(next: AppSettings): AppSettings { export function saveSettings(next: AppSettings): AppSettings {
// 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false, // 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false,
// 避免遗留入口或旧版本把它重新打开。 // 避免遗留入口或旧版本把它重新打开。
cache = { ...next, recallProtectionEnabled: false } const sanitized = { ...next } as AppSettings & {
keepPersonalWechatProcess?: unknown
}
delete sanitized.keepPersonalWechatProcess
cache = { ...sanitized, recallProtectionEnabled: false }
try { try {
ensureDir() ensureDir()
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 }) fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })

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