mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97c74859c0 | ||
|
|
4d5c6459fe | ||
|
|
c835d5c570 | ||
|
|
59872daf52 | ||
|
|
3bd3935042 | ||
|
|
77a6912ade | ||
|
|
4b83aaed61 | ||
|
|
7268188e40 | ||
|
|
069d836dea | ||
|
|
95384a4a92 | ||
|
|
f5633a29cb | ||
|
|
766cd94788 | ||
|
|
b6d287a299 | ||
|
|
4c25236070 | ||
|
|
c1224cb612 | ||
|
|
5036639aa1 | ||
|
|
9593ca0f54 | ||
|
|
b441287d54 | ||
|
|
9b82d29037 | ||
|
|
0f270366aa | ||
|
|
12b8fb34c6 | ||
|
|
774346d503 | ||
|
|
b97e1f4bc6 | ||
|
|
4b2395a8d2 | ||
|
|
1337bcb7de | ||
|
|
b8f08d54c8 | ||
|
|
24399f1d70 |
@@ -25,11 +25,6 @@ jobs:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: services/wechat-connector/go.mod
|
||||
cache-dependency-path: services/wechat-connector/go.sum
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -51,9 +46,6 @@ jobs:
|
||||
- name: Skill installation instruction tests
|
||||
run: pnpm test:skill-install
|
||||
|
||||
- name: WeChat connector tests
|
||||
run: pnpm test:wechat-connector
|
||||
|
||||
- name: Build Electron test application
|
||||
run: pnpm test:e2e:build
|
||||
|
||||
|
||||
+2
-2
@@ -10,8 +10,8 @@ out
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
resources/connectors/wechat/
|
||||
resources/connectors/wechat-personal/
|
||||
resources/runtime/darwin-arm64
|
||||
.native-runtime-source
|
||||
.omc
|
||||
.codex/
|
||||
skills-lock.json
|
||||
|
||||
+2
-1
@@ -49,6 +49,8 @@ Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP A
|
||||
## 开发文档
|
||||
|
||||
- [开发、测试与构建](./development/overview.md)
|
||||
- [界面开发规范:按钮与主题色](./development/ui-guidelines.md)
|
||||
- [微信系统消息解析与格式兼容](./development/wechat-system-message-parsing.md)
|
||||
- [Query Agent POC(开发测试入口)](./development/query-agent-poc.md)
|
||||
- [本地启动排障](./development/local-startup-troubleshooting.md)
|
||||
- [macOS 数据访问说明](./platform/macos.md)
|
||||
@@ -59,7 +61,6 @@ Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP A
|
||||
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
||||
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
|
||||
- [TraceMemo Reader Skill 文件](./skill/tracememo-reader/SKILL.md)
|
||||
- [第三方组件说明](./third-party/wechat-chatter/NOTICE.md)
|
||||
|
||||
## 版本说明
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
执行 `pnpm dev` 后,以下状态同时满足,说明本地开发环境已经可用:
|
||||
|
||||
- 控制台显示连接器已生成,例如 `resources/connectors/wechat/win32-x64/wechat-connector.exe`;
|
||||
- Electron 窗口已打开,或 `http://localhost:5173/` 返回 HTTP `200`;
|
||||
- 控制台显示 Local HTTP API 正在监听 `http://127.0.0.1:6131`。
|
||||
|
||||
@@ -22,18 +21,6 @@ WCDB_DEBUG_LOGS=1 pnpm dev
|
||||
|
||||
开启后会输出 `GETMSG-xxx` 请求耗时和 native `WCDB-EXPLAIN` 执行计划,不记录聊天正文。取消该环境变量或设为 `0` 即可关闭。
|
||||
|
||||
## Go 命令找不到
|
||||
|
||||
如果 `pnpm dev` 在构建微信连接器时出现 `spawnSync go ENOENT`,先执行:
|
||||
|
||||
```bash
|
||||
go version
|
||||
```
|
||||
|
||||
命令不可用表示当前终端的 `PATH` 没有找到 Go。Windows 默认安装位置是 `C:\Program Files\Go\bin`。确认 Go 已安装并把该目录加入系统 `PATH` 后,关闭并重新打开终端或 IDE,再重新执行 `go version` 和 `pnpm dev`。
|
||||
|
||||
如果 Go 刚完成安装,已经打开的终端不会自动继承新的环境变量;重开终端是必要步骤。不要绕过连接器构建直接启动 `electron-vite dev`,否则 Agent Hub 的微信连接器不会生成。
|
||||
|
||||
## Electron 二进制缺失或下载失败
|
||||
|
||||
`electron-vite dev` 报 `Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 Electron 二进制缺失或下载未完成。这不是应用业务代码的启动错误。
|
||||
@@ -68,4 +55,4 @@ Vite 在某些 Windows 环境中只监听 IPv6 本机回环地址 `::1`。这时
|
||||
|
||||
## 仍无法启动时
|
||||
|
||||
保留首次错误的完整输出,并同时记录操作系统、Node.js、pnpm 和 Go 版本,以及 `pnpm install --frozen-lockfile` 与 `pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。
|
||||
保留首次错误的完整输出,并同时记录操作系统、Node.js 与 pnpm 版本,以及 `pnpm install --frozen-lockfile` 与 `pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
- Electron + React + TypeScript;
|
||||
- pnpm 7+;
|
||||
- Go(构建微信连接器);
|
||||
- 平台对应的 Electron/native 构建环境。
|
||||
|
||||
产品文档的事实来源优先级是:当前源码 → 当前 UI/Renderer → 测试 → package/config → README/docs → 历史资料。功能、API、版本、隐私和兼容性变更时,不要只改 README。
|
||||
@@ -18,7 +17,7 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
|
||||
本地依赖安装与 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
|
||||
|
||||
常用检查:
|
||||
|
||||
@@ -30,7 +29,7 @@ pnpm test:integration
|
||||
pnpm test:e2e:build
|
||||
```
|
||||
|
||||
完整测试入口 `pnpm test` 还会运行 Skill 安装指令、微信连接器、构建和 Playwright 测试;需要对应平台环境。
|
||||
完整测试入口 `pnpm test` 还会运行 Skill 安装指令、构建和 Playwright 测试;需要对应平台环境。
|
||||
|
||||
## 代码变更对应文档
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# 界面开发规范:按钮与主题色
|
||||
|
||||
这份规范回答一件事:**为什么同一个产品里,有的按钮是主题色,有的还是浏览器默认的黑白方角。**
|
||||
|
||||
先看一个真实案例 —— 同一屏里的两组按钮:
|
||||
|
||||
```
|
||||
主界面:「更新图片文字索引」 ← 主题色(正确)
|
||||
弹窗里:「取消」「开始索引」 ← 浏览器默认样式(错误)
|
||||
```
|
||||
|
||||
两者渲染出来完全不同,用户会以为是两个不同的产品。根因不是"设计没定颜色",
|
||||
而是**组件在导出时把样式丢了**。下面写清楚怎么避免。
|
||||
|
||||
---
|
||||
|
||||
## 1. 永远不要写裸 `<button>`
|
||||
|
||||
任何可点的按钮都必须来自 `components/ui/button`:
|
||||
|
||||
```tsx
|
||||
import { Button } from '../ui'
|
||||
|
||||
<Button variant="outline" onClick={handleCancel}>取消</Button>
|
||||
```
|
||||
|
||||
**唯一的例外**:结构性控件(导航项、Tab、列表行、图标热区)——它们有自己
|
||||
成套的布局样式,用原生 `<button>` 是合理的,但**必须**带 `className`,
|
||||
且样式写在对应的 `.scss` 里,不要在 JSX 里临时拼颜色。
|
||||
|
||||
```tsx
|
||||
// 可以:结构性控件,样式来自 .scss
|
||||
<button type="button" role="tab" className={active ? 'active' : ''} onClick={...}>
|
||||
今日日报
|
||||
</button>
|
||||
```
|
||||
|
||||
**判据**:如果这个按钮在别的界面也会以同样形态出现("取消"、"保存"、"删除"),
|
||||
它就该是 `Button`;如果它只在某一个位置有意义(侧栏导航项),才考虑原生。
|
||||
|
||||
---
|
||||
|
||||
## 2. 三种角色,只有三个默认变体
|
||||
|
||||
`Button` 提供 6 个变体,但**日常只用其中 3 个**:
|
||||
|
||||
| 角色 | `variant` | 长什么样 | 用在哪 |
|
||||
| --- | --- | --- | --- |
|
||||
| 主要 | `default` | 主题色实底 | 这一步用户唯一该做的事 |
|
||||
| 次要 | `outline` / `ghost` | 描边 / 无底色 | 取消、返回、并列的辅助操作 |
|
||||
| 危险 | `destructive` | 红色实底 | 删除、清空、不可恢复的操作 |
|
||||
|
||||
另外两个(`secondary` / `link`)按需用;`link` 只用于正文里的行内跳转。
|
||||
|
||||
**一条硬约束:同一个界面(或同一个弹窗)里,`default` 最多出现一次。**
|
||||
两个主题色实底按钮并排,等于没有主次。
|
||||
|
||||
---
|
||||
|
||||
## 3. 弹窗按钮:组件已经带样式了,不要再包一层
|
||||
|
||||
`AlertDialogCancel` 和 `AlertDialogAction` **自带**按钮样式(分别是 `outline`
|
||||
和 `default`),直接写文字即可:
|
||||
|
||||
```tsx
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleStart}>开始索引</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
```
|
||||
|
||||
**不要**再套一层 `Button`:
|
||||
|
||||
```tsx
|
||||
// 反面写法:外层已经有样式了,再包一层只会产生重复类名
|
||||
<AlertDialogCancel asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</AlertDialogCancel>
|
||||
```
|
||||
|
||||
需要危险动作时,用 `className` 覆盖(`cn` 走 tailwind-merge,同族类后者生效):
|
||||
|
||||
```tsx
|
||||
<AlertDialogAction className="bg-destructive text-destructive-foreground">
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 颜色只能用语义 token,禁止硬编码
|
||||
|
||||
颜色全部走 Tailwind 的语义类,它们背后是 `--tm-*` 变量,换主题时自动跟随:
|
||||
|
||||
```
|
||||
背景 bg-primary / bg-surface / bg-accent / bg-destructive
|
||||
文字 text-foreground / text-primary-foreground / text-muted-foreground
|
||||
描边 border-border / border-border-subtle / border-disabled-border
|
||||
```
|
||||
|
||||
```tsx
|
||||
// 对
|
||||
<Button className="bg-primary text-primary-foreground">保存</Button>
|
||||
|
||||
// 错 —— 换主题时这行不会跟着变
|
||||
<Button className="bg-[#247a63] text-white">保存</Button>
|
||||
```
|
||||
|
||||
**判据**:JSX 里出现 `#` 开头的颜色、`rgb(...)`、或 Tailwind 的调色板名
|
||||
(`bg-green-600`、`text-slate-500`)—— 都是漏用 semantic token 的信号。
|
||||
|
||||
---
|
||||
|
||||
## 5. 「默认样式」的三个常见来源
|
||||
|
||||
排查界面里冒出来的黑白方角按钮时,按这个顺序找:
|
||||
|
||||
**① 组件导出时把样式丢了。** 最常见。把 Radix 的 primitive 原样导出:
|
||||
|
||||
```tsx
|
||||
// 错:渲染出来就是浏览器默认按钮
|
||||
const AlertDialogCancel = AlertDialogPrimitive.Cancel
|
||||
```
|
||||
|
||||
正确做法是 `forwardRef` 包一层,挂上 `buttonVariants`:
|
||||
|
||||
```tsx
|
||||
const AlertDialogCancel = React.forwardRef<...>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
```
|
||||
|
||||
**判据**:`components/ui/` 里凡是导出 Radix primitive 的地方,都要确认它是
|
||||
"样式化的封装"还是"原样透传"。原样透传只对布局容器(`Root` / `Portal` /
|
||||
`Group`)成立,对**可点元素**(`Close` / `Action` / `Cancel` / `Item`)不成立。
|
||||
|
||||
**② `asChild` 里重复包了一层。** 外层已经带样式、子元素又带一次,虽然因为
|
||||
同族类后生效而不会出错,但会产生冗余类名。**能去掉一层就去掉。**
|
||||
|
||||
**③ 原生 `<button>` 忘写 `className`。** 见第 1 节的例外条款 —— 结构性控件也必须
|
||||
有样式来源。
|
||||
|
||||
---
|
||||
|
||||
## 6. 提交前检查清单
|
||||
|
||||
- [ ] 新增的可点元素来自 `Button`,不是裸 `<button>`
|
||||
- [ ] 同一界面里 `default` 变体不超过一个
|
||||
- [ ] 危险操作走 `destructive`,不是红色硬编码
|
||||
- [ ] 弹窗按钮没有重复包 `Button`
|
||||
- [ ] JSX 里没有 `#` 开头的颜色、没有 Tailwind 调色板名
|
||||
- [ ] `components/ui/` 里新导出的可点 primitive 已经挂上 `buttonVariants`
|
||||
- [ ] 组件测试覆盖到按钮的可见性与点击行为(testid 用 `xxx-yyy` 连字符命名)
|
||||
|
||||
---
|
||||
|
||||
## 7. 一个反面案例的复盘
|
||||
|
||||
弹窗里的「取消 / 开始索引」显示成浏览器默认样式,原因就是第 5 节第 ① 条:
|
||||
`alert-dialog.tsx` 把 `Cancel` / `Action` 两个 primitive 原样导出了。
|
||||
|
||||
修复是给它们各加一个 `forwardRef` 封装,挂上 `buttonVariants`。**组件本身没坏**,
|
||||
所有调用方一行不用改,样式自动生效 —— 这正是把样式收在 `components/ui/` 里的价值:
|
||||
**修一处,全产品对齐。**
|
||||
|
||||
如果你发现某个地方的按钮"没跟上主题",先别去改那个界面 ——
|
||||
**先看它用的组件是不是漏了样式。**
|
||||
@@ -0,0 +1,92 @@
|
||||
# 微信系统消息(sysmsg)解析与格式兼容
|
||||
|
||||
微信的「系统消息」(入群、撤回、成员变动等)以 XML(`<sysmsg>`)存放在消息内容里,
|
||||
但**同一类提示的 XML 结构会随客户端版本变化**。本文说明 TraceMemo 的解析方式,
|
||||
以及在遇到新格式时应当怎么扩展。
|
||||
|
||||
## 两类格式
|
||||
|
||||
### 旧格式:正文直接放在 `<plain>`
|
||||
|
||||
```xml
|
||||
<sysmsg type="delchatroommember">
|
||||
<delchatroommember>
|
||||
<plain><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></plain>
|
||||
<text><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></text>
|
||||
<link>
|
||||
<scene>qrcode</scene>
|
||||
<text><![CDATA[撤销]]></text>
|
||||
</link>
|
||||
</delchatroommember>
|
||||
</sysmsg>
|
||||
```
|
||||
|
||||
解析:命中 `delchatroommember`,直接取 `<plain>`。
|
||||
|
||||
### 新格式:正文在 `<template>`,用 `$名称$` 引用 link
|
||||
|
||||
```xml
|
||||
<sysmsg type="sysmsgtemplate">
|
||||
<sysmsgtemplate>
|
||||
<content_template type="tmpl_type_profilewithrevokeqrcode">
|
||||
<plain><![CDATA[]]></plain>
|
||||
<template><![CDATA["$adder$"通过扫描你分享的二维码加入群聊 $revoke$]]></template>
|
||||
<link_list>
|
||||
<link name="adder" type="link_profile">
|
||||
<memberlist><member>
|
||||
<username><![CDATA[wxid_xxxxxxxx]]></username>
|
||||
<nickname><![CDATA[成员昵称]]></nickname>
|
||||
</member></memberlist>
|
||||
</link>
|
||||
<link name="revoke" type="link_revoke_qrcode" hidden="1">
|
||||
<title><![CDATA[撤销]]></title>
|
||||
</link>
|
||||
</link_list>
|
||||
</content_template>
|
||||
</sysmsgtemplate>
|
||||
</sysmsg>
|
||||
```
|
||||
|
||||
三个要点:
|
||||
|
||||
- `<plain>` 变成**空 CDATA**,正文挪进 `<template>`;
|
||||
- 正文里的 `$名称$` 是占位符,按 `<link_list>` 中 `link[name]` 回填;
|
||||
- `hidden="1"` 的 link 在微信里是**可点击按钮**,纯文本展示时应省略其文案。
|
||||
|
||||
## 解析流程
|
||||
|
||||
`src/main/message-parser.ts` 的 `parseSystemMessage()` 按以下顺序尝试:
|
||||
|
||||
| 顺序 | 分支 | 处理对象 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `extractRecallMessage` | `<revokemsg>` 撤回通知 |
|
||||
| 2 | `extractSysmsgTemplateText` | `<sysmsgtemplate>` 模板消息 |
|
||||
| 3 | `extractDelChatroomMemberText` | `<delchatroommember>` 成员变动 |
|
||||
| 4 | 通用提取(`plain` → `text` → `title`),再退回 `fallbackSystemText` | 其余未覆盖类型 |
|
||||
|
||||
第 4 步之前会先调用 `stripSysmsgLinkList()` 剥掉 `<link_list>`。
|
||||
|
||||
## 为什么必须显式处理新格式
|
||||
|
||||
通用提取链只在第 1~3 步全部落空时才执行,而新格式恰好让它落空:
|
||||
`<plain>` 是空 CDATA,又没有 `<text>`,于是取到 `<title>` ——
|
||||
那是 `hidden="1"` 按钮的标题。**结果是整条系统消息只剩一个按钮文案**,
|
||||
例如把「某某通过扫描你分享的二维码加入群聊」显示成「撤销」。
|
||||
|
||||
因此三处约束缺一不可:
|
||||
|
||||
1. 模板分支必须排在通用提取之前;
|
||||
2. 占位符回填必须尊重 `hidden="1"`;
|
||||
3. 通用提取前先剥 `<link_list>`,作为未知类型的防护。
|
||||
|
||||
## 新增一类系统消息时
|
||||
|
||||
1. 从真实消息中取出 `content`(`<sysmsg>` 原文),确认 `type` 与承载正文的标签;
|
||||
2. 在 `parseSystemMessage()` 里加一个**早于通用提取**的分支;
|
||||
3. 补 `tests/unit/message-parser.test.ts` 用例,**新旧两版各一条**,防止回归;
|
||||
4. 文档与代码注释只写结构,不粘贴真实会话内容、昵称、wxid 或二维码链接。
|
||||
|
||||
## 相关位置
|
||||
|
||||
- 解析实现:`src/main/message-parser.ts`
|
||||
- 单元测试:`tests/unit/message-parser.test.ts`
|
||||
+60
-3
@@ -1,5 +1,62 @@
|
||||
# macOS 数据访问说明(兼容入口)
|
||||
# macOS 关闭 SIP 教程
|
||||
|
||||
完整内容已移到[macOS 数据访问与系统权限](./platform/macos.md)。
|
||||
SIP(System Integrity Protection,系统完整性保护)是 macOS 的系统安全机制。关闭 SIP 会降低系统安全性,只建议在确实需要读取或调试本地微信数据时临时关闭;操作完成后,建议重新开启。
|
||||
|
||||
保留此文件是为了兼容应用内已经发布的帮助链接。请不要把“关闭 SIP”当作默认安装步骤;只有当当前连接页面明确要求时才处理,并在完成后恢复系统安全设置。
|
||||
> 只在连接页面明确提示需要关闭 SIP 时才处理。首次连接失败时,先确认微信版本、账号目录和登录时机,再按本文操作。关闭 SIP 不是 TraceMemo 的常规安装步骤,也不应长期保持关闭。
|
||||
|
||||
## 准备
|
||||
|
||||
- 一台 Mac 电脑,Intel 芯片和 Apple Silicon 芯片均可。
|
||||
- 需要进入 macOS 恢复模式。
|
||||
- 请先保存正在编辑的文件,并预留一次重启时间。
|
||||
|
||||
## 关闭 SIP
|
||||
|
||||
### Intel Mac
|
||||
|
||||
1. 关机。
|
||||
2. 按下开机键后,立刻按住 `Command + R`。
|
||||
3. 保持按住,直到进入 macOS 恢复模式。
|
||||
|
||||
### Apple Silicon Mac(M1/M2/M3/M4/M5)
|
||||
|
||||
1. 关机。
|
||||
2. 长按开机键不放。
|
||||
3. 直到出现启动选项界面后松开。
|
||||
4. 选择"选项",进入 macOS 恢复模式。
|
||||
|
||||
### 在恢复模式中执行命令
|
||||
|
||||
1. 进入恢复模式后,点击顶部菜单栏的 **Utilities(实用工具)**。
|
||||
2. 选择 **Terminal(终端)**。
|
||||
3. 在终端中输入:
|
||||
|
||||
```bash
|
||||
csrutil disable
|
||||
```
|
||||
|
||||
4. 按回车执行。
|
||||
5. 看到关闭成功提示后,重启电脑。
|
||||
|
||||
## 确认是否生效
|
||||
|
||||
重启回到正常桌面后,打开"终端",执行:
|
||||
|
||||
```bash
|
||||
csrutil status
|
||||
```
|
||||
|
||||
看到 `System Integrity Protection status: disabled.` 才算关闭成功。
|
||||
|
||||
若仍显示 `enabled`,说明没有生效。常见原因是没在恢复模式里执行,或系统刚做过大版本更新——
|
||||
macOS 大版本更新会把 SIP 重置回开启状态,此前关过也会失效,需要重新按上面的步骤操作。
|
||||
|
||||
## 重新开启 SIP
|
||||
|
||||
拿到数据库密钥后,建议重新进入恢复模式,在终端中执行:
|
||||
|
||||
```bash
|
||||
csrutil enable
|
||||
```
|
||||
|
||||
然后重启电脑,恢复系统安全设置。
|
||||
|
||||
@@ -8,7 +8,7 @@ TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信
|
||||
|
||||
1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。
|
||||
2. 确认微信数据目录指向当前账号。
|
||||
3. 只在页面明确要求时处理系统授权或 SIP;按页面提示完成密钥获取后,恢复你平时使用的安全设置。
|
||||
3. 只在页面明确要求时处理系统授权或 SIP;关闭 SIP 的具体步骤见[关闭 SIP 教程](../mac-disable-sip.md),按页面提示完成密钥获取后,恢复你平时使用的安全设置。
|
||||
4. 返回应用重新检测账号、数据库和图片资源状态。
|
||||
|
||||
不要直接复制网上针对其他微信版本的命令。系统授权失败时,记录 macOS 版本、微信版本和页面错误,再按[排障文档](../user-guide/troubleshooting.md#连接微信失败)处理。
|
||||
|
||||
-674
@@ -1,674 +0,0 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2026 yincongcyincong
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
# wechat_chatter / OneBot 第三方组件说明
|
||||
|
||||
TraceMemo 的 macOS 个人微信发送功能会按需使用以下第三方组件:
|
||||
|
||||
- 项目:`yincongcyincong/wechat_chatter`
|
||||
- 上游仓库:https://github.com/yincongcyincong/wechat_chatter
|
||||
- 当前运行时版本:`v0.0.18`
|
||||
- 运行时文件:`onebot_mac_arm64.tar.gz`
|
||||
- 许可证:GNU General Public License version 3(GPL-3.0)
|
||||
- 上游版权:Copyright (C) 2026 yincongcyincong
|
||||
- TraceMemo 修改日期:2026-08-17
|
||||
|
||||
## 集成方式
|
||||
|
||||
OneBot 运行时不会随 TraceMemo 安装包一起分发。用户启用该实验性功能时,TraceMemo 会从上述上游项目的 GitHub Release 按需下载运行时,并将其安装到应用的用户数据目录。
|
||||
|
||||
运行时作为独立进程启动,TraceMemo 通过本机 HTTP 接口与其通信。
|
||||
|
||||
## 本地修改
|
||||
|
||||
为适配连续发送、图片上传 Hook 状态检测以及微信核心模块基址定位,TraceMemo 会在用户设备上对上游 `onebot/script.js` 应用兼容性补丁。补丁逻辑位于:
|
||||
|
||||
- `scripts/prepare-wechat-chatter-runtime.cjs`
|
||||
- `src/main/services/personal-wechat-runtime-manager.ts`
|
||||
|
||||
补丁中源自或修改自上游 `script.js` 的部分,以及补丁应用后产生的修改版 `script.js`,继续按照 GPL-3.0 提供。本说明只针对该第三方组件及相关修改,不用于声明 TraceMemo 仓库其他部分的许可证。
|
||||
|
||||
GPL-3.0 的完整文本见本目录下的 `LICENSE`。
|
||||
@@ -19,6 +19,8 @@ asarUnpack:
|
||||
- node_modules/silk-wasm/**
|
||||
- node_modules/sherpa-onnx-node/**
|
||||
- node_modules/sherpa-onnx-*/**
|
||||
- node_modules/@napi-rs/system-ocr/**
|
||||
- node_modules/@napi-rs/system-ocr-*/**
|
||||
extraResources:
|
||||
# Keep the updater provider in every packaged Windows app. electron-builder also
|
||||
# regenerates this file during publish, using the same release configuration.
|
||||
@@ -33,7 +35,6 @@ extraResources:
|
||||
- mobile_daily_report.html
|
||||
- mobile_daily_report_v1.html
|
||||
- mobile_daily_report_v2.html
|
||||
- connectors/wechat/win32-x64/**
|
||||
- key/win32/x64/**
|
||||
- runtime/win32/**
|
||||
- wcdb/win32/x64/**
|
||||
|
||||
@@ -19,6 +19,11 @@ asarUnpack:
|
||||
- node_modules/silk-wasm/**
|
||||
- node_modules/sherpa-onnx-node/**
|
||||
- node_modules/sherpa-onnx-*/**
|
||||
# System OCR(@napi-rs/system-ocr)的 native binding 必须 unpacked,否则
|
||||
# macOS 的系统 OCR 会在运行时 MODULE_NOT_FOUND。平台本机的 binding 由
|
||||
# scripts/after-pack.cjs 校验,外架构的同级包在 afterPack 里被裁掉。
|
||||
- node_modules/@napi-rs/system-ocr/**
|
||||
- node_modules/@napi-rs/system-ocr-*/**
|
||||
extraResources:
|
||||
# Keep the updater provider in every packaged macOS app. electron-builder also
|
||||
# regenerates this file during publish, using the same release configuration.
|
||||
@@ -29,7 +34,6 @@ extraResources:
|
||||
to: resources
|
||||
filter:
|
||||
- '**/*'
|
||||
- '!connectors/wechat-personal/**'
|
||||
- from: docs/skill/tracememo-reader
|
||||
to: skill/tracememo-reader
|
||||
filter:
|
||||
|
||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
||||
output: {
|
||||
entryFileNames: '[name].js'
|
||||
},
|
||||
external: ['koffi', 'sherpa-onnx-node']
|
||||
external: ['koffi', 'sherpa-onnx-node', '@napi-rs/system-ocr']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+14
-16
@@ -25,12 +25,13 @@
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:wechat-connector && pnpm test:e2e:build && playwright test",
|
||||
"test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:e2e:build && playwright test",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache .",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web && npm run typecheck:test",
|
||||
"typecheck:test": "node scripts/typecheck-tests.cjs",
|
||||
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
|
||||
"cp:env": "node scripts/ensure-env.cjs",
|
||||
"prepare:env": "node scripts/ensure-env.cjs",
|
||||
@@ -38,14 +39,13 @@
|
||||
"prepare:ffmpeg:mac:arm64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch arm64",
|
||||
"prepare:ffmpeg:mac:x64": "node scripts/prepare-electron-runtime.cjs --platform darwin --arch x64",
|
||||
"prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win",
|
||||
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
||||
"prepare:wechat-native": "node scripts/prepare-wechat-native-runtime.cjs",
|
||||
"start": "electron-vite preview",
|
||||
"predev": "node scripts/ensure-electron-binary.cjs",
|
||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||
"dev": "node scripts/ensure-env.cjs && electron-vite dev",
|
||||
"dev:update": "cross-env TRACEMEMO_UPDATE_SIMULATION=true pnpm dev",
|
||||
"poc:query-agent": "electron-vite build && node scripts/run-query-agent-poc.cjs",
|
||||
"poc:query-agent:run": "node scripts/run-query-agent-poc.cjs",
|
||||
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
||||
"test:unit": "vitest run --config vitest.unit.config.ts",
|
||||
"test:component": "vitest run --config vitest.component.config.ts",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
@@ -56,21 +56,17 @@
|
||||
"test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual",
|
||||
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
|
||||
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
|
||||
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
|
||||
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64",
|
||||
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch arm64",
|
||||
"build:native-services": "npm run build:wechat-connector",
|
||||
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs && node scripts/ensure-electron-binary.cjs",
|
||||
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
|
||||
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64",
|
||||
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
||||
"build:mac:x64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch x64 && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
|
||||
"build:win": "npm run typecheck && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64",
|
||||
"build:mac:arm64": "npm run typecheck && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
||||
"build:mac:x64": "npm run typecheck && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
|
||||
"release": "npm run release:mac && npm run release:win",
|
||||
"release:mac": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64,x64 && electron-vite build && npm run release:mac:arm64 && npm run release:mac:x64",
|
||||
"release:mac": "npm run typecheck && electron-vite build && npm run release:mac:arm64 && npm run release:mac:x64",
|
||||
"release:mac:arm64": "npm run prepare:ffmpeg:mac:arm64 && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
|
||||
"release:mac:x64": "npm run prepare:ffmpeg:mac:x64 && electron-builder --config electron-builder.yml --mac --x64 --publish always",
|
||||
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
|
||||
"release:win": "npm run typecheck && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
|
||||
"release:beta": "cross-env EP_PRE_RELEASE=true npm run release",
|
||||
"release:stable": "npm run release",
|
||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||
@@ -79,6 +75,8 @@
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.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-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
@@ -171,4 +169,4 @@
|
||||
"ffmpeg-static"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+42
@@ -12,6 +12,8 @@ specifiers:
|
||||
'@electron-toolkit/tsconfig': ^2.0.0
|
||||
'@electron-toolkit/utils': ^4.0.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
|
||||
'@radix-ui/react-alert-dialog': ^1.1.23
|
||||
'@radix-ui/react-checkbox': ^1.3.11
|
||||
@@ -86,6 +88,8 @@ dependencies:
|
||||
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
||||
'@electron-toolkit/utils': 4.0.0_electron@43.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-checkbox': 1.3.11_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||
'@radix-ui/react-dialog': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||
@@ -1685,6 +1689,44 @@ packages:
|
||||
- supports-color
|
||||
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:
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
@@ -68,21 +68,29 @@
|
||||
.overview {
|
||||
margin-top: 2px;
|
||||
}
|
||||
/*
|
||||
* hero 头像簇的几何必须由 contract 变量驱动,不能再写死容器尺寸。
|
||||
*
|
||||
* 生产导出会额外注入 `report-template-fragment-contract.ts`,其中
|
||||
* `img.tm-avatar.tm-avatar--hero` 用 !important 把头像钉在
|
||||
* clamp(28px, var(--tm-avatar-hero-size, 40px), 56px)。
|
||||
* 旧版这里写死 58x58(单头像 28x28),两个权威打架:头像实际 40px,
|
||||
* 2 列 x 40px + 3px gap = 83px 塞不进 58px 的盒子,于是头像向右向下溢出容器,
|
||||
* 视觉上越过卡片内边距、压到卡片边缘之外。
|
||||
*
|
||||
* 现在容器尺寸由内容决定(列宽/行高都取同一个变量):头像数 1..4 都不会溢出,
|
||||
* 主题调整 --tm-avatar-hero-size 时容器与头像也不会分叉。
|
||||
*/
|
||||
.avatar-grid {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: repeat(2, var(--tm-avatar-hero-size, 40px));
|
||||
grid-auto-rows: var(--tm-avatar-hero-size, 40px);
|
||||
gap: 3px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* 单头像时不保留空列,簇宽恰好等于一个头像。 */
|
||||
.avatar-grid.avatar-count-1 {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.avatar-grid.avatar-count-2 {
|
||||
height: 28px;
|
||||
grid-template-columns: var(--tm-avatar-hero-size, 40px);
|
||||
}
|
||||
.avatar-grid.empty-section {
|
||||
display: none;
|
||||
|
||||
@@ -61,21 +61,29 @@
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
/*
|
||||
* hero 头像簇的几何必须由 contract 变量驱动,不能再写死容器尺寸。
|
||||
*
|
||||
* 生产导出会额外注入 `report-template-fragment-contract.ts`,其中
|
||||
* `img.tm-avatar.tm-avatar--hero` 用 !important 把头像钉在
|
||||
* clamp(28px, var(--tm-avatar-hero-size, 40px), 56px)。
|
||||
* 旧版这里写死 58x58(单头像 28x28),两个权威打架:头像实际 40px,
|
||||
* 2 列 x 40px + 3px gap = 83px 塞不进 58px 的盒子,于是头像向右向下溢出容器,
|
||||
* 视觉上越过卡片内边距、压到卡片边缘之外。
|
||||
*
|
||||
* 现在容器尺寸由内容决定(列宽/行高都取同一个变量):头像数 1..4 都不会溢出,
|
||||
* 主题调整 --tm-avatar-hero-size 时容器与头像也不会分叉。
|
||||
*/
|
||||
.avatar-grid {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: repeat(2, var(--tm-avatar-hero-size, 40px));
|
||||
grid-auto-rows: var(--tm-avatar-hero-size, 40px);
|
||||
gap: 3px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* 单头像时不保留空列,簇宽恰好等于一个头像。 */
|
||||
.avatar-grid.avatar-count-1 {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.avatar-grid.avatar-count-2 {
|
||||
height: 28px;
|
||||
grid-template-columns: var(--tm-avatar-hero-size, 40px);
|
||||
}
|
||||
.avatar-grid.empty-section {
|
||||
display: none;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
||||
f1bbd88584075c0e6d3487357a06424949de26d7e6dcad803d210aab83b0cbda xkey_helper_4_1_13
|
||||
a35d3fa67387e049cc30bc073f9e65b077aa1db9c5c2b183125dec695fa013b9 xkey_helper_4_1_13
|
||||
|
||||
+130
-6
@@ -16,6 +16,15 @@ const REQUIRED_RUNTIME_PACKAGES = [
|
||||
'koffi'
|
||||
]
|
||||
|
||||
// electron-builder 26 skips macOS signing entirely when no Developer ID
|
||||
// identity is configured, so an unpacked bundle can ship without a usable
|
||||
// signature. macOS kills a helper whose code or signature is missing or
|
||||
// modified even when SIP is disabled, which is what customers hit on newer
|
||||
// macOS releases. Ad-hoc re-sign the runtime helpers and the outer bundle so
|
||||
// every Mach-O verifies strictly; spctl still rejects ad-hoc code, which is
|
||||
// acceptable for the SIP-disabled customer workflow.
|
||||
const MACOS_HELPER_NAMES = ['xkey_helper', 'xkey_helper_4_1_13']
|
||||
|
||||
function getRuntimeResources(context) {
|
||||
const productName = context.packager.appInfo.productFilename
|
||||
return context.electronPlatformName === 'darwin'
|
||||
@@ -78,11 +87,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) {
|
||||
if (typeof arch === 'string') return arch
|
||||
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
|
||||
}
|
||||
|
||||
function runCodesign(args) {
|
||||
execFileSync('/usr/bin/codesign', args, { stdio: 'ignore' })
|
||||
}
|
||||
|
||||
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
|
||||
* 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
|
||||
* are dead weight — drop them.
|
||||
*/
|
||||
// 每个条目返回 platform package 的**完整后缀**(不含 package 前缀与连字符)。
|
||||
const NATIVE_RUNTIME_PACKAGES = [
|
||||
{
|
||||
modules: [],
|
||||
prefix: 'sherpa-onnx',
|
||||
platformName: (platform) => (platform === 'win32' ? 'win' : platform)
|
||||
platformName: (platform, arch) => `${platform === 'win32' ? 'win' : platform}-${arch}`
|
||||
},
|
||||
{
|
||||
modules: ['@koromix'],
|
||||
prefix: 'koffi',
|
||||
platformName: (platform) => platform
|
||||
platformName: (platform, arch) => `${platform}-${arch}`
|
||||
},
|
||||
{
|
||||
// @napi-rs 的 platform package 目录名带 -msvc 后缀(win32-x64-msvc)。
|
||||
modules: ['@napi-rs'],
|
||||
prefix: 'system-ocr',
|
||||
platformName: (platform, arch) => systemOcrTarget(platform, arch),
|
||||
foreignPattern: /^system-ocr-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)(-msvc)?$/
|
||||
}
|
||||
]
|
||||
|
||||
@@ -173,12 +285,16 @@ function pruneForeignArchNativeRuntimes(runtimeResources, platform, arch) {
|
||||
for (const runtime of NATIVE_RUNTIME_PACKAGES) {
|
||||
const modulesRoot = path.join(unpackedRoot, ...runtime.modules)
|
||||
if (!existsSync(modulesRoot)) continue
|
||||
const expected = `${runtime.prefix}-${runtime.platformName(platform)}-${arch}`
|
||||
const foreign = new RegExp(`^${runtime.prefix}-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)$`)
|
||||
const expected = `${runtime.prefix}-${runtime.platformName(platform, arch)}`
|
||||
const foreign =
|
||||
runtime.foreignPattern ||
|
||||
new RegExp(`^${runtime.prefix}-[a-z0-9]+-(arm64|x64|ia32|loong64|riscv64)$`)
|
||||
for (const entry of readdirSync(modulesRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name === expected || !foreign.test(entry.name)) continue
|
||||
rmSync(path.join(modulesRoot, entry.name), { recursive: true, force: true })
|
||||
removed.push(runtime.modules.length ? `${runtime.modules.join('/')}/${entry.name}` : entry.name)
|
||||
removed.push(
|
||||
runtime.modules.length ? `${runtime.modules.join('/')}/${entry.name}` : entry.name
|
||||
)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
@@ -223,6 +339,7 @@ exports.default = async function afterPack(context) {
|
||||
'Bundled ffmpeg'
|
||||
)
|
||||
validateSherpaRuntime(runtimeResources, context.electronPlatformName, arch)
|
||||
validateSystemOcrRuntime(runtimeResources, context.electronPlatformName, arch)
|
||||
pruneIntelMacKeyTool(runtimeResources, context.electronPlatformName, arch)
|
||||
pruneForeignArchConnectors(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], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
signMacosHelpers(runtimeResources)
|
||||
const productName = context.packager.appInfo.productFilename
|
||||
signMacosAppBundle(path.join(context.appOutDir, productName + '.app'))
|
||||
}
|
||||
|
||||
if (context.electronPlatformName === 'win32') {
|
||||
@@ -249,7 +369,6 @@ exports.default = async function afterPack(context) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
exports.getRuntimeResources = getRuntimeResources
|
||||
@@ -258,7 +377,12 @@ exports.validateReaderSkillRuntime = validateReaderSkillRuntime
|
||||
exports.validateFfmpegRuntime = validateFfmpegRuntime
|
||||
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
|
||||
exports.validateSherpaRuntime = validateSherpaRuntime
|
||||
exports.validateSystemOcrRuntime = validateSystemOcrRuntime
|
||||
exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool
|
||||
exports.pruneForeignArchConnectors = pruneForeignArchConnectors
|
||||
exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes
|
||||
exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture
|
||||
exports.findMacosHelperPaths = findMacosHelperPaths
|
||||
exports.isMacosCodeValid = isMacosCodeValid
|
||||
exports.signMacosHelpers = signMacosHelpers
|
||||
exports.signMacosAppBundle = signMacosAppBundle
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const sourceDir = path.join(projectRoot, 'services', 'wechat-connector')
|
||||
const outputRoot = path.join(projectRoot, 'resources', 'connectors', 'wechat')
|
||||
|
||||
function normalizePlatform(value) {
|
||||
if (value === 'win32' || value === 'windows') return 'windows'
|
||||
if (value === 'darwin' || value === 'macos') return 'darwin'
|
||||
if (value === 'linux') return 'linux'
|
||||
throw new Error(`Unsupported connector platform: ${value}`)
|
||||
}
|
||||
|
||||
function normalizeArch(value) {
|
||||
if (value === 'x64' || value === 'amd64') return 'amd64'
|
||||
if (value === 'arm64') return 'arm64'
|
||||
throw new Error(`Unsupported connector architecture: ${value}`)
|
||||
}
|
||||
|
||||
function detectHostArch() {
|
||||
if (process.platform !== 'darwin') return process.arch
|
||||
try {
|
||||
const arm64Supported = execFileSync('sysctl', ['-n', 'hw.optional.arm64'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
return arm64Supported === '1' ? 'arm64' : process.arch
|
||||
} catch {
|
||||
return process.arch
|
||||
}
|
||||
}
|
||||
|
||||
function parseTargets() {
|
||||
const platformArg = process.argv.indexOf('--platform')
|
||||
const archArg = process.argv.indexOf('--arch')
|
||||
const platforms = platformArg >= 0 ? process.argv[platformArg + 1].split(',') : [process.platform]
|
||||
const arches = archArg >= 0 ? process.argv[archArg + 1].split(',') : [detectHostArch()]
|
||||
return platforms.flatMap((platform) =>
|
||||
arches.map((arch) => ({ goos: normalizePlatform(platform), goarch: normalizeArch(arch) }))
|
||||
)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(sourceDir, 'go.mod'))) {
|
||||
throw new Error(`Repository-local WeChat connector source is missing: ${sourceDir}`)
|
||||
}
|
||||
|
||||
for (const target of parseTargets()) {
|
||||
const directoryName = `${target.goos === 'windows' ? 'win32' : target.goos}-${target.goarch === 'amd64' ? 'x64' : target.goarch}`
|
||||
const outputDir = path.join(outputRoot, directoryName)
|
||||
const outputPath = path.join(
|
||||
outputDir,
|
||||
target.goos === 'windows' ? 'wechat-connector.exe' : 'wechat-connector'
|
||||
)
|
||||
fs.rmSync(outputDir, { recursive: true, force: true })
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
execFileSync('go', ['build', '-trimpath', '-o', outputPath, '.'], {
|
||||
cwd: sourceDir,
|
||||
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' },
|
||||
stdio: 'inherit'
|
||||
})
|
||||
if (target.goos !== 'windows') fs.chmodSync(outputPath, 0o755)
|
||||
console.log(`[build-wechat-connector] built ${directoryName}: ${outputPath}`)
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
/*
|
||||
* wechat_chatter runtime integration
|
||||
*
|
||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||
* Runtime version: v0.0.18
|
||||
* Upstream license: GNU General Public License version 3 (GPL-3.0)
|
||||
*
|
||||
* This file applies local compatibility patches to the upstream
|
||||
* onebot/script.js. See docs/third-party/wechat-chatter/NOTICE.md.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const release = 'v0.0.18'
|
||||
const asset = 'onebot_mac_arm64.tar.gz'
|
||||
const url = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${release}/${asset}`
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const outputDir = path.join(
|
||||
projectRoot,
|
||||
'resources',
|
||||
'connectors',
|
||||
'wechat-personal',
|
||||
'darwin-arm64'
|
||||
)
|
||||
const archive = path.join(os.tmpdir(), `wechat-chatter-${release}-${asset}`)
|
||||
const appleSilicon =
|
||||
process.platform === 'darwin' &&
|
||||
execFileSync('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'], { encoding: 'utf8' }).trim() === '1'
|
||||
|
||||
if (!appleSilicon) {
|
||||
throw new Error('个人微信发送运行时当前仅支持 macOS arm64')
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
let archiveReady = false
|
||||
if (fs.existsSync(archive)) {
|
||||
try {
|
||||
execFileSync('/usr/bin/tar', ['-tzf', archive], { stdio: 'ignore' })
|
||||
archiveReady = true
|
||||
console.log(`[wechat-personal] 复用已下载归档:${archive}`)
|
||||
} catch {
|
||||
// The archive is partial or invalid; curl will resume it below.
|
||||
}
|
||||
}
|
||||
if (!archiveReady) {
|
||||
console.log(`[wechat-personal] 下载 ${release},支持断点续传:${archive}`)
|
||||
execFileSync(
|
||||
'/usr/bin/curl',
|
||||
['--http1.1', '-L', '--fail', '--retry', '3', '--continue-at', '-', '--output', archive, url],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
}
|
||||
|
||||
const extractionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-chatter-extract-'))
|
||||
console.log(`[wechat-personal] 解压并原子安装到 ${outputDir}`)
|
||||
try {
|
||||
execFileSync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDir], { stdio: 'inherit' })
|
||||
fs.mkdirSync(path.join(outputDir, 'onebot'), { recursive: true })
|
||||
fs.mkdirSync(path.join(outputDir, 'wechat_version'), { recursive: true })
|
||||
fs.copyFileSync(
|
||||
path.join(extractionDir, 'onebot', 'script.js'),
|
||||
path.join(outputDir, 'onebot', 'script.js')
|
||||
)
|
||||
fs.cpSync(path.join(extractionDir, 'wechat_version'), path.join(outputDir, 'wechat_version'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
const stagedExecutable = path.join(outputDir, 'onebot', `.onebot-${process.pid}.tmp`)
|
||||
fs.copyFileSync(path.join(extractionDir, 'onebot', 'onebot'), stagedExecutable)
|
||||
fs.chmodSync(stagedExecutable, 0o755)
|
||||
fs.renameSync(stagedExecutable, path.join(outputDir, 'onebot', 'onebot'))
|
||||
} finally {
|
||||
fs.rmSync(extractionDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const executable = path.join(outputDir, 'onebot', 'onebot')
|
||||
const script = path.join(outputDir, 'onebot', 'script.js')
|
||||
const config = path.join(outputDir, 'wechat_version', '4_1_11_53_mac.json')
|
||||
for (const required of [executable, script, config]) {
|
||||
if (!fs.existsSync(required)) throw new Error(`运行时文件缺失:${required}`)
|
||||
}
|
||||
|
||||
function patchPerSendPayload(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return
|
||||
|
||||
const activeSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||
activeTriggerX1Payload = Memory.alloc(payloadData.length);
|
||||
activeTriggerX1Payload.writeByteArray(payloadData);
|
||||
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
|
||||
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
|
||||
const upstreamSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||
triggerX1Payload.writeByteArray(payloadData);
|
||||
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
|
||||
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
|
||||
if (!source.includes(activeSend)) throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
|
||||
source = source
|
||||
.replace(
|
||||
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;',
|
||||
'var triggerX1Payload;\nvar triggerX0;'
|
||||
)
|
||||
.replace(activeSend, upstreamSend)
|
||||
.replace(
|
||||
' MMStartTask(triggerX0, activeTriggerX1Payload);',
|
||||
' MMStartTask(triggerX0, triggerX1Payload);'
|
||||
)
|
||||
.replace(
|
||||
' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
|
||||
' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
|
||||
)
|
||||
.replace(
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn',
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn'
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已恢复原生发送 payload 布局')
|
||||
}
|
||||
|
||||
function patchSendContextCapture(scriptPath, strict = true) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('function isLikelySendContext(')) return
|
||||
const original = `function AttachSendFunc() {
|
||||
Interceptor.attach(sendFuncAddr.add(0x10), {
|
||||
onEnter: function (args) {
|
||||
|
||||
if (triggerX1Payload) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerX0 = this.context.x0;
|
||||
triggerX1Payload = this.context.x1;
|
||||
console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
||||
}
|
||||
})
|
||||
}`
|
||||
const patched = `function isLikelySendContext(candidateX0, candidateX1) {
|
||||
try {
|
||||
if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false;
|
||||
var manager = readPointerIfReadable(candidateX0.add(0x18));
|
||||
var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18)));
|
||||
console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi);
|
||||
return !manager.equals(ptr(0));
|
||||
} catch (e) {
|
||||
console.error("[debug] StartTask candidate inspect failed: " + e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function AttachSendFunc() {
|
||||
Interceptor.attach(sendFuncAddr.add(0x10), {
|
||||
onEnter: function (args) {
|
||||
if (triggerX1Payload) return;
|
||||
var candidateX0 = this.context.x0;
|
||||
var candidateX1 = this.context.x1;
|
||||
if (!isLikelySendContext(candidateX0, candidateX1)) return;
|
||||
triggerX0 = candidateX0;
|
||||
triggerX1Payload = candidateX1;
|
||||
console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
||||
}
|
||||
})
|
||||
}`
|
||||
if (!source.includes(original)) {
|
||||
if (strict) throw new Error('无法定位 StartTask 上下文 Hook')
|
||||
return
|
||||
}
|
||||
source = source.replace(original, patched)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
function patchVoiceAudioBuffer(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
|
||||
|
||||
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
|
||||
if (!source.includes(staticAllocation)) {
|
||||
throw new Error('无法定位 wechat_chatter 语音缓冲区')
|
||||
}
|
||||
source = source.replace(
|
||||
staticAllocation,
|
||||
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
|
||||
)
|
||||
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
|
||||
if (!source.includes(audioLengthMarker)) {
|
||||
throw new Error('无法定位 wechat_chatter 语音上传逻辑')
|
||||
}
|
||||
source = source.replace(
|
||||
audioLengthMarker,
|
||||
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用按语音长度分配上传缓冲区补丁')
|
||||
}
|
||||
|
||||
function patchImageHookReadiness(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (
|
||||
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
|
||||
source.includes('图片上传 Hook Setup Complete')
|
||||
)
|
||||
return
|
||||
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
|
||||
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
|
||||
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
|
||||
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
|
||||
\t\t\t}
|
||||
\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||
if (!source.includes(original)) throw new Error('无法定位 wechat_chatter 图片 Hook 状态补丁位置')
|
||||
source = source.replace(original, patched)
|
||||
source = source.replace(
|
||||
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
|
||||
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用图片 Hook 状态补丁')
|
||||
}
|
||||
|
||||
// Backport of wechat_chatter PR #36 by @Leslielu:
|
||||
// https://github.com/yincongcyincong/wechat_chatter/pull/36
|
||||
// TraceMemo adds the verified macOS WeChat 4.1.11.53 addresses.
|
||||
// macOS WeChat 4.1.11.53, located and verified by TraceMemo
|
||||
function patchCdnColdStart(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('function resolveCdnManager()')) return
|
||||
|
||||
const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
||||
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});`
|
||||
const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
||||
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});
|
||||
// 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为)
|
||||
{{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}}
|
||||
{{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}`
|
||||
if (!source.includes(initAddresses)) throw new Error('下载的微信版本配置与当前应用不兼容')
|
||||
source = source.replace(initAddresses, patchedInitAddresses)
|
||||
|
||||
const downloadChunkEnd = `}
|
||||
|
||||
function fillUploadX1AndStart`
|
||||
const resolver = `}
|
||||
|
||||
// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器
|
||||
// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。
|
||||
function resolveCdnManager() {
|
||||
if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) {
|
||||
return ptr(0);
|
||||
}
|
||||
try {
|
||||
// libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。
|
||||
var strDefault = Memory.alloc(24);
|
||||
strDefault.writeUtf8String("default");
|
||||
strDefault.add(0x17).writeU8(7);
|
||||
|
||||
var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']);
|
||||
var svc = getService(strDefault);
|
||||
if (!isReadablePointer(svc)) {
|
||||
console.error("[!] GetService(\\"default\\") 返回不可读: " + svc);
|
||||
return ptr(0);
|
||||
}
|
||||
var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']);
|
||||
var ctx = getCtx(svc);
|
||||
if (!isReadablePointer(ctx)) {
|
||||
console.error("[!] CdnManager getter 返回不可读: " + ctx);
|
||||
return ptr(0);
|
||||
}
|
||||
var mgr = readPointerIfReadable(ctx.add(0x40));
|
||||
if (!isReadablePointer(mgr)) {
|
||||
console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx);
|
||||
return ptr(0);
|
||||
}
|
||||
return mgr;
|
||||
} catch (e) {
|
||||
console.error("[!] resolveCdnManager 异常: " + e);
|
||||
return ptr(0);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCdnManagerX0() {
|
||||
if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) {
|
||||
uploadGlobalX0 = downloadGlobalX0;
|
||||
console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0);
|
||||
}
|
||||
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) {
|
||||
downloadGlobalX0 = uploadGlobalX0;
|
||||
console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0);
|
||||
}
|
||||
if (uploadGlobalX0.equals(ptr(0))) {
|
||||
var mgr = resolveCdnManager();
|
||||
if (!mgr.equals(ptr(0))) {
|
||||
uploadGlobalX0 = mgr;
|
||||
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
||||
downloadGlobalX0 = mgr;
|
||||
}
|
||||
console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr);
|
||||
}
|
||||
}
|
||||
return !uploadGlobalX0.equals(ptr(0));
|
||||
}
|
||||
|
||||
function fillUploadX1AndStart`
|
||||
if (!source.includes(downloadChunkEnd)) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
|
||||
source = source.replace(downloadChunkEnd, resolver)
|
||||
|
||||
const declarations = 'var uploadImageAddr;\n'
|
||||
const patchedDeclarations =
|
||||
'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n'
|
||||
if (!source.includes(declarations)) throw new Error('无法定位 wechat_chatter 媒体地址声明')
|
||||
source = source.replace(declarations, patchedDeclarations)
|
||||
|
||||
const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {
|
||||
ensureCdnManagerX0();
|
||||
}
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
if (!source.includes(uploadGuard)) throw new Error('无法定位 wechat_chatter 媒体上传入口')
|
||||
source = source.replace(uploadGuard, patchedUploadGuard)
|
||||
|
||||
const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {
|
||||
ensureCdnManagerX0();
|
||||
}
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
if (!source.includes(voiceGuard)) throw new Error('无法定位 wechat_chatter 语音上传入口')
|
||||
source = source.replace(voiceGuard, patchedVoiceGuard)
|
||||
|
||||
const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||
const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;
|
||||
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) {
|
||||
downloadGlobalX0 = capturedUploadX0;
|
||||
console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0);
|
||||
}`
|
||||
if (!source.includes(uploadHook)) throw new Error('无法定位 wechat_chatter 图片 Hook')
|
||||
source = source.replace(uploadHook, patchedUploadHook)
|
||||
|
||||
const downloadHook = ` downloadGlobalX0 = this.context.x0;`
|
||||
const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0;
|
||||
if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) {
|
||||
uploadGlobalX0 = downloadGlobalX0;
|
||||
console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0);
|
||||
}`
|
||||
if (!source.includes(downloadHook)) throw new Error('无法定位 wechat_chatter 下载 Hook')
|
||||
source = source.replace(downloadHook, patchedDownloadHook)
|
||||
|
||||
const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
||||
if (!downloadGlobalX0) {`
|
||||
const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
||||
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
||||
ensureCdnManagerX0();
|
||||
}
|
||||
if (!downloadGlobalX0) {`
|
||||
if (!source.includes(downloadGuard)) throw new Error('无法定位 wechat_chatter 媒体下载入口')
|
||||
source = source.replace(downloadGuard, patchedDownloadGuard)
|
||||
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用 CdnManager 冷启动解析补丁')
|
||||
}
|
||||
|
||||
function patchCdnColdStartConfig(configPath) {
|
||||
const source = fs.readFileSync(configPath, 'utf8')
|
||||
let config
|
||||
try {
|
||||
config = JSON.parse(source)
|
||||
} catch {
|
||||
throw new Error('4.1.11.53 版本配置不是有效 JSON')
|
||||
}
|
||||
config.cdnGetServiceAddr = '0x50a15d0'
|
||||
config.cdnManagerGetterAddr = '0x5259290'
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
console.log('[wechat-personal] 已写入 4.1.11.53 CdnManager 地址')
|
||||
}
|
||||
|
||||
function patchWechatCoreModuleBase(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('WeChat core module base:')) return
|
||||
const initMarker = 'function initAddresses() {'
|
||||
const initIndex = source.indexOf(initMarker)
|
||||
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
|
||||
throw new Error('无法定位 wechat_chatter 基址扫描逻辑')
|
||||
}
|
||||
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
|
||||
var module = Process.enumerateModules().find(function(m) {
|
||||
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
|
||||
});
|
||||
if (!module) {
|
||||
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
|
||||
}
|
||||
var moduleBase = module.base;
|
||||
var baseAddr = moduleBase;
|
||||
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
|
||||
setImmediate(initAddresses);
|
||||
|
||||
`
|
||||
source = patchedHeader + source.slice(initIndex)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用微信核心模块基址补丁')
|
||||
}
|
||||
|
||||
function addModifiedWorkNotice(scriptPath) {
|
||||
let source = fs.readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
|
||||
|
||||
const notice = `/*
|
||||
* TraceMemo wechat_chatter compatibility modifications
|
||||
* Modified: 2026-08-17
|
||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||
* Runtime version: v0.0.18
|
||||
* License: GNU General Public License version 3 (GPL-3.0)
|
||||
* Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers,
|
||||
* CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging.
|
||||
* These modifications are not provided by the upstream author.
|
||||
*/
|
||||
|
||||
`
|
||||
source = notice + source
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已写入 GPL 修改声明')
|
||||
}
|
||||
|
||||
patchWechatCoreModuleBase(script)
|
||||
patchPerSendPayload(script)
|
||||
patchSendContextCapture(script)
|
||||
patchVoiceAudioBuffer(script)
|
||||
patchImageHookReadiness(script)
|
||||
patchCdnColdStartConfig(config)
|
||||
patchCdnColdStart(script)
|
||||
addModifiedWorkNotice(script)
|
||||
fs.chmodSync(executable, 0o755)
|
||||
console.log('[wechat-personal] 运行时准备完成')
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const REQUIRED_FILES = ['tm-wechat-host', 'libtmwechat.dylib', 'runtime-manifest.json']
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index]
|
||||
if (value === '--source' || value === '--target') {
|
||||
if (!argv[index + 1]) throw new Error(`${value} requires a path`)
|
||||
result[value.slice(2)] = argv[index + 1]
|
||||
index += 1
|
||||
} else {
|
||||
throw new Error(`Unknown option: ${value}`)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function sha256(filePath) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
|
||||
}
|
||||
|
||||
function listFiles(root) {
|
||||
const files = []
|
||||
function visit(directory) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolute = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) visit(absolute)
|
||||
else if (entry.isFile()) files.push(path.relative(root, absolute))
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
function assertMachOArm64(filePath, label) {
|
||||
const buffer = fs.readFileSync(filePath)
|
||||
if (buffer.length < 8 || buffer.readUInt32LE(0) !== 0xfeedfacf) {
|
||||
throw new Error(`${label} is not a 64-bit Mach-O binary: ${filePath}`)
|
||||
}
|
||||
if (buffer.readUInt32LE(4) !== 0x0100000c) {
|
||||
throw new Error(`${label} is not arm64: ${filePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
function readAndValidateArtifact(sourceDir) {
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
throw new Error('native runtime artifact not found: build the macOS runtime artifact first')
|
||||
}
|
||||
|
||||
const actualFiles = listFiles(sourceDir)
|
||||
const expectedFiles = [...REQUIRED_FILES].sort()
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||
throw new Error(`native runtime artifact has an unexpected tree: ${actualFiles.join(', ')}`)
|
||||
}
|
||||
|
||||
const manifestPath = path.join(sourceDir, 'runtime-manifest.json')
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
if (manifest.runtime !== 'tm-wechat-native') throw new Error('Unexpected runtime name')
|
||||
if (manifest.platform !== 'darwin-arm64') throw new Error('Runtime platform must be darwin-arm64')
|
||||
if (manifest.architecture !== 'arm64') throw new Error('Runtime architecture must be arm64')
|
||||
if (manifest.protocolVersion !== 1) throw new Error('Unsupported runtime protocolVersion')
|
||||
if (!/^[a-f0-9]{64}$/.test(manifest.tmSendSourceSha256 || '')) {
|
||||
throw new Error('Runtime manifest has no valid tmSendSourceSha256')
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/.test(manifest.addressProfileSha256 || '')) {
|
||||
throw new Error('Runtime manifest has no valid addressProfileSha256')
|
||||
}
|
||||
const capabilities = new Set(manifest.capabilities || [])
|
||||
for (const capability of ['text', 'image', 'voice']) {
|
||||
if (!capabilities.has(capability)) throw new Error(`Runtime capability missing: ${capability}`)
|
||||
}
|
||||
if (
|
||||
!Array.isArray(manifest.supportedWechatVersions) ||
|
||||
manifest.supportedWechatVersions.length === 0
|
||||
) {
|
||||
throw new Error('Runtime manifest has no supportedWechatVersions')
|
||||
}
|
||||
|
||||
for (const relative of REQUIRED_FILES) {
|
||||
const absolute = path.join(sourceDir, relative)
|
||||
if (!fs.statSync(absolute).isFile()) throw new Error(`Runtime file missing: ${relative}`)
|
||||
}
|
||||
const hostPath = path.join(sourceDir, 'tm-wechat-host')
|
||||
const dylibPath = path.join(sourceDir, 'libtmwechat.dylib')
|
||||
assertMachOArm64(hostPath, 'tm-wechat-host')
|
||||
assertMachOArm64(dylibPath, 'libtmwechat.dylib')
|
||||
if (!fs.readFileSync(dylibPath).includes(Buffer.from(manifest.tmSendSourceSha256, 'utf8'))) {
|
||||
throw new Error('Embedded agent hash is not present in libtmwechat.dylib')
|
||||
}
|
||||
if (!fs.readFileSync(dylibPath).includes(Buffer.from(manifest.addressProfileSha256, 'utf8'))) {
|
||||
throw new Error('Embedded address profile hash is not present in libtmwechat.dylib')
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
hashes: Object.fromEntries(
|
||||
REQUIRED_FILES.map((file) => [file, sha256(path.join(sourceDir, file))])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function syncArtifact(sourceDir, targetDir) {
|
||||
const source = readAndValidateArtifact(sourceDir)
|
||||
const parent = path.dirname(targetDir)
|
||||
const name = path.basename(targetDir)
|
||||
const staging = path.join(parent, `.${name}.prepare-${process.pid}`)
|
||||
const backup = path.join(parent, `.${name}.backup-${process.pid}`)
|
||||
|
||||
fs.mkdirSync(parent, { recursive: true })
|
||||
fs.rmSync(staging, { recursive: true, force: true })
|
||||
fs.rmSync(backup, { recursive: true, force: true })
|
||||
fs.mkdirSync(staging, { recursive: true })
|
||||
|
||||
try {
|
||||
for (const relative of REQUIRED_FILES) {
|
||||
const destination = path.join(staging, relative)
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true })
|
||||
fs.copyFileSync(path.join(sourceDir, relative), destination)
|
||||
}
|
||||
fs.chmodSync(path.join(staging, 'tm-wechat-host'), 0o755)
|
||||
|
||||
const staged = readAndValidateArtifact(staging)
|
||||
for (const relative of REQUIRED_FILES) {
|
||||
if (source.hashes[relative] !== staged.hashes[relative]) {
|
||||
throw new Error(`Packaged runtime hash mismatch: ${relative}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(targetDir)) fs.renameSync(targetDir, backup)
|
||||
try {
|
||||
fs.renameSync(staging, targetDir)
|
||||
} catch (error) {
|
||||
if (fs.existsSync(backup)) fs.renameSync(backup, targetDir)
|
||||
throw error
|
||||
}
|
||||
fs.rmSync(backup, { recursive: true, force: true })
|
||||
return staged
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSource(explicit) {
|
||||
if (explicit) return path.resolve(explicit)
|
||||
const fromEnv = String(process.env.TM_NATIVE_RUNTIME_DIR || '').trim()
|
||||
if (fromEnv) return path.resolve(fromEnv)
|
||||
// 本机路径写在这里即可,该文件不进仓库。
|
||||
const localConfig = path.join(__dirname, '..', '.native-runtime-source')
|
||||
if (fs.existsSync(localConfig)) {
|
||||
const configured = fs.readFileSync(localConfig, 'utf8').trim()
|
||||
if (configured) return path.resolve(configured)
|
||||
}
|
||||
throw new Error(
|
||||
'runtime artifact source is required: pass --source <dir>, set TM_NATIVE_RUNTIME_DIR, ' +
|
||||
'or write the path into .native-runtime-source'
|
||||
)
|
||||
}
|
||||
|
||||
function main() {
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const sourceDir = resolveSource(args.source)
|
||||
const targetDir = path.resolve(
|
||||
args.target || path.join(projectRoot, 'resources', 'runtime', 'darwin-arm64')
|
||||
)
|
||||
const result = syncArtifact(sourceDir, targetDir)
|
||||
console.log(`[prepare-wechat-native] source: ${sourceDir}`)
|
||||
console.log(`[prepare-wechat-native] target: ${targetDir}`)
|
||||
console.log(
|
||||
`[prepare-wechat-native] runtime: ${result.manifest.runtime}/${result.manifest.version}`
|
||||
)
|
||||
console.log(`[prepare-wechat-native] agent: ${result.manifest.tmSendSourceSha256}`)
|
||||
console.log(`[prepare-wechat-native] address profile: ${result.manifest.addressProfileSha256}`)
|
||||
console.log('[prepare-wechat-native] files: 3')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REQUIRED_FILES,
|
||||
assertMachOArm64,
|
||||
readAndValidateArtifact,
|
||||
syncArtifact
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[prepare-wechat-native] ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,17 @@ const avatarSvg = (label, color) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="${color}"/><text x="48" y="58" text-anchor="middle" font-family="PingFang SC, sans-serif" font-size="36" fill="#0f172a">${label}</text></svg>`
|
||||
).toString('base64')}`
|
||||
|
||||
const localImagePath = '/Users/user/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/fixture_account_1a2b/temp/RWTemp/2026-07/fixture-image-hash.png'
|
||||
const sampleImage = fs.existsSync(localImagePath)
|
||||
? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
|
||||
: avatarSvg('图', '#dbeafe')
|
||||
/**
|
||||
* 可选的本地样例图(用于人工核对图片区块的排版)。
|
||||
*
|
||||
* 走环境变量传入,**不要在源码里写本机路径** —— 微信数据目录会连带暴露
|
||||
* 系统用户名与账号目录名。不传就退回内置的 SVG 头像占位。
|
||||
*/
|
||||
const localImagePath = process.env.REPORT_FIXTURE_IMAGE || ''
|
||||
const sampleImage =
|
||||
localImagePath && fs.existsSync(localImagePath)
|
||||
? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
|
||||
: avatarSvg('图', '#dbeafe')
|
||||
|
||||
const avatars = {
|
||||
阿宇: avatarSvg('宇', '#dcfce7'),
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 测试文件的类型检查棘轮(ratchet)。
|
||||
*
|
||||
* 背景:`tsconfig.node.json` / `tsconfig.web.json` 的 include 都不含 `tests/`,
|
||||
* 所以测试里的类型错误对 `pnpm typecheck` 与 CI 完全不可见——已经积累了一批历史债。
|
||||
*
|
||||
* 策略:**不阻塞既有债,但禁止新增**。
|
||||
* - 基线按「文件 → 错误数」记录,而不是只记总数:
|
||||
* 否则在 A 文件修掉 1 条、同时在 B 文件新增 1 条会互相抵消,棘轮形同虚设。
|
||||
* - 某个文件的错误数超过基线即失败;新增了带类型错误的文件同样失败。
|
||||
* - 需要主动下调基线时用 `--update`(只在确实修好了错误之后)。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/typecheck-tests.cjs # 校验
|
||||
* node scripts/typecheck-tests.cjs --update # 用当前结果重写基线
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const configPath = path.join(projectRoot, 'tsconfig.test.json')
|
||||
const baselinePath = path.join(projectRoot, 'tests', 'typecheck-baseline.json')
|
||||
const ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/
|
||||
const MAX_REPORTED = 20
|
||||
|
||||
function runTypeScript() {
|
||||
// 直接用本地 typescript 包,避免依赖 node_modules/.bin 在各平台的差异。
|
||||
const tscPath = require.resolve('typescript/bin/tsc')
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[tscPath, '--noEmit', '--pretty', 'false', '-p', configPath],
|
||||
{ cwd: projectRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
|
||||
)
|
||||
if (result.error) throw result.error
|
||||
return `${result.stdout || ''}${result.stderr || ''}`
|
||||
}
|
||||
|
||||
function relative(file) {
|
||||
const rel = path.relative(projectRoot, path.resolve(projectRoot, file))
|
||||
return rel.split(path.sep).join('/')
|
||||
}
|
||||
|
||||
/** 解析出「文件 → 错误数」与「文件 → 错误信息列表」。 */
|
||||
function collectErrors(output) {
|
||||
const counts = new Map()
|
||||
const details = new Map()
|
||||
for (const line of output.split('\n')) {
|
||||
const match = ERROR_LINE.exec(line.trim())
|
||||
if (!match) continue
|
||||
const file = relative(match[1])
|
||||
counts.set(file, (counts.get(file) ?? 0) + 1)
|
||||
if (!details.has(file)) details.set(file, [])
|
||||
if (details.get(file).length < 3) details.get(file).push(`${match[2]}:${match[3]} ${match[4]}`)
|
||||
}
|
||||
return { counts, details }
|
||||
}
|
||||
|
||||
function readBaseline() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8'))
|
||||
if (parsed && typeof parsed.files === 'object' && parsed.files !== null) return parsed
|
||||
} catch {
|
||||
// 基线缺失或损坏时按「空基线」处理,会在下面明确报错提示。
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function total(counts) {
|
||||
let sum = 0
|
||||
for (const value of counts.values()) sum += value
|
||||
return sum
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
console.error(`[typecheck:test] 缺少 ${path.relative(projectRoot, configPath)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const { counts, details } = collectErrors(runTypeScript())
|
||||
|
||||
if (process.argv.includes('--update')) {
|
||||
const files = Object.fromEntries([...counts.entries()].sort(([a], [b]) => a.localeCompare(b)))
|
||||
const payload = {
|
||||
note: '测试文件类型检查基线:只允许下降,不允许上升。用 node scripts/typecheck-tests.cjs --update 下调。',
|
||||
total: total(counts),
|
||||
files
|
||||
}
|
||||
fs.mkdirSync(path.dirname(baselinePath), { recursive: true })
|
||||
fs.writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
||||
console.log(
|
||||
`[typecheck:test] 基线已更新:${payload.total} 个错误 / ${Object.keys(files).length} 个文件`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const baseline = readBaseline()
|
||||
if (!baseline) {
|
||||
console.error(
|
||||
`[typecheck:test] 找不到基线 ${path.relative(projectRoot, baselinePath)}。\n` +
|
||||
' 首次启用请运行:node scripts/typecheck-tests.cjs --update'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const regressions = []
|
||||
for (const [file, count] of counts) {
|
||||
const allowed = baseline.files[file] ?? 0
|
||||
if (count > allowed) regressions.push({ file, count, allowed })
|
||||
}
|
||||
|
||||
const now = total(counts)
|
||||
const baselineTotal = Number(baseline.total) || 0
|
||||
const improved = baselineTotal - now
|
||||
|
||||
if (regressions.length > 0) {
|
||||
console.error('[typecheck:test] 测试文件出现新的类型错误 ❌')
|
||||
console.error(` 基线 ${baselineTotal} → 当前 ${now}(+${now - baselineTotal})\n`)
|
||||
let printed = 0
|
||||
for (const item of regressions) {
|
||||
console.error(` ${item.file} ${item.allowed} → ${item.count}`)
|
||||
for (const line of details.get(item.file) ?? []) {
|
||||
if (printed >= MAX_REPORTED) break
|
||||
console.error(` ${line}`)
|
||||
printed += 1
|
||||
}
|
||||
}
|
||||
console.error('\n 修好之后用 --update 下调基线(不要为了过检查而放宽它)。')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[typecheck:test] PASS ✅ 当前 ${now} 个既有类型错误 / ${counts.size} 个文件` +
|
||||
(improved > 0 ? `(比基线少 ${improved} 个,可运行 --update 下调)` : '')
|
||||
)
|
||||
console.log(' 注意:这是棘轮,只保证「不新增」。修完历史债后可改为阻断式检查。')
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 fastclaw-ai
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,25 +0,0 @@
|
||||
# TraceMemo WeChat Connector
|
||||
|
||||
This repository-local service provides the minimal WeChat bridge required by TraceMemo:
|
||||
|
||||
- QR-code login with a single persisted credential
|
||||
- account discovery
|
||||
- inbound long polling and authenticated webhook delivery
|
||||
- local HTTP health and send endpoints
|
||||
- text and local/remote media sending
|
||||
|
||||
The executable is managed by the Electron main process. It is not a general-purpose agent runtime and does not load external AI command-line tools.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
go run . login --json
|
||||
go run . accounts --json
|
||||
go run . start --foreground --api-addr 127.0.0.1:18011 --account-id <account-id>
|
||||
```
|
||||
|
||||
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. This legacy directory name is intentionally retained so upgrades can reuse existing accounts. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
|
||||
|
||||
## Attribution
|
||||
|
||||
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). TraceMemo-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding TraceMemo project.
|
||||
@@ -1,135 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
|
||||
)
|
||||
|
||||
// Server provides an HTTP API for sending messages.
|
||||
type Server struct {
|
||||
clients []*ilink.Client
|
||||
addr string
|
||||
}
|
||||
|
||||
// NewServer creates an API server.
|
||||
func NewServer(clients []*ilink.Client, addr string) *Server {
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:18011"
|
||||
}
|
||||
return &Server{clients: clients, addr: addr}
|
||||
}
|
||||
|
||||
// SendRequest is the JSON body for POST /api/send.
|
||||
type SendRequest struct {
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
To string `json:"to"`
|
||||
Text string `json:"text,omitempty"`
|
||||
MediaURL string `json:"media_url,omitempty"` // image/video/file URL
|
||||
}
|
||||
|
||||
// Run starts the HTTP server. Blocks until ctx is cancelled.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/send", s.handleSend)
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintln(w, "ok")
|
||||
})
|
||||
|
||||
srv := &http.Server{Addr: s.addr, Handler: mux}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
srv.Shutdown(context.Background())
|
||||
}()
|
||||
|
||||
log.Printf("[api] listening on %s", s.addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req SendRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.To == "" {
|
||||
http.Error(w, `"to" is required`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Text == "" && req.MediaURL == "" {
|
||||
http.Error(w, `"text" or "media_url" is required`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(s.clients) == 0 {
|
||||
http.Error(w, "no accounts configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
client := s.clientForAccount(req.AccountID)
|
||||
if client == nil {
|
||||
http.Error(w, "requested account is not available", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
// Send text if provided
|
||||
if req.Text != "" {
|
||||
if err := messaging.SendTextReply(ctx, client, req.To, req.Text, "", ""); err != nil {
|
||||
log.Printf("[api] send text failed: %v", err)
|
||||
http.Error(w, "send text failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("[api] sent text to %s: %q", req.To, req.Text)
|
||||
|
||||
// Extract and send any markdown images embedded in text
|
||||
for _, imgURL := range messaging.ExtractImageURLs(req.Text) {
|
||||
if err := messaging.SendMediaFromURL(ctx, client, req.To, imgURL, ""); err != nil {
|
||||
log.Printf("[api] send extracted image failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[api] sent extracted image to %s: %s", req.To, imgURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send media if provided
|
||||
if req.MediaURL != "" {
|
||||
if err := messaging.SendMediaFromURL(ctx, client, req.To, req.MediaURL, ""); err != nil {
|
||||
log.Printf("[api] send media failed: %v", err)
|
||||
http.Error(w, "send media failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("[api] sent media to %s: %s", req.To, req.MediaURL)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) clientForAccount(accountID string) *ilink.Client {
|
||||
if accountID == "" {
|
||||
return s.clients[0]
|
||||
}
|
||||
for _, client := range s.clients {
|
||||
if client.BotID() == accountID {
|
||||
return client
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
)
|
||||
|
||||
func TestClientForAccountSelectsMatchingBot(t *testing.T) {
|
||||
oldClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-old"})
|
||||
newClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-new"})
|
||||
server := NewServer([]*ilink.Client{oldClient, newClient}, "")
|
||||
|
||||
if got := server.clientForAccount("bot-new"); got != newClient {
|
||||
t.Fatal("clientForAccount did not select the requested account")
|
||||
}
|
||||
if got := server.clientForAccount("missing"); got != nil {
|
||||
t.Fatal("clientForAccount should reject an unknown account")
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
module github.com/Wxw-Gu/WechatExplorer/services/wechat-connector
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
@@ -1,236 +0,0 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
qrCodeURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3"
|
||||
qrStatusURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode="
|
||||
statusWait = "wait"
|
||||
statusScanned = "scaned"
|
||||
statusConfirmed = "confirmed"
|
||||
statusExpired = "expired"
|
||||
)
|
||||
|
||||
// FetchQRCode retrieves a new QR code for login.
|
||||
func FetchQRCode(ctx context.Context) (*QRCodeResponse, error) {
|
||||
c := NewUnauthenticatedClient()
|
||||
var resp QRCodeResponse
|
||||
if err := c.doGet(ctx, qrCodeURL, &resp); err != nil {
|
||||
return nil, fmt.Errorf("fetch QR code: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// PollQRStatus polls for QR code scan status until confirmed or expired.
|
||||
// It calls onStatus for each status change so the caller can display progress.
|
||||
func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status string)) (*Credentials, error) {
|
||||
c := NewUnauthenticatedClient()
|
||||
url := qrStatusURL + qrcode
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
pollCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
|
||||
var resp QRStatusResponse
|
||||
err := c.doGet(pollCtx, url, &resp)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
// Timeout is normal for long-poll, retry
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if onStatus != nil {
|
||||
onStatus(resp.Status)
|
||||
}
|
||||
|
||||
switch resp.Status {
|
||||
case statusConfirmed:
|
||||
creds := &Credentials{
|
||||
BotToken: resp.BotToken,
|
||||
ILinkBotID: resp.ILinkBotID,
|
||||
BaseURL: resp.BaseURL,
|
||||
ILinkUserID: resp.ILinkUserID,
|
||||
}
|
||||
return creds, nil
|
||||
case statusExpired:
|
||||
return nil, fmt.Errorf("QR code expired")
|
||||
case statusWait, statusScanned:
|
||||
// Continue polling
|
||||
default:
|
||||
// Unknown status, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func accountsDir(rootName string) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, rootName, "wechat-connector", "accounts"), nil
|
||||
}
|
||||
|
||||
// AccountsDir returns the TraceMemo directory where new credentials are stored.
|
||||
func AccountsDir() (string, error) {
|
||||
return accountsDir(".tracememo")
|
||||
}
|
||||
|
||||
// LegacyAccountsDir is read-only compatibility for v2.1.9 and earlier.
|
||||
func LegacyAccountsDir() (string, error) {
|
||||
return accountsDir(".wechatexplorer")
|
||||
}
|
||||
|
||||
func accountDirectoryForID(accountID string) (string, error) {
|
||||
current, err := AccountsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(current, accountID+".json")); err == nil {
|
||||
return current, nil
|
||||
}
|
||||
legacy, err := LegacyAccountsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(legacy, accountID+".json")); err == nil {
|
||||
return legacy, nil
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
|
||||
func NormalizeAccountID(raw string) string {
|
||||
s := raw
|
||||
for _, ch := range []string{"@", ".", ":"} {
|
||||
s = filepath.Clean(s)
|
||||
s = replaceAll(s, ch, "-")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func replaceAll(s, old, new string) string {
|
||||
for {
|
||||
i := indexOf(s, old)
|
||||
if i < 0 {
|
||||
return s
|
||||
}
|
||||
s = s[:i] + new + s[i+len(old):]
|
||||
}
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := range s {
|
||||
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// SaveCredentials saves the latest credentials and removes older accounts.
|
||||
// The new credential is written first so a failed login never destroys the
|
||||
// previously working credential.
|
||||
func SaveCredentials(creds *Credentials) error {
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create accounts dir: %w", err)
|
||||
}
|
||||
|
||||
id := NormalizeAccountID(creds.ILinkBotID)
|
||||
path := filepath.Join(dir, id+".json")
|
||||
|
||||
data, err := json.MarshalIndent(creds, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal credentials: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write credentials: %w", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prune old credentials: %w", err)
|
||||
}
|
||||
keepPrefix := id + "."
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), keepPrefix) {
|
||||
continue
|
||||
}
|
||||
if filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove old credential %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadCredentialsFromDir(dir string) ([]*Credentials, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read accounts dir: %w", err)
|
||||
}
|
||||
|
||||
var result []*Credentials
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var creds Credentials
|
||||
if json.Unmarshal(data, &creds) == nil && creds.BotToken != "" {
|
||||
result = append(result, &creds)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LoadAllCredentials loads TraceMemo credentials first and falls back to the
|
||||
// untouched WechatExplorer directory for one-version upgrade compatibility.
|
||||
func LoadAllCredentials() ([]*Credentials, error) {
|
||||
current, err := AccountsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentials, err := loadCredentialsFromDir(current)
|
||||
if err != nil || len(credentials) > 0 {
|
||||
return credentials, err
|
||||
}
|
||||
legacy, err := LegacyAccountsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadCredentialsFromDir(legacy)
|
||||
}
|
||||
|
||||
// CredentialsPath returns the path for display purposes.
|
||||
func CredentialsPath() (string, error) {
|
||||
return AccountsDir()
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setTestHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
return home
|
||||
}
|
||||
|
||||
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
|
||||
setTestHome(t)
|
||||
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
|
||||
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
|
||||
if err := SaveCredentials(old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json"), []byte(`{}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveCredentials(latest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts, err := LoadAllCredentials()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(accounts) != 1 || accounts[0].ILinkBotID != latest.ILinkBotID {
|
||||
t.Fatalf("accounts = %#v", accounts)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("old sync state still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) {
|
||||
home := setTestHome(t)
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".tracememo", "wechat-connector", "accounts")
|
||||
if dir != want {
|
||||
t.Fatalf("AccountsDir() = %q, want %q", dir, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllCredentialsFallsBackToLegacyDirectory(t *testing.T) {
|
||||
setTestHome(t)
|
||||
legacyDir, err := LegacyAccountsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := &Credentials{ILinkBotID: "legacy@im.bot", BotToken: "legacy-token"}
|
||||
data, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(legacyDir, NormalizeAccountID(legacy.ILinkBotID)+".json"), data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts, err := LoadAllCredentials()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(accounts) != 1 || accounts[0].BotToken != legacy.BotToken {
|
||||
t.Fatalf("accounts = %#v", accounts)
|
||||
}
|
||||
if _, err := os.Stat(legacyDir); err != nil {
|
||||
t.Fatalf("legacy directory changed or removed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://ilinkai.weixin.qq.com"
|
||||
longPollTimeout = 35 * time.Second
|
||||
sendTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// Client is an iLink HTTP API client.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
botToken string
|
||||
botID string
|
||||
httpClient *http.Client
|
||||
wechatUIN string
|
||||
}
|
||||
|
||||
// NewClient creates a new iLink API client.
|
||||
func NewClient(creds *Credentials) *Client {
|
||||
baseURL := creds.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
botToken: creds.BotToken,
|
||||
botID: creds.ILinkBotID,
|
||||
httpClient: &http.Client{},
|
||||
wechatUIN: generateWechatUIN(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewUnauthenticatedClient creates a client without credentials for login flow.
|
||||
func NewUnauthenticatedClient() *Client {
|
||||
return &Client{
|
||||
baseURL: defaultBaseURL,
|
||||
httpClient: &http.Client{Timeout: 40 * time.Second},
|
||||
wechatUIN: generateWechatUIN(),
|
||||
}
|
||||
}
|
||||
|
||||
// BotID returns the bot's user ID.
|
||||
func (c *Client) BotID() string {
|
||||
return c.botID
|
||||
}
|
||||
|
||||
// GetUpdates performs a long-poll for new messages.
|
||||
func (c *Client) GetUpdates(ctx context.Context, buf string) (*GetUpdatesResponse, error) {
|
||||
reqBody := GetUpdatesRequest{
|
||||
GetUpdatesBuf: buf,
|
||||
BaseInfo: BaseInfo{ChannelVersion: "1.0.0"},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, longPollTimeout+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var resp GetUpdatesResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/getupdates", reqBody, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// SendMessage sends a message through iLink.
|
||||
func (c *Client) SendMessage(ctx context.Context, msg *SendMessageRequest) (*SendMessageResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
defer cancel()
|
||||
|
||||
var resp SendMessageResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/sendmessage", msg, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetConfig fetches bot config for a user (includes typing_ticket).
|
||||
func (c *Client) GetConfig(ctx context.Context, userID, contextToken string) (*GetConfigResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := GetConfigRequest{
|
||||
ILinkUserID: userID,
|
||||
ContextToken: contextToken,
|
||||
BaseInfo: BaseInfo{},
|
||||
}
|
||||
|
||||
var resp GetConfigResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/getconfig", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// SendTyping sends a typing indicator to a user.
|
||||
func (c *Client) SendTyping(ctx context.Context, userID, typingTicket string, status int) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := SendTypingRequest{
|
||||
ILinkUserID: userID,
|
||||
TypingTicket: typingTicket,
|
||||
Status: status,
|
||||
BaseInfo: BaseInfo{},
|
||||
}
|
||||
|
||||
var resp SendTypingResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/sendtyping", req, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Ret != 0 {
|
||||
return fmt.Errorf("sendtyping failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUploadURL gets a pre-signed CDN upload URL for media files.
|
||||
func (c *Client) GetUploadURL(ctx context.Context, req *GetUploadURLRequest) (*GetUploadURLResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
defer cancel()
|
||||
|
||||
var resp GetUploadURLResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/getuploadurl", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// BaseURL returns the base URL for CDN operations.
|
||||
func (c *Client) BaseURL() string {
|
||||
return c.baseURL
|
||||
}
|
||||
|
||||
func (c *Client) doPost(ctx context.Context, path string, body interface{}, result interface{}) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) doGet(ctx context.Context, url string, result interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||
req.Header.Set("Authorization", "Bearer "+c.botToken)
|
||||
req.Header.Set("X-WECHAT-UIN", c.wechatUIN)
|
||||
}
|
||||
|
||||
func generateWechatUIN() string {
|
||||
var n uint32
|
||||
_ = binary.Read(rand.Reader, binary.LittleEndian, &n)
|
||||
s := fmt.Sprintf("%d", n)
|
||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxConsecutiveFailures = 5
|
||||
initialBackoff = 3 * time.Second
|
||||
maxBackoff = 60 * time.Second
|
||||
sessionExpiredBackoff = 5 * time.Second
|
||||
errCodeSessionExpired = -14
|
||||
)
|
||||
|
||||
// MessageHandler is called for each received message.
|
||||
type MessageHandler func(ctx context.Context, client *Client, msg WeixinMessage)
|
||||
|
||||
// Monitor manages the long-poll loop for receiving messages.
|
||||
type Monitor struct {
|
||||
client *Client
|
||||
handler MessageHandler
|
||||
getUpdatesBuf string
|
||||
bufPath string
|
||||
failures int
|
||||
lastActivity time.Time
|
||||
}
|
||||
|
||||
// NewMonitor creates a new long-poll monitor.
|
||||
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
|
||||
accountID := NormalizeAccountID(client.BotID())
|
||||
accountsRoot, err := accountDirectoryForID(accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bufPath := filepath.Join(accountsRoot, accountID+".sync.json")
|
||||
|
||||
m := &Monitor{
|
||||
client: client,
|
||||
handler: handler,
|
||||
bufPath: bufPath,
|
||||
lastActivity: time.Now(),
|
||||
}
|
||||
m.loadBuf()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Run starts the long-poll loop. It blocks until ctx is cancelled.
|
||||
// Automatically recovers from errors with exponential backoff.
|
||||
func (m *Monitor) Run(ctx context.Context) error {
|
||||
log.Println("[monitor] starting long-poll loop")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("[monitor] shutting down")
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
resp, err := m.client.GetUpdates(ctx, m.getUpdatesBuf)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
m.failures++
|
||||
backoff := m.calcBackoff()
|
||||
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
|
||||
m.failures, maxConsecutiveFailures, backoff, err)
|
||||
if m.failures == maxConsecutiveFailures {
|
||||
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from TraceMemo if this persists.", maxConsecutiveFailures)
|
||||
}
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset failure counter on any successful response
|
||||
m.failures = 0
|
||||
m.lastActivity = time.Now()
|
||||
|
||||
// Session expired — reset sync buf and reconnect silently
|
||||
if resp.ErrCode == errCodeSessionExpired {
|
||||
if m.getUpdatesBuf != "" {
|
||||
log.Printf("[monitor] session expired, resetting sync buf")
|
||||
m.getUpdatesBuf = ""
|
||||
m.saveBuf()
|
||||
} else {
|
||||
// Sync buf already empty but still getting session expired:
|
||||
// the bot token itself has expired. The user needs to re-login.
|
||||
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from TraceMemo.")
|
||||
}
|
||||
select {
|
||||
case <-time.After(sessionExpiredBackoff):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Other server errors
|
||||
if resp.Ret != 0 && resp.ErrCode != 0 {
|
||||
log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update buf for next poll
|
||||
if resp.GetUpdatesBuf != "" {
|
||||
m.getUpdatesBuf = resp.GetUpdatesBuf
|
||||
m.saveBuf()
|
||||
}
|
||||
|
||||
// Process messages concurrently — don't block the poll loop
|
||||
for _, msg := range resp.Msgs {
|
||||
go m.handler(ctx, m.client, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calcBackoff returns an exponential backoff duration capped at maxBackoff.
|
||||
func (m *Monitor) calcBackoff() time.Duration {
|
||||
d := initialBackoff
|
||||
for i := 1; i < m.failures; i++ {
|
||||
d *= 2
|
||||
if d > maxBackoff {
|
||||
return maxBackoff
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type syncData struct {
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
}
|
||||
|
||||
func (m *Monitor) loadBuf() {
|
||||
data, err := os.ReadFile(m.bufPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var s syncData
|
||||
if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" {
|
||||
m.getUpdatesBuf = s.GetUpdatesBuf
|
||||
log.Printf("[monitor] loaded sync buf from %s", m.bufPath)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) saveBuf() {
|
||||
dir := filepath.Dir(m.bufPath)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
log.Printf("[monitor] failed to create buf dir: %v", err)
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf})
|
||||
if err := os.WriteFile(m.bufPath, data, 0o600); err != nil {
|
||||
log.Printf("[monitor] failed to save buf: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatMessageSummary returns a short description of a message for logging.
|
||||
func FormatMessageSummary(msg WeixinMessage) string {
|
||||
text := ""
|
||||
for _, item := range msg.ItemList {
|
||||
if item.Type == ItemTypeText && item.TextItem != nil {
|
||||
text = item.TextItem.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(text) > 50 {
|
||||
text = text[:50] + "..."
|
||||
}
|
||||
return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text)
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
package ilink
|
||||
|
||||
// Message types
|
||||
const (
|
||||
MessageTypeNone = 0
|
||||
MessageTypeUser = 1
|
||||
MessageTypeBot = 2
|
||||
)
|
||||
|
||||
// Message states
|
||||
const (
|
||||
MessageStateNew = 0
|
||||
MessageStateGenerating = 1
|
||||
MessageStateFinish = 2
|
||||
)
|
||||
|
||||
// Item types
|
||||
const (
|
||||
ItemTypeNone = 0
|
||||
ItemTypeText = 1
|
||||
ItemTypeImage = 2
|
||||
ItemTypeVoice = 3
|
||||
ItemTypeFile = 4
|
||||
ItemTypeVideo = 5
|
||||
)
|
||||
|
||||
// QRCodeResponse is the response from get_bot_qrcode.
|
||||
type QRCodeResponse struct {
|
||||
QRCode string `json:"qrcode"`
|
||||
QRCodeImgContent string `json:"qrcode_img_content"`
|
||||
}
|
||||
|
||||
// QRStatusResponse is the response from get_qrcode_status.
|
||||
type QRStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
BotToken string `json:"bot_token"`
|
||||
ILinkBotID string `json:"ilink_bot_id"`
|
||||
BaseURL string `json:"baseurl"`
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
}
|
||||
|
||||
// Credentials stores login session data.
|
||||
type Credentials struct {
|
||||
BotToken string `json:"bot_token"`
|
||||
ILinkBotID string `json:"ilink_bot_id"`
|
||||
BaseURL string `json:"baseurl"`
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
}
|
||||
|
||||
// BaseInfo is included in request bodies.
|
||||
type BaseInfo struct {
|
||||
ChannelVersion string `json:"channel_version,omitempty"`
|
||||
}
|
||||
|
||||
// GetUpdatesRequest is the body for getupdates.
|
||||
type GetUpdatesRequest struct {
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// GetUpdatesResponse is the response from getupdates.
|
||||
type GetUpdatesResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
Msgs []WeixinMessage `json:"msgs"`
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
LongPollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
|
||||
}
|
||||
|
||||
// WeixinMessage represents a message from WeChat.
|
||||
type WeixinMessage struct {
|
||||
Seq int `json:"seq,omitempty"`
|
||||
MessageID int64 `json:"message_id,omitempty"`
|
||||
FromUserID string `json:"from_user_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
MessageType int `json:"message_type"`
|
||||
MessageState int `json:"message_state"`
|
||||
ItemList []MessageItem `json:"item_list"`
|
||||
ContextToken string `json:"context_token"`
|
||||
}
|
||||
|
||||
// MessageItem is a single item in a message.
|
||||
type MessageItem struct {
|
||||
Type int `json:"type"`
|
||||
TextItem *TextItem `json:"text_item,omitempty"`
|
||||
ImageItem *ImageItem `json:"image_item,omitempty"`
|
||||
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
|
||||
VideoItem *VideoItem `json:"video_item,omitempty"`
|
||||
FileItem *FileItem `json:"file_item,omitempty"`
|
||||
}
|
||||
|
||||
// CDN media type constants.
|
||||
const (
|
||||
CDNMediaTypeImage = 1
|
||||
CDNMediaTypeVideo = 2
|
||||
CDNMediaTypeFile = 3
|
||||
)
|
||||
|
||||
// GetUploadURLRequest is the body for getuploadurl.
|
||||
type GetUploadURLRequest struct {
|
||||
FileKey string `json:"filekey"`
|
||||
MediaType int `json:"media_type"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
RawSize int `json:"rawsize"`
|
||||
RawFileMD5 string `json:"rawfilemd5"`
|
||||
FileSize int `json:"filesize"`
|
||||
NoNeedThumb bool `json:"no_need_thumb"`
|
||||
AESKey string `json:"aeskey"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// GetUploadURLResponse is the response from getuploadurl.
|
||||
type GetUploadURLResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
UploadParam string `json:"upload_param"`
|
||||
UploadFullURL string `json:"upload_full_url,omitempty"`
|
||||
}
|
||||
|
||||
// TextItem holds text content.
|
||||
type TextItem struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// MediaInfo holds CDN media reference for uploaded files.
|
||||
type MediaInfo struct {
|
||||
EncryptQueryParam string `json:"encrypt_query_param"`
|
||||
AESKey string `json:"aes_key"` // base64-encoded
|
||||
EncryptType int `json:"encrypt_type"` // 1 = AES-128-ECB
|
||||
}
|
||||
|
||||
// VoiceItem holds voice content.
|
||||
type VoiceItem struct {
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
VoiceSize int `json:"voice_size,omitempty"`
|
||||
EncodeType int `json:"encode_type,omitempty"` // 1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3
|
||||
BitsPerSample int `json:"bits_per_sample,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"` // Hz
|
||||
Playtime int `json:"playtime,omitempty"` // duration in milliseconds
|
||||
Text string `json:"text,omitempty"` // speech-to-text transcription from WeChat
|
||||
}
|
||||
|
||||
// ImageItem holds image content.
|
||||
type ImageItem struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
MidSize int `json:"mid_size,omitempty"` // ciphertext size
|
||||
}
|
||||
|
||||
// VideoItem holds video content.
|
||||
type VideoItem struct {
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
VideoSize int `json:"video_size,omitempty"`
|
||||
}
|
||||
|
||||
// FileItem holds file content.
|
||||
type FileItem struct {
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
Len string `json:"len,omitempty"` // plaintext size as string
|
||||
}
|
||||
|
||||
// SendMessageRequest is the body for sendmessage.
|
||||
type SendMessageRequest struct {
|
||||
Msg SendMsg `json:"msg"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// SendMsg is the message payload for sending.
|
||||
type SendMsg struct {
|
||||
FromUserID string `json:"from_user_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
MessageType int `json:"message_type"`
|
||||
MessageState int `json:"message_state"`
|
||||
ItemList []MessageItem `json:"item_list"`
|
||||
ContextToken string `json:"context_token"`
|
||||
}
|
||||
|
||||
// SendMessageResponse is the response from sendmessage.
|
||||
type SendMessageResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
}
|
||||
|
||||
// Typing status constants.
|
||||
const (
|
||||
TypingStatusTyping = 1
|
||||
TypingStatusCancel = 2
|
||||
)
|
||||
|
||||
// GetConfigRequest is the body for getconfig.
|
||||
type GetConfigRequest struct {
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
ContextToken string `json:"context_token,omitempty"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// GetConfigResponse is the response from getconfig.
|
||||
type GetConfigResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
TypingTicket string `json:"typing_ticket,omitempty"`
|
||||
}
|
||||
|
||||
// SendTypingRequest is the body for sendtyping.
|
||||
type SendTypingRequest struct {
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
TypingTicket string `json:"typing_ticket"`
|
||||
Status int `json:"status"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// SendTypingResponse is the response from sendtyping.
|
||||
type SendTypingResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/api"
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
|
||||
"rsc.io/qr"
|
||||
)
|
||||
|
||||
type loginEvent struct {
|
||||
Status string `json:"status"`
|
||||
QRCodeDataURL string `json:"qr_code_data_url,omitempty"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
WeChatUserID string `json:"wechat_user_id,omitempty"`
|
||||
}
|
||||
|
||||
type accountSummary struct {
|
||||
AccountID string `json:"account_id"`
|
||||
WeChatUserID string `json:"wechat_user_id"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fatal(errors.New("expected one of: login, accounts, start"))
|
||||
}
|
||||
var err error
|
||||
switch os.Args[1] {
|
||||
case "login":
|
||||
err = runLogin(os.Args[2:])
|
||||
case "accounts":
|
||||
err = runAccounts(os.Args[2:])
|
||||
case "start":
|
||||
err = runStart(os.Args[2:])
|
||||
default:
|
||||
err = fmt.Errorf("unknown command %q", os.Args[1])
|
||||
}
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func signalContext() (context.Context, context.CancelFunc) {
|
||||
return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
}
|
||||
|
||||
func runLogin(args []string) error {
|
||||
flags := flag.NewFlagSet("login", flag.ContinueOnError)
|
||||
jsonOutput := flags.Bool("json", false, "emit JSON Lines events")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := signalContext()
|
||||
defer cancel()
|
||||
creds, err := login(ctx, *jsonOutput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !*jsonOutput {
|
||||
fmt.Printf("WeChat account %s connected.\n", creds.ILinkBotID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func login(ctx context.Context, jsonOutput bool) (*ilink.Credentials, error) {
|
||||
qrResponse, err := ilink.FetchQRCode(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, err := qr.Encode(qrResponse.QRCodeImgContent, qr.L)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode QR image: %w", err)
|
||||
}
|
||||
emit := func(event loginEvent) {
|
||||
if jsonOutput {
|
||||
_ = json.NewEncoder(os.Stdout).Encode(event)
|
||||
}
|
||||
}
|
||||
emit(loginEvent{Status: "qrcode", QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())})
|
||||
lastStatus := ""
|
||||
creds, err := ilink.PollQRStatus(ctx, qrResponse.QRCode, func(status string) {
|
||||
if status != lastStatus {
|
||||
lastStatus = status
|
||||
emit(loginEvent{Status: status})
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ilink.SaveCredentials(creds); err != nil {
|
||||
return nil, fmt.Errorf("save credentials: %w", err)
|
||||
}
|
||||
emit(loginEvent{Status: "active", AccountID: creds.ILinkBotID, WeChatUserID: creds.ILinkUserID})
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func runAccounts(args []string) error {
|
||||
flags := flag.NewFlagSet("accounts", flag.ContinueOnError)
|
||||
jsonOutput := flags.Bool("json", false, "print JSON")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
accounts, err := ilink.LoadAllCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]accountSummary, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
items = append(items, accountSummary{AccountID: account.ILinkBotID, WeChatUserID: account.ILinkUserID})
|
||||
}
|
||||
if *jsonOutput {
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"accounts": items})
|
||||
}
|
||||
for _, item := range items {
|
||||
fmt.Printf("%s\t%s\n", item.AccountID, item.WeChatUserID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStart(args []string) error {
|
||||
flags := flag.NewFlagSet("start", flag.ContinueOnError)
|
||||
_ = flags.Bool("foreground", false, "kept for host compatibility")
|
||||
apiAddr := flags.String("api-addr", "127.0.0.1:18011", "local send API address")
|
||||
accountID := flags.String("account-id", "", "account to start")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
accounts, err := ilink.LoadAllCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return errors.New("no connected WeChat account; scan a QR code first")
|
||||
}
|
||||
selected := accounts[len(accounts)-1]
|
||||
if *accountID != "" {
|
||||
selected = nil
|
||||
for _, account := range accounts {
|
||||
if account.ILinkBotID == *accountID {
|
||||
selected = account
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
return fmt.Errorf("account %q not found", *accountID)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := signalContext()
|
||||
defer cancel()
|
||||
client := ilink.NewClient(selected)
|
||||
server := api.NewServer([]*ilink.Client{client}, *apiAddr)
|
||||
webhookURL := strings.TrimSpace(os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL"))
|
||||
webhook := messaging.NewInboundWebhook(webhookURL, os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN"))
|
||||
|
||||
monitor, err := ilink.NewMonitor(client, func(messageContext context.Context, source *ilink.Client, message ilink.WeixinMessage) {
|
||||
if webhookURL != "" {
|
||||
webhook.Dispatch(messageContext, source, message)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var wait sync.WaitGroup
|
||||
wait.Add(2)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
if err := server.Run(ctx); err != nil && ctx.Err() == nil {
|
||||
log.Printf("[api] stopped: %v", err)
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
if err := monitor.Run(ctx); err != nil && ctx.Err() == nil {
|
||||
log.Printf("[monitor] stopped: %v", err)
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
wait.Wait()
|
||||
return nil
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
)
|
||||
|
||||
const cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
|
||||
// UploadedFile holds the result of a CDN upload.
|
||||
type UploadedFile struct {
|
||||
DownloadParam string // encrypted query param for download
|
||||
AESKeyHex string // hex-encoded AES key
|
||||
FileSize int // plaintext size
|
||||
CipherSize int // ciphertext size
|
||||
}
|
||||
|
||||
// UploadFileToCDN encrypts and uploads a file to the WeChat CDN.
|
||||
func UploadFileToCDN(ctx context.Context, client *ilink.Client, data []byte, toUserID string, mediaType int) (*UploadedFile, error) {
|
||||
// Generate random filekey and AES key
|
||||
filekey := make([]byte, 16)
|
||||
aeskey := make([]byte, 16)
|
||||
if _, err := rand.Read(filekey); err != nil {
|
||||
return nil, fmt.Errorf("generate filekey: %w", err)
|
||||
}
|
||||
if _, err := rand.Read(aeskey); err != nil {
|
||||
return nil, fmt.Errorf("generate aeskey: %w", err)
|
||||
}
|
||||
|
||||
filekeyHex := hex.EncodeToString(filekey)
|
||||
aeskeyHex := hex.EncodeToString(aeskey)
|
||||
|
||||
// Calculate MD5 of plaintext
|
||||
hash := md5.Sum(data)
|
||||
rawMD5 := hex.EncodeToString(hash[:])
|
||||
|
||||
// Calculate ciphertext size (PKCS7 padding)
|
||||
cipherSize := aesECBPaddedSize(len(data))
|
||||
|
||||
// Get upload URL from iLink API
|
||||
uploadReq := &ilink.GetUploadURLRequest{
|
||||
FileKey: filekeyHex,
|
||||
MediaType: mediaType,
|
||||
ToUserID: toUserID,
|
||||
RawSize: len(data),
|
||||
RawFileMD5: rawMD5,
|
||||
FileSize: cipherSize,
|
||||
NoNeedThumb: true,
|
||||
AESKey: aeskeyHex,
|
||||
BaseInfo: ilink.BaseInfo{},
|
||||
}
|
||||
|
||||
uploadResp, err := client.GetUploadURL(ctx, uploadReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get upload URL: %w", err)
|
||||
}
|
||||
if uploadResp.Ret != 0 {
|
||||
return nil, fmt.Errorf("get upload URL failed: ret=%d errmsg=%s", uploadResp.Ret, uploadResp.ErrMsg)
|
||||
}
|
||||
|
||||
// Encrypt data with AES-128-ECB
|
||||
encrypted, err := encryptAESECB(data, aeskey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt: %w", err)
|
||||
}
|
||||
|
||||
// Upload to CDN: prefer server-provided full URL, fall back to param-based construction
|
||||
cdnURL := strings.TrimSpace(uploadResp.UploadFullURL)
|
||||
if cdnURL == "" {
|
||||
if uploadResp.UploadParam == "" {
|
||||
return nil, fmt.Errorf("getuploadurl returned no upload URL (need upload_full_url or upload_param)")
|
||||
}
|
||||
cdnURL = fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s",
|
||||
cdnBaseURL, url.QueryEscape(uploadResp.UploadParam), url.QueryEscape(filekeyHex))
|
||||
}
|
||||
|
||||
downloadParam, err := uploadToCDN(ctx, encrypted, cdnURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CDN upload: %w", err)
|
||||
}
|
||||
|
||||
return &UploadedFile{
|
||||
DownloadParam: downloadParam,
|
||||
AESKeyHex: aeskeyHex,
|
||||
FileSize: len(data),
|
||||
CipherSize: cipherSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AESKeyToBase64 converts a hex AES key to base64 format for message items.
|
||||
func AESKeyToBase64(hexKey string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(hexKey))
|
||||
}
|
||||
|
||||
// DownloadFileFromCDN downloads and decrypts a file from the WeChat CDN.
|
||||
func DownloadFileFromCDN(ctx context.Context, encryptQueryParam, aesKeyBase64 string) ([]byte, error) {
|
||||
// Decode AES key: base64 -> hex string -> raw bytes
|
||||
aesKeyHexBytes, err := base64.StdEncoding.DecodeString(aesKeyBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode AES key base64: %w", err)
|
||||
}
|
||||
aesKey, err := hex.DecodeString(string(aesKeyHexBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode AES key hex: %w", err)
|
||||
}
|
||||
|
||||
// Download encrypted data from CDN
|
||||
downloadURL := fmt.Sprintf("%s/download?encrypted_query_param=%s",
|
||||
cdnBaseURL, url.QueryEscape(encryptQueryParam))
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download from CDN: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("CDN download HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
encrypted, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CDN response: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt AES-128-ECB
|
||||
return decryptAESECB(encrypted, aesKey)
|
||||
}
|
||||
|
||||
// decryptAESECB decrypts data encrypted with AES-128-ECB and removes PKCS7 padding.
|
||||
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext is not a multiple of block size")
|
||||
}
|
||||
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
|
||||
// Remove PKCS7 padding
|
||||
if len(plaintext) == 0 {
|
||||
return plaintext, nil
|
||||
}
|
||||
padLen := int(plaintext[len(plaintext)-1])
|
||||
if padLen > aes.BlockSize || padLen == 0 {
|
||||
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||
}
|
||||
return plaintext[:len(plaintext)-padLen], nil
|
||||
}
|
||||
|
||||
func uploadToCDN(ctx context.Context, encrypted []byte, cdnURL string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(encrypted))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("CDN upload HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
downloadParam := resp.Header.Get("X-Encrypted-Param")
|
||||
if downloadParam == "" {
|
||||
return "", fmt.Errorf("CDN upload: missing X-Encrypted-Param header")
|
||||
}
|
||||
|
||||
return downloadParam, nil
|
||||
}
|
||||
|
||||
// encryptAESECB encrypts data using AES-128-ECB with PKCS7 padding.
|
||||
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// PKCS7 padding
|
||||
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
|
||||
padded := make([]byte, len(plaintext)+padLen)
|
||||
copy(padded, plaintext)
|
||||
for i := len(plaintext); i < len(padded); i++ {
|
||||
padded[i] = byte(padLen)
|
||||
}
|
||||
|
||||
// ECB mode: encrypt each block independently
|
||||
encrypted := make([]byte, len(padded))
|
||||
for i := 0; i < len(padded); i += aes.BlockSize {
|
||||
block.Encrypt(encrypted[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
|
||||
}
|
||||
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
func aesECBPaddedSize(plaintextSize int) int {
|
||||
return (plaintextSize/aes.BlockSize + 1) * aes.BlockSize
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
)
|
||||
|
||||
const (
|
||||
webhookAttempts = 3
|
||||
webhookTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type InboundWebhook struct {
|
||||
url string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type inboundWebhookPayload struct {
|
||||
AccountID string `json:"account_id"`
|
||||
FromUserID string `json:"from_user_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
MessageType int `json:"message_type"`
|
||||
Items []inboundWebhookItem `json:"items"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
}
|
||||
|
||||
type inboundWebhookItem struct {
|
||||
Type int `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func NewInboundWebhook(url, token string) *InboundWebhook {
|
||||
return &InboundWebhook{
|
||||
url: strings.TrimSpace(url),
|
||||
token: token,
|
||||
client: &http.Client{Timeout: webhookTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch is intentionally non-blocking so webhook failures never stall iLink polling.
|
||||
func (w *InboundWebhook) Dispatch(ctx context.Context, client *ilink.Client, msg ilink.WeixinMessage) {
|
||||
payload := normalizeInboundMessage(client.BotID(), msg)
|
||||
go func() {
|
||||
if err := w.deliver(ctx, payload); err != nil {
|
||||
log.Printf("[webhook] inbound delivery failed for message %d: %v", msg.MessageID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (w *InboundWebhook) deliver(ctx context.Context, payload inboundWebhookPayload) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode payload: %w", err)
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= webhookAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
timer := time.NewTimer(time.Duration(attempt-1) * time.Second)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body))
|
||||
if reqErr != nil {
|
||||
return fmt.Errorf("create request: %w", reqErr)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if w.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+w.token)
|
||||
}
|
||||
resp, doErr := w.client.Do(req)
|
||||
if doErr != nil {
|
||||
lastErr = doErr
|
||||
continue
|
||||
}
|
||||
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("status %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func normalizeInboundMessage(accountID string, msg ilink.WeixinMessage) inboundWebhookPayload {
|
||||
items := make([]inboundWebhookItem, 0, len(msg.ItemList))
|
||||
for _, item := range msg.ItemList {
|
||||
normalized := inboundWebhookItem{Type: item.Type}
|
||||
if item.TextItem != nil {
|
||||
normalized.Text = item.TextItem.Text
|
||||
} else if item.VoiceItem != nil {
|
||||
normalized.Text = item.VoiceItem.Text
|
||||
}
|
||||
items = append(items, normalized)
|
||||
}
|
||||
return inboundWebhookPayload{
|
||||
AccountID: accountID,
|
||||
FromUserID: msg.FromUserID,
|
||||
MessageID: msg.MessageID,
|
||||
MessageType: msg.MessageType,
|
||||
Items: items,
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
)
|
||||
|
||||
func TestInboundWebhookDeliversNormalizedPayload(t *testing.T) {
|
||||
var got inboundWebhookPayload
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer secret" {
|
||||
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Errorf("decode: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
webhook := NewInboundWebhook(server.URL, "secret")
|
||||
err := webhook.deliver(context.Background(), normalizeInboundMessage("bot-new", ilink.WeixinMessage{
|
||||
MessageID: 7, FromUserID: "user-1", MessageType: ilink.MessageTypeUser,
|
||||
ItemList: []ilink.MessageItem{{Type: ilink.ItemTypeText, TextItem: &ilink.TextItem{Text: "最近5条消息"}}},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("deliver: %v", err)
|
||||
}
|
||||
if got.AccountID != "bot-new" || got.MessageID != 7 || len(got.Items) != 1 || got.Items[0].Text != "最近5条消息" {
|
||||
t.Fatalf("payload = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundWebhookRetriesServerErrors(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if calls.Add(1) < 3 {
|
||||
http.Error(w, "temporary", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
webhook := NewInboundWebhook(server.URL, "")
|
||||
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err != nil {
|
||||
t.Fatalf("deliver: %v", err)
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatalf("calls = %d, want 3", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundWebhookDoesNotRetryClientErrors(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
webhook := NewInboundWebhook(server.URL, "")
|
||||
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err == nil {
|
||||
t.Fatal("deliver error = nil")
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("calls = %d, want 1", calls.Load())
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// Code blocks: strip fences, keep code content
|
||||
reCodeBlock = regexp.MustCompile("(?s)```[^\n]*\n?(.*?)```")
|
||||
// Inline code: strip backticks, keep content
|
||||
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||
// Images: remove entirely
|
||||
reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`)
|
||||
// Links: keep display text only
|
||||
reLink = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)
|
||||
// Table separator rows: remove
|
||||
reTableSep = regexp.MustCompile(`(?m)^\|[\s:|\-]+\|$`)
|
||||
// Table rows: convert pipe-delimited to space-delimited
|
||||
reTableRow = regexp.MustCompile(`(?m)^\|(.+)\|$`)
|
||||
// Headers: remove # prefix
|
||||
reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+`)
|
||||
// Bold: **text** or __text__
|
||||
reBold = regexp.MustCompile(`\*\*(.+?)\*\*|__(.+?)__`)
|
||||
// Italic: *text* or _text_
|
||||
reItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)|(?:^|[^_])_([^_]+)_(?:[^_]|$)`)
|
||||
// Strikethrough: ~~text~~
|
||||
reStrike = regexp.MustCompile(`~~(.+?)~~`)
|
||||
// Blockquote: > prefix
|
||||
reBlockquote = regexp.MustCompile(`(?m)^>\s?`)
|
||||
// Horizontal rule
|
||||
reHR = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`)
|
||||
// Unordered list markers: -, *, +
|
||||
reUL = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
|
||||
)
|
||||
|
||||
// MarkdownToPlainText converts markdown to readable plain text for WeChat.
|
||||
func MarkdownToPlainText(text string) string {
|
||||
result := text
|
||||
|
||||
// Code blocks: strip fences, keep code content
|
||||
result = reCodeBlock.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := reCodeBlock.FindStringSubmatch(match)
|
||||
if len(parts) > 1 {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// Images: remove entirely
|
||||
result = reImage.ReplaceAllString(result, "")
|
||||
|
||||
// Links: keep display text only
|
||||
result = reLink.ReplaceAllString(result, "$1")
|
||||
|
||||
// Table separator rows: remove
|
||||
result = reTableSep.ReplaceAllString(result, "")
|
||||
|
||||
// Table rows: pipe-delimited to space-delimited
|
||||
result = reTableRow.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := reTableRow.FindStringSubmatch(match)
|
||||
if len(parts) > 1 {
|
||||
cells := strings.Split(parts[1], "|")
|
||||
for i := range cells {
|
||||
cells[i] = strings.TrimSpace(cells[i])
|
||||
}
|
||||
return strings.Join(cells, " ")
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// Headers: remove # prefix
|
||||
result = reHeader.ReplaceAllString(result, "")
|
||||
|
||||
// Bold
|
||||
result = reBold.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := reBold.FindStringSubmatch(match)
|
||||
if parts[1] != "" {
|
||||
return parts[1]
|
||||
}
|
||||
return parts[2]
|
||||
})
|
||||
|
||||
// Strikethrough
|
||||
result = reStrike.ReplaceAllString(result, "$1")
|
||||
|
||||
// Blockquote
|
||||
result = reBlockquote.ReplaceAllString(result, "")
|
||||
|
||||
// Horizontal rule -> empty line
|
||||
result = reHR.ReplaceAllString(result, "")
|
||||
|
||||
// Unordered list: replace markers with "• "
|
||||
result = reUL.ReplaceAllString(result, "${1}• ")
|
||||
|
||||
// Inline code: strip backticks (do after code blocks)
|
||||
result = reInlineCode.ReplaceAllString(result, "$1")
|
||||
|
||||
// Clean up excessive blank lines
|
||||
result = regexp.MustCompile(`\n{3,}`).ReplaceAllString(result, "\n\n")
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
)
|
||||
|
||||
// reMarkdownImage matches markdown image syntax: 
|
||||
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
|
||||
|
||||
// ExtractImageURLs extracts image URLs from markdown text.
|
||||
func ExtractImageURLs(text string) []string {
|
||||
matches := reMarkdownImage.FindAllStringSubmatch(text, -1)
|
||||
var urls []string
|
||||
for _, m := range matches {
|
||||
url := strings.TrimSpace(m[1])
|
||||
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
|
||||
urls = append(urls, url)
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message.
|
||||
func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error {
|
||||
// Check if it's a local file
|
||||
if _, err := os.Stat(mediaURL); err == nil {
|
||||
return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken)
|
||||
}
|
||||
// Must be a valid HTTP URL to download
|
||||
if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") {
|
||||
return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL)
|
||||
}
|
||||
data, contentType, err := downloadFile(ctx, mediaURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download %s: %w", mediaURL, err)
|
||||
}
|
||||
|
||||
return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken)
|
||||
}
|
||||
|
||||
// SendMediaFromPath reads a local file and sends it as a media message.
|
||||
func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken)
|
||||
}
|
||||
|
||||
func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error {
|
||||
if fileName == "" {
|
||||
fileName = "file"
|
||||
}
|
||||
|
||||
cdnMediaType, itemType := classifyMedia(contentType, source)
|
||||
|
||||
log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID)
|
||||
|
||||
uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload to CDN: %w", err)
|
||||
}
|
||||
|
||||
media := &ilink.MediaInfo{
|
||||
EncryptQueryParam: uploaded.DownloadParam,
|
||||
AESKey: AESKeyToBase64(uploaded.AESKeyHex),
|
||||
EncryptType: 1,
|
||||
}
|
||||
|
||||
var item ilink.MessageItem
|
||||
switch itemType {
|
||||
case ilink.ItemTypeImage:
|
||||
item = ilink.MessageItem{
|
||||
Type: ilink.ItemTypeImage,
|
||||
ImageItem: &ilink.ImageItem{
|
||||
Media: media,
|
||||
MidSize: uploaded.CipherSize,
|
||||
},
|
||||
}
|
||||
case ilink.ItemTypeVideo:
|
||||
item = ilink.MessageItem{
|
||||
Type: ilink.ItemTypeVideo,
|
||||
VideoItem: &ilink.VideoItem{
|
||||
Media: media,
|
||||
VideoSize: uploaded.CipherSize,
|
||||
},
|
||||
}
|
||||
default:
|
||||
item = ilink.MessageItem{
|
||||
Type: ilink.ItemTypeFile,
|
||||
FileItem: &ilink.FileItem{
|
||||
Media: media,
|
||||
FileName: fileName,
|
||||
Len: fmt.Sprintf("%d", uploaded.FileSize),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req := &ilink.SendMessageRequest{
|
||||
Msg: ilink.SendMsg{
|
||||
FromUserID: client.BotID(),
|
||||
ToUserID: toUserID,
|
||||
ClientID: NewClientID(),
|
||||
MessageType: ilink.MessageTypeBot,
|
||||
MessageState: ilink.MessageStateFinish,
|
||||
ItemList: []ilink.MessageItem{item},
|
||||
ContextToken: contextToken,
|
||||
},
|
||||
BaseInfo: ilink.BaseInfo{},
|
||||
}
|
||||
|
||||
resp, err := client.SendMessage(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send media message: %w", err)
|
||||
}
|
||||
if resp.Ret != 0 {
|
||||
return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||
}
|
||||
|
||||
log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFile(ctx context.Context, url string) ([]byte, string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = inferContentType(url)
|
||||
}
|
||||
|
||||
return data, contentType, nil
|
||||
}
|
||||
|
||||
func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) {
|
||||
ct := strings.ToLower(contentType)
|
||||
|
||||
if strings.HasPrefix(ct, "image/") || isImageExt(url) {
|
||||
return ilink.CDNMediaTypeImage, ilink.ItemTypeImage
|
||||
}
|
||||
if strings.HasPrefix(ct, "video/") || isVideoExt(url) {
|
||||
return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo
|
||||
}
|
||||
return ilink.CDNMediaTypeFile, ilink.ItemTypeFile
|
||||
}
|
||||
|
||||
func isImageExt(url string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
|
||||
switch ext {
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isVideoExt(url string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
|
||||
switch ext {
|
||||
case ".mp4", ".mov", ".webm", ".mkv", ".avi":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func inferContentType(url string) string {
|
||||
ext := filepath.Ext(stripQuery(url))
|
||||
if ct := mime.TypeByExtension(ext); ct != "" {
|
||||
return ct
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func filenameFromURL(rawURL string) string {
|
||||
u := stripQuery(rawURL)
|
||||
name := filepath.Base(u)
|
||||
if name == "" || name == "." || name == "/" {
|
||||
return "file"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func stripQuery(rawURL string) string {
|
||||
if i := strings.IndexByte(rawURL, '?'); i >= 0 {
|
||||
return rawURL[:i]
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package messaging
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractImageURLs(t *testing.T) {
|
||||
text := "check  and "
|
||||
urls := ExtractImageURLs(text)
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("expected 2 urls, got %d", len(urls))
|
||||
}
|
||||
if urls[0] != "https://example.com/a.png" {
|
||||
t.Errorf("urls[0] = %q", urls[0])
|
||||
}
|
||||
if urls[1] != "https://example.com/b.jpg" {
|
||||
t.Errorf("urls[1] = %q", urls[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractImageURLs_NoImages(t *testing.T) {
|
||||
urls := ExtractImageURLs("just plain text")
|
||||
if len(urls) != 0 {
|
||||
t.Errorf("expected 0 urls, got %d", len(urls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractImageURLs_RelativeURL(t *testing.T) {
|
||||
text := ""
|
||||
urls := ExtractImageURLs(text)
|
||||
if len(urls) != 0 {
|
||||
t.Errorf("expected 0 urls for relative path, got %d", len(urls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilenameFromURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"https://example.com/photo.png", "photo.png"},
|
||||
{"https://example.com/path/to/report.pdf", "report.pdf"},
|
||||
{"https://example.com/file", "file"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := filenameFromURL(tt.url)
|
||||
if got != tt.want {
|
||||
t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilenameFromURL_WithQuery(t *testing.T) {
|
||||
got := filenameFromURL("https://example.com/photo.png?token=abc")
|
||||
if got != "photo.png" {
|
||||
t.Errorf("got %q, want %q", got, "photo.png")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"https://example.com/a?b=c", "https://example.com/a"},
|
||||
{"https://example.com/a", "https://example.com/a"},
|
||||
{"https://example.com/?x=1&y=2", "https://example.com/"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := stripQuery(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// NewClientID generates a new unique client ID for message correlation.
|
||||
func NewClientID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// SendTypingState sends a typing indicator to a user via the iLink sendtyping API.
|
||||
// It first fetches a typing_ticket via getconfig, then sends the typing status.
|
||||
func SendTypingState(ctx context.Context, client *ilink.Client, userID, contextToken string) error {
|
||||
// Get typing ticket
|
||||
configResp, err := client.GetConfig(ctx, userID, contextToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get config for typing: %w", err)
|
||||
}
|
||||
if configResp.TypingTicket == "" {
|
||||
return fmt.Errorf("no typing_ticket returned from getconfig")
|
||||
}
|
||||
|
||||
// Send typing
|
||||
if err := client.SendTyping(ctx, userID, configResp.TypingTicket, ilink.TypingStatusTyping); err != nil {
|
||||
return fmt.Errorf("send typing: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[sender] sent typing indicator to %s", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendTextReply sends a text reply to a user through the iLink API.
|
||||
// If clientID is empty, a new one is generated.
|
||||
func SendTextReply(ctx context.Context, client *ilink.Client, toUserID, text, contextToken, clientID string) error {
|
||||
if clientID == "" {
|
||||
clientID = NewClientID()
|
||||
}
|
||||
|
||||
// Convert markdown to plain text for WeChat display
|
||||
plainText := MarkdownToPlainText(text)
|
||||
|
||||
req := &ilink.SendMessageRequest{
|
||||
Msg: ilink.SendMsg{
|
||||
FromUserID: client.BotID(),
|
||||
ToUserID: toUserID,
|
||||
ClientID: clientID,
|
||||
MessageType: ilink.MessageTypeBot,
|
||||
MessageState: ilink.MessageStateFinish,
|
||||
ItemList: []ilink.MessageItem{
|
||||
{
|
||||
Type: ilink.ItemTypeText,
|
||||
TextItem: &ilink.TextItem{
|
||||
Text: plainText,
|
||||
},
|
||||
},
|
||||
},
|
||||
ContextToken: contextToken,
|
||||
},
|
||||
BaseInfo: ilink.BaseInfo{},
|
||||
}
|
||||
|
||||
resp, err := client.SendMessage(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send message: %w", err)
|
||||
}
|
||||
|
||||
if resp.Ret != 0 {
|
||||
return fmt.Errorf("send message failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||
}
|
||||
|
||||
log.Printf("[sender] sent reply to %s: %q", toUserID, truncate(text, 50))
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
@@ -1246,10 +1246,15 @@ async function runSingleExport(
|
||||
const wavChannels =
|
||||
audioBuffer.length >= 44 ? audioBuffer.readUInt16LE(22) : 1
|
||||
const pcmBytes = Math.max(0, audioBuffer.length - 44)
|
||||
message.voiceDuration = Math.max(
|
||||
1,
|
||||
Math.round(pcmBytes / (wavSampleRate * wavChannels * 2))
|
||||
)
|
||||
// 口径统一:消息解析阶段已从 <voicemsg voicelength> 拿到微信的原始秒数(带小数),
|
||||
// 它是唯一权威来源,不要覆盖。只有拿不到时才退回用 PCM 字节数估算——
|
||||
// 那份估算是整秒、且下限 1 秒(WAV 缺失头部时的兜底),语义不同。
|
||||
if (message.voiceDuration == null) {
|
||||
message.voiceDuration = Math.max(
|
||||
1,
|
||||
Math.round(pcmBytes / (wavSampleRate * wavChannels * 2))
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
keepMediaError(
|
||||
request,
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
GroupReportRenderSnapshotExportRequest,
|
||||
ReportHeat,
|
||||
ReportSectionMeta,
|
||||
buildReportAvatarAliasIndex,
|
||||
mergeReportAvatars,
|
||||
selectHeroParticipantNames
|
||||
} from '../shared/group-report'
|
||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||
@@ -99,51 +101,155 @@ const fallbackAvatar = (name: string): RenderedAvatar => {
|
||||
const hue = hashName(name) % 360
|
||||
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
|
||||
return { source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, fallback: true }
|
||||
}
|
||||
|
||||
const imageMimeType = (contentType: string | null, source: string): string => {
|
||||
if (contentType?.startsWith('image/')) return contentType.split(';')[0]
|
||||
const extension = path.extname(source).toLowerCase()
|
||||
if (extension === '.png') return 'image/png'
|
||||
if (extension === '.webp') return 'image/webp'
|
||||
if (extension === '.gif') return 'image/gif'
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
const embedAvatar = async (source: string | undefined, name: string): Promise<RenderedAvatar> => {
|
||||
if (!source) return fallbackAvatar(name)
|
||||
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return { source, fallback: false }
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const response = await fetch(source, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 TraceMemo',
|
||||
Referer: 'https://weixin.qq.com/'
|
||||
},
|
||||
signal: AbortSignal.timeout(8000)
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const mime = imageMimeType(response.headers.get('content-type'), source)
|
||||
return { source: `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`, fallback: false }
|
||||
}
|
||||
|
||||
const localPath = source.startsWith('file://') ? new URL(source) : source
|
||||
const buffer = await fs.readFile(localPath)
|
||||
return { source: `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`, fallback: false }
|
||||
} catch (error) {
|
||||
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
|
||||
return fallbackAvatar(name)
|
||||
return {
|
||||
source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
|
||||
fallback: true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只认真实图片字节。
|
||||
*
|
||||
* `content-type` 与 URL 扩展名都**不可信**:微信 CDN 在限流 / 反盗链时会返回 200 + HTML 正文。
|
||||
* 旧实现按扩展名猜 mime 并默认 `image/jpeg`,会把 HTML 内联成"解码失败的 data URL" ——
|
||||
* 在报告里表现为**空白头像**(比首字占位更糟:用户看不到任何东西,也不知道为什么)。
|
||||
*/
|
||||
const IMAGE_MAGIC: Array<{ mime: string; bytes: number[] }> = [
|
||||
{ mime: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
|
||||
{ mime: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
||||
{ mime: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] },
|
||||
{ mime: 'image/bmp', bytes: [0x42, 0x4d] }
|
||||
]
|
||||
|
||||
export const detectImageMime = (bytes: Buffer): string | undefined => {
|
||||
for (const signature of IMAGE_MAGIC) {
|
||||
if (
|
||||
bytes.length >= signature.bytes.length &&
|
||||
signature.bytes.every((byte, index) => bytes[index] === byte)
|
||||
) {
|
||||
return signature.mime
|
||||
}
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes.toString('ascii', 0, 4) === 'RIFF' &&
|
||||
bytes.toString('ascii', 8, 12) === 'WEBP'
|
||||
) {
|
||||
return 'image/webp'
|
||||
}
|
||||
const head = bytes.subarray(0, 64).toString('utf8').trimStart()
|
||||
if (head.startsWith('<svg')) return 'image/svg+xml'
|
||||
if (head.startsWith('<?xml') && head.includes('<svg')) return 'image/svg+xml'
|
||||
return undefined
|
||||
}
|
||||
|
||||
const AVATAR_FETCH_TIMEOUT_MS = 8000
|
||||
/** 首次 + 一次重试:单次瞬时失败(限流 / 连接重置 / 超时)不该让一个人永久退回首字。 */
|
||||
const AVATAR_FETCH_ATTEMPTS = 2
|
||||
/** 同一 origin 的并发上限。几十个头像同时打一个 CDN 会显著抬高被限流的概率。 */
|
||||
const AVATAR_FETCH_CONCURRENCY = 6
|
||||
/** 进程内头像缓存条目上限(老报告重渲染 / 连续生成同一群时不必重复下载)。 */
|
||||
const AVATAR_CACHE_LIMIT = 256
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const avatarEmbedCache = new Map<string, RenderedAvatar>()
|
||||
|
||||
const rememberAvatarEmbed = (source: string, rendered: RenderedAvatar): void => {
|
||||
if (rendered.fallback) return
|
||||
avatarEmbedCache.set(source, rendered)
|
||||
while (avatarEmbedCache.size > AVATAR_CACHE_LIMIT) {
|
||||
const oldest = avatarEmbedCache.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
avatarEmbedCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
/** 有界并发:把 N 个任务压到 limit 个同时在飞,结果顺序与输入一致。 */
|
||||
export const mapWithConcurrency = async <T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
task: (item: T) => Promise<R>
|
||||
): Promise<R[]> => {
|
||||
const results: R[] = new Array(items.length)
|
||||
let cursor = 0
|
||||
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
||||
for (;;) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
if (index >= items.length) return
|
||||
results[index] = await task(items[index])
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
|
||||
const readAvatarSource = async (source: string): Promise<RenderedAvatar> => {
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const response = await fetch(source, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 TraceMemo',
|
||||
Referer: 'https://weixin.qq.com/'
|
||||
},
|
||||
signal: AbortSignal.timeout(AVATAR_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const bytes = Buffer.from(await response.arrayBuffer())
|
||||
const mime = detectImageMime(bytes)
|
||||
if (!mime) {
|
||||
throw new Error(
|
||||
`not an image (content-type=${response.headers.get('content-type') || 'unknown'}, ${bytes.length} bytes)`
|
||||
)
|
||||
}
|
||||
return { source: `data:${mime};base64,${bytes.toString('base64')}`, fallback: false }
|
||||
}
|
||||
|
||||
const localPath = source.startsWith('file://') ? new URL(source) : source
|
||||
const bytes = await fs.readFile(localPath)
|
||||
const mime = detectImageMime(bytes)
|
||||
if (!mime) throw new Error(`not an image (${bytes.length} bytes)`)
|
||||
return { source: `data:${mime};base64,${bytes.toString('base64')}`, fallback: false }
|
||||
}
|
||||
|
||||
export const embedAvatar = async (
|
||||
source: string | undefined,
|
||||
name: string
|
||||
): Promise<RenderedAvatar> => {
|
||||
if (!source) return fallbackAvatar(name)
|
||||
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source))
|
||||
return { source, fallback: false }
|
||||
|
||||
const cached = avatarEmbedCache.get(source)
|
||||
if (cached) return cached
|
||||
|
||||
let lastError: unknown
|
||||
for (let attempt = 1; attempt <= AVATAR_FETCH_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const embedded = await readAvatarSource(source)
|
||||
rememberAvatarEmbed(source, embedded)
|
||||
return embedded
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < AVATAR_FETCH_ATTEMPTS) await sleep(150 * attempt)
|
||||
}
|
||||
}
|
||||
console.warn(`[GroupReport] avatar fallback for ${name}:`, lastError)
|
||||
return fallbackAvatar(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从群成员快照反推真头像,填进 metadata.avatars。
|
||||
* - 没传 talker → 跳过(向后兼容)
|
||||
* - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback
|
||||
* - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补
|
||||
* - 同名取首条(P2 风险:群里两人同名)
|
||||
*
|
||||
* **必须按多个别名建索引**:报告里的显示名取决于 `memberNameMode`
|
||||
* (默认 `groupNickname` = 群昵称),而快照的 `nickname` 字段是
|
||||
* `wechatNickname || groupNickname || username`(见 `normalizeGroupMembers`)。
|
||||
* 一个成员同时有微信昵称与群昵称且两者不同时,只按 `nickname` 建索引就会**全部对不上**,
|
||||
* 于是头像 enrichment 静默失效 —— 这正是原先只用单一索引键时的问题。
|
||||
*/
|
||||
const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<void> => {
|
||||
if (!metadata.talker) return
|
||||
@@ -162,22 +268,16 @@ const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<vo
|
||||
return
|
||||
}
|
||||
|
||||
const index = new Map<string, string>()
|
||||
for (const member of snapshot.members) {
|
||||
if (member.nickname && member.avatar && !index.has(member.nickname)) {
|
||||
index.set(member.nickname, member.avatar)
|
||||
}
|
||||
}
|
||||
// 每个成员的所有可用显示名都指向同一个头像 URL;先到先得,避免同名互相覆盖。
|
||||
// 只按 `member.nickname` 建索引会在"微信昵称 ≠ 群昵称"时全部对不上 —— 见 shared 里的注释。
|
||||
const index = buildReportAvatarAliasIndex(snapshot.members)
|
||||
|
||||
metadata.avatars = metadata.avatars ?? {}
|
||||
for (const [name, url] of index) {
|
||||
if (metadata.avatars[name]) continue
|
||||
metadata.avatars[name] = url
|
||||
}
|
||||
const filled = mergeReportAvatars(metadata.avatars, index)
|
||||
|
||||
metadata.warnings = metadata.warnings ?? []
|
||||
metadata.warnings.push(
|
||||
`enriched ${index.size} member avatars from snapshot (${snapshot.memberCount} members)`
|
||||
`enriched ${filled}/${index.size} member avatar aliases from snapshot (${snapshot.memberCount} members)`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -276,12 +376,22 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
|
||||
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
|
||||
|
||||
const avatars = new Map<string, RenderedAvatar>()
|
||||
await Promise.all(
|
||||
Array.from(avatarNames).map(async (name) => {
|
||||
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
|
||||
})
|
||||
// 有界并发 + 单条重试:几十个头像同时打同一个 CDN 会被限流,瞬时失败会让一个人
|
||||
// 在整份报告里永久退化成首字占位(实测同一天三次生成:0% / 0% / 32% 失败)。
|
||||
const renderedAvatars = await mapWithConcurrency(
|
||||
Array.from(avatarNames),
|
||||
AVATAR_FETCH_CONCURRENCY,
|
||||
async (name) => [name, await embedAvatar(metadata.avatars[name], name)] as const
|
||||
)
|
||||
const avatars = new Map(renderedAvatars)
|
||||
const fallbackCount = renderedAvatars.filter(([, item]) => item.fallback).length
|
||||
if (fallbackCount > 0) {
|
||||
// 只记数量,不记人名:报告本身已经有名字,这里只需要一个可诊断的信号。
|
||||
metadata.warnings = metadata.warnings ?? []
|
||||
metadata.warnings.push(
|
||||
`avatar fallback ${fallbackCount}/${renderedAvatars.length}: 未取到真实头像,已用首字占位`
|
||||
)
|
||||
}
|
||||
const avatar = (name: string): RenderedAvatar => avatars.get(name) || fallbackAvatar(name)
|
||||
const renderAvatar = (
|
||||
name: string,
|
||||
@@ -295,9 +405,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
}
|
||||
|
||||
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
|
||||
const heroAvatars = heroNames
|
||||
.map((name) => renderAvatar(name, 'hero', '', name))
|
||||
.join('')
|
||||
const heroAvatars = heroNames.map((name) => renderAvatar(name, 'hero', '', name)).join('')
|
||||
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
|
||||
|
||||
const topicCards = report.topics
|
||||
@@ -480,7 +588,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
(item, index) => `<div class="rank tm-fragment tm-ranking-item">
|
||||
${renderAvatar(item.sender, 'ranking')}
|
||||
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
|
||||
<span>${item.count} 条 · ${item.durationSec} 秒</span>
|
||||
<span>${item.count} 条 · ${Math.round(item.durationSec)} 秒</span>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
@@ -656,7 +764,10 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
||||
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
||||
return addReportCsp(injectReportTemplateFragmentContract(html), resolvedTemplate.source === 'builtin')
|
||||
return addReportCsp(
|
||||
injectReportTemplateFragmentContract(html),
|
||||
resolvedTemplate.source === 'builtin'
|
||||
)
|
||||
}
|
||||
|
||||
const renderReportSnapshotHtml = async (
|
||||
@@ -679,7 +790,10 @@ const renderReportSnapshotHtml = async (
|
||||
REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate)
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
return addReportCsp(html.replace(/\{\{[A-Z0-9_]+\}\}/g, ''), resolvedTemplate.source === 'builtin')
|
||||
return addReportCsp(
|
||||
html.replace(/\{\{[A-Z0-9_]+\}\}/g, ''),
|
||||
resolvedTemplate.source === 'builtin'
|
||||
)
|
||||
}
|
||||
|
||||
export const extractGroupReportRenderSnapshot = async (
|
||||
@@ -1021,15 +1135,10 @@ export const exportGroupReport = async (
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(
|
||||
htmlPath,
|
||||
pngPath,
|
||||
request.templateId,
|
||||
{
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
}
|
||||
)
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
})
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
@@ -1074,15 +1183,10 @@ export const exportGroupReportSnapshot = async (
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(
|
||||
htmlPath,
|
||||
pngPath,
|
||||
request.templateId,
|
||||
{
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
}
|
||||
)
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
})
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
|
||||
+10
-4
@@ -3,6 +3,7 @@ import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
||||
import {
|
||||
isReady,
|
||||
listContacts,
|
||||
listContactsAsync,
|
||||
listMessages,
|
||||
getGroupSnapshot,
|
||||
listRecentChat,
|
||||
@@ -230,21 +231,26 @@ const routes: Record<string, RouteHandler> = {
|
||||
})
|
||||
},
|
||||
|
||||
'/api/v1/contact': ({ res, url }) => {
|
||||
'/api/v1/contact': async ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const filter = url.searchParams.get('filter') || undefined
|
||||
const type = url.searchParams.get('type') || undefined
|
||||
let contacts = listContacts(filter)
|
||||
// Use the hydrated data source: on macOS the session cache only carries raw
|
||||
// ids until display names / contact identities are hydrated, so the sync
|
||||
// `listContacts` would miss nickname and remark matches (Issue #51).
|
||||
let contacts = await listContactsAsync(filter)
|
||||
if (type === 'user' || type === 'group') {
|
||||
contacts = contacts.filter((c) => c.type === type)
|
||||
}
|
||||
sendJson(res, 200, { count: contacts.length, contacts })
|
||||
},
|
||||
|
||||
'/api/v1/chatroom': ({ res, url }) => {
|
||||
'/api/v1/chatroom': async ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const keyword = url.searchParams.get('keyword') || ''
|
||||
let groups = listContacts().filter((c) => c.type === 'group')
|
||||
// Same hydration requirement as /api/v1/contact: group display names are
|
||||
// exactly the fields that stay un-hydrated on macOS.
|
||||
let groups = (await listContactsAsync()).filter((c) => c.type === 'group')
|
||||
if (keyword) {
|
||||
const lower = keyword.toLowerCase()
|
||||
groups = groups.filter(
|
||||
|
||||
@@ -114,7 +114,11 @@ function getFfmpegCandidates(selectedPath = loadSettings().ffmpegPath): FfmpegCa
|
||||
)
|
||||
}
|
||||
|
||||
function resolveFfmpegExecutable(): string {
|
||||
/**
|
||||
* 解析可用的 ffmpeg 可执行文件。除图片解密自身使用外,也供 System OCR 的
|
||||
* 图片归一化(GIF/BMP/WebP/TIFF → PNG)复用,避免重复一套路径探测逻辑。
|
||||
*/
|
||||
export function resolveFfmpegExecutable(): string {
|
||||
for (const candidate of getFfmpegCandidates()) {
|
||||
const pathLike = candidate.executable.includes('/') || candidate.executable.includes('\\')
|
||||
if (pathLike) {
|
||||
|
||||
+683
-130
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,15 @@ export function parseXkeyHelperOutput(output: string): DatabaseKeyResult {
|
||||
return mapXkeyHelperFailure(rawError)
|
||||
}
|
||||
|
||||
export const SIP_ENABLED_ERROR =
|
||||
'macOS 系统完整性保护(SIP)已开启,无法自动获取数据库密钥。请先关闭 SIP,或改用手动粘贴。'
|
||||
|
||||
export function parseSipEnabled(statusOutput: string): boolean {
|
||||
const status = statusOutput.match(/status:\s*([a-z]+)/i)?.[1]?.toLowerCase()
|
||||
if (status) return status === 'enabled'
|
||||
return statusOutput.toLowerCase().includes('enabled')
|
||||
}
|
||||
|
||||
export class KeyServiceMac {
|
||||
private getMacKeyRuntimeDir(): string {
|
||||
return path.join(app.getPath('userData'), 'key-runtime')
|
||||
@@ -306,7 +315,7 @@ export class KeyServiceMac {
|
||||
private async isSipEnabled(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
|
||||
return stdout.toLowerCase().includes('enabled')
|
||||
return parseSipEnabled(stdout)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -346,10 +355,7 @@ export class KeyServiceMac {
|
||||
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
|
||||
}
|
||||
if (await this.isSipEnabled()) {
|
||||
return {
|
||||
success: false,
|
||||
error: '当前系统还未完成连接环境准备,请按页面提示完成设置。'
|
||||
}
|
||||
return { success: false, code: 'SIP_ENABLED', error: SIP_ENABLED_ERROR }
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { monitorEventLoopDelay } from 'perf_hooks'
|
||||
import * as chat from '../services/chat-service'
|
||||
import type {
|
||||
KnowledgeImageOcrState,
|
||||
KnowledgeAttachmentMetadata,
|
||||
KnowledgeEvidence,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeMessageKind,
|
||||
KnowledgePassProgress,
|
||||
KnowledgeRuntimeState,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
emptyKnowledgeSearchTimings
|
||||
} from '../../shared/knowledge'
|
||||
import { KnowledgeService } from './knowledge-service'
|
||||
import { sourceMessageId } from './message-identity'
|
||||
import {
|
||||
voiceAccountIdentity,
|
||||
voiceMessageIdentity
|
||||
@@ -118,11 +121,6 @@ function groupMemberDisplayName(member: chat.GroupSnapshot['members'][number]):
|
||||
)
|
||||
}
|
||||
|
||||
function sourceMessageId(message: chat.FormattedMessage): string {
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
if (message.id) return String(message.id)
|
||||
return `${message.createTime || 0}:${message.serverId || message.content}`
|
||||
}
|
||||
|
||||
function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
|
||||
if (message.voiceTranscript || message.type === '语音') return 'voice'
|
||||
@@ -130,6 +128,16 @@ function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
|
||||
return message.exportMediaType
|
||||
}
|
||||
if (message.exportMediaType === 'file') return 'file'
|
||||
// 索引路径上 `exportMediaType` **不会被赋值**(只有 export-service 会设它),
|
||||
// 所以图片/视频/表情包必须从 contentData.type 判定,否则图片会静默落成 'other',
|
||||
// 进而让"图片文字索引"的 Evidence 丢掉真正的来源类型。
|
||||
if (
|
||||
message.contentData?.type === 'image' ||
|
||||
message.contentData?.type === 'video' ||
|
||||
message.contentData?.type === 'sticker'
|
||||
) {
|
||||
return message.contentData.type
|
||||
}
|
||||
if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
|
||||
return message.contentData.type === 'share' && message.contentData.typeVal === '6'
|
||||
? 'file'
|
||||
@@ -201,12 +209,16 @@ function toSourceMessage(
|
||||
accountId: string,
|
||||
conversationId: string,
|
||||
message: chat.FormattedMessage,
|
||||
transcriptOverride?: string
|
||||
transcriptOverride?: string,
|
||||
imageOcr?: { state: KnowledgeImageOcrState; text: string }
|
||||
): KnowledgeSourceMessage | null {
|
||||
if (!message.createTime) return null
|
||||
const extracted = sourceTextAndAttachment(message)
|
||||
const voiceTranscript = transcriptOverride?.trim() || message.voiceTranscript?.trim() || undefined
|
||||
if (!extracted.text && !extracted.attachment && !voiceTranscript) return null
|
||||
// 图片 OCR 文本走与语音转写完全相同的派生通道:有文本才入库,
|
||||
// 没有文字的图片(表情包/风景)不会污染索引。
|
||||
const imageOcrText = imageOcr?.text?.trim() || undefined
|
||||
if (!extracted.text && !extracted.attachment && !voiceTranscript && !imageOcrText) return null
|
||||
return {
|
||||
accountId,
|
||||
conversationId,
|
||||
@@ -218,7 +230,9 @@ function toSourceMessage(
|
||||
kind: sourceKind(message),
|
||||
text: extracted.text,
|
||||
attachment: extracted.attachment,
|
||||
voiceTranscript
|
||||
voiceTranscript,
|
||||
...(imageOcrText ? { imageOcrText } : {}),
|
||||
...(imageOcrText && imageOcr?.state ? { imageOcrState: imageOcr.state } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +270,14 @@ export class KnowledgeSearchService {
|
||||
private interactiveIdleResolve: (() => void) | null = null
|
||||
private wcdbQueueMsTotal = 0
|
||||
private wcdbExecutionMsTotal = 0
|
||||
/**
|
||||
* 图片 OCR 文本解析器(由 main 注入)。
|
||||
*
|
||||
* 与语音同构:派生文本在**主进程**解析后贴到消息上,派生库不进 worker。
|
||||
*/
|
||||
private imageOcrResolver:
|
||||
| ((conversationId: string, messageId: string) => { state: KnowledgeImageOcrState; text: string } | undefined)
|
||||
| undefined
|
||||
private voiceTranscriptResolver:
|
||||
| ((reference: VoiceMessageReference) => VoiceTranscriptSnapshot)
|
||||
| undefined
|
||||
@@ -372,6 +394,15 @@ export class KnowledgeSearchService {
|
||||
this.voiceTranscriptResolver = resolver
|
||||
}
|
||||
|
||||
/** 注入图片 OCR 文本解析器(本地 System OCR 的派生结果)。 */
|
||||
setImageOcrResolver(
|
||||
resolver:
|
||||
| ((conversationId: string, messageId: string) => { state: KnowledgeImageOcrState; text: string } | undefined)
|
||||
| undefined
|
||||
): void {
|
||||
this.imageOcrResolver = resolver
|
||||
}
|
||||
|
||||
/**
|
||||
* A successful recognition updates its source conversation. Consecutive
|
||||
* updates for the same conversation are coalesced because a complete
|
||||
@@ -468,6 +499,37 @@ export class KnowledgeSearchService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单群发言聚合(群员统计的数据来源)。
|
||||
*
|
||||
* 这一层只**如实**返回引擎能给出的东西(含 `indexLatestAt`),不做追赶决策 ——
|
||||
* 「要不要等索引、要不要把结果标成不完整」是产品判断,属于 GroupStatsService。
|
||||
* 同时返回一次 `sourceLatestAt`(同步、零额外 WCDB 调用),让调用方一次拿到
|
||||
* freshness 的两个口径,不必再发一次 status 请求。
|
||||
*/
|
||||
async memberStats(request: {
|
||||
conversationId: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
}): Promise<{ result: KnowledgeMemberStatsResult | null; sourceLatestAt: number | null }> {
|
||||
const sourceLatestAt = this.sourceLatestAt()
|
||||
const accountId = this.currentAccountId()
|
||||
if (!accountId) return { result: null, sourceLatestAt }
|
||||
try {
|
||||
const result = await this.service.memberStats({
|
||||
accountId,
|
||||
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
||||
conversationId: request.conversationId,
|
||||
startTime: request.startTime,
|
||||
endTime: request.endTime
|
||||
})
|
||||
return { result, sourceLatestAt }
|
||||
} catch (error) {
|
||||
console.warn('[Knowledge] member stats failed:', error)
|
||||
return { result: null, sourceLatestAt }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。
|
||||
*/
|
||||
@@ -1051,7 +1113,7 @@ export class KnowledgeSearchService {
|
||||
lane: WcdbReadLane = 'interactive'
|
||||
): ReturnType<typeof chat.listMessagesAsync> {
|
||||
return this.enqueueWcdbRead(
|
||||
() => chat.listMessagesAsync(conversationId, startTime, endTime),
|
||||
() => chat.listMessagesAsync(conversationId, startTime, endTime, undefined, undefined, 'knowledge'),
|
||||
lane
|
||||
)
|
||||
}
|
||||
@@ -1074,8 +1136,16 @@ export class KnowledgeSearchService {
|
||||
const reference = this.voiceReferenceFromMessage(message)
|
||||
const snapshot = reference ? this.voiceTranscriptResolver?.(reference) : undefined
|
||||
const hydrated = this.withVoiceTranscript(message)
|
||||
const source = toSourceMessage(accountId, conversationId, hydrated, transcriptOverride)
|
||||
if (!source || source.kind !== 'voice') return source
|
||||
const imageOcr = this.imageOcrResolver?.(conversationId, sourceMessageId(message))
|
||||
const source = toSourceMessage(
|
||||
accountId,
|
||||
conversationId,
|
||||
hydrated,
|
||||
transcriptOverride,
|
||||
imageOcr
|
||||
)
|
||||
if (!source) return source
|
||||
if (source.kind !== 'image' && source.kind !== 'voice') return source
|
||||
return {
|
||||
...source,
|
||||
voiceTranscriptState:
|
||||
@@ -1105,6 +1175,38 @@ export class KnowledgeSearchService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个会话的图片 OCR 处理完成 → 重建该会话的索引。
|
||||
*
|
||||
* 与"语音转写完成后单会话重索引"完全同构:整会话重读 + completeSnapshot 重建,
|
||||
* 让 OCR 派生文本进入 chunks/FTS,从而可被 search_messages 检索。
|
||||
* 原图片消息仍然是 authoritative source —— 这里只是让它多了一段派生文本,
|
||||
* 不产生任何"OCR 消息"。
|
||||
*/
|
||||
async indexImageOcr(conversationId: string): Promise<void> {
|
||||
if (!chat.isReady()) return
|
||||
const accountId = this.currentAccountId()
|
||||
if (!accountId) return
|
||||
const activeIndex = this.indexing.get(accountId)
|
||||
if (activeIndex) await activeIndex
|
||||
const contacts = await this.listContacts()
|
||||
const contact = contacts.find((item) => item.md5 === conversationId)
|
||||
if (!contact) return
|
||||
const messages = await this.listMessages(contact.md5, undefined, undefined, 'background')
|
||||
const sourceMessages = messages
|
||||
.map((message) => this.toSourceMessage(accountId, contact.md5, message))
|
||||
.filter((message): message is KnowledgeSourceMessage => Boolean(message))
|
||||
await this.service.index({
|
||||
accountId,
|
||||
conversations: [
|
||||
{ conversationId: contact.md5, completeSnapshot: true, messages: sourceMessages }
|
||||
],
|
||||
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
|
||||
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG
|
||||
})
|
||||
await this.refreshStatus(accountId)
|
||||
}
|
||||
|
||||
private async indexVoiceTranscriptNow(update: VoiceTranscriptUpdate): Promise<void> {
|
||||
if (!chat.isReady()) return
|
||||
if (update.state === 'transcribed' && !update.transcript?.trim()) return
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchResult,
|
||||
@@ -53,6 +55,13 @@ export class KnowledgeService {
|
||||
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
|
||||
}
|
||||
|
||||
/** 单个会话内「按发送者聚合」的发言统计(群员统计用)。 */
|
||||
memberStats(
|
||||
request: Omit<KnowledgeMemberStatsRequest, 'databaseRoot'>
|
||||
): Promise<KnowledgeMemberStatsResult> {
|
||||
return this.worker.memberStats({ ...request, databaseRoot: this.databaseRoot })
|
||||
}
|
||||
|
||||
/** 只中止正在跑的索引任务;查询请求不受影响。 */
|
||||
cancelIndex(): Promise<boolean> {
|
||||
return this.worker.cancelActiveIndex()
|
||||
|
||||
@@ -13,13 +13,18 @@ import type {
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeNormalizedMessage,
|
||||
KnowledgeQuery,
|
||||
KnowledgeSearchTimings,
|
||||
KnowledgeSearchResult
|
||||
} from '../../shared/knowledge'
|
||||
import { emptyKnowledgeSearchTimings, KNOWLEDGE_SCHEMA_VERSION } from '../../shared/knowledge'
|
||||
import {
|
||||
emptyKnowledgeSearchTimings,
|
||||
KNOWLEDGE_SCHEMA_VERSION,
|
||||
toEvidenceDisplayText
|
||||
} from '../../shared/knowledge'
|
||||
import { chunkConversation } from './chunker'
|
||||
import { normalizeKnowledgeMessage } from './normalizer'
|
||||
|
||||
@@ -313,6 +318,93 @@ export class KnowledgeStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个会话在时间窗内的「按发送者聚合」统计。
|
||||
*
|
||||
* 只回聚合结果、不回消息正文:群员统计只需要「谁说了几条、最后一条是什么时候」,
|
||||
* 把消息逐条搬到主进程再统计会把几十万行推过 IPC 边界。
|
||||
*
|
||||
* 三条硬规则**全部由 SQL 保证**,不指望调用方记得:
|
||||
* 1. `kind <> 'system'`:系统消息不是任何成员的发言(微信侧 10000/10002 在建库时
|
||||
* 已归一为 `kind = 'system'`,见 `knowledge-search-service` 的 kind 映射);
|
||||
* 2. `sender_id IS NULL` 的行不归给任何人,只计入 `unattributedMessages` ——
|
||||
* 硬塞给某个成员会让「未发言」名单出现错误否定;
|
||||
* 3. 时间窗口是**闭区间**,单位 **epoch 毫秒**,与 knowledge 内部口径一致,
|
||||
* 不经过 WCDB 的秒级边界(跨错单位会静默读到 0 条)。
|
||||
*/
|
||||
memberStats(request: {
|
||||
conversationId: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
}): KnowledgeMemberStatsResult {
|
||||
const { conversationId, startTime, endTime } = request
|
||||
const indexLatestAt = this.readIndexLatestAt()
|
||||
const empty: KnowledgeMemberStatsResult = {
|
||||
conversationId,
|
||||
totalMessages: 0,
|
||||
senders: [],
|
||||
unattributedMessages: 0,
|
||||
excludedSystemMessages: 0,
|
||||
earliestMessageTime: null,
|
||||
indexLatestAt
|
||||
}
|
||||
if (!conversationId) return empty
|
||||
|
||||
// 用 `(conversation_id, create_time)` 索引直接命中:这是本查询唯一的访问路径,
|
||||
// 写成全表扫描等价于把单群统计的 26ms 变成 10s。
|
||||
const scope = 'conversation_id = ? AND create_time >= ? AND create_time <= ?'
|
||||
const args = [conversationId, startTime, endTime]
|
||||
|
||||
const count = (extra: string): number => {
|
||||
const row = this.database
|
||||
.prepare(`SELECT COUNT(*) AS n FROM knowledge_messages WHERE ${scope} AND ${extra}`)
|
||||
.get(...args) as DbRow | undefined
|
||||
return Number(row?.n) || 0
|
||||
}
|
||||
|
||||
const rows = asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
`SELECT sender_id,
|
||||
COUNT(*) AS message_count,
|
||||
MAX(create_time) AS last_message_time
|
||||
FROM knowledge_messages
|
||||
WHERE ${scope} AND kind <> 'system' AND sender_id IS NOT NULL
|
||||
GROUP BY sender_id
|
||||
ORDER BY message_count DESC, last_message_time DESC`
|
||||
)
|
||||
.all(...args)
|
||||
)
|
||||
|
||||
const senders: KnowledgeMemberStatsResult['senders'] = []
|
||||
for (const row of rows) {
|
||||
const senderId = String(row.sender_id ?? '').trim()
|
||||
if (!senderId) continue
|
||||
senders.push({
|
||||
senderId,
|
||||
messageCount: Number(row.message_count) || 0,
|
||||
lastMessageTime: Number(row.last_message_time) || 0
|
||||
})
|
||||
}
|
||||
|
||||
// 窗口内最早一条消息:**不过滤 kind** —— 群的第一条常常是建群通知,
|
||||
// 那才是用户认知里的「这个群第一条消息」。选「全部」时用它显示真实起点。
|
||||
const earliestRow = this.database
|
||||
.prepare(`SELECT MIN(create_time) AS m FROM knowledge_messages WHERE ${scope}`)
|
||||
.get(...args) as DbRow | undefined
|
||||
const earliestValue = Number(earliestRow?.m)
|
||||
|
||||
return {
|
||||
conversationId,
|
||||
totalMessages: count("kind <> 'system'"),
|
||||
senders,
|
||||
unattributedMessages: count("kind <> 'system' AND sender_id IS NULL"),
|
||||
excludedSystemMessages: count("kind = 'system'"),
|
||||
earliestMessageTime: Number.isFinite(earliestValue) && earliestValue > 0 ? earliestValue : null,
|
||||
indexLatestAt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。
|
||||
*
|
||||
@@ -761,7 +853,8 @@ export class KnowledgeStore {
|
||||
evidence: asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
`SELECT m.conversation_id, m.message_id, m.create_time, m.searchable_text, m.kind, m.sender_id, m.sender_name
|
||||
`SELECT m.conversation_id, m.message_id, m.create_time, m.searchable_text, m.kind, m.sender_id, m.sender_name,
|
||||
m.image_ocr_text, m.voice_transcript
|
||||
FROM knowledge_messages m
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
ORDER BY m.create_time DESC
|
||||
@@ -789,7 +882,22 @@ export class KnowledgeStore {
|
||||
timestamp: Number(row.create_time),
|
||||
messageIds: chunk ? chunk.map((item) => String(item.message_id)) : [messageId],
|
||||
sourceKind: String(row.kind) as KnowledgeEvidence['sourceKind'],
|
||||
text: String(row.searchable_text),
|
||||
// 内部前缀(`图片文字:`)绝不能进 Evidence:面向用户与模型的是可读文本,
|
||||
// 来源信息由下面的结构化字段表达。
|
||||
text: toEvidenceDisplayText(String(row.searchable_text)),
|
||||
...(row.image_ocr_text ? { imageOcrText: String(row.image_ocr_text) } : {}),
|
||||
/*
|
||||
* 来源标记按"这条消息带什么派生内容"判定,与 `sourceKind` 正交:
|
||||
* `image_ocr` = 靠图片里的文字命中,`voice_transcript` = 靠语音转写命中。
|
||||
*
|
||||
* 两者都有时以图片 OCR 为先 —— 图片消息不会同时带语音转写,这里只是取确定值,
|
||||
* 实际不会出现需要二选一的数据。
|
||||
*/
|
||||
...(row.image_ocr_text
|
||||
? { derivedSource: 'image_ocr' as const }
|
||||
: String(row.voice_transcript || '').trim()
|
||||
? { derivedSource: 'voice_transcript' as const }
|
||||
: {}),
|
||||
score: String(row.kind) === 'system' ? 1 : 0
|
||||
}
|
||||
}
|
||||
@@ -899,6 +1007,7 @@ export class KnowledgeStore {
|
||||
attachment_json TEXT,
|
||||
voice_transcript TEXT,
|
||||
voice_transcript_state TEXT,
|
||||
image_ocr_text TEXT,
|
||||
PRIMARY KEY (conversation_id, message_id)
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time
|
||||
@@ -957,6 +1066,14 @@ export class KnowledgeStore {
|
||||
if (!messageColumns.has('voice_transcript_state')) {
|
||||
this.database.exec('ALTER TABLE knowledge_messages ADD COLUMN voice_transcript_state TEXT')
|
||||
}
|
||||
// 图片 OCR 派生文本单独留一列(不只是埋进 searchable_text)。
|
||||
//
|
||||
// 为什么必须落列而不是从 searchable_text 里截字符串:Evidence 需要回答
|
||||
// "这条结果是不是来自图片里的文字",并按此给出来源标记与 OCR 片段。
|
||||
// 靠解析前缀来判来源,一旦前缀格式调整就会静默失效。
|
||||
if (!messageColumns.has('image_ocr_text')) {
|
||||
this.database.exec('ALTER TABLE knowledge_messages ADD COLUMN image_ocr_text TEXT')
|
||||
}
|
||||
this.writeMetaIfMissing('schema_version', String(KNOWLEDGE_SCHEMA_VERSION))
|
||||
const storedAccount = this.readMeta('account_id')
|
||||
if (storedAccount && storedAccount !== this.accountId) {
|
||||
@@ -1137,8 +1254,9 @@ export class KnowledgeStore {
|
||||
const upsert = this.database.prepare(
|
||||
`INSERT INTO knowledge_messages (
|
||||
account_id, conversation_id, message_id, create_time, content_hash, searchable_text,
|
||||
kind, sender_id, sender_name, attachment_json, voice_transcript, voice_transcript_state
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
kind, sender_id, sender_name, attachment_json, voice_transcript, voice_transcript_state,
|
||||
image_ocr_text
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(conversation_id, message_id) DO UPDATE SET
|
||||
create_time = excluded.create_time,
|
||||
content_hash = excluded.content_hash,
|
||||
@@ -1148,7 +1266,8 @@ export class KnowledgeStore {
|
||||
sender_name = excluded.sender_name,
|
||||
attachment_json = excluded.attachment_json,
|
||||
voice_transcript = excluded.voice_transcript,
|
||||
voice_transcript_state = excluded.voice_transcript_state`
|
||||
voice_transcript_state = excluded.voice_transcript_state,
|
||||
image_ocr_text = excluded.image_ocr_text`
|
||||
)
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
this.assertNotAborted(signal)
|
||||
@@ -1165,7 +1284,8 @@ export class KnowledgeStore {
|
||||
message.senderName ?? null,
|
||||
message.attachment ? encodedJson(message.attachment) : null,
|
||||
message.voiceTranscript ?? null,
|
||||
message.voiceTranscriptState ?? null
|
||||
message.voiceTranscriptState ?? null,
|
||||
message.imageOcrText ?? null
|
||||
)
|
||||
if (index % YIELD_EVERY === 0) {
|
||||
onProgress(index + 1, 0)
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchResult,
|
||||
@@ -19,6 +21,7 @@ type WorkerResult =
|
||||
| KnowledgeCapacityPreflight
|
||||
| KnowledgeSearchResult
|
||||
| KnowledgeRuntimeStatus
|
||||
| KnowledgeMemberStatsResult
|
||||
| { marks: Record<string, number> }
|
||||
| { removed: true }
|
||||
type PendingRequest = {
|
||||
@@ -76,6 +79,11 @@ export class KnowledgeWorkerHost {
|
||||
)
|
||||
}
|
||||
|
||||
/** 单个会话内「按发送者聚合」的发言统计(群员统计用,只回聚合不回正文)。 */
|
||||
memberStats(payload: KnowledgeMemberStatsRequest): Promise<KnowledgeMemberStatsResult> {
|
||||
return this.request('memberStats', payload) as Promise<KnowledgeMemberStatsResult>
|
||||
}
|
||||
|
||||
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
|
||||
async cancelActiveIndex(): Promise<boolean> {
|
||||
const target = this.activeIndexRequestId
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
KnowledgeCapacityPreflightRequest,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeStatusRequest,
|
||||
@@ -187,6 +189,38 @@ async function handleHighWater(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 群员统计的按发送者聚合。
|
||||
*
|
||||
* 与 `handleSearch` 同样先判库是否存在:**「还没建索引」是正常状态,不是故障**,
|
||||
* 返回空结果而不是抛错,让上层能稳定地区分「没人发言」与「索引不存在」。
|
||||
*/
|
||||
async function handleMemberStats(
|
||||
request: KnowledgeWorkerRequest,
|
||||
payload: KnowledgeMemberStatsRequest
|
||||
): Promise<void> {
|
||||
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
|
||||
if (!existsSync(path)) {
|
||||
const unavailable: KnowledgeMemberStatsResult = {
|
||||
conversationId: payload.conversationId,
|
||||
totalMessages: 0,
|
||||
senders: [],
|
||||
unattributedMessages: 0,
|
||||
excludedSystemMessages: 0,
|
||||
earliestMessageTime: null,
|
||||
indexLatestAt: null
|
||||
}
|
||||
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
|
||||
return
|
||||
}
|
||||
send({
|
||||
version: 1,
|
||||
type: 'result',
|
||||
requestId: request.requestId,
|
||||
payload: getStore(payload).memberStats(payload)
|
||||
})
|
||||
}
|
||||
|
||||
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
|
||||
try {
|
||||
if (request.type === 'cancel') {
|
||||
@@ -226,6 +260,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
|
||||
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
|
||||
return
|
||||
}
|
||||
if (request.type === 'memberStats') {
|
||||
await handleMemberStats(request, request.payload as KnowledgeMemberStatsRequest)
|
||||
return
|
||||
}
|
||||
if (request.type === 'index') {
|
||||
await handleIndex(request, request.payload as KnowledgeIndexRequest)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 消息身份的**唯一真源**。
|
||||
*
|
||||
* 这个规则同时被三处需要:
|
||||
* - Knowledge 索引写入 `knowledge_messages.message_id`
|
||||
* - 图片文字索引的 binding(必须与 Knowledge 里的 message_id 完全一致,否则 OCR 文本贴不到消息上)
|
||||
* - Evidence → 档案跳转的 messageRef
|
||||
*
|
||||
* 任何一处各自复制一份,都会在 `local:` 前缀上静默失配(项目里已经有这个坑的历史注释),
|
||||
* 所以抽成一个模块,谁都不许再抄。
|
||||
*/
|
||||
import type * as chat from '../services/chat-service'
|
||||
|
||||
/**
|
||||
* 源消息 → 稳定消息 id。
|
||||
*
|
||||
* 降级顺序刻意保守:`localId` 是 WCDB 行内最稳的本地 id;其次用消息自带 id;
|
||||
* 最后才退化成「时间 + 服务端 id / 内容」的组合(仅在极端缺字段时命中)。
|
||||
*/
|
||||
export function sourceMessageId(message: chat.FormattedMessage): string {
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
if (message.id) return String(message.id)
|
||||
return `${message.createTime || 0}:${message.serverId || message.content}`
|
||||
}
|
||||
@@ -24,6 +24,10 @@ export function normalizeKnowledgeMessage(
|
||||
const transcript = compact(source.voiceTranscript)
|
||||
if (transcript) sections.push(`语音转写:${transcript}`)
|
||||
|
||||
// 图片 OCR 文本:与语音同样的"固定前缀"约定,让检索与展示都能识别这是派生内容。
|
||||
const imageText = compact(source.imageOcrText)
|
||||
if (imageText) sections.push(`图片文字:${imageText}`)
|
||||
|
||||
const attachmentName = compact(source.attachment?.name)
|
||||
if (attachmentName) {
|
||||
const label = source.attachment?.kind === 'link' ? '链接' : '附件'
|
||||
@@ -45,6 +49,7 @@ export function normalizeKnowledgeMessage(
|
||||
senderId: source.senderId || '',
|
||||
kind: source.kind,
|
||||
voiceTranscriptState: source.voiceTranscriptState || '',
|
||||
imageOcrState: source.imageOcrState || '',
|
||||
searchableText
|
||||
})
|
||||
)
|
||||
|
||||
+119
-8
@@ -125,7 +125,10 @@ export type ParsedContent =
|
||||
export function parseMessageContent(content: string, messageType: number): ParsedContent {
|
||||
// Voice rows may keep their binary payload outside msgContent, so an empty
|
||||
// content string is still a valid voice message.
|
||||
if (messageType === 34) return { type: 'voice' }
|
||||
if (messageType === 34) {
|
||||
const duration = parseVoiceDurationSeconds(content)
|
||||
return duration === undefined ? { type: 'voice' } : { type: 'voice', duration }
|
||||
}
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { type: 'unknown', raw: content || '' }
|
||||
}
|
||||
@@ -157,6 +160,33 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音时长藏在解压后的 message_content 里:`<voicemsg ... voicelength="1600" ...>`,单位毫秒。
|
||||
* Msg_* 表没有 voice_length 列,这是唯一来源。
|
||||
*
|
||||
* `<voicemsg>` 上两个极易混淆的属性(真机实测,同一条 1.6 秒语音,2026-09-20):
|
||||
*
|
||||
* - `voicelength="1600"` → **毫秒时长**。这条语音微信气泡显示 2"(1.6 秒四舍五入)。
|
||||
* **要取的是它。**
|
||||
* - `length="6672"` → **SILK 编码数据的字节数,与时长无关**。
|
||||
* 已验证:`wcdb_get_voice_data` 取出的 SILK 恰好是 6672 字节,
|
||||
* 解码后为 51200 字节 PCM(1.6 秒)。误取它会算出 6.672 秒,把 2" 显示成 0:07。
|
||||
*
|
||||
* 换算成秒后**刻意保留小数**(1600ms → 1.6):在这里取整会把精度永久丢掉,
|
||||
* 后面显示层再怎么四舍五入都对不回微信的口径(微信是四舍五入到整秒)。
|
||||
*
|
||||
* 注:`<videomsg length="...">` 的 `length` 同理是字节数,不是时长。
|
||||
*/
|
||||
function parseVoiceDurationSeconds(content: string): number | undefined {
|
||||
if (!content || typeof content !== 'string') return undefined
|
||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||
const rawLength = extractXmlAttribute(decoded, 'voicemsg', 'voicelength')
|
||||
if (!rawLength) return undefined
|
||||
const milliseconds = Number(rawLength)
|
||||
if (!Number.isFinite(milliseconds) || milliseconds <= 0) return undefined
|
||||
return milliseconds / 1000
|
||||
}
|
||||
|
||||
function parseVideoMessage(content: string): ParsedContent {
|
||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
|
||||
@@ -181,6 +211,14 @@ function parseSystemMessage(content: string): ParsedContent {
|
||||
recall
|
||||
}
|
||||
}
|
||||
const templateText = extractSysmsgTemplateText(decoded)
|
||||
if (templateText) {
|
||||
return {
|
||||
type: 'system',
|
||||
content: templateText,
|
||||
raw: content
|
||||
}
|
||||
}
|
||||
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
||||
if (delChatroomMemberText) {
|
||||
return {
|
||||
@@ -189,16 +227,19 @@ function parseSystemMessage(content: string): ParsedContent {
|
||||
raw: content
|
||||
}
|
||||
}
|
||||
// <link_list> 里放的是富文本片段(可能含 hidden="1" 的可点击按钮),
|
||||
// 不是消息正文;先剥掉再走通用提取,避免把按钮文案当成整条系统消息。
|
||||
const withoutLinkList = stripSysmsgLinkList(decoded)
|
||||
const plainText =
|
||||
extractXmlNodeText(decoded, 'plain') ||
|
||||
extractXmlNodeText(decoded, 'text') ||
|
||||
extractXmlNodeText(decoded, 'title') ||
|
||||
extractXmlValue(decoded, 'plain') ||
|
||||
extractXmlValue(decoded, 'text') ||
|
||||
extractXmlValue(decoded, 'title') ||
|
||||
extractXmlNodeText(withoutLinkList, 'plain') ||
|
||||
extractXmlNodeText(withoutLinkList, 'text') ||
|
||||
extractXmlNodeText(withoutLinkList, 'title') ||
|
||||
extractXmlValue(withoutLinkList, 'plain') ||
|
||||
extractXmlValue(withoutLinkList, 'text') ||
|
||||
extractXmlValue(withoutLinkList, 'title') ||
|
||||
''
|
||||
|
||||
const normalized = normalizeSystemText(plainText || fallbackSystemText(decoded))
|
||||
const normalized = normalizeSystemText(plainText || fallbackSystemText(withoutLinkList))
|
||||
return {
|
||||
type: 'system',
|
||||
content: normalized || '[系统消息]',
|
||||
@@ -838,6 +879,76 @@ function extractDelChatroomMemberText(xml: string): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 剥掉 <link_list> 区块 —— 其中的文案属于富文本片段,不是消息正文。 */
|
||||
function stripSysmsgLinkList(xml: string): string {
|
||||
return String(xml || '').replace(/<link_list\b[\s\S]*?<\/link_list>/gi, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信 4.x 起,部分系统消息改成「模板」格式,正文不再写在 <plain> 里。
|
||||
*
|
||||
* 旧格式(正文就在 <plain>,取到即可):
|
||||
*
|
||||
* <sysmsg type="delchatroommember"><delchatroommember>
|
||||
* <plain><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></plain>
|
||||
* <link><scene>qrcode</scene><text><![CDATA[撤销]]></text>…</link>
|
||||
* </delchatroommember></sysmsg>
|
||||
*
|
||||
* 新格式(<plain> 变空,正文挪进 <template>,用 $名称$ 引用 <link_list> 里的 link):
|
||||
*
|
||||
* <sysmsg type="sysmsgtemplate"><sysmsgtemplate>
|
||||
* <content_template type="tmpl_type_profilewithrevokeqrcode">
|
||||
* <plain><![CDATA[]]></plain>
|
||||
* <template><![CDATA["$adder$"通过扫描你分享的二维码加入群聊 $revoke$]]></template>
|
||||
* <link_list>
|
||||
* <link name="adder" type="link_profile">
|
||||
* <memberlist><member><nickname><![CDATA[成员昵称]]></nickname></member></memberlist>
|
||||
* </link>
|
||||
* <link name="revoke" type="link_revoke_qrcode" hidden="1">
|
||||
* <title><![CDATA[撤销]]></title>
|
||||
* </link>
|
||||
* </link_list>
|
||||
* </content_template>
|
||||
* </sysmsgtemplate></sysmsg>
|
||||
*
|
||||
* 两个要点:
|
||||
* 1. 正文取自 <template>,其中的 $名称$ 占位符按 <link_list> 的 link name 回填;
|
||||
* 2. hidden="1" 的 link 在微信里是可点击按钮,纯文本展示时省略其文案。
|
||||
*
|
||||
* 漏掉这段会让新格式消息落进通用提取链:<plain> 为空、又没有 <text>,
|
||||
* 于是取到 <title> —— 也就是那个隐藏按钮的标题,整条系统消息只剩一个按钮名。
|
||||
*/
|
||||
function extractSysmsgTemplateText(xml: string): string {
|
||||
if (!/<sysmsgtemplate\b|<content_template\b/i.test(xml)) return ''
|
||||
|
||||
const template = extractXmlNodeText(xml, 'template')
|
||||
if (!template) return ''
|
||||
|
||||
const links = new Map<string, { text: string; hidden: boolean }>()
|
||||
const linkPattern = /<link\b([^>]*)>([\s\S]*?)<\/link>/gi
|
||||
let linkMatch: RegExpExecArray | null
|
||||
while ((linkMatch = linkPattern.exec(xml)) !== null) {
|
||||
const name = extractXmlValue(linkMatch[1], 'name')
|
||||
if (!name) continue
|
||||
links.set(name, {
|
||||
// title 用于按钮文案,nickname 用于成员展示名,text 作最后兜底。
|
||||
text:
|
||||
extractXmlNodeText(linkMatch[2], 'title') ||
|
||||
extractXmlNodeText(linkMatch[2], 'nickname') ||
|
||||
extractXmlNodeText(linkMatch[2], 'text') ||
|
||||
'',
|
||||
hidden: /hidden\s*=\s*["']1["']/i.test(linkMatch[1])
|
||||
})
|
||||
}
|
||||
|
||||
const rendered = template.replace(/\$([A-Za-z0-9_]+)\$/g, (_raw, name: string) => {
|
||||
const link = links.get(name)
|
||||
return link && !link.hidden ? link.text : ''
|
||||
})
|
||||
|
||||
return normalizeSystemText(rendered)
|
||||
}
|
||||
|
||||
function normalizeMd5(value: unknown): string | undefined {
|
||||
const md5 = String(value || '')
|
||||
.trim()
|
||||
|
||||
@@ -23,6 +23,45 @@ import {
|
||||
|
||||
const aiProvider = new AIProviderService()
|
||||
|
||||
/**
|
||||
* 用真实群成员快照补全消息的显示名与头像。
|
||||
*
|
||||
* **头像与名称的门槛刻意不同**:
|
||||
*
|
||||
* 此前这里先用 `isInternalName(message.name)` 做整体早退 —— 只有当消息里的名字还是内部标识
|
||||
* (空 / `wxid_*` / `*@chatroom` / 18+ 位字母数字)时才继续。但 `listMessages` 产出的 `name`
|
||||
* 优先取 `senderNickname`,在真实群里通常是**已可读的昵称**,于是整条记录被跳过、
|
||||
* `member.avatar` 永远补不上,导出层只能退化成首字头像;软件内日报没有这个门槛,
|
||||
* 所以它能显示真实头像。
|
||||
*
|
||||
* 现在:**只要 senderId 命中真实群成员就允许补头像**;名称只在解析结果确实是可读名时才采用,
|
||||
* 避免把调用方已有的昵称降级成空串或内部标识。消息自带的 `img` 始终优先。
|
||||
*/
|
||||
export function hydrateGroupMemberIdentity(
|
||||
messages: Message[],
|
||||
members: ReadonlyArray<NonNullable<ReturnType<typeof getGroupSnapshot>>['members'][number]>,
|
||||
memberNameMode: ScheduledReportMemberNameMode
|
||||
): Message[] {
|
||||
const index = new Map(
|
||||
members.map((member) => [
|
||||
member.wxid,
|
||||
{ name: resolveMemberName(member, memberNameMode), avatar: member.avatar }
|
||||
])
|
||||
)
|
||||
return messages.map((message) => {
|
||||
const member = index.get(String(message.senderId || message.name || ''))
|
||||
if (!member) return message
|
||||
const shouldFillAvatar = !message.img && Boolean(member.avatar)
|
||||
const resolvedName = member.name && !isInternalName(member.name) ? member.name : message.name
|
||||
if (!shouldFillAvatar && resolvedName === message.name) return message
|
||||
return {
|
||||
...message,
|
||||
name: resolvedName,
|
||||
...(shouldFillAvatar ? { img: member.avatar } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export interface AgentGroupReportRequest {
|
||||
group: string
|
||||
range?: SummaryDateRange | 'recent24h'
|
||||
@@ -101,22 +140,11 @@ export async function generateAgentGroupReport(
|
||||
|
||||
const snapshot = getGroupSnapshot(contact.md5)
|
||||
if (snapshot) {
|
||||
const members = new Map(
|
||||
snapshot.members.map((member) => [
|
||||
member.wxid,
|
||||
{
|
||||
name: resolveMemberName(member, request.memberNameMode || 'groupNickname'),
|
||||
avatar: member.avatar
|
||||
}
|
||||
])
|
||||
messages = hydrateGroupMemberIdentity(
|
||||
messages,
|
||||
snapshot.members,
|
||||
request.memberNameMode || 'groupNickname'
|
||||
)
|
||||
messages = messages.map((message) => {
|
||||
if (!isInternalName(message.name)) return message
|
||||
const member = members.get(String(message.senderId || message.name || ''))
|
||||
return member?.name
|
||||
? { ...message, name: member.name, img: message.img || member.avatar }
|
||||
: message
|
||||
})
|
||||
}
|
||||
|
||||
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
agentHubKindPlaceholder,
|
||||
type AgentHubConversation,
|
||||
type AgentHubConversationMessage,
|
||||
type AgentHubConversationSummary,
|
||||
type AgentHubMessageKind,
|
||||
type AgentHubMessageStatus
|
||||
} from '../../shared/agent-hub-conversation'
|
||||
|
||||
/**
|
||||
* Agent Hub 对话记录存储。
|
||||
*
|
||||
* 设计取向:
|
||||
* - 一个 JSON 文件装全部会话,按「最近活跃」排序,避免为几十个会话开目录;
|
||||
* - 每个会话最多保留 N 条(默认 500),超出丢最旧的,文件不会无限增长;
|
||||
* - 会话数也有上限(默认 50),只保留最近活跃的;
|
||||
* - 原子写(tmp + rename)+ 0600,任何一个环节失败都不能影响真实收发。
|
||||
*
|
||||
* 与 `WechatInboundInbox` 的区别:收件箱是**待处理的在途消息**(处理完即删),
|
||||
* 这里是**供人回看的历史**(按上限长期保留)。
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_MESSAGES_PER_CONVERSATION = 500
|
||||
const DEFAULT_MAX_CONVERSATIONS = 50
|
||||
|
||||
interface ConversationFile {
|
||||
version: 1
|
||||
conversations: AgentHubConversation[]
|
||||
}
|
||||
|
||||
export interface AgentHubConversationStoreOptions {
|
||||
filePath: () => string
|
||||
maxMessagesPerConversation?: number
|
||||
maxConversations?: number
|
||||
now?: () => number
|
||||
createId?: () => string
|
||||
}
|
||||
|
||||
export interface AppendInput {
|
||||
userId: string
|
||||
accountId?: string
|
||||
direction: 'in' | 'out'
|
||||
kind: AgentHubMessageKind
|
||||
text: string
|
||||
messageId?: string
|
||||
status?: AgentHubMessageStatus
|
||||
errorCode?: string
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
export class AgentHubConversationStore {
|
||||
private readonly options: AgentHubConversationStoreOptions
|
||||
private readonly maxMessages: number
|
||||
private readonly maxConversations: number
|
||||
private cache: AgentHubConversation[] | null = null
|
||||
|
||||
constructor(options: AgentHubConversationStoreOptions) {
|
||||
this.options = options
|
||||
this.maxMessages = options.maxMessagesPerConversation ?? DEFAULT_MAX_MESSAGES_PER_CONVERSATION
|
||||
this.maxConversations = options.maxConversations ?? DEFAULT_MAX_CONVERSATIONS
|
||||
}
|
||||
|
||||
/** 追加一条消息,返回受影响会话的摘要与这条消息(供 UI 增量刷新)。 */
|
||||
append(
|
||||
input: AppendInput
|
||||
): { summary: AgentHubConversationSummary; message: AgentHubConversationMessage } | null {
|
||||
const userId = String(input.userId || '').trim()
|
||||
if (!userId) return null
|
||||
|
||||
const createdAt = input.createdAt ?? this.options.now?.() ?? Date.now()
|
||||
const message: AgentHubConversationMessage = {
|
||||
id: this.options.createId?.() ?? randomUUID(),
|
||||
direction: input.direction,
|
||||
kind: input.kind,
|
||||
text: String(input.text ?? ''),
|
||||
createdAt,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.errorCode ? { errorCode: input.errorCode } : {}),
|
||||
...(input.messageId ? { messageId: input.messageId } : {})
|
||||
}
|
||||
|
||||
try {
|
||||
const conversations = this.load()
|
||||
const index = conversations.findIndex((item) => item.userId === userId)
|
||||
if (index >= 0) {
|
||||
const existing = conversations[index]
|
||||
const messages = [...existing.messages, message].slice(-this.maxMessages)
|
||||
conversations[index] = {
|
||||
...existing,
|
||||
...(input.accountId ? { accountId: input.accountId } : {}),
|
||||
lastAt: createdAt,
|
||||
messages
|
||||
}
|
||||
} else {
|
||||
conversations.push({
|
||||
userId,
|
||||
...(input.accountId ? { accountId: input.accountId } : {}),
|
||||
firstAt: createdAt,
|
||||
lastAt: createdAt,
|
||||
messages: [message]
|
||||
})
|
||||
}
|
||||
|
||||
// 只保留最近活跃的 maxConversations 个会话。
|
||||
const trimmed = conversations
|
||||
.sort((left, right) => right.lastAt - left.lastAt)
|
||||
.slice(0, this.maxConversations)
|
||||
this.persist(trimmed)
|
||||
this.cache = trimmed
|
||||
const summary = this.toSummary(trimmed.find((item) => item.userId === userId) ?? null)
|
||||
return summary ? { summary, message } : null
|
||||
} catch (error) {
|
||||
// 记录历史失败绝不能影响真实收发。
|
||||
console.warn('[AgentHubConversation] 写入对话记录失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
listSummaries(): AgentHubConversationSummary[] {
|
||||
return this.load()
|
||||
.slice()
|
||||
.sort((left, right) => right.lastAt - left.lastAt)
|
||||
.map((conversation) => this.toSummary(conversation))
|
||||
.filter((summary): summary is AgentHubConversationSummary => summary !== null)
|
||||
}
|
||||
|
||||
get(userId: string): AgentHubConversation | null {
|
||||
const normalized = String(userId || '').trim()
|
||||
if (!normalized) return null
|
||||
const found = this.load().find((item) => item.userId === normalized)
|
||||
if (!found) return null
|
||||
return { ...found, messages: found.messages.map((message) => ({ ...message })) }
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.persist([])
|
||||
this.cache = []
|
||||
}
|
||||
|
||||
private toSummary(conversation: AgentHubConversation | null): AgentHubConversationSummary | null {
|
||||
if (!conversation) return null
|
||||
const last = conversation.messages[conversation.messages.length - 1]
|
||||
const preview = last ? last.text.trim() || agentHubKindPlaceholder(last.kind) : ''
|
||||
return {
|
||||
userId: conversation.userId,
|
||||
...(conversation.accountId ? { accountId: conversation.accountId } : {}),
|
||||
firstAt: conversation.firstAt,
|
||||
lastAt: conversation.lastAt,
|
||||
messageCount: conversation.messages.length,
|
||||
lastPreview: preview,
|
||||
lastDirection: last?.direction ?? 'in'
|
||||
}
|
||||
}
|
||||
|
||||
private load(): AgentHubConversation[] {
|
||||
if (this.cache) return this.cache
|
||||
let parsed: ConversationFile | null = null
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(this.options.filePath(), 'utf8')) as ConversationFile
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
const conversations = Array.isArray(parsed?.conversations)
|
||||
? parsed!.conversations.filter((item): item is AgentHubConversation =>
|
||||
Boolean(item && typeof item === 'object' && String(item.userId || '').trim())
|
||||
)
|
||||
: []
|
||||
this.cache = conversations
|
||||
return conversations
|
||||
}
|
||||
|
||||
private persist(conversations: AgentHubConversation[]): void {
|
||||
const path = this.options.filePath()
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
||||
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`
|
||||
try {
|
||||
writeFileSync(
|
||||
tempPath,
|
||||
JSON.stringify({ version: 1, conversations } satisfies ConversationFile),
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
)
|
||||
chmodSync(tempPath, 0o600)
|
||||
renameSync(tempPath, path)
|
||||
chmodSync(path, 0o600)
|
||||
} catch (error) {
|
||||
rmSync(tempPath, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,15 +87,50 @@ export function matchGroupMemberChatIntent(text: string): GroupMemberChatIntent
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 「列出会话」的**形态标记**:用户在问"有哪些 / 和谁 / 列表 / 最近 N 条",
|
||||
* 而不是在问"聊了什么内容"。
|
||||
*/
|
||||
const RECENT_LIST_SHAPE = /(哪些|哪个|都有谁|都跟谁|和谁|跟谁|是谁|列表|名单|\d{1,2}(条|个|位))/
|
||||
/** 名单类问题必须落到会话 / 联系人这个对象上。 */
|
||||
const RECENT_LIST_TARGET = /(聊天|会话|联系人|好友|人|群|消息|窗口)/
|
||||
/** 「和谁 / 跟谁」问法本身就在问会话对象,不要求额外载体词。 */
|
||||
const RECENT_PEER_QUESTION = /(和谁|跟谁)/
|
||||
/** 内容探针:问的是消息里的内容 / 是否提到某事物 —— 必须交给 Query Agent。 */
|
||||
const RECENT_CONTENT_PROBE =
|
||||
/(提到|提过|说过|说啥|说什么|说了什么|聊了啥|聊了什么|都聊什么|都说什么|什么话题|聊到|讨论|内容|讲了什么|哪条|哪一句|有没有|是否)/
|
||||
|
||||
/**
|
||||
* "最近有哪些会话"类请求。
|
||||
*
|
||||
* 这是**确定性能力**(列出会话),不是消息内容查询 —— Query Agent 无法表达,
|
||||
* 因此保留为不经过模型的无 LLM 快捷路径。
|
||||
*
|
||||
* 判定必须**正向**:只有用户确实在要一份"会话 / 联系人名单"时才算 recent_list。
|
||||
* 早先的实现只要求「最近」+「消息|会话|聊天」同时出现,于是
|
||||
* 「最近群里聊的消息里有没有提到报价?」这类**内容查询**会被截走,
|
||||
* 直接回一串会话名,用户永远得不到答案。
|
||||
*/
|
||||
export function matchRecentChatIntent(text: string): number | null {
|
||||
const normalized = text.replace(/\s+/g, '')
|
||||
if (!normalized.includes('最近') || !/(消息|会话|聊天)/.test(normalized)) return null
|
||||
if (!normalized.includes('最近')) return null
|
||||
|
||||
// 内容探针优先排除:问"有没有提到 X / 谁提过 X / 聊了什么"是在查消息内容,不是要名单。
|
||||
if (RECENT_CONTENT_PROBE.test(normalized)) return null
|
||||
|
||||
/**
|
||||
* 只有两种形态算"要最近会话列表":
|
||||
* 1) 「和谁 / 跟谁」问法 —— 它本身就在问会话对象,不需要额外的载体词
|
||||
* (如「最近和谁聊过」);
|
||||
* 2) 名单形态 + 会话载体 —— 如「最近有哪些聊天」「最近 5 个会话」「最近3条消息」。
|
||||
*
|
||||
* 两种都不满足时交给 Query Agent:形状不像"要名单"的,就是在问内容。
|
||||
*/
|
||||
const listLike =
|
||||
RECENT_PEER_QUESTION.test(normalized) ||
|
||||
(RECENT_LIST_SHAPE.test(normalized) && RECENT_LIST_TARGET.test(normalized))
|
||||
if (!listLike) return null
|
||||
|
||||
const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5)
|
||||
return Math.max(1, Math.min(20, limit))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -249,16 +249,33 @@ function selectCoverage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Citation sanitize 的允许集合。
|
||||
*
|
||||
* 两个入口都只能引用「Host 侧已分配」的编号,但它们的形状不同:
|
||||
* - Query Agent:`citationId` 字符串集合(`EvidenceCollector` 分配的 `E1`…`En`);
|
||||
* - Legacy AI Search:Final Evidence 列表(取其中的 `id`)。
|
||||
*
|
||||
* 故意不接受裸 `string`:那会被当成字符序列迭代,静默退化成单字符白名单。
|
||||
*/
|
||||
export type CitationAllowList =
|
||||
| ReadonlyArray<string | Pick<AiSearchFinalEvidence, 'id'>>
|
||||
| ReadonlySet<string>
|
||||
|
||||
/** Do not expose citations that cannot resolve to program-owned Final Evidence. */
|
||||
export function sanitizeAnswerCitations(
|
||||
answer: string,
|
||||
evidence: Array<Pick<AiSearchFinalEvidence, 'id'>>
|
||||
allowed: CitationAllowList
|
||||
): CitationValidationResult {
|
||||
const allowed = new Set(evidence.map((item) => item.id))
|
||||
const allowedIds = new Set<string>()
|
||||
for (const entry of allowed) {
|
||||
if (typeof entry === 'string') allowedIds.add(entry)
|
||||
else if (entry && typeof entry.id === 'string') allowedIds.add(entry.id)
|
||||
}
|
||||
const invalidCitationIds = new Set<string>()
|
||||
const sanitized = answer.replace(/\[E(\d+)\]/g, (citation, number: string) => {
|
||||
const id = `E${number}`
|
||||
if (allowed.has(id as AiSearchFinalEvidence['id'])) return citation
|
||||
if (allowedIds.has(id)) return citation
|
||||
invalidCitationIds.add(id)
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -86,7 +86,8 @@ export class AskWechatService {
|
||||
const diagnostics = this.diagnostics(
|
||||
{ provider: '', model: '', modelCallCount: 0, toolCallCount: 0, traces: [] },
|
||||
startedAt,
|
||||
'runtime_error'
|
||||
'runtime_error',
|
||||
{ question }
|
||||
)
|
||||
this.writeLog('error', `Query Agent Runtime 异常(${this.options.entry})`, diagnostics)
|
||||
return this.fallback(request, 'runtime_error', diagnostics)
|
||||
@@ -97,13 +98,13 @@ export class AskWechatService {
|
||||
engine: 'query-agent',
|
||||
status: 'error',
|
||||
message: EMPTY_QUESTION_MESSAGE,
|
||||
diagnostics: this.diagnostics(result, startedAt, 'invalid_question')
|
||||
diagnostics: this.diagnostics(result, startedAt, 'invalid_question', { question })
|
||||
}
|
||||
}
|
||||
|
||||
if (result.errorKind === 'provider_unavailable' || result.errorKind === 'provider_failure') {
|
||||
const outcome: AskWechatOutcome = result.errorKind
|
||||
const diagnostics = this.diagnostics(result, startedAt, outcome)
|
||||
const diagnostics = this.diagnostics(result, startedAt, outcome, { question })
|
||||
this.writeLog('warn', `查询 Provider 不可用(${this.options.entry})`, diagnostics)
|
||||
return {
|
||||
engine: 'query-agent',
|
||||
@@ -114,13 +115,13 @@ export class AskWechatService {
|
||||
}
|
||||
|
||||
if (result.errorKind === 'tool_limit') {
|
||||
const diagnostics = this.diagnostics(result, startedAt, 'tool_limit')
|
||||
const diagnostics = this.diagnostics(result, startedAt, 'tool_limit', { question })
|
||||
this.writeLog('warn', `查询超出工具调用上限(${this.options.entry})`, diagnostics)
|
||||
return this.fallback(request, 'runtime_error', diagnostics)
|
||||
}
|
||||
|
||||
if (!result.answer?.trim()) {
|
||||
const diagnostics = this.diagnostics(result, startedAt, 'runtime_error')
|
||||
const diagnostics = this.diagnostics(result, startedAt, 'runtime_error', { question })
|
||||
this.writeLog('warn', `Query Agent 未返回回答(${this.options.entry})`, diagnostics)
|
||||
return this.fallback(request, 'runtime_error', diagnostics)
|
||||
}
|
||||
@@ -128,14 +129,19 @@ export class AskWechatService {
|
||||
const answer = result.answer.trim()
|
||||
// 澄清回答也记录:下一句("是 BOBO")需要接得上上文。
|
||||
this.memory.record(conversationKey, question, answer)
|
||||
const diagnostics = this.diagnostics(result, startedAt, 'answered')
|
||||
const diagnostics = this.diagnostics(result, startedAt, 'answered', { question, answer })
|
||||
this.writeLog('info', `Query Agent 回答完成(${this.options.entry})`, diagnostics)
|
||||
return {
|
||||
engine: 'query-agent',
|
||||
status: 'answered',
|
||||
answer,
|
||||
// 直接透传 Runtime 收集的真实证据:UI 不允许从 answer 文本反解析。
|
||||
// citationId 由 Runtime 分配,Adapter 不改写、不重编号。
|
||||
evidence: (result.evidence || []) as AskWechatEvidenceItem[],
|
||||
// Host 侧 citation 校验中被移除的非法编号(非空 = 模型引用过不存在的 E#)。
|
||||
...(result.invalidCitationIds?.length
|
||||
? { invalidCitationIds: result.invalidCitationIds }
|
||||
: {}),
|
||||
stats: buildAskWechatStats(result, request.scope, startedAt),
|
||||
diagnostics
|
||||
}
|
||||
@@ -167,7 +173,12 @@ export class AskWechatService {
|
||||
requestId: request.requestId,
|
||||
text: request.text
|
||||
})
|
||||
this.writeLog('warn', `Query Agent 失败后回退 Legacy(${this.options.entry})`, diagnostics, reason)
|
||||
this.writeLog(
|
||||
'warn',
|
||||
`Query Agent 失败后回退 Legacy(${this.options.entry})`,
|
||||
diagnostics,
|
||||
reason
|
||||
)
|
||||
return { engine: 'legacy', status: 'legacy', reason, result: legacyResult }
|
||||
} catch {
|
||||
this.writeLog('error', `Legacy fallback 也失败(${this.options.entry})`, diagnostics, reason)
|
||||
@@ -187,17 +198,35 @@ export class AskWechatService {
|
||||
> &
|
||||
Partial<Pick<QueryAgentResult, 'totalMs'>>,
|
||||
startedAt: number,
|
||||
outcome: AskWechatOutcome
|
||||
outcome: AskWechatOutcome,
|
||||
/**
|
||||
* 问答原文(可选)。只在本地应用日志里用,不上传、不进遥测。
|
||||
*
|
||||
* 排查这类"同一问题时对时错"的故障,光有工具名与次数是不够的 ——
|
||||
* 必须能对着"问题 + 模型回答"回放,否则无法判断是理解错了、链路断了,还是索引没建。
|
||||
*/
|
||||
content?: { question?: string; answer?: string }
|
||||
): QueryAgentDiagnostics {
|
||||
const traces = result.traces || []
|
||||
// 图片 OCR 的两条结构化事实:不回读正文,只统计"取到了几条"与"当时覆盖度是多少"。
|
||||
const imageOcrTextCount = traces.reduce((sum, trace) => sum + (trace.imageOcrTextCount || 0), 0)
|
||||
const coverageState = traces
|
||||
.map((trace) => trace.imageOcrCoverageState)
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.at(-1)
|
||||
return {
|
||||
entry: this.options.entry,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
modelCallCount: result.modelCallCount,
|
||||
toolCallCount: result.toolCallCount,
|
||||
tools: (result.traces || []).map((trace) => trace.toolName),
|
||||
tools: traces.map((trace) => trace.toolName),
|
||||
totalMs: result.totalMs || Date.now() - startedAt,
|
||||
outcome
|
||||
outcome,
|
||||
...(imageOcrTextCount > 0 ? { imageOcrTextCount } : {}),
|
||||
...(coverageState ? { imageOcrCoverageState: coverageState } : {}),
|
||||
...(content?.question ? { question: content.question } : {}),
|
||||
...(content?.answer ? { answer: content.answer } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,9 +274,7 @@ export function buildAskWechatStats(
|
||||
const totalMs = result.totalMs || Date.now() - startedAt
|
||||
// 真实拆解:模型总耗时直接来自每次模型调用的测量;本地查询 = 所有 Tool 的 durationMs 之和。
|
||||
// 两者不互相推算(用 total - model 反推会把"框架开销"混进"本地查询",那是另一种谎)。
|
||||
const modelDurationsMs = (result.modelDurationsMs || []).filter((value) =>
|
||||
Number.isFinite(value)
|
||||
)
|
||||
const modelDurationsMs = (result.modelDurationsMs || []).filter((value) => Number.isFinite(value))
|
||||
const toolDurationsMs = (result.traces || [])
|
||||
.map((trace) => trace.durationMs)
|
||||
.filter((value) => Number.isFinite(value))
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
import {
|
||||
AUTOMATION_STEP_LABELS,
|
||||
AUTOMATION_SEND_ORIGIN,
|
||||
AUTOMATION_SEND_PURPOSE,
|
||||
DEFAULT_REPLY_TEXT,
|
||||
automationIdempotencyKey,
|
||||
normalizeReplyDelaySeconds,
|
||||
scheduledReportRangeLabel,
|
||||
type AutomationAction,
|
||||
type AutomationExecutionTrigger,
|
||||
type AutomationRule,
|
||||
type AutomationStep,
|
||||
type AutomationStepKey,
|
||||
type LeaveNotificationConfig,
|
||||
type ScheduledReportAutomationConfig
|
||||
} from '../../shared/automation'
|
||||
import {
|
||||
renderLeaveNotificationText,
|
||||
type GroupMemberExitedEvent
|
||||
} from '../../shared/group-exit-event'
|
||||
import type { WechatActionContent, WechatActionRequest, WechatActionResult } from '../../shared/wechat-action'
|
||||
import type { SaveGeneratedReportRequest, SaveGeneratedReportResult } from '../../shared/report-history'
|
||||
import {
|
||||
generateAgentGroupReport,
|
||||
type AgentGroupReportRequest,
|
||||
type AgentGroupReportResult
|
||||
} from './agent-group-report-service'
|
||||
import { wechatActionGateway } from './wechat-action-gateway'
|
||||
import { saveGeneratedReport } from '../report-history-service'
|
||||
import { getContactAvatars, resolveMd5 } from './chat-service'
|
||||
import type { AutomationTargetResolution } from './automation-wechat-target'
|
||||
|
||||
/**
|
||||
* AutomationActionRunner —— 把一次命中跑成**步骤序列**。
|
||||
*
|
||||
* 两条硬要求:
|
||||
*
|
||||
* 1. **每一步都有独立的状态、起止时间与耗时。** 用户层日志的全部价值就在于此:
|
||||
* 只告诉用户「失败了」等于没说,得告诉他是「生成日报」那步坏了。
|
||||
* 2. **失败必须切断后续。** 日报没生成出来就绝不能继续发图 —— 那会发出一条
|
||||
* 空的 / 过期的 / 上一条的消息,比不发更糟。所以失败后剩下的步骤一律 `skipped`。
|
||||
*/
|
||||
|
||||
/** 一步执行完毕后,后续步骤的处置方式。 */
|
||||
const STEP_ORDER: AutomationStepKey[] = ['received', 'matched', 'reply', 'report', 'send']
|
||||
|
||||
/** 退群通知的步骤顺序(与消息型规则**不共用**)。 */
|
||||
const LEAVE_NOTIFICATION_STEP_ORDER: AutomationStepKey[] = [
|
||||
'exit_received',
|
||||
'exit_matched',
|
||||
'exit_target',
|
||||
'exit_send'
|
||||
]
|
||||
|
||||
/**
|
||||
* 定时日报的步骤顺序(与另外两族**不共用**)。
|
||||
*
|
||||
* 「生成中 / 已生成」与「确定目标 / 已发送」**刻意拆成四步**:
|
||||
* 用户必须能一眼看出"日报确实生成了,只是没发出去" —— 合成两步就表达不了。
|
||||
*/
|
||||
const SCHEDULED_REPORT_STEP_ORDER: AutomationStepKey[] = [
|
||||
'schedule_triggered',
|
||||
'report_generating',
|
||||
'report_generated',
|
||||
'send_resolved',
|
||||
'report_sent'
|
||||
]
|
||||
|
||||
/**
|
||||
* 前置步骤失败时,后续步骤的 `skipReason`。
|
||||
*
|
||||
* 必须写清「是因为前面那步没成」,否则用户看到一连串「已跳过」会以为是规则没配好。
|
||||
*/
|
||||
const SKIPPED_AFTER_REPLY_FAILURE = '前置步骤失败(回复确认未成功),本次不再继续。'
|
||||
const SKIPPED_AFTER_REPORT_FAILURE = '前置步骤失败(日报未生成),没有图片可发送。'
|
||||
|
||||
/**
|
||||
* 定时日报「生成 / 落库」失败时,后续步骤的 `skipReason`。
|
||||
*
|
||||
* 与 `SKIPPED_AFTER_REPORT_FAILURE` 的区别:那条说的是「@我日报没图可发」,
|
||||
* 这条说的是「定时日报这一步就没跑起来」—— 文案分开,避免用户混淆两条链路。
|
||||
*/
|
||||
const SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE =
|
||||
'前置步骤失败(定时日报未生成或未保存),本次未发送。'
|
||||
|
||||
/**
|
||||
* 规则启用了「发送日报图片」,但手上没有图片文件。
|
||||
*
|
||||
* 这不是「正常跳过」,也不允许退而求其次去发空路径 / 上一张旧图 / 不存在的文件 ——
|
||||
* 发错东西比不发更糟,所以直接判失败。
|
||||
*/
|
||||
const SEND_WITHOUT_IMAGE_ERROR = '日报图片未生成,无法发送。'
|
||||
|
||||
export interface AutomationRunInput {
|
||||
executionId: string
|
||||
rule: AutomationRule
|
||||
/** 会话标识:群为 `xxx@chatroom`,私聊为 wxid。同时也是发送对象。 */
|
||||
conversationId: string
|
||||
isGroup: boolean
|
||||
/** 用户可读的来源名(群名 / 昵称)。**不是** id。 */
|
||||
sourceDisplayName: string
|
||||
}
|
||||
|
||||
export interface AutomationRunResult {
|
||||
steps: AutomationStep[]
|
||||
status: 'success' | 'failed'
|
||||
errorSummary?: string
|
||||
pngPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群通知的执行输入。
|
||||
*
|
||||
* 目标解析与发送能力预检都由 `AutomationService` 在调用前完成,
|
||||
* 这里只负责"把它跑成步骤序列" —— 于是**所有步骤构造只在一处**,
|
||||
* 不会出现"服务拼一半、runner 拼一半"的裂口。
|
||||
*/
|
||||
export interface LeaveNotificationRunInput {
|
||||
executionId: string
|
||||
event: GroupMemberExitedEvent
|
||||
config: LeaveNotificationConfig
|
||||
resolution: AutomationTargetResolution
|
||||
/** 事件来源的显示名(群名 / 「群聊」)。**不含 wxid**。 */
|
||||
sourceDisplayName: string
|
||||
/** 发送能力缺失时的一句话说明;有值时 `exit_send` 直接判失败。 */
|
||||
sendBlockedReason?: string
|
||||
}
|
||||
|
||||
export interface LeaveNotificationRunResult {
|
||||
steps: AutomationStep[]
|
||||
status: 'success' | 'failed'
|
||||
errorSummary?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时日报的执行输入。
|
||||
*
|
||||
* 与退群通知同构:目标解析、来源显示名、发送能力预检都在 `AutomationService`
|
||||
* 调用前算好,runner 只负责"跑成步骤序列"。
|
||||
*/
|
||||
export interface ScheduledReportRunInput {
|
||||
executionId: string
|
||||
rule: AutomationRule
|
||||
config: ScheduledReportAutomationConfig
|
||||
resolution: AutomationTargetResolution
|
||||
/** 日报来源群的显示名(群名 / 「日报来源群」)。**不含 roomId**。 */
|
||||
sourceDisplayName: string
|
||||
/**
|
||||
* 本次触发方式。
|
||||
*
|
||||
* 只影响**发送节流口径**:定时触发算 `automation`(纳入发送节流),
|
||||
* 用户在页面上点「立即执行」算 `user`(与旧定时日报的手动执行一致)。
|
||||
* 执行日志里的 `trigger` 由 `AutomationService` 单独记录,不从这里读。
|
||||
*/
|
||||
trigger: Extract<AutomationExecutionTrigger, 'schedule' | 'manual'>
|
||||
/** 发送能力缺失时的一句话说明;有值时 `report_sent` 直接判失败。 */
|
||||
sendBlockedReason?: string
|
||||
}
|
||||
|
||||
export interface ScheduledReportRunResult {
|
||||
steps: AutomationStep[]
|
||||
status: 'success' | 'failed'
|
||||
errorSummary?: string
|
||||
/**
|
||||
* 日报是否**已经生成并落库**。
|
||||
*
|
||||
* 这是「生成成功但发送失败」的判据:`reportGenerated === true && status === 'failed'`
|
||||
* 就是"日报在,只是没发出去"。日报本身已进日报历史,不会被丢掉。
|
||||
*/
|
||||
reportGenerated: boolean
|
||||
/** 生成出来的 PNG(仅在内存里传递,**不落执行日志**)。 */
|
||||
pngPath?: string
|
||||
/**
|
||||
* 底层生成错误的**机器可读码**(例如 `NO_MESSAGES`)。
|
||||
*
|
||||
* 用途只有一个:让上层区分「真的失败了」和「这一天没有消息可生成」——
|
||||
* 后者不该给用户推微信异常通知。
|
||||
*/
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
export interface AutomationActionRunnerDependencies {
|
||||
generateReport?: (request: AgentGroupReportRequest) => Promise<AgentGroupReportResult>
|
||||
executeAction?: (request: WechatActionRequest) => Promise<WechatActionResult>
|
||||
now?: () => number
|
||||
/** 延迟实现。默认真 sleep;单测注入即时 resolve 的假实现,避免真的等 2 秒。 */
|
||||
delay?: (ms: number) => Promise<void>
|
||||
/** 日报落库(日报历史)。与旧定时日报**复用同一个**实现,不复制。 */
|
||||
saveGeneratedReport?: (request: SaveGeneratedReportRequest) => Promise<SaveGeneratedReportResult>
|
||||
/** 把日报来源群标识解析成联系人(拿头像 / 显示名)。 */
|
||||
resolveReportContact?: (raw: string) => {
|
||||
md5?: string
|
||||
m_nsUsrName?: string
|
||||
m_nsNickName?: string
|
||||
avatar?: string
|
||||
} | null
|
||||
getContactAvatars?: (usernames: string[]) => Promise<Record<string, string>>
|
||||
}
|
||||
|
||||
/** 策略层的错误码 → 用户可读短句。UI 直接展示这些文案,不做二次翻译。 */
|
||||
const ACTION_ERROR_MESSAGES: Record<string, string> = {
|
||||
INVALID_REQUEST: '发送请求不合法',
|
||||
INVALID_RECIPIENT: '找不到有效的发送对象',
|
||||
ACTION_NOT_ALLOWED: '该自动化动作未被允许执行',
|
||||
RECIPIENT_SCOPE_VIOLATION: '发送对象与触发来源不一致',
|
||||
SEND_CAPABILITY_UNAVAILABLE: '当前环境没有可用的微信发送能力',
|
||||
SEND_NOT_READY: '微信发送能力尚未就绪,请先绑定个人微信',
|
||||
SEND_FAILED: '微信发送失败',
|
||||
POLICY_BLOCKED: '该发送动作未通过策略检查',
|
||||
UNKNOWN: '发送失败(未知原因)'
|
||||
}
|
||||
|
||||
function createStep(key: AutomationStepKey): AutomationStep {
|
||||
return { key, label: AUTOMATION_STEP_LABELS[key], status: 'pending' }
|
||||
}
|
||||
|
||||
function markSuccess(step: AutomationStep, at: number): void {
|
||||
step.status = 'success'
|
||||
step.startedAt = step.startedAt ?? at
|
||||
step.finishedAt = at
|
||||
step.durationMs = Math.max(0, at - step.startedAt)
|
||||
}
|
||||
|
||||
function markFailed(step: AutomationStep, at: number, error: string): void {
|
||||
step.status = 'failed'
|
||||
step.startedAt = step.startedAt ?? at
|
||||
step.finishedAt = at
|
||||
step.durationMs = Math.max(0, at - step.startedAt)
|
||||
step.error = error
|
||||
}
|
||||
|
||||
function markSkipped(step: AutomationStep, skipReason?: string): void {
|
||||
step.status = 'skipped'
|
||||
if (skipReason) step.skipReason = skipReason
|
||||
}
|
||||
|
||||
function actionErrorMessage(code: string | undefined, fallback: string | undefined): string {
|
||||
if (fallback && fallback.trim()) {
|
||||
// 策略层给的 reason 已是中文短句;直接用,避免二次包装丢信息。
|
||||
return fallback.trim()
|
||||
}
|
||||
return (code && ACTION_ERROR_MESSAGES[code]) || ACTION_ERROR_MESSAGES.UNKNOWN
|
||||
}
|
||||
|
||||
export class AutomationActionRunner {
|
||||
private readonly generateReport: (request: AgentGroupReportRequest) => Promise<AgentGroupReportResult>
|
||||
private readonly executeAction: (request: WechatActionRequest) => Promise<WechatActionResult>
|
||||
private readonly now: () => number
|
||||
private readonly delay: (ms: number) => Promise<void>
|
||||
private readonly saveGeneratedReport: (
|
||||
request: SaveGeneratedReportRequest
|
||||
) => Promise<SaveGeneratedReportResult>
|
||||
private readonly resolveReportContact: NonNullable<
|
||||
AutomationActionRunnerDependencies['resolveReportContact']
|
||||
>
|
||||
private readonly getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
||||
|
||||
constructor(dependencies: AutomationActionRunnerDependencies = {}) {
|
||||
this.generateReport = dependencies.generateReport ?? generateAgentGroupReport
|
||||
this.executeAction = dependencies.executeAction ?? ((request) => wechatActionGateway.execute(request))
|
||||
this.now = dependencies.now ?? (() => Date.now())
|
||||
this.delay =
|
||||
dependencies.delay ??
|
||||
((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
|
||||
this.saveGeneratedReport = dependencies.saveGeneratedReport ?? saveGeneratedReport
|
||||
this.resolveReportContact = dependencies.resolveReportContact ?? ((raw) => resolveMd5(raw))
|
||||
this.getContactAvatars = dependencies.getContactAvatars ?? ((ids) => getContactAvatars(ids))
|
||||
}
|
||||
|
||||
async run(input: AutomationRunInput): Promise<AutomationRunResult> {
|
||||
const steps = STEP_ORDER.map((key) => createStep(key))
|
||||
const stepAt = (key: AutomationStepKey): AutomationStep =>
|
||||
steps.find((step) => step.key === key) as AutomationStep
|
||||
|
||||
markSuccess(stepAt('received'), this.now())
|
||||
markSuccess(stepAt('matched'), this.now())
|
||||
|
||||
const replyAction = findAction(input.rule, 'replyText')
|
||||
const reportAction = findAction(input.rule, 'generateReport')
|
||||
const sendAction = findAction(input.rule, 'sendReportImage')
|
||||
|
||||
// ---- 步骤 3:回复确认 ----
|
||||
//
|
||||
// 回复等待:规则一命中就秒回,看起来就是个机器人(消息刚到、回复就到)。
|
||||
// 等待时长是**规则自己的一项执行参数**(`rule.replyDelaySeconds`,在
|
||||
// 「编辑自动化 → 3 · 触发后执行」里配),所以不同规则可以不一样。
|
||||
//
|
||||
// 等待刻意放在 `reply` 步骤计时**之外** —— `reply.durationMs` 只应该反映发送本身,
|
||||
// 否则用户看到「回复确认 2000ms」会误以为是发送慢。
|
||||
// 已经命中就不再回头重判规则:等待窗口里规则被停用/删掉也不中断本次执行,
|
||||
// 与 cooldown、同消息幂等的口径一致(都是「命中那一刻」的快照)。
|
||||
if (replyAction) {
|
||||
// 用共享的归一化函数,而不是 `Number(...) || 0`:旧版 rules.json 里没有这个字段,
|
||||
// 那应该按**默认 2 秒**处理(否则「默认 2 秒」要等用户手动进编辑页才会生效)。
|
||||
const replyDelayMs = normalizeReplyDelaySeconds(input.rule.replyDelaySeconds) * 1_000
|
||||
if (replyDelayMs > 0) await this.delay(replyDelayMs)
|
||||
}
|
||||
|
||||
if (!replyAction) {
|
||||
markSkipped(stepAt('reply'))
|
||||
} else {
|
||||
const replyStep = stepAt('reply')
|
||||
replyStep.status = 'running'
|
||||
replyStep.startedAt = this.now()
|
||||
const sent = await this.sendThroughGateway(input, 'reply', {
|
||||
type: 'text',
|
||||
text: replyAction.text?.trim() || DEFAULT_REPLY_TEXT
|
||||
})
|
||||
if (!sent.ok) {
|
||||
markFailed(replyStep, this.now(), sent.error || '回复确认失败')
|
||||
markRemainingSkipped(steps, 'reply', SKIPPED_AFTER_REPLY_FAILURE)
|
||||
return { steps, status: 'failed', errorSummary: replyStep.error }
|
||||
}
|
||||
markSuccess(replyStep, this.now())
|
||||
}
|
||||
|
||||
// ---- 步骤 4:生成日报 ----
|
||||
let pngPath: string | undefined
|
||||
if (!reportAction) {
|
||||
markSkipped(stepAt('report'))
|
||||
} else {
|
||||
const reportStep = stepAt('report')
|
||||
reportStep.status = 'running'
|
||||
reportStep.startedAt = this.now()
|
||||
let result: AgentGroupReportResult
|
||||
try {
|
||||
result = await this.generateReport({ group: input.conversationId, range: 'today' })
|
||||
} catch (error) {
|
||||
result = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
if (!result.success || !result.pngPath) {
|
||||
markFailed(reportStep, this.now(), result.error || '日报生成失败')
|
||||
markRemainingSkipped(steps, 'report', SKIPPED_AFTER_REPORT_FAILURE)
|
||||
return { steps, status: 'failed', errorSummary: reportStep.error }
|
||||
}
|
||||
pngPath = result.pngPath
|
||||
markSuccess(reportStep, this.now())
|
||||
}
|
||||
|
||||
// ---- 步骤 5:发送日报图片 ----
|
||||
//
|
||||
// 三种情况必须分开判断 —— 合并成 `!sendAction || !pngPath` 会把
|
||||
// 「规则要求发图、但图根本没生成」当成正常跳过,execution 还记成 success:
|
||||
//
|
||||
// A. 规则**本来就没有启用**这个动作 → skipped,这是正常的,不影响整体结果;
|
||||
// B. 启用了,但要发的东西不存在 → **failed**,不能假装成功,
|
||||
// 更不能退而求其次去发空路径 / 上一次的旧图 / 不存在的文件;
|
||||
// C. 前置(生成日报)已经失败 → 上面就 return 了,走不到这里。
|
||||
if (!sendAction) {
|
||||
markSkipped(stepAt('send'))
|
||||
return { steps, status: 'success', ...(pngPath ? { pngPath } : {}) }
|
||||
}
|
||||
const sendStep = stepAt('send')
|
||||
if (!pngPath) {
|
||||
markFailed(sendStep, this.now(), SEND_WITHOUT_IMAGE_ERROR)
|
||||
return { steps, status: 'failed', errorSummary: sendStep.error }
|
||||
}
|
||||
sendStep.status = 'running'
|
||||
sendStep.startedAt = this.now()
|
||||
const sent = await this.sendThroughGateway(input, 'report', { type: 'image', path: pngPath })
|
||||
if (!sent.ok) {
|
||||
markFailed(sendStep, this.now(), sent.error || '发送日报图片失败')
|
||||
return { steps, status: 'failed', errorSummary: sendStep.error, pngPath }
|
||||
}
|
||||
markSuccess(sendStep, this.now())
|
||||
return { steps, status: 'success', pngPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时日报:把一次「到点触发 / 手动立即执行」跑成一次生成 + 一次发送。
|
||||
*
|
||||
* 与旧 `ScheduledReportService.executeTask` 的行为对齐(**不重写日报能力**):
|
||||
* 1. 先生成(含落库进日报历史)—— 发送能力不足**也照常生成**(旧语义如此);
|
||||
* 2. 再解析目标 —— 解析失败**不 fallback**,直接判失败;
|
||||
* 3. 最后经 `WechatActionGateway` 发图片 —— 仍然是**发图片**,不退化成纯文本。
|
||||
*
|
||||
* 「生成成功但发送失败」是可表达的:`report_generated` 为 success、
|
||||
* `report_sent` 为 failed,整体 `failed`,且 `reportGenerated === true`。
|
||||
*/
|
||||
async runScheduledReport(input: ScheduledReportRunInput): Promise<ScheduledReportRunResult> {
|
||||
const steps = SCHEDULED_REPORT_STEP_ORDER.map((key) => createStep(key))
|
||||
const stepAt = (key: AutomationStepKey): AutomationStep =>
|
||||
steps.find((step) => step.key === key) as AutomationStep
|
||||
|
||||
markSuccess(stepAt('schedule_triggered'), this.now())
|
||||
|
||||
// ---- 生成日报(含落进日报历史)----
|
||||
const generatingStep = stepAt('report_generating')
|
||||
generatingStep.status = 'running'
|
||||
generatingStep.startedAt = this.now()
|
||||
let generated: AgentGroupReportResult
|
||||
try {
|
||||
generated = await this.generateReport({
|
||||
group: input.config.report.sourceConversationId,
|
||||
range: input.config.report.range,
|
||||
messageTypes: input.config.report.messageTypes,
|
||||
templateId: input.config.report.templateId,
|
||||
memberNameMode: input.config.report.memberNameMode,
|
||||
timeoutSeconds: input.config.report.timeoutSeconds
|
||||
})
|
||||
} catch (error) {
|
||||
generated = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
if (!generated.success || !generated.pngPath) {
|
||||
// 生成失败就**不发**:绝不生成空图片、也绝不退而求其次发上一张旧图。
|
||||
const reason = generated.error || '日报生成失败'
|
||||
markFailed(generatingStep, this.now(), reason)
|
||||
markSkipped(stepAt('report_generated'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
|
||||
markSkipped(stepAt('send_resolved'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
|
||||
markSkipped(stepAt('report_sent'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
|
||||
return {
|
||||
steps,
|
||||
status: 'failed',
|
||||
errorSummary: reason,
|
||||
reportGenerated: false,
|
||||
...(generated.errorCode ? { errorCode: generated.errorCode } : {})
|
||||
}
|
||||
}
|
||||
|
||||
let pngPath = generated.pngPath
|
||||
try {
|
||||
const reportContact = this.resolveReportContact(input.config.report.sourceConversationId)
|
||||
let contactAvatar = reportContact?.avatar
|
||||
if (!contactAvatar && reportContact?.m_nsUsrName) {
|
||||
try {
|
||||
const avatars = await this.getContactAvatars([reportContact.m_nsUsrName])
|
||||
contactAvatar = avatars[reportContact.m_nsUsrName]
|
||||
} catch (error) {
|
||||
console.warn('[Automation] 日报群头像补全失败:', error)
|
||||
}
|
||||
}
|
||||
const savedHistory = await this.saveGeneratedReport({
|
||||
contactId: reportContact?.md5 || input.config.report.sourceConversationId,
|
||||
contactName:
|
||||
generated.groupName || reportContact?.m_nsNickName || input.sourceDisplayName || '群聊',
|
||||
contactAvatar,
|
||||
source: 'scheduled',
|
||||
dateRange: generated.reportMetadata?.dateRange || scheduledReportRangeLabel(input.config.report.range),
|
||||
reportDate: generated.reportMetadata?.reportDate,
|
||||
messageCount: generated.messageCount ?? generated.reportMetadata?.messageCount ?? 0,
|
||||
generatedAt: new Date(this.now()).toISOString(),
|
||||
htmlPath: generated.htmlPath,
|
||||
pngPath: generated.pngPath,
|
||||
duration: generated.duration,
|
||||
modelName: generated.modelName,
|
||||
tokenUsage: generated.tokenUsage,
|
||||
reportSnapshot: generated.reportSnapshot,
|
||||
reportMetadata: generated.reportMetadata,
|
||||
templateId: input.config.report.templateId
|
||||
})
|
||||
if (!savedHistory.success) {
|
||||
throw new Error(savedHistory.error || '日报历史保存失败')
|
||||
}
|
||||
const recordPath = savedHistory.record?.pngPath || generated.pngPath
|
||||
if (!recordPath) throw new Error('日报历史未返回可发送的 PNG 文件')
|
||||
pngPath = recordPath
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
markFailed(generatingStep, this.now(), reason)
|
||||
markSkipped(stepAt('report_generated'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
|
||||
markSkipped(stepAt('send_resolved'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
|
||||
markSkipped(stepAt('report_sent'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
|
||||
return { steps, status: 'failed', errorSummary: reason, reportGenerated: false }
|
||||
}
|
||||
markSuccess(generatingStep, this.now())
|
||||
|
||||
const generatedStep = stepAt('report_generated')
|
||||
markSuccess(generatedStep, this.now())
|
||||
const messageCount = generated.messageCount ?? 0
|
||||
if (messageCount > 0) generatedStep.detail = `共 ${messageCount} 条消息`
|
||||
|
||||
// ---- 目标解析失败:不许 fallback 到任何地方,直接判失败 ----
|
||||
const targetStep = stepAt('send_resolved')
|
||||
if (!input.resolution.ok) {
|
||||
markFailed(targetStep, this.now(), input.resolution.error)
|
||||
markSkipped(stepAt('report_sent'), '没有可用的发送目标,本次未发送。')
|
||||
return {
|
||||
steps,
|
||||
status: 'failed',
|
||||
errorSummary: input.resolution.error,
|
||||
reportGenerated: true,
|
||||
pngPath
|
||||
}
|
||||
}
|
||||
targetStep.status = 'success'
|
||||
targetStep.startedAt = this.now()
|
||||
targetStep.finishedAt = targetStep.startedAt
|
||||
targetStep.durationMs = 0
|
||||
// 用户可读的目标名(例如「文件传输助手」「张三」「我」)—— 不带任何 id。
|
||||
targetStep.detail = input.resolution.target.displayName
|
||||
|
||||
const sendStep = stepAt('report_sent')
|
||||
|
||||
// ---- 发送能力缺失:日报已生成,如实判失败(不回滚、不隐藏)----
|
||||
if (input.sendBlockedReason) {
|
||||
markFailed(sendStep, this.now(), input.sendBlockedReason)
|
||||
return {
|
||||
steps,
|
||||
status: 'failed',
|
||||
errorSummary: input.sendBlockedReason,
|
||||
reportGenerated: true,
|
||||
pngPath
|
||||
}
|
||||
}
|
||||
|
||||
sendStep.status = 'running'
|
||||
sendStep.startedAt = this.now()
|
||||
const sent = await this.sendScheduledReportImage(input, pngPath)
|
||||
if (!sent.ok) {
|
||||
markFailed(sendStep, this.now(), sent.error || '发送日报失败')
|
||||
return {
|
||||
steps,
|
||||
status: 'failed',
|
||||
errorSummary: sendStep.error,
|
||||
reportGenerated: true,
|
||||
pngPath
|
||||
}
|
||||
}
|
||||
markSuccess(sendStep, this.now())
|
||||
return { steps, status: 'success', reportGenerated: true, pngPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时日报的统一发送出口。
|
||||
*
|
||||
* **必须走 `WechatActionGateway`**:幂等 + 3 秒发送间隔 + 审计落盘都在那里。
|
||||
* Automation 层不允许知道 OneBot / WCHook / native host / Windows hook 的存在。
|
||||
*/
|
||||
private async sendScheduledReportImage(
|
||||
input: ScheduledReportRunInput,
|
||||
pngPath: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
if (!input.resolution.ok) return { ok: false, error: input.resolution.error }
|
||||
// 定时触发走 automation(纳入发送节流);用户手动执行走 user(不纳入节流)。
|
||||
const triggerType = input.trigger === 'manual' ? 'user' : 'automation'
|
||||
try {
|
||||
const result = await this.executeAction({
|
||||
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.scheduledReport}:${input.executionId}`,
|
||||
origin: AUTOMATION_SEND_ORIGIN,
|
||||
purpose: AUTOMATION_SEND_PURPOSE.scheduledReport,
|
||||
triggerType,
|
||||
executionId: input.executionId,
|
||||
recipient: input.resolution.target.recipient,
|
||||
content: { type: 'image', path: pngPath }
|
||||
})
|
||||
if (result.status !== 'sent') {
|
||||
return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) }
|
||||
}
|
||||
|
||||
/*
|
||||
* 后置词:**只在图片明确 sent 之后**才发,图片失败时严格短路 ——
|
||||
* 与 `WechatActionGateway.executeReportImageSequence`(手动发送)同一口径。
|
||||
* 空字符串 = 用户只要图片,什么都不补发。
|
||||
*
|
||||
* 独立 purpose + 独立幂等位:共用一个 key 会让「图片发成功、后置词被
|
||||
* 幂等短路」变成常态。
|
||||
*/
|
||||
const postfixText = String(input.config.postfixText || '').trim()
|
||||
if (!postfixText) return { ok: true }
|
||||
|
||||
const postfix = await this.executeAction({
|
||||
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.scheduledReportPostfix}:${input.executionId}`,
|
||||
origin: AUTOMATION_SEND_ORIGIN,
|
||||
purpose: AUTOMATION_SEND_PURPOSE.scheduledReportPostfix,
|
||||
triggerType,
|
||||
executionId: input.executionId,
|
||||
recipient: input.resolution.target.recipient,
|
||||
content: { type: 'text', text: postfixText }
|
||||
})
|
||||
if (postfix.status === 'sent') return { ok: true }
|
||||
// 绝不吞掉:图片确实发出去了,但这一次执行**没有完成**,必须如实报出来。
|
||||
return {
|
||||
ok: false,
|
||||
error: `日报图片已发送,但后置词发送失败:${actionErrorMessage(
|
||||
postfix.errorCode,
|
||||
postfix.reason
|
||||
)}`
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群通知:把「检测到成员退出」这件事跑成一次发送。
|
||||
*
|
||||
* 与 `run()` 的差别:没有动作链、没有回复等待、没有 cooldown
|
||||
* (退群是低频事件,且**不能被时间窗合并** —— 两个成员先后退出就是两条通知)。
|
||||
*/
|
||||
async runLeaveNotification(input: LeaveNotificationRunInput): Promise<LeaveNotificationRunResult> {
|
||||
const steps = LEAVE_NOTIFICATION_STEP_ORDER.map((key) => createStep(key))
|
||||
const stepAt = (key: AutomationStepKey): AutomationStep =>
|
||||
steps.find((step) => step.key === key) as AutomationStep
|
||||
|
||||
markSuccess(stepAt('exit_received'), this.now())
|
||||
markSuccess(stepAt('exit_matched'), this.now())
|
||||
|
||||
// ---- 目标解析失败:不许 fallback 到任何地方,直接判失败 ----
|
||||
if (!input.resolution.ok) {
|
||||
markFailed(stepAt('exit_target'), this.now(), input.resolution.error)
|
||||
markSkipped(stepAt('exit_send'), '没有可用的通知目标,未发送。')
|
||||
return { steps, status: 'failed', errorSummary: input.resolution.error }
|
||||
}
|
||||
const targetStep = stepAt('exit_target')
|
||||
targetStep.status = 'success'
|
||||
targetStep.startedAt = this.now()
|
||||
targetStep.finishedAt = targetStep.startedAt
|
||||
targetStep.durationMs = 0
|
||||
// 用户可读的目标名(例如「文件传输助手」「张三」「我」)—— 不带任何 id。
|
||||
targetStep.detail = input.resolution.target.displayName
|
||||
|
||||
const sendStep = stepAt('exit_send')
|
||||
|
||||
// ---- 发送能力缺失:如实判失败(退群事实本身仍然是成功的) ----
|
||||
if (input.sendBlockedReason) {
|
||||
markFailed(sendStep, this.now(), input.sendBlockedReason)
|
||||
return { steps, status: 'failed', errorSummary: input.sendBlockedReason }
|
||||
}
|
||||
|
||||
const text = renderLeaveNotificationText(input.event, input.config.template).trim()
|
||||
if (!text) {
|
||||
const error = '通知内容为空,未发送。'
|
||||
markFailed(sendStep, this.now(), error)
|
||||
return { steps, status: 'failed', errorSummary: error }
|
||||
}
|
||||
|
||||
sendStep.status = 'running'
|
||||
sendStep.startedAt = this.now()
|
||||
const sent = await this.sendLeaveNotification(input, text)
|
||||
if (!sent.ok) {
|
||||
markFailed(sendStep, this.now(), sent.error || '发送退群通知失败')
|
||||
return { steps, status: 'failed', errorSummary: sendStep.error }
|
||||
}
|
||||
markSuccess(sendStep, this.now())
|
||||
return { steps, status: 'success' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群通知的统一发送出口。
|
||||
*
|
||||
* **必须走 `WechatActionGateway`**:幂等 + 3 秒发送间隔 + 审计落盘都在那里,
|
||||
* 直接调个人微信发送会绕过全部三样。Automation 层也不允许知道
|
||||
* OneBot / WCHook / native host / Windows hook 的存在。
|
||||
*
|
||||
* 幂等键由 **eventId** 派生(不是 executionId):审计是落盘的,
|
||||
* 于是"重启后重复投递同一退群事件"会被持久层直接短路。
|
||||
*/
|
||||
private async sendLeaveNotification(
|
||||
input: LeaveNotificationRunInput,
|
||||
text: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
if (!input.resolution.ok) return { ok: false, error: input.resolution.error }
|
||||
try {
|
||||
const result = await this.executeAction({
|
||||
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.leaveNotification}:${input.event.eventId}`,
|
||||
origin: AUTOMATION_SEND_ORIGIN,
|
||||
purpose: AUTOMATION_SEND_PURPOSE.leaveNotification,
|
||||
triggerType: 'automation',
|
||||
executionId: input.executionId,
|
||||
sourceId: input.event.eventId,
|
||||
recipient: input.resolution.target.recipient,
|
||||
content: { type: 'text', text }
|
||||
})
|
||||
if (result.status === 'sent') return { ok: true }
|
||||
return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一发送出口。
|
||||
*
|
||||
* **刻意不直接调 `WechatSendGateway`,而是走 `WechatActionGateway`**:后者在
|
||||
* `WechatSendGateway` 之上多给了三样本功能必须的东西 ——
|
||||
* 幂等(同一 executionId 不会重复发)、自动化发送节流(3s 间隔,防刷屏)、
|
||||
* 审计落盘。底层实际发送仍然经由 `WechatSendGateway.sendPersonal`,
|
||||
* 所以「所有发送统一走 WechatSendGateway」这条约束依然成立。
|
||||
*/
|
||||
private async sendThroughGateway(
|
||||
input: AutomationRunInput,
|
||||
kind: 'reply' | 'report',
|
||||
content: WechatActionContent
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const result = await this.executeAction({
|
||||
idempotencyKey: automationIdempotencyKey(kind, input.executionId),
|
||||
origin: AUTOMATION_SEND_ORIGIN,
|
||||
purpose: AUTOMATION_SEND_PURPOSE[kind],
|
||||
triggerType: 'automation',
|
||||
executionId: input.executionId,
|
||||
recipient: {
|
||||
type: input.isGroup ? 'group' : 'contact',
|
||||
id: input.conversationId,
|
||||
name: input.sourceDisplayName
|
||||
},
|
||||
content
|
||||
})
|
||||
if (result.status === 'sent') return { ok: true }
|
||||
return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) }
|
||||
} catch (error) {
|
||||
// execute() 本身刻意不抛,这里兜的是注入实现或意外异常。
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findAction(rule: AutomationRule, type: AutomationAction['type']): AutomationAction | undefined {
|
||||
return rule.actions.find((action) => action.type === type && action.enabled)
|
||||
}
|
||||
|
||||
/** 把 `after` 之后的步骤全部标成 `skipped`(前一步挂了,后面的不许再动)。 */
|
||||
function markRemainingSkipped(
|
||||
steps: AutomationStep[],
|
||||
after: AutomationStepKey,
|
||||
skipReason?: string
|
||||
): void {
|
||||
const from = STEP_ORDER.indexOf(after) + 1
|
||||
for (const key of STEP_ORDER.slice(from)) {
|
||||
const step = steps.find((item) => item.key === key)
|
||||
if (step) markSkipped(step, skipReason)
|
||||
}
|
||||
}
|
||||
|
||||
export const automationActionRunner = new AutomationActionRunner()
|
||||
@@ -0,0 +1,153 @@
|
||||
import path from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import type {
|
||||
AutomationExecution,
|
||||
AutomationExecutionTrigger
|
||||
} from '../../shared/automation'
|
||||
|
||||
/**
|
||||
* AutomationExecutionLogService —— **用户层**执行日志。
|
||||
*
|
||||
* 与 debug 日志的区别:这份记录是给用户看「哪一步坏了」的,所以
|
||||
* - 只保留用户可读字段(规则名、来源显示名、步骤状态、耗时、错误);
|
||||
* - **不含** wxid / localId / serverId / source XML / raw payload / 图片绝对路径;
|
||||
* - 必须落盘,重启后还能看到近期记录。
|
||||
*
|
||||
* 容量有界(`MAX_RECORDS`),否则长期运行会把文件撑到几十兆。
|
||||
*/
|
||||
|
||||
const STORAGE_DIR = 'automation'
|
||||
const EXECUTIONS_FILE = 'executions.json'
|
||||
const MAX_RECORDS = 200
|
||||
|
||||
export interface AutomationExecutionLogDependencies {
|
||||
userDataPath?: () => string
|
||||
}
|
||||
|
||||
const EXECUTION_STATUSES: AutomationExecution['status'][] = ['running', 'success', 'failed']
|
||||
|
||||
const EXECUTION_TRIGGERS: AutomationExecutionTrigger[] = ['message', 'exit', 'schedule', 'manual']
|
||||
|
||||
function normalizeExecution(value: unknown): AutomationExecution | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const record = value as Partial<AutomationExecution>
|
||||
const executionId = String(record.executionId || '').trim()
|
||||
if (!executionId) return null
|
||||
return {
|
||||
executionId,
|
||||
ruleId: String(record.ruleId || ''),
|
||||
ruleName: String(record.ruleName || ''),
|
||||
triggerTime: Number(record.triggerTime) || 0,
|
||||
/*
|
||||
* `trigger` 必须在这里**显式透传**。
|
||||
*
|
||||
* 这个归一化是白名单式的:没列出来的字段会被静默丢掉。定时日报的
|
||||
* 「本次是定时跑的还是用户点的立即执行」全靠这个字段区分,
|
||||
* 漏掉它就会变成"写的时候有、读出来永远没有"—— 而且没有任何报错。
|
||||
* 旧记录没有这个字段,保持 `undefined`(读盘不猜测)。
|
||||
*/
|
||||
...(EXECUTION_TRIGGERS.includes(record.trigger as AutomationExecutionTrigger)
|
||||
? { trigger: record.trigger as AutomationExecutionTrigger }
|
||||
: {}),
|
||||
sourceDisplayName: String(record.sourceDisplayName || ''),
|
||||
// 不认识的 status(含历史遗留值)一律降级成 `running`,绝不凭空造出成功/失败。
|
||||
status: EXECUTION_STATUSES.includes(record.status as AutomationExecution['status'])
|
||||
? (record.status as AutomationExecution['status'])
|
||||
: 'running',
|
||||
durationMs: Number(record.durationMs) || 0,
|
||||
steps: Array.isArray(record.steps) ? record.steps : [],
|
||||
...(record.errorSummary ? { errorSummary: String(record.errorSummary) } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export class AutomationExecutionLogService {
|
||||
private readonly userDataPath: () => string
|
||||
private records: AutomationExecution[] = []
|
||||
private loaded = false
|
||||
|
||||
constructor(dependencies: AutomationExecutionLogDependencies = {}) {
|
||||
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
|
||||
}
|
||||
|
||||
list(query: { limit?: number } = {}): AutomationExecution[] {
|
||||
this.ensureLoaded()
|
||||
const requested = Number(query?.limit)
|
||||
const limit = Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : MAX_RECORDS
|
||||
return this.records.slice(0, Math.min(limit, MAX_RECORDS)).map((record) => structuredClone(record))
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入(或按 `executionId` 覆盖)一条执行记录。
|
||||
*
|
||||
* 覆盖语义是必需的:执行是「先建 running、跑完再回落终态」,
|
||||
* 中间态与终态共用同一个 `executionId`。
|
||||
*/
|
||||
record(execution: AutomationExecution): void {
|
||||
this.ensureLoaded()
|
||||
const normalized = normalizeExecution(execution)
|
||||
if (!normalized) return
|
||||
const withoutSame = this.records.filter((item) => item.executionId !== normalized.executionId)
|
||||
this.records = [normalized, ...withoutSame].slice(0, MAX_RECORDS)
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clear(): boolean {
|
||||
this.ensureLoaded()
|
||||
this.records = []
|
||||
this.persist()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计 `sinceMs` 之后(含)的记录数与成功数。用于顶部「今日执行」。
|
||||
*
|
||||
* 每条记录都对应一次**真正跑过**的执行(gate 拦下的消息不会产生记录),
|
||||
* 所以这里直接计数即可。
|
||||
*/
|
||||
countSince(sinceMs: number): { total: number; success: number } {
|
||||
this.ensureLoaded()
|
||||
const from = Number(sinceMs) || 0
|
||||
let total = 0
|
||||
let success = 0
|
||||
for (const record of this.records) {
|
||||
if (record.triggerTime < from) continue
|
||||
total += 1
|
||||
if (record.status === 'success') success += 1
|
||||
}
|
||||
return { total, success }
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
try {
|
||||
const raw = fs.readJsonSync(this.executionsFilePath()) as unknown
|
||||
const values = Array.isArray(raw) ? raw : []
|
||||
this.records = values
|
||||
.map((value) => normalizeExecution(value))
|
||||
.filter((value): value is AutomationExecution => value !== null)
|
||||
.slice(0, MAX_RECORDS)
|
||||
} catch {
|
||||
this.records = []
|
||||
}
|
||||
}
|
||||
|
||||
private executionsFilePath(): string {
|
||||
return path.join(this.userDataPath(), STORAGE_DIR, EXECUTIONS_FILE)
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
const filePath = this.executionsFilePath()
|
||||
fs.ensureDirSync(path.dirname(filePath))
|
||||
fs.writeJsonSync(filePath, this.records, { spaces: 2 })
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Automation] 保存执行日志失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const automationExecutionLogService = new AutomationExecutionLogService()
|
||||
@@ -0,0 +1,652 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import {
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
createDefaultDailyReportRule,
|
||||
createDefaultLeaveNotificationRule,
|
||||
createDefaultScheduledReportRule,
|
||||
normalizeRuleDraft,
|
||||
type AutomationRule,
|
||||
type AutomationRuleType,
|
||||
type ScheduledReportAutomationConfig
|
||||
} from '../../shared/automation'
|
||||
import {
|
||||
planLeaveNotificationMigration,
|
||||
type LeaveNotificationMigrationPlan,
|
||||
type LegacyLeaveNotificationState
|
||||
} from './leave-notification-migration'
|
||||
import {
|
||||
planScheduledReportMigrationBatch,
|
||||
type LegacyScheduledReportTask,
|
||||
type ScheduledReportMigrationSummary
|
||||
} from './scheduled-report-migration'
|
||||
import {
|
||||
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
|
||||
insertGroupNamePlaceholder
|
||||
} from '../../shared/group-exit-monitor'
|
||||
|
||||
/**
|
||||
* AutomationRuleStore —— 自动化规则的持久化与增删改查。
|
||||
*
|
||||
* 存储形态刻意做到最简:一个 JSON 文件(`{userData}/automation/rules.json`)。
|
||||
* 规则总量是个位数到几十条,引入数据库只会增加迁移负担。
|
||||
*
|
||||
* **一次性标记全部单独存**(`builtinSeeded` / `leaveNotificationMigrated` /
|
||||
* `notificationTemplateUpgraded` / `scheduledReportMigrated`):
|
||||
* 如果靠"文件里有没有那条内置规则"来判断是否播种,用户一旦删掉它,
|
||||
* 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。迁移同理。
|
||||
*/
|
||||
|
||||
const STORAGE_DIR = 'automation'
|
||||
const RULES_FILE = 'rules.json'
|
||||
const MIGRATION_BACKUP_FILE = 'leave-notification-migration-backup.json'
|
||||
/** 旧退群监控的状态文件(迁移时读一次,之后运行期不再读)。 */
|
||||
const LEGACY_MONITOR_FILE = 'group-exit-monitor.json'
|
||||
/** 旧定时日报的**任务**文件(迁移时读一次,之后运行期不再读)。 */
|
||||
const LEGACY_SCHEDULED_DIR = 'scheduled-reports'
|
||||
const LEGACY_SCHEDULED_TASKS_FILE = 'tasks.json'
|
||||
const SCHEDULED_MIGRATION_BACKUP_FILE = 'scheduled-report-migration-backup.json'
|
||||
const CURRENT_VERSION = 4
|
||||
|
||||
interface StoredRules {
|
||||
version: number
|
||||
/** 内置「@我生成日报」是否已经播种过。**即使随后被删除也保持 true**。 */
|
||||
builtinSeeded: boolean
|
||||
/** 旧退群通知配置是否已经迁移过。**即使随后被删除也保持 true**。 */
|
||||
leaveNotificationMigrated: boolean
|
||||
/**
|
||||
* 是否已经替用户往模板里补过 `{groupName}`。
|
||||
*
|
||||
* **必须有这个标记**:没有它就得靠"模板里有没有 `{groupName}`"来判断,
|
||||
* 于是用户主动删掉那一行后、下次启动又会被补回来 —— 删不掉的东西最烦人。
|
||||
*/
|
||||
notificationTemplateUpgraded: boolean
|
||||
/**
|
||||
* 旧「定时日报」任务是否已经迁移过。
|
||||
*
|
||||
* ⚠️ 与其它标记有一点不同:迁移需要**把旧群标识解析成稳定会话 id**,
|
||||
* 而那需要数据库。数据库没就绪时这一位**保持 false**,等解析器注入后再补跑 ——
|
||||
* 否则会把每一条规则都误判成"目标无法确认"。
|
||||
*/
|
||||
scheduledReportMigrated: boolean
|
||||
rules: AutomationRule[]
|
||||
}
|
||||
|
||||
export interface AutomationRuleStoreDependencies {
|
||||
userDataPath?: () => string
|
||||
now?: () => number
|
||||
/**
|
||||
* 迁移旧定时日报时,把 legacy 群标识(会话 md5 / 群名 / roomId)解析成
|
||||
* **稳定会话 id**(`xxx@chatroom`)。解析不到返回 `undefined`。
|
||||
*
|
||||
* 不注入 ⇒ 迁移**推迟**(不写任何规则、不置标记)。
|
||||
*/
|
||||
resolveLegacyConversationId?: (raw: string) => string | undefined
|
||||
}
|
||||
|
||||
function emptyState(): StoredRules {
|
||||
return {
|
||||
version: CURRENT_VERSION,
|
||||
builtinSeeded: false,
|
||||
leaveNotificationMigrated: false,
|
||||
notificationTemplateUpgraded: false,
|
||||
scheduledReportMigrated: false,
|
||||
rules: []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuleType(value: unknown): AutomationRuleType {
|
||||
return value === 'leave_notification'
|
||||
? 'leave_notification'
|
||||
: value === 'scheduled_report'
|
||||
? 'scheduled_report'
|
||||
: 'daily_report'
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条规则的读盘归一化。
|
||||
*
|
||||
* 走 `normalizeRuleDraft` —— 它覆盖了 `AutomationRule` 除 id / 时间戳之外的**全部**字段,
|
||||
* 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / 各类 config。
|
||||
*/
|
||||
function normalizeStoredRule(value: unknown): AutomationRule | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const raw = value as Partial<AutomationRule>
|
||||
const id = String(raw.id || '').trim()
|
||||
if (!id) return null
|
||||
const ruleType = normalizeRuleType(raw.ruleType)
|
||||
const normalized = normalizeRuleDraft(
|
||||
{
|
||||
...raw,
|
||||
ruleType,
|
||||
// 历史规则没有这些字段;反过来也要避免脏字段落盘。
|
||||
...(ruleType === 'leave_notification' ? { leaveNotification: raw.leaveNotification } : {}),
|
||||
...(ruleType === 'scheduled_report' ? { scheduledReport: raw.scheduledReport } : {})
|
||||
},
|
||||
String(raw.name || '').trim() || '未命名自动化'
|
||||
)
|
||||
const createdAt = Number(raw.createdAt) || 0
|
||||
const updatedAt = Number(raw.updatedAt) || createdAt
|
||||
return {
|
||||
...normalized,
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
...(normalized.ruleType === 'leave_notification' && normalized.leaveNotification
|
||||
? { leaveNotification: normalized.leaveNotification }
|
||||
: {}),
|
||||
...(normalized.ruleType === 'scheduled_report' && normalized.scheduledReport
|
||||
? { scheduledReport: normalized.scheduledReport }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性替用户把 `{groupName}` 补进退群通知模板。
|
||||
*
|
||||
* **为什么替他填**:模板里原先根本没有群名变量(`{groupRemark}` 是成员在本群的昵称)。
|
||||
* 目标一旦不是「当前群聊」,通知就等于「张三退群了」—— 收件人不知道是哪个群。
|
||||
*
|
||||
* **为什么只跑一次**:靠"模板里有没有 `{groupName}`"判断会导致用户删掉后被反复补回来。
|
||||
* 所以由 `notificationTemplateUpgraded` 标记保证一次性;用户之后删掉不会回来。
|
||||
*/
|
||||
function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): {
|
||||
rules: AutomationRule[]
|
||||
count: number
|
||||
} {
|
||||
let count = 0
|
||||
const next = rules.map((rule) => {
|
||||
if (rule.ruleType !== 'leave_notification' || !rule.leaveNotification) return rule
|
||||
const current = rule.leaveNotification.template
|
||||
const upgraded = insertGroupNamePlaceholder(current)
|
||||
// 补完不能超长:超了下次读盘会被校验拦下、整条模板被重置成默认,反而更糟。
|
||||
if (upgraded === current || upgraded.length > GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH) {
|
||||
return rule
|
||||
}
|
||||
count += 1
|
||||
return { ...rule, leaveNotification: { ...rule.leaveNotification, template: upgraded } }
|
||||
})
|
||||
return { rules: next, count }
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度游标(`lastRunAt` / `lastScheduledSlot`)**由 scheduler 拥有**,草稿改不动它们。
|
||||
*
|
||||
* 保存规则时一律从 `current` 取,而不是从草稿取 —— 否则用户进一次编辑页保存,
|
||||
* 就会把"已消费的槽位"抹掉,导致当天重复补跑一次日报。
|
||||
*/
|
||||
function pickScheduledReportRuntime(
|
||||
current: ScheduledReportAutomationConfig | undefined
|
||||
): Partial<ScheduledReportAutomationConfig> {
|
||||
if (!current) return {}
|
||||
return {
|
||||
...(current.lastRunAt ? { lastRunAt: current.lastRunAt } : {}),
|
||||
...(current.lastScheduledSlot ? { lastScheduledSlot: current.lastScheduledSlot } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */
|
||||
function normalizeStored(value: unknown): StoredRules {
|
||||
if (!value || typeof value !== 'object') return emptyState()
|
||||
const input = value as Partial<StoredRules>
|
||||
const rules = Array.isArray(input.rules)
|
||||
? input.rules
|
||||
.map((rule) => normalizeStoredRule(rule))
|
||||
.filter((rule): rule is AutomationRule => rule !== null)
|
||||
: []
|
||||
return {
|
||||
version: Number(input.version) || 1,
|
||||
builtinSeeded: input.builtinSeeded === true,
|
||||
leaveNotificationMigrated: input.leaveNotificationMigrated === true,
|
||||
notificationTemplateUpgraded: input.notificationTemplateUpgraded === true,
|
||||
scheduledReportMigrated: input.scheduledReportMigrated === true,
|
||||
rules
|
||||
}
|
||||
}
|
||||
|
||||
export class AutomationRuleStore {
|
||||
private readonly userDataPath: () => string
|
||||
private readonly now: () => number
|
||||
private state: StoredRules = emptyState()
|
||||
private loaded = false
|
||||
/** 迁移结果,供启动日志/报告读取(不含任何 id)。 */
|
||||
private lastMigrationPlan: LeaveNotificationMigrationPlan | null = null
|
||||
private lastScheduledMigrationSummary: ScheduledReportMigrationSummary | null = null
|
||||
private legacyConversationResolver: ((raw: string) => string | undefined) | null
|
||||
|
||||
constructor(dependencies: AutomationRuleStoreDependencies = {}) {
|
||||
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
|
||||
this.now = dependencies.now ?? (() => Date.now())
|
||||
this.legacyConversationResolver = dependencies.resolveLegacyConversationId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入 / 替换旧定时日报迁移所需的会话解析器(数据库就绪后由 main 调用)。
|
||||
*
|
||||
* 若之前因为"解析器不可用"推迟了迁移,这里会**立刻补跑**并落盘。
|
||||
*/
|
||||
setLegacyConversationResolver(resolver: (raw: string) => string | undefined): void {
|
||||
this.legacyConversationResolver = resolver
|
||||
if (!this.loaded) return
|
||||
if (this.state.scheduledReportMigrated) return
|
||||
if (this.runScheduledReportMigration(this.state)) this.persist()
|
||||
}
|
||||
|
||||
/** 旧定时日报迁移是否仍在等待解析器(仅用于启动日志与测试断言)。 */
|
||||
hasPendingScheduledReportMigration(): boolean {
|
||||
this.ensureLoaded()
|
||||
return !this.state.scheduledReportMigrated && this.readLegacyScheduledTasks().length > 0
|
||||
}
|
||||
|
||||
listRules(): AutomationRule[] {
|
||||
this.ensureLoaded()
|
||||
return this.state.rules.map((rule) => structuredClone(rule))
|
||||
}
|
||||
|
||||
getRule(id: string): AutomationRule | undefined {
|
||||
const key = String(id || '').trim()
|
||||
if (!key) return undefined
|
||||
const found = this.listRules().find((rule) => rule.id === key)
|
||||
return found
|
||||
}
|
||||
|
||||
/** 上一次迁移的判定结果(未迁移时为 null)。 */
|
||||
getLastLeaveNotificationMigration(): LeaveNotificationMigrationPlan | null {
|
||||
this.ensureLoaded()
|
||||
return this.lastMigrationPlan ? { ...this.lastMigrationPlan } : null
|
||||
}
|
||||
|
||||
/** 上一次旧定时日报迁移的统计(未迁移时为 null)。 */
|
||||
getLastScheduledReportMigration(): ScheduledReportMigrationSummary | null {
|
||||
this.ensureLoaded()
|
||||
return this.lastScheduledMigrationSummary ? { ...this.lastScheduledMigrationSummary } : null
|
||||
}
|
||||
|
||||
createRule(draft: unknown): AutomationRule {
|
||||
this.ensureLoaded()
|
||||
const timestamp = this.now()
|
||||
const normalized = normalizeRuleDraft(draft)
|
||||
const rule: AutomationRule = {
|
||||
...normalized,
|
||||
id: randomUUID(),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
}
|
||||
this.state.rules = [...this.state.rules, rule]
|
||||
this.persist()
|
||||
return structuredClone(rule)
|
||||
}
|
||||
|
||||
updateRule(id: string, draft: unknown): AutomationRule | undefined {
|
||||
this.ensureLoaded()
|
||||
const key = String(id || '').trim()
|
||||
const index = this.state.rules.findIndex((rule) => rule.id === key)
|
||||
if (index < 0) return undefined
|
||||
const current = this.state.rules[index]
|
||||
// 名字留空时沿用原名,而不是变成「未命名自动化」—— 编辑页只改开关时不该改名。
|
||||
const normalized = normalizeRuleDraft(draft, current.name)
|
||||
const rule: AutomationRule = {
|
||||
...current,
|
||||
...normalized,
|
||||
id: current.id,
|
||||
// ruleType 不允许被草稿改掉:它是规则的**身份**,不是可编辑字段。
|
||||
ruleType: current.ruleType,
|
||||
...(current.ruleType === 'leave_notification' && normalized.leaveNotification
|
||||
? { leaveNotification: normalized.leaveNotification }
|
||||
: {}),
|
||||
...(current.ruleType === 'scheduled_report' && normalized.scheduledReport
|
||||
? {
|
||||
scheduledReport: {
|
||||
...normalized.scheduledReport,
|
||||
...pickScheduledReportRuntime(current.scheduledReport)
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
createdAt: current.createdAt,
|
||||
updatedAt: this.now()
|
||||
}
|
||||
// 切类型时不能留下另一种类型才认识的字段。
|
||||
if (rule.ruleType === 'daily_report') {
|
||||
delete rule.leaveNotification
|
||||
delete rule.scheduledReport
|
||||
} else if (rule.ruleType === 'leave_notification') {
|
||||
delete rule.scheduledReport
|
||||
} else {
|
||||
delete rule.leaveNotification
|
||||
}
|
||||
this.state.rules = this.state.rules.map((item, at) => (at === index ? rule : item))
|
||||
this.persist()
|
||||
return structuredClone(rule)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存「退群通知」规则(**singleton upsert**)。
|
||||
*
|
||||
* 不存在则创建(固定 id),存在则更新 —— 所以点多少次保存都只有一条规则。
|
||||
*/
|
||||
saveLeaveNotificationRule(draft: unknown): AutomationRule {
|
||||
this.ensureLoaded()
|
||||
const existing = this.state.rules.find(
|
||||
(rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID
|
||||
)
|
||||
if (existing) {
|
||||
const updated = this.updateRule(existing.id, { ...(draft as object), ruleType: 'leave_notification' })
|
||||
if (updated) return updated
|
||||
}
|
||||
const timestamp = this.now()
|
||||
const normalized = normalizeRuleDraft({ ...(draft as object), ruleType: 'leave_notification' })
|
||||
const rule: AutomationRule = {
|
||||
...createDefaultLeaveNotificationRule(timestamp),
|
||||
...normalized,
|
||||
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
ruleType: 'leave_notification',
|
||||
name: normalized.name || '退群通知',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
}
|
||||
this.state.rules = [...this.state.rules, rule]
|
||||
this.persist()
|
||||
return structuredClone(rule)
|
||||
}
|
||||
|
||||
deleteRule(id: string): boolean {
|
||||
this.ensureLoaded()
|
||||
const key = String(id || '').trim()
|
||||
const before = this.state.rules.length
|
||||
this.state.rules = this.state.rules.filter((rule) => rule.id !== key)
|
||||
if (this.state.rules.length === before) return false
|
||||
this.persist()
|
||||
return true
|
||||
}
|
||||
|
||||
setRuleEnabled(id: string, enabled: boolean): AutomationRule | undefined {
|
||||
const rule = this.getRule(id)
|
||||
if (!rule) return undefined
|
||||
return this.updateRule(id, { ...rule, enabled: enabled === true })
|
||||
}
|
||||
|
||||
/**
|
||||
* 写**调度游标**(`lastRunAt` / `lastScheduledSlot`)。
|
||||
*
|
||||
* 刻意不走 `updateRule`:这不是用户配置编辑,走草稿归一化会顺带触碰别的字段,
|
||||
* 也可能被"草稿里没这个字段"给抹掉。这里只允许改这两个键。
|
||||
*/
|
||||
setScheduledReportRuntime(
|
||||
id: string,
|
||||
patch: { lastRunAt?: string; lastScheduledSlot?: string }
|
||||
): AutomationRule | undefined {
|
||||
this.ensureLoaded()
|
||||
const key = String(id || '').trim()
|
||||
const index = this.state.rules.findIndex((rule) => rule.id === key)
|
||||
if (index < 0) return undefined
|
||||
const current = this.state.rules[index]
|
||||
if (current.ruleType !== 'scheduled_report' || !current.scheduledReport) return undefined
|
||||
const next: AutomationRule = {
|
||||
...current,
|
||||
scheduledReport: {
|
||||
...current.scheduledReport,
|
||||
...(patch.lastRunAt !== undefined ? { lastRunAt: patch.lastRunAt } : {}),
|
||||
...(patch.lastScheduledSlot !== undefined
|
||||
? { lastScheduledSlot: patch.lastScheduledSlot }
|
||||
: {})
|
||||
},
|
||||
updatedAt: this.now()
|
||||
}
|
||||
this.state.rules = this.state.rules.map((item, at) => (at === index ? next : item))
|
||||
this.persist()
|
||||
return structuredClone(next)
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
let stored: StoredRules
|
||||
try {
|
||||
stored = normalizeStored(fs.readJsonSync(this.rulesFilePath()) as unknown)
|
||||
} catch {
|
||||
stored = emptyState()
|
||||
}
|
||||
if (!stored.builtinSeeded) {
|
||||
stored.builtinSeeded = true
|
||||
stored.rules = [...stored.rules, createDefaultDailyReportRule(this.now())]
|
||||
}
|
||||
if (!stored.leaveNotificationMigrated) {
|
||||
stored.leaveNotificationMigrated = true
|
||||
stored.rules = this.migrateLeaveNotification(stored.rules)
|
||||
}
|
||||
if (!stored.notificationTemplateUpgraded) {
|
||||
stored.notificationTemplateUpgraded = true
|
||||
const upgraded = upgradeLeaveNotificationTemplate(stored.rules)
|
||||
stored.rules = upgraded.rules
|
||||
// 只记条数,**不记模板正文**。
|
||||
if (upgraded.count > 0) {
|
||||
console.log(`[Automation] 退群通知模板已补上群名变量(${upgraded.count} 条)`)
|
||||
}
|
||||
}
|
||||
this.runScheduledReportMigration(stored)
|
||||
stored.version = CURRENT_VERSION
|
||||
this.state = stored
|
||||
this.persist()
|
||||
}
|
||||
|
||||
/** 旧退群通知配置 → 内置退群通知规则。**只跑一次**,且先备份旧配置。 */
|
||||
private migrateLeaveNotification(rules: AutomationRule[]): AutomationRule[] {
|
||||
if (rules.some((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)) return rules
|
||||
const timestamp = this.now()
|
||||
const legacy = this.readLegacyLeaveNotificationState()
|
||||
if (!legacy) {
|
||||
// 全新安装:没有历史可迁,直接建默认规则(默认目标:文件传输助手)。
|
||||
this.logMigration('fresh_install', null)
|
||||
return [...rules, createDefaultLeaveNotificationRule(timestamp)]
|
||||
}
|
||||
const plan = planLeaveNotificationMigration(legacy)
|
||||
this.lastMigrationPlan = plan
|
||||
this.writeMigrationBackup(legacy, plan)
|
||||
this.logMigration(plan.outcome, plan)
|
||||
return [
|
||||
...rules,
|
||||
createDefaultLeaveNotificationRule(timestamp, {
|
||||
template: plan.template,
|
||||
target: plan.target,
|
||||
enabled: plan.enabled,
|
||||
// 二次勾选(旧「通知群聊」)逐字保留,迁移不再是有损的。
|
||||
notifyScope: plan.notifyScope,
|
||||
notifyRoomIds: plan.notifyRoomIds
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧「定时日报」任务 → `scheduled_report` 规则。**只跑一次**。
|
||||
*
|
||||
* 返回 `true` 表示本次真的做了迁移并需要落盘。
|
||||
* **解析器不可用时返回 `false` 且不置标记** —— 迁移推迟到数据库就绪后补跑。
|
||||
*/
|
||||
private runScheduledReportMigration(stored: StoredRules): boolean {
|
||||
if (stored.scheduledReportMigrated) return false
|
||||
const tasks = this.readLegacyScheduledTasks()
|
||||
if (!tasks.length) {
|
||||
// 全新安装 / 从没用过旧定时日报:没有历史可迁,直接置位。
|
||||
stored.scheduledReportMigrated = true
|
||||
console.log('[Automation] 定时日报迁移 outcome=fresh_install')
|
||||
return true
|
||||
}
|
||||
const resolver = this.legacyConversationResolver
|
||||
if (!resolver) {
|
||||
console.log(
|
||||
`[Automation] 定时日报迁移已推迟:数据库尚未就绪,无法解析旧群标识(待迁 ${tasks.length} 条)`
|
||||
)
|
||||
return false
|
||||
}
|
||||
const { plans, summary } = planScheduledReportMigrationBatch(tasks, resolver, this.now())
|
||||
this.lastScheduledMigrationSummary = summary
|
||||
this.writeScheduledMigrationBackup(tasks, summary)
|
||||
const existing = new Set(stored.rules.map((rule) => rule.id))
|
||||
const migrated: AutomationRule[] = []
|
||||
for (const plan of plans) {
|
||||
// 幂等:同一 id 已存在就不再插入(重复执行迁移也不会多出规则)。
|
||||
if (existing.has(plan.ruleId)) continue
|
||||
existing.add(plan.ruleId)
|
||||
migrated.push({
|
||||
...createDefaultScheduledReportRule(plan.createdAt, {
|
||||
id: plan.ruleId,
|
||||
name: plan.name,
|
||||
enabled: plan.enabled,
|
||||
config: plan.config
|
||||
}),
|
||||
createdAt: plan.createdAt,
|
||||
updatedAt: plan.updatedAt
|
||||
})
|
||||
}
|
||||
stored.rules = [...stored.rules, ...migrated]
|
||||
stored.scheduledReportMigrated = true
|
||||
// 只记数量与判定分布,**禁止**出现群名 / roomId / 群主昵称。
|
||||
console.log(
|
||||
`[Automation] 定时日报迁移 outcome=done total=${summary.total} migrated=${migrated.length}` +
|
||||
` lossless=${summary.lossless} needsReview=${summary.needsReview}` +
|
||||
` duplicatesSkipped=${summary.duplicatesSkipped}`
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 读旧定时日报**任务**文件。
|
||||
*
|
||||
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
|
||||
* 读失败视为"没有历史任务",不抛。
|
||||
*/
|
||||
private readLegacyScheduledTasks(): LegacyScheduledReportTask[] {
|
||||
try {
|
||||
const value = fs.readJsonSync(
|
||||
path.join(this.userDataPath(), LEGACY_SCHEDULED_DIR, LEGACY_SCHEDULED_TASKS_FILE)
|
||||
) as unknown
|
||||
return Array.isArray(value) ? (value as LegacyScheduledReportTask[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 迁移前把旧任务**原样**落一份 backup(不做不可逆覆盖)。 */
|
||||
private writeScheduledMigrationBackup(
|
||||
tasks: LegacyScheduledReportTask[],
|
||||
summary: ScheduledReportMigrationSummary
|
||||
): void {
|
||||
try {
|
||||
const filePath = path.join(
|
||||
this.userDataPath(),
|
||||
STORAGE_DIR,
|
||||
SCHEDULED_MIGRATION_BACKUP_FILE
|
||||
)
|
||||
fs.ensureDirSync(path.dirname(filePath))
|
||||
fs.writeJsonSync(
|
||||
filePath,
|
||||
{
|
||||
migratedAt: this.now(),
|
||||
summary,
|
||||
// 备份里保留原始字段:这是**用户数据**,不是日志,不进任何用户可见界面。
|
||||
legacyTasks: tasks
|
||||
},
|
||||
{ spaces: 2 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(`[Automation] 写入定时日报迁移备份失败: ${errorText(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读旧退群监控状态文件里的通知相关字段。
|
||||
*
|
||||
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
|
||||
* 读失败视为"没有历史配置",不抛。
|
||||
*/
|
||||
private readLegacyLeaveNotificationState(): LegacyLeaveNotificationState | null {
|
||||
try {
|
||||
const raw = fs.readJsonSync(
|
||||
path.join(this.userDataPath(), LEGACY_MONITOR_FILE)
|
||||
) as Partial<LegacyLeaveNotificationState>
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
return {
|
||||
monitoredRoomIds: Array.isArray(raw.monitoredRoomIds) ? raw.monitoredRoomIds : [],
|
||||
notificationRoomIds: Array.isArray(raw.notificationRoomIds) ? raw.notificationRoomIds : [],
|
||||
...(raw.notificationTemplate !== undefined
|
||||
? { notificationTemplate: raw.notificationTemplate }
|
||||
: {})
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移前把旧配置原样落一份 backup。
|
||||
*
|
||||
* 不做不可逆覆盖:旧状态文件本身也**不删**,只是不再被运行时代码读取。
|
||||
*/
|
||||
private writeMigrationBackup(
|
||||
legacy: LegacyLeaveNotificationState,
|
||||
plan: LeaveNotificationMigrationPlan
|
||||
): void {
|
||||
try {
|
||||
const filePath = path.join(this.userDataPath(), STORAGE_DIR, MIGRATION_BACKUP_FILE)
|
||||
fs.ensureDirSync(path.dirname(filePath))
|
||||
fs.writeJsonSync(
|
||||
filePath,
|
||||
{
|
||||
migratedAt: this.now(),
|
||||
outcome: plan.outcome,
|
||||
// 备份里保留原始 roomId:这是**用户数据**,不是日志,不进任何用户可见界面。
|
||||
legacy: {
|
||||
monitoredRoomIds: legacy.monitoredRoomIds,
|
||||
notificationRoomIds: legacy.notificationRoomIds,
|
||||
notificationTemplate: legacy.notificationTemplate
|
||||
},
|
||||
applied: {
|
||||
enabled: plan.enabled,
|
||||
targetType: plan.target.type,
|
||||
notifyScope: plan.notifyScope,
|
||||
notifyRoomIds: plan.notifyRoomIds
|
||||
}
|
||||
},
|
||||
{ spaces: 2 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(`[Automation] 写入退群通知迁移备份失败: ${errorText(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 迁移日志:只允许出现判定结果与布尔量,**禁止** roomId / 模板正文。 */
|
||||
private logMigration(outcome: string, plan: LeaveNotificationMigrationPlan | null): void {
|
||||
if (!plan) {
|
||||
console.log(`[Automation] 退群通知迁移 outcome=${outcome}`)
|
||||
return
|
||||
}
|
||||
console.log(
|
||||
`[Automation] 退群通知迁移 outcome=${outcome} enabled=${plan.enabled}` +
|
||||
` targetType=${plan.target.type} needsReview=${plan.targetNeedsReview}`
|
||||
)
|
||||
}
|
||||
|
||||
private rulesFilePath(): string {
|
||||
return path.join(this.userDataPath(), STORAGE_DIR, RULES_FILE)
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
const filePath = this.rulesFilePath()
|
||||
fs.ensureDirSync(path.dirname(filePath))
|
||||
fs.writeJsonSync(filePath, this.state, { spaces: 2 })
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Automation] 保存规则失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
export const automationRuleStore = new AutomationRuleStore()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||
SCHEDULED_REPORT_TARGET_OPTIONS,
|
||||
leaveNotificationTargetLabel,
|
||||
scheduledReportTargetLabel
|
||||
} from '../../shared/automation'
|
||||
import { WECHAT_FILE_HELPER_USERNAME, isFileHelperUsername } from '../../shared/wechat-identities'
|
||||
|
||||
/**
|
||||
* **中性的微信发送目标解析**。
|
||||
*
|
||||
* 退群通知与定时日报的目标语义完全一致(来源会话 / 自己 / 文件传输助手 / 指定好友),
|
||||
* 差的只是"来源会话"从哪来:
|
||||
* - 退群通知 → 事件所在群(`event.conversationId`);
|
||||
* - 定时日报 → 该规则配置的日报来源群(`config.report.sourceConversationId`)。
|
||||
*
|
||||
* ⇒ 所以**只允许有一份实现**(只保留一套 resolve)。
|
||||
* 两个业务各自只有一层薄适配(把参数摊平后交给这里),不重复任何判定逻辑。
|
||||
*
|
||||
* 五条规则(两边共用):
|
||||
* 1. 目标解析失败 → **报错**,绝不 fallback 到别的目标(尤其不许偷偷发文件助手 / 来源群);
|
||||
* 2. 只允许出现在选项表里的目标类型;
|
||||
* 3. 显示名不带 wxid / roomId:日志与界面只出现用户能看懂的称呼;
|
||||
* 4. 联系人必须**可发送**(排除群聊 / 公众号 / 文件传输助手 / 自己);
|
||||
* 5. 只有 `source_chat` 用得到来源会话,其余目标完全不看它。
|
||||
*/
|
||||
|
||||
/** 解析所需的最小联系人结构(main 侧 `FormattedContact` 结构上可赋值给它)。 */
|
||||
export interface AutomationTargetContact {
|
||||
m_nsUsrName: string
|
||||
m_nsNickName?: string
|
||||
md5?: string
|
||||
type: 'user' | 'group'
|
||||
isOfficialAccount?: boolean
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
/** 目标类型(两个业务共用同一组字面量)。 */
|
||||
export type AutomationTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact'
|
||||
|
||||
export interface ResolvedAutomationTarget {
|
||||
recipient: { type: 'group' | 'contact'; id: string; name: string }
|
||||
/** 用户可读的目标名,直接进执行日志的 `detail`。 */
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export type AutomationTargetResolution =
|
||||
| { ok: true; target: ResolvedAutomationTarget }
|
||||
| { ok: false; error: string }
|
||||
|
||||
/** 联系人的展示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
|
||||
export function automationContactDisplayName(contact: AutomationTargetContact): string {
|
||||
return (
|
||||
contact.remark?.trim() ||
|
||||
contact.wechatNickname?.trim() ||
|
||||
contact.m_nsNickName?.trim() ||
|
||||
'指定好友'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个联系人能不能作为「指定好友」的发送目标。
|
||||
*
|
||||
* 排除:群聊、公众号(`gh_`)、文件传输助手、自己。
|
||||
* UI 的联系人选择器与运行时的目标解析**共用这一条判定**,
|
||||
* 否则会出现"能选但发不出去"。
|
||||
*/
|
||||
export function isSendableFriendContact(
|
||||
contact: AutomationTargetContact,
|
||||
selfWxid?: string
|
||||
): boolean {
|
||||
if (contact.type !== 'user') return false
|
||||
if (contact.isOfficialAccount) return false
|
||||
const username = String(contact.m_nsUsrName || '').trim()
|
||||
if (!username) return false
|
||||
if (isFileHelperUsername(username)) return false
|
||||
const self = String(selfWxid || '').trim()
|
||||
if (self && username === self) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
|
||||
export function filterSendableFriendContacts<T extends AutomationTargetContact>(
|
||||
contacts: T[],
|
||||
selfWxid?: string
|
||||
): T[] {
|
||||
return contacts.filter((contact) => isSendableFriendContact(contact, selfWxid))
|
||||
}
|
||||
|
||||
/** 目标类型是否在(该业务的)选项表里。 */
|
||||
function isKnownTargetType(
|
||||
type: string,
|
||||
options: ReadonlyArray<{ type: string }>
|
||||
): boolean {
|
||||
return options.some((option) => option.type === type)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把群标识解析成**稳定会话 id**(`xxx@chatroom`)。
|
||||
*
|
||||
* 兼容三种历史形态:roomId 本身 / 会话 md5 / 群名。
|
||||
* 解析不出来返回 `undefined` —— 调用方必须如实报错,**不许回落**。
|
||||
*/
|
||||
export function resolveGroupConversationId(
|
||||
raw: string,
|
||||
contacts: AutomationTargetContact[]
|
||||
): string | undefined {
|
||||
const value = String(raw || '').trim()
|
||||
if (!value) return undefined
|
||||
if (value.endsWith('@chatroom')) return value
|
||||
const matched = contacts.find(
|
||||
(contact) =>
|
||||
(contact.type === 'group' || contact.m_nsUsrName.endsWith('@chatroom')) &&
|
||||
(contact.md5 === value ||
|
||||
contact.m_nsUsrName === value ||
|
||||
contact.m_nsNickName?.trim() === value)
|
||||
)
|
||||
const conversationId = String(matched?.m_nsUsrName || '').trim()
|
||||
return conversationId.endsWith('@chatroom') ? conversationId : undefined
|
||||
}
|
||||
|
||||
/** 群标识 → 可读群名(解析不到时返回空串,由调用方决定兜底文案)。 */
|
||||
export function resolveGroupDisplayName(
|
||||
raw: string,
|
||||
contacts: AutomationTargetContact[]
|
||||
): string {
|
||||
const value = String(raw || '').trim()
|
||||
if (!value) return ''
|
||||
const matched = contacts.find(
|
||||
(contact) =>
|
||||
contact.m_nsUsrName === value ||
|
||||
contact.md5 === value ||
|
||||
contact.m_nsNickName?.trim() === value
|
||||
)
|
||||
return String(matched?.m_nsNickName || '').trim()
|
||||
}
|
||||
|
||||
export interface ResolveAutomationTargetInput {
|
||||
targetType: AutomationTargetType
|
||||
/**
|
||||
* `source_chat` 时使用:来源会话(退群事件所在群 / 日报来源群)。
|
||||
* 允许是 roomId / 会话 md5 / 群名 —— 一律经 `resolveGroupConversationId` 收敛。
|
||||
*/
|
||||
sourceConversationId?: string
|
||||
/** 来源会话的可读名(拿不到时留空,由这里给兜底称呼)。 */
|
||||
sourceDisplayName?: string
|
||||
/** `contact` 时使用:稳定 id(wxid / username)。 */
|
||||
contactId?: string
|
||||
contacts: AutomationTargetContact[]
|
||||
/** 当前登录账号的 wxid;拿不到时传空串。 */
|
||||
selfWxid?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标解析的**文案与类型表**(按业务注入)。
|
||||
*
|
||||
* 为什么把文案参数化而不是各写一份 switch:两个业务的**判定逻辑必须一模一样**,
|
||||
* 只有"主语"不同。文案集中放在这里,判定仍然只有一份。
|
||||
*/
|
||||
export interface AutomationTargetMessages {
|
||||
/** 目标类型表的单一来源(与 UI 选项同源)。 */
|
||||
options: ReadonlyArray<{ type: string }>
|
||||
/** 类型非法(伪造值 / 未知值)。 */
|
||||
invalidTarget: string
|
||||
/** `source_chat` 但来源会话拿不到。 */
|
||||
sourceMissing: string
|
||||
/** `source_chat` 且来源会话没有可读名时,`recipient.name` 的兜底。 */
|
||||
sourceRecipientName: string
|
||||
/** `source_chat` 且来源会话没有可读名时,`displayName` 的兜底。 */
|
||||
sourceDisplayName: string
|
||||
/** `self` 但拿不到自身身份。 */
|
||||
selfMissing: string
|
||||
/** `contact` 但没选联系人。 */
|
||||
contactNotChosen: string
|
||||
/** `contact` 但联系人失效 / 不可发送。 */
|
||||
contactUnavailable: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 中性目标解析。**唯一的实现**。
|
||||
*/
|
||||
export function resolveAutomationTarget(
|
||||
input: ResolveAutomationTargetInput,
|
||||
messages: AutomationTargetMessages
|
||||
): AutomationTargetResolution {
|
||||
const type = input.targetType
|
||||
if (!type || !isKnownTargetType(type, messages.options)) {
|
||||
return { ok: false, error: messages.invalidTarget }
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'source_chat': {
|
||||
const conversationId = resolveGroupConversationId(
|
||||
String(input.sourceConversationId || ''),
|
||||
input.contacts
|
||||
)
|
||||
if (!conversationId) return { ok: false, error: messages.sourceMissing }
|
||||
const explicitName = String(input.sourceDisplayName || '').trim()
|
||||
const resolvedName = resolveGroupDisplayName(conversationId, input.contacts)
|
||||
const name = explicitName || resolvedName || messages.sourceRecipientName
|
||||
const displayName = explicitName || resolvedName || messages.sourceDisplayName
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: { type: 'group', id: conversationId, name },
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'self': {
|
||||
const selfWxid = String(input.selfWxid || '').trim()
|
||||
// 明确报错,不 fallback。
|
||||
if (!selfWxid) return { ok: false, error: messages.selfMissing }
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: { type: 'contact', id: selfWxid, name: '我' },
|
||||
displayName: '我'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'file_transfer':
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: {
|
||||
type: 'contact',
|
||||
id: WECHAT_FILE_HELPER_USERNAME,
|
||||
name: '文件传输助手'
|
||||
},
|
||||
displayName: '文件传输助手'
|
||||
}
|
||||
}
|
||||
|
||||
case 'contact': {
|
||||
const contactId = String(input.contactId || '').trim()
|
||||
if (!contactId) return { ok: false, error: messages.contactNotChosen }
|
||||
const contact = input.contacts.find(
|
||||
(item) => String(item.m_nsUsrName || '').trim() === contactId
|
||||
)
|
||||
if (!contact || !isSendableFriendContact(contact, input.selfWxid)) {
|
||||
// 之前选择的联系人被删除 / 不可发送 / 找不到时,规则不偷偷改发别处。
|
||||
return { ok: false, error: messages.contactUnavailable }
|
||||
}
|
||||
const displayName = automationContactDisplayName(contact)
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: { type: 'contact', id: contactId, name: displayName },
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 退群通知的已知目标类型表(与文案同源)。 */
|
||||
export const LEAVE_NOTIFICATION_TARGET_TYPES = LEAVE_NOTIFICATION_TARGET_OPTIONS
|
||||
|
||||
/** 定时日报的已知目标类型表(与文案同源)。 */
|
||||
export const SCHEDULED_REPORT_TARGET_TYPES = SCHEDULED_REPORT_TARGET_OPTIONS
|
||||
|
||||
export { leaveNotificationTargetLabel, scheduledReportTargetLabel }
|
||||
@@ -8,9 +8,12 @@ export type { CacheClearScope } from '../../shared/cache'
|
||||
|
||||
const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap')
|
||||
const KNOWLEDGE_CACHE_DIR = path.join(app.getPath('userData'), 'knowledge')
|
||||
const IMAGE_TEXT_INDEX_CACHE_DIR = path.join(app.getPath('userData'), 'image-text-index')
|
||||
|
||||
export interface CacheClearOptions {
|
||||
beforeClearKnowledge?: () => Promise<void>
|
||||
/** 清理图片文字索引前调用:停任务 + 关闭派生库句柄。 */
|
||||
beforeClearImageTextIndex?: () => Promise<void>
|
||||
}
|
||||
|
||||
function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } {
|
||||
@@ -46,6 +49,7 @@ export function getCacheSummary(): CacheSummary {
|
||||
const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR)
|
||||
const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache'))
|
||||
const knowledge = inspectDirectory(KNOWLEDGE_CACHE_DIR)
|
||||
const imageTextIndex = inspectDirectory(IMAGE_TEXT_INDEX_CACHE_DIR)
|
||||
const items: CacheSummaryItem[] = [
|
||||
{
|
||||
id: 'bootstrap',
|
||||
@@ -65,6 +69,13 @@ export function getCacheSummary(): CacheSummary {
|
||||
description:
|
||||
'为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。',
|
||||
...knowledge
|
||||
},
|
||||
{
|
||||
id: 'image-text-index',
|
||||
label: '图片文字索引',
|
||||
description:
|
||||
'本机从微信图片里识别出的文字及其检索索引。清理后无法搜索图片中的文字,可重新建立;不影响微信原始图片与聊天记录。',
|
||||
...imageTextIndex
|
||||
}
|
||||
]
|
||||
return {
|
||||
@@ -89,6 +100,11 @@ export async function clearCache(
|
||||
await options.beforeClearKnowledge?.()
|
||||
await fs.remove(KNOWLEDGE_CACHE_DIR)
|
||||
}
|
||||
if (scope === 'image-text-index' || scope === 'all') {
|
||||
// 先停下任务再删库,避免"边写边删"。
|
||||
await options.beforeClearImageTextIndex?.()
|
||||
await fs.remove(IMAGE_TEXT_INDEX_CACHE_DIR)
|
||||
}
|
||||
return getCacheSummary()
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
} from '../../shared/windows-runtime'
|
||||
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
|
||||
import type { ExportImageQuality } from '../../shared/image-quality'
|
||||
import type { ImageMessageCountProbe } from '../../shared/image-text-index'
|
||||
import { imageTextWindowToSeconds } from '../../shared/image-text-index'
|
||||
import { wcdbDebugLog } from '../wcdb-debug'
|
||||
import {
|
||||
buildContactSearchIndex,
|
||||
@@ -23,6 +25,87 @@ import {
|
||||
type ContactSearchIndex
|
||||
} from '../../shared/contact-search'
|
||||
|
||||
/**
|
||||
* 谁在读消息。
|
||||
*
|
||||
* 只允许下面这几个固定标签 —— 日志里**不能**出现会话 md5 / session id / wxid /
|
||||
* 群名 / 联系人 / 路径,所以调用方身份只能靠标签表达。
|
||||
* 落在集合外的调用点一律记 `unknown`。
|
||||
*/
|
||||
export type ListMessagesCaller =
|
||||
| 'image-text-index'
|
||||
| 'group-monitor'
|
||||
| 'archive'
|
||||
| 'knowledge'
|
||||
| 'unknown'
|
||||
|
||||
/** 进程生命周期内单调递增的读取序号,用来把"同一段时间的几次调用"关联起来(不是稳定标识)。 */
|
||||
let listMessagesRequestSeq = 0
|
||||
|
||||
export function nextListMessagesRequestId(): string {
|
||||
listMessagesRequestSeq += 1
|
||||
return `request-${listMessagesRequestSeq}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次 `listMessages` 的性能拆解。
|
||||
*
|
||||
* 存在的意义:大会话的全量读取会把主进程卡住数秒,而原来只有一行 `totalMs`,
|
||||
* 无法判断时间花在 **WCDB 查询**、**JS 逐条格式化**,还是 **内容解析**上。
|
||||
*
|
||||
* 覆盖面:`totalMs` 是外层入口的整段耗时;`formatMs` 包含 `contentParseMs` 与
|
||||
* `dateFormatMs`(后两者是它的子集,不可与 `formatMs` 相加)。
|
||||
*/
|
||||
export interface ListMessagesPerf {
|
||||
caller: ListMessagesCaller
|
||||
requestId: string
|
||||
/** WCDB 返回的原始行数(异步路径里含原生查询时间)。 */
|
||||
rawRows: number
|
||||
formattedRows: number
|
||||
totalMs: number
|
||||
/** 取原始行:同步 `getUserMessages` 或 `await getUserMessagesAsync`。 */
|
||||
rawReadMs: number
|
||||
/** 逐条构造 `FormattedMessage`(整个 `map`)。 */
|
||||
formatMs: number
|
||||
/** └ 其中:日期格式化(`toLocaleString`)。 */
|
||||
dateFormatMs: number
|
||||
/** └ 其中:内容解析(`parseMessageContent` / `parseStickerMessageFromRow`)。 */
|
||||
contentParseMs: number
|
||||
/** 召回归档合并与排序。 */
|
||||
sortMs: number
|
||||
/** `totalMs` 减去上面已计部分。 */
|
||||
otherMs: number
|
||||
}
|
||||
|
||||
function emptyPerf(caller: ListMessagesCaller, requestId: string): ListMessagesPerf {
|
||||
return {
|
||||
caller,
|
||||
requestId,
|
||||
rawRows: 0,
|
||||
formattedRows: 0,
|
||||
totalMs: 0,
|
||||
rawReadMs: 0,
|
||||
formatMs: 0,
|
||||
dateFormatMs: 0,
|
||||
contentParseMs: 0,
|
||||
sortMs: 0,
|
||||
otherMs: 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只在**值得看**的时候打一行:大会话、或者总耗时已经明显影响交互。
|
||||
* 单行、可 grep、无任何会话标识。
|
||||
*/
|
||||
function logListMessagesPerf(perf: ListMessagesPerf): void {
|
||||
perf.otherMs = Math.max(0, perf.totalMs - perf.rawReadMs - perf.formatMs - perf.sortMs)
|
||||
const noteworthy = perf.formattedRows >= 20_000 || perf.totalMs >= 1_000
|
||||
if (!noteworthy) return
|
||||
console.log(
|
||||
`[ChatServicePerf] caller=${perf.caller} request=${perf.requestId} rows=${perf.formattedRows} rawRows=${perf.rawRows} totalMs=${perf.totalMs} rawReadMs=${perf.rawReadMs} formatMs=${perf.formatMs} dateFormatMs=${perf.dateFormatMs} contentParseMs=${perf.contentParseMs} sortMs=${perf.sortMs} otherMs=${perf.otherMs}`
|
||||
)
|
||||
}
|
||||
|
||||
export function getCurrentKey(): string {
|
||||
if (!dbRef) return ''
|
||||
try {
|
||||
@@ -198,7 +281,8 @@ export function isReady(): boolean {
|
||||
|
||||
/** Session 行的时间字段可能是秒,也可能是毫秒;1e11 以下按秒换算。 */
|
||||
function sessionTimeToEpochMs(value: unknown): number | null {
|
||||
const numeric = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN
|
||||
const numeric =
|
||||
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return null
|
||||
return Math.round(numeric < 1e11 ? numeric * 1000 : numeric)
|
||||
}
|
||||
@@ -378,28 +462,52 @@ function listSourceMessages(
|
||||
endTime?: number,
|
||||
options?: { limit?: number },
|
||||
rawMessagesOverride?: WechatMessage[],
|
||||
requestId = 'NO-REQUEST'
|
||||
requestId = 'NO-REQUEST',
|
||||
perf?: ListMessagesPerf
|
||||
): FormattedMessage[] {
|
||||
if (!dbRef) return []
|
||||
|
||||
const startedAt = Date.now()
|
||||
const wcdb4Client = dbRef.getWcdb4Client()
|
||||
const username = wcdb4Client.getUsernameByMd5(userMd5)
|
||||
const isGroupChat = Boolean(username?.endsWith('@chatroom'))
|
||||
wcdbDebugLog(
|
||||
`[${requestId}] ChatService listSourceMessages start md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
|
||||
`[${requestId}] ChatService listSourceMessages start start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0} hasOverride=${rawMessagesOverride ? 1 : 0}`
|
||||
)
|
||||
const rawReadStartedAt = Date.now()
|
||||
const rawMessages =
|
||||
rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options)
|
||||
if (perf) {
|
||||
perf.rawReadMs += Date.now() - rawReadStartedAt
|
||||
perf.rawRows += rawMessages.length
|
||||
}
|
||||
wcdbDebugLog(
|
||||
`[${requestId}] ChatService raw snapshot ready raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
||||
`[${requestId}] ChatService raw snapshot ready raw=${rawMessages.length} cost=${Date.now() - rawReadStartedAt}ms`
|
||||
)
|
||||
|
||||
/** 把"内容解析"单独计时,才能区分"消息多"和"每条都在做解析"。 */
|
||||
const timedParse = <T>(fn: () => T): T => {
|
||||
if (!perf) return fn()
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
perf.contentParseMs += Date.now() - startedAt
|
||||
}
|
||||
}
|
||||
|
||||
const formatStartedAt = Date.now()
|
||||
const formatted = rawMessages.map((msg: WechatMessage) => {
|
||||
const rawMsgType = parseInt(msg.messageType)
|
||||
const msgType = normalizeMsgType(msg.messageType)
|
||||
const createTime = parseInt(msg.msgCreateTime)
|
||||
const date = new Date(createTime * 1000)
|
||||
/**
|
||||
* 逐条 `toLocaleString` 每次都会新建一个 ICU formatter —— 大会话里这是主要成本,
|
||||
* 所以单独计时,避免它被笼统算进"格式化耗时"。
|
||||
*/
|
||||
const dateFormatStartedAt = perf ? Date.now() : 0
|
||||
const datetimeText = date.toLocaleString('zh-CN', { hour12: false })
|
||||
if (perf) perf.dateFormatMs += Date.now() - dateFormatStartedAt
|
||||
const isMine = msg.mesDes !== 1
|
||||
const localId = parseInt(msg.mesLocalID) || 0
|
||||
|
||||
@@ -433,7 +541,7 @@ function listSourceMessages(
|
||||
/<patinfo\b|<type>\s*62\s*<\/type>/i.test(rawContent) ||
|
||||
([10000, 10002].includes(msgType) && /拍了拍/i.test(rawContent))
|
||||
if (isPatMessage) {
|
||||
const system = parseMessageContent(content, 10000)
|
||||
const system = timedParse(() => parseMessageContent(content, 10000))
|
||||
const patContent =
|
||||
system.type === 'system'
|
||||
? { ...system, pat: true }
|
||||
@@ -460,9 +568,9 @@ function listSourceMessages(
|
||||
/<(?:emoji|sticker|emoticon)\b/i.test(content) || /<type>\s*47\s*<\/type>/i.test(content)
|
||||
const rowSticker =
|
||||
inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
|
||||
? parseStickerMessageFromRow(msg, content)
|
||||
? timedParse(() => parseStickerMessageFromRow(msg, content))
|
||||
: undefined
|
||||
const parsedContent = parseMessageContent(content, inferredMsgType)
|
||||
const parsedContent = timedParse(() => parseMessageContent(content, inferredMsgType))
|
||||
const rowStickerUrl = rowSticker?.type === 'sticker' ? String(rowSticker.url || '') : ''
|
||||
const parsedShareUrl = parsedContent.type === 'share' ? parsedContent.url : ''
|
||||
const redPacketUrl = rowStickerUrl || parsedShareUrl
|
||||
@@ -534,7 +642,7 @@ function listSourceMessages(
|
||||
}
|
||||
|
||||
if (!contentData && typeof content === 'string' && /^[0-9a-fA-F]{64,}$/.test(content.trim())) {
|
||||
const parsed = parseStickerMessageFromRow(msg, content)
|
||||
const parsed = timedParse(() => parseStickerMessageFromRow(msg, content))
|
||||
if (parsed.type === 'sticker') {
|
||||
if (!parsed.url && parsed.md5) {
|
||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||
@@ -561,6 +669,9 @@ function listSourceMessages(
|
||||
: msg.mesLocalID || Math.random().toString()
|
||||
)
|
||||
const imageContent = contentData?.type === 'image' ? contentData : undefined
|
||||
// 语音时长来自 message_content 的 <voicemsg voicelength>(毫秒)——注意不是 length,
|
||||
// 那是 SILK 数据字节数。已在 parseMessageContent 里换算成秒。
|
||||
const voiceDuration = contentData?.type === 'voice' ? contentData.duration : undefined
|
||||
// Local ids repeat across conversations. Scope media handles to this database
|
||||
// connection and image without changing the message id used by other clients.
|
||||
const mediaId = imageContent
|
||||
@@ -615,7 +726,7 @@ function listSourceMessages(
|
||||
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
|
||||
isSender: isMine,
|
||||
type: displayType,
|
||||
datetime: date.toLocaleString('zh-CN', { hour12: false }),
|
||||
datetime: datetimeText,
|
||||
content,
|
||||
img,
|
||||
name,
|
||||
@@ -629,13 +740,15 @@ function listSourceMessages(
|
||||
createTime,
|
||||
recoveredFromRecallJournal,
|
||||
contentData,
|
||||
media
|
||||
media,
|
||||
voiceDuration
|
||||
}
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[ChatService] listMessages end md5=${userMd5} formatted=${formatted.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
if (perf) {
|
||||
perf.formatMs += Date.now() - formatStartedAt
|
||||
perf.formattedRows += formatted.length
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
@@ -643,13 +756,38 @@ export function listMessages(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number }
|
||||
options?: { limit?: number },
|
||||
caller: ListMessagesCaller = 'unknown'
|
||||
): FormattedMessage[] {
|
||||
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options)
|
||||
if (!dbRef) return sourceMessages
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||
const perf = emptyPerf(caller, nextListMessagesRequestId())
|
||||
const totalStartedAt = Date.now()
|
||||
try {
|
||||
const sourceMessages = listSourceMessages(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
options,
|
||||
undefined,
|
||||
perf.requestId,
|
||||
perf
|
||||
)
|
||||
if (!dbRef) return sourceMessages
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
const recallStartedAt = Date.now()
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
const result = mergeRecallArchiveMessages(
|
||||
userMd5,
|
||||
sourceMessages,
|
||||
startTime,
|
||||
endTime,
|
||||
options?.limit
|
||||
)
|
||||
perf.sortMs += Date.now() - recallStartedAt
|
||||
return result
|
||||
} finally {
|
||||
perf.totalMs = Date.now() - totalStartedAt
|
||||
logListMessagesPerf(perf)
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMessagesAsync(
|
||||
@@ -657,42 +795,128 @@ export async function listMessagesAsync(
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number },
|
||||
requestId = 'NO-REQUEST'
|
||||
requestId = '',
|
||||
caller: ListMessagesCaller = 'unknown'
|
||||
): Promise<FormattedMessage[]> {
|
||||
if (!dbRef) return []
|
||||
const startedAt = Date.now()
|
||||
wcdbDebugLog(`[${requestId}] ChatService listMessagesAsync start md5=${userMd5}`)
|
||||
const rawMessages = await dbRef.getUserMessagesAsync(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
options,
|
||||
requestId
|
||||
)
|
||||
wcdbDebugLog(
|
||||
`[${requestId}] ChatService getUserMessagesAsync end raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
const sourceMessages = listSourceMessages(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
options,
|
||||
rawMessages,
|
||||
requestId
|
||||
)
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
const result = mergeRecallArchiveMessages(
|
||||
userMd5,
|
||||
sourceMessages,
|
||||
startTime,
|
||||
endTime,
|
||||
options?.limit
|
||||
)
|
||||
wcdbDebugLog(
|
||||
`[${requestId}] ChatService listMessagesAsync end formatted=${result.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return result
|
||||
const perf = emptyPerf(caller, requestId || nextListMessagesRequestId())
|
||||
const totalStartedAt = Date.now()
|
||||
try {
|
||||
wcdbDebugLog(`[${perf.requestId}] ChatService listMessagesAsync start`)
|
||||
const rawReadStartedAt = Date.now()
|
||||
const rawMessages = await dbRef.getUserMessagesAsync(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
options,
|
||||
perf.requestId
|
||||
)
|
||||
perf.rawReadMs += Date.now() - rawReadStartedAt
|
||||
wcdbDebugLog(
|
||||
`[${perf.requestId}] ChatService getUserMessagesAsync end raw=${rawMessages.length} cost=${Date.now() - rawReadStartedAt}ms`
|
||||
)
|
||||
const sourceMessages = listSourceMessages(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
options,
|
||||
rawMessages,
|
||||
perf.requestId,
|
||||
perf
|
||||
)
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
const recallStartedAt = Date.now()
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
const result = mergeRecallArchiveMessages(
|
||||
userMd5,
|
||||
sourceMessages,
|
||||
startTime,
|
||||
endTime,
|
||||
options?.limit
|
||||
)
|
||||
perf.sortMs += Date.now() - recallStartedAt
|
||||
wcdbDebugLog(
|
||||
`[${perf.requestId}] ChatService listMessagesAsync end formatted=${result.length} cost=${Date.now() - totalStartedAt}ms`
|
||||
)
|
||||
return result
|
||||
} finally {
|
||||
perf.totalMs = Date.now() - totalStartedAt
|
||||
logListMessagesPerf(perf)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只取**图片消息**(图片文字索引专用)。
|
||||
*
|
||||
* 与 `listMessagesAsync` 的唯一差别是"读哪些行":由 WCDB 在 SQL 层按消息类型过滤,
|
||||
* 而不是把整个会话读进来再在 JS 里筛。格式化和消息身份走的是**同一套代码**
|
||||
* (同一个 `listSourceMessages`),所以 `messageId` / `contentData` / 派生键完全不变。
|
||||
*
|
||||
* 存在的理由:大会话(十几万到二十几万条消息)全量读一次要 15s 以上,
|
||||
* 而图片索引只关心图片;这是数据边界错了,不是性能调优问题。
|
||||
*/
|
||||
export async function listImageMessagesAsync(
|
||||
userMd5: string,
|
||||
window: {
|
||||
/** 闭下界(epoch ms)。 */
|
||||
sinceMs?: number
|
||||
/** 开上界(epoch ms)。 */
|
||||
beforeMs?: number
|
||||
limit?: number
|
||||
} = {},
|
||||
requestId = '',
|
||||
caller: ListMessagesCaller = 'unknown'
|
||||
): Promise<FormattedMessage[]> {
|
||||
if (!dbRef) return []
|
||||
const perf = emptyPerf(caller, requestId || nextListMessagesRequestId())
|
||||
const totalStartedAt = Date.now()
|
||||
// ms 半开区间 → 秒闭区间。换算只有共享契约里那一处实现。
|
||||
const { sinceSec, beforeSecInclusive } = imageTextWindowToSeconds(window)
|
||||
const startTime = sinceSec ?? undefined
|
||||
const endTime = beforeSecInclusive ?? undefined
|
||||
try {
|
||||
const rawReadStartedAt = Date.now()
|
||||
const rawMessages = await dbRef.getWcdb4Client().listImageMessagesAsync(userMd5, {
|
||||
...(window.sinceMs !== undefined ? { sinceMs: window.sinceMs } : {}),
|
||||
...(window.beforeMs !== undefined ? { beforeMs: window.beforeMs } : {}),
|
||||
...(window.limit !== undefined ? { limit: window.limit } : {}),
|
||||
// recent-first:同一时间窗内**新的图片先处理**。
|
||||
order: 'desc',
|
||||
requestId: perf.requestId
|
||||
})
|
||||
perf.rawReadMs += Date.now() - rawReadStartedAt
|
||||
/**
|
||||
* 时间边界必须同时交给格式化与召回归档合并。
|
||||
*
|
||||
* 少了这一步,归档合并会把**窗口之外**的撤回图片补回来 —— 于是"最近 7 天"
|
||||
* 这一段会混进十年前的消息,分段窗口形同虚设。
|
||||
*/
|
||||
const sourceMessages = listSourceMessages(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
window.limit !== undefined ? { limit: window.limit } : undefined,
|
||||
rawMessages,
|
||||
perf.requestId,
|
||||
perf
|
||||
)
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
const recallStartedAt = Date.now()
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
// 召回归档里可能还留着已被撤回的图片;跳过合并会漏索引,所以照旧合并。
|
||||
const result = mergeRecallArchiveMessages(
|
||||
userMd5,
|
||||
sourceMessages,
|
||||
startTime,
|
||||
endTime,
|
||||
window.limit
|
||||
)
|
||||
perf.sortMs += Date.now() - recallStartedAt
|
||||
return result
|
||||
} finally {
|
||||
perf.totalMs = Date.now() - totalStartedAt
|
||||
logListMessagesPerf(perf)
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMessagesForExport(
|
||||
@@ -701,22 +925,61 @@ export async function listMessagesForExport(
|
||||
endTime?: number
|
||||
): Promise<FormattedMessage[]> {
|
||||
if (!dbRef) return []
|
||||
const rawMessages = await dbRef.getUserMessagesForExport(userMd5, startTime, endTime)
|
||||
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, undefined, rawMessages)
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
const mergedMessages = mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime)
|
||||
console.log(
|
||||
`[ChatService] listMessagesForExport end md5=${userMd5} source=${sourceMessages.length} merged=${mergedMessages.length}`
|
||||
)
|
||||
return mergedMessages
|
||||
const perf = emptyPerf('archive', nextListMessagesRequestId())
|
||||
const totalStartedAt = Date.now()
|
||||
try {
|
||||
const rawReadStartedAt = Date.now()
|
||||
const rawMessages = await dbRef.getUserMessagesForExport(userMd5, startTime, endTime)
|
||||
perf.rawReadMs += Date.now() - rawReadStartedAt
|
||||
const sourceMessages = listSourceMessages(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
undefined,
|
||||
rawMessages,
|
||||
perf.requestId,
|
||||
perf
|
||||
)
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
const recallStartedAt = Date.now()
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
const mergedMessages = mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime)
|
||||
perf.sortMs += Date.now() - recallStartedAt
|
||||
return mergedMessages
|
||||
} finally {
|
||||
perf.totalMs = Date.now() - totalStartedAt
|
||||
logListMessagesPerf(perf)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count voice rows without hydrating message content. This is used by the
|
||||
* batch-selection view, where loading every conversation would make opening
|
||||
* Settings noticeably slow.
|
||||
* 图片消息计数(SQL 统计,不解密)。
|
||||
*
|
||||
* 返回 `count: null` 表示**统计失败**,不是 0 张。调用方必须区分这两件事 ——
|
||||
* 否则"数不出来"会被显示成"账号里没有图片",用户会因此放弃建立索引。
|
||||
*/
|
||||
export async function countImageMessagesAsync(
|
||||
userMd5: string,
|
||||
range?: number | { sinceMs?: number; beforeMs?: number }
|
||||
): Promise<ImageMessageCountProbe> {
|
||||
if (!dbRef) return { count: null, typeColumn: null, error: '微信数据库尚未就绪' }
|
||||
return dbRef.getWcdb4Client().countImageMessagesAsync(userMd5, range)
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片消息的增量水位(条数 + 最大插入序)。
|
||||
*
|
||||
* 增量索引**不能只比条数**:召回一张旧图的同时新增一张新图,条数不变但集合变了。
|
||||
* 返回 null = 当前数据库不支持该统计(调用方须退化成"每轮重扫",宁可慢也不可漏)。
|
||||
*/
|
||||
export async function imageConversationWatermarkAsync(
|
||||
userMd5: string,
|
||||
range?: number | { sinceMs?: number; beforeMs?: number }
|
||||
): Promise<{ count: number; maxLocalId: number } | null> {
|
||||
if (!dbRef) return null
|
||||
return dbRef.getWcdb4Client().imageConversationWatermarkAsync(userMd5, range)
|
||||
}
|
||||
|
||||
export async function countVoiceMessagesAsync(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
@@ -773,6 +1036,20 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把群会话 md5 解析成原生接口真正需要的 roomId(`xxx@chatroom`)。
|
||||
*
|
||||
* 存在的理由:群员统计从界面拿到的是会话 md5(与 `getGroupSnapshot(userMd5)` 同口径),
|
||||
* 而成员相关的原生接口吃的是 username。没有这层导出,调用方就得自己去碰
|
||||
* `dbRef.getWcdb4Client()`,等于让服务层绕开 chat-service 的封装。
|
||||
* 非群会话或解析不到时返回 null。
|
||||
*/
|
||||
export function resolveGroupRoomId(userMd5: string): string | null {
|
||||
if (!dbRef) return null
|
||||
const roomId = dbRef.getWcdb4Client().getUsernameByMd5(userMd5)
|
||||
return roomId && roomId.endsWith('@chatroom') ? roomId : null
|
||||
}
|
||||
|
||||
/** 退群检测专用轻量读取,不执行成员名称或头像 hydration。 */
|
||||
export async function getGroupMemberIdsAsync(
|
||||
roomId: string
|
||||
|
||||
@@ -2,19 +2,17 @@ import { app, BrowserWindow } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import * as chat from './chat-service'
|
||||
import { wechatActionGateway, type WechatActionGateway } from './wechat-action-gateway'
|
||||
import {
|
||||
buildMemberLeftNotification,
|
||||
findRemovedGroupMembers,
|
||||
groupExitMemberName,
|
||||
normalizeGroupExitNotificationTemplate,
|
||||
validateGroupExitNotificationTemplate,
|
||||
type GroupExitMonitorEvent,
|
||||
type GroupExitMonitorMember,
|
||||
type GroupExitNotificationStatus,
|
||||
type GroupExitNotificationState,
|
||||
type GroupExitMonitorState
|
||||
} from '../../shared/group-exit-monitor'
|
||||
import {
|
||||
toGroupMemberExitedEvent,
|
||||
type GroupMemberExitedEvent
|
||||
} from '../../shared/group-exit-event'
|
||||
|
||||
type StoredState = {
|
||||
enabled?: boolean
|
||||
@@ -23,9 +21,14 @@ type StoredState = {
|
||||
lastReadAt?: number
|
||||
monitorSelectionConfigured?: boolean
|
||||
monitoredRoomIds?: string[]
|
||||
notificationRoomIds?: string[]
|
||||
notificationTemplate?: unknown
|
||||
snapshots?: Partial<StoredGroupSnapshot>[]
|
||||
/*
|
||||
* 历史遗留字段(`notificationRoomIds` / `notificationTemplate`)**刻意不再声明**。
|
||||
*
|
||||
* 它们已经迁到「自动化 → 退群通知」规则里,本服务运行期不再读、不再写。
|
||||
* 旧状态文件原样保留在磁盘上(迁移时另有一份 backup),只是没有任何读取方 ——
|
||||
* 这是"不双读"的落地方式。迁移逻辑在 `automation-rule-store.ts`。
|
||||
*/
|
||||
}
|
||||
|
||||
type GroupSnapshotRecord = {
|
||||
@@ -57,19 +60,30 @@ type StoredGroupSnapshot = Pick<
|
||||
|
||||
const DB_CHANGE_DEBOUNCE_MS = 350
|
||||
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
|
||||
/**
|
||||
* **只约束「一次状态回传带多少条」**,不是存储上限。
|
||||
*
|
||||
* 事件现在写在 append-only 的 JSONL 里(`eventsPath()`),**永久保留、不做截断**。
|
||||
* 之所以必须把「存储」和「回传」分开:状态文件是整体重写的(`save()`),
|
||||
* 事件留在里面时每来一条新事件都要重写整个文件 —— 越写越慢。
|
||||
* 而 `getState()` 每次都把结果推过 IPC,也不能无上限。
|
||||
*/
|
||||
const MAX_EVENTS = 500
|
||||
|
||||
/**
|
||||
* 退群事件处理器。
|
||||
*
|
||||
* 由主进程注入成 `automationService.handleGroupExit` —— 本服务**不再自己发送**。
|
||||
* 契约:**必须自己吞掉异常**(实现方负责),本服务也会再兜一层。
|
||||
*/
|
||||
export type GroupExitEventHandler = (event: GroupMemberExitedEvent) => void | Promise<void>
|
||||
|
||||
export interface GroupExitMonitorServiceDependencies {
|
||||
actionGateway?: GroupExitActionGateway
|
||||
onGroupExit?: GroupExitEventHandler
|
||||
}
|
||||
|
||||
type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
|
||||
Partial<
|
||||
Pick<WechatActionGateway, 'registerMemberEvent' | 'registerMemberEvents' | 'clearMemberEvents'>
|
||||
>
|
||||
|
||||
class GroupExitMonitorService {
|
||||
private readonly actionGateway: GroupExitActionGateway
|
||||
private onGroupExit: GroupExitEventHandler | undefined
|
||||
private active = false
|
||||
private enabled = true
|
||||
private nativeMonitorActive = false
|
||||
@@ -87,7 +101,6 @@ class GroupExitMonitorService {
|
||||
private events: GroupExitMonitorEvent[] = []
|
||||
private monitorSelectionConfigured = true
|
||||
private monitoredRoomIds = new Set<string>()
|
||||
private notificationRoomIds = new Set<string>()
|
||||
private loaded = false
|
||||
private accountRoot = ''
|
||||
private groupNamesByRoomId = new Map<string, string>()
|
||||
@@ -95,24 +108,34 @@ class GroupExitMonitorService {
|
||||
private eventSequence = 0
|
||||
private scopeGeneration = 0
|
||||
private legacyFallbackLogged = false
|
||||
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
||||
|
||||
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
|
||||
this.actionGateway = deps.actionGateway || wechatActionGateway
|
||||
this.onGroupExit = deps.onGroupExit
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入退群事件处理器(主进程在 AutomationService 初始化后调用)。
|
||||
*
|
||||
* 用 setter 而不是构造参数:AutomationService 依赖 `Wcdb4Client`,
|
||||
* 要等数据库解锁后才存在,而本服务是模块级单例。
|
||||
*/
|
||||
setGroupExitHandler(handler: GroupExitEventHandler | undefined): void {
|
||||
this.onGroupExit = handler
|
||||
}
|
||||
|
||||
getState(): GroupExitMonitorState {
|
||||
this.ensureLoaded()
|
||||
return {
|
||||
events: [...this.events],
|
||||
// this.events 恒为「新在前」,所以这里取到的就是**最新**的 MAX_EVENTS 条。
|
||||
events: this.events.slice(0, MAX_EVENTS),
|
||||
/** 永久保留的事件总数(`events` 只是最新的一批)。 */
|
||||
totalEventCount: this.events.length,
|
||||
enabled: this.enabled,
|
||||
running: this.enabled && this.active && chat.isReady(),
|
||||
nativeMonitorActive: this.nativeMonitorActive,
|
||||
monitoredGroupCount: this.snapshots.size,
|
||||
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
||||
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
||||
notificationRoomIds: Array.from(this.notificationRoomIds),
|
||||
notificationTemplate: this.notificationTemplate,
|
||||
lastCheckedAt: this.lastCheckedAt,
|
||||
lastReadAt: this.lastReadAt,
|
||||
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
|
||||
@@ -132,12 +155,11 @@ class GroupExitMonitorService {
|
||||
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
|
||||
(!this.accountRoot && this.snapshots.size > 0)
|
||||
) {
|
||||
this.actionGateway.clearMemberEvents?.()
|
||||
this.events = []
|
||||
this.rewriteEventsToDisk([])
|
||||
this.lastReadAt = 0
|
||||
this.monitorSelectionConfigured = true
|
||||
this.monitoredRoomIds.clear()
|
||||
this.notificationRoomIds.clear()
|
||||
this.snapshots.clear()
|
||||
this.groupNamesByRoomId.clear()
|
||||
}
|
||||
@@ -208,10 +230,13 @@ class GroupExitMonitorService {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
async setMonitoredRoomIds(
|
||||
roomIds: string[],
|
||||
notificationRoomIds: string[] = []
|
||||
): Promise<GroupExitMonitorState> {
|
||||
/**
|
||||
* 保存**监控范围**。
|
||||
*
|
||||
* 参数只剩监控目标 —— 旧版第二个参数(通知群聊)已经迁到自动化规则里,
|
||||
* 本服务不再持有任何通知配置。
|
||||
*/
|
||||
async setMonitoredRoomIds(roomIds: string[]): Promise<GroupExitMonitorState> {
|
||||
this.ensureLoaded()
|
||||
this.eventSequence += 1
|
||||
this.scopeGeneration += 1
|
||||
@@ -225,10 +250,6 @@ class GroupExitMonitorService {
|
||||
}
|
||||
this.monitoredRoomIds = nextMonitoredRoomIds
|
||||
this.groupNamesRefreshPending = true
|
||||
const requestedNotifications = normalizeRoomIds(notificationRoomIds)
|
||||
this.notificationRoomIds = new Set(
|
||||
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
|
||||
)
|
||||
this.lastCheckedAt = undefined
|
||||
this.save()
|
||||
this.broadcast()
|
||||
@@ -261,22 +282,41 @@ class GroupExitMonitorService {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
setNotificationTemplate(value: unknown): GroupExitMonitorState {
|
||||
/**
|
||||
* 按群 / 时间范围查退群事件(档案合并展示用)。
|
||||
*
|
||||
* 与 `getState()` 的分工:后者只带回最近 `MAX_EVENTS` 条、且是**给管理页**看的概览;
|
||||
* 档案要的是「某个群在这段时间里的全部事件」,所以单独开一个查询入口,
|
||||
* 直接打在内存里的完整历史上(事件是永久保留的)。
|
||||
*
|
||||
* 返回**按时间升序**(旧 → 新),与档案消息流的顺序一致。
|
||||
*/
|
||||
listEvents(
|
||||
query: { roomId?: string; sinceMs?: number; untilMs?: number; limit?: number } = {}
|
||||
): GroupExitMonitorEvent[] {
|
||||
this.ensureLoaded()
|
||||
const result = validateGroupExitNotificationTemplate(value)
|
||||
if (!result.valid || !result.template) {
|
||||
throw new Error(result.error || '退群监测模板无效')
|
||||
const roomId = String(query.roomId || '').trim()
|
||||
const since = Number(query.sinceMs)
|
||||
const until = Number(query.untilMs)
|
||||
const limit = Number(query.limit)
|
||||
|
||||
// this.events 是倒序(新在前)。
|
||||
let result = [...this.events].reverse()
|
||||
if (roomId) result = result.filter((event) => event.roomId === roomId)
|
||||
if (Number.isFinite(since)) result = result.filter((event) => event.detectedAt >= since)
|
||||
if (Number.isFinite(until)) result = result.filter((event) => event.detectedAt <= until)
|
||||
// 超量时保留**最近**的一批(尾部即最新)。
|
||||
if (Number.isFinite(limit) && limit > 0 && result.length > limit) {
|
||||
result = result.slice(-limit)
|
||||
}
|
||||
this.notificationTemplate = result.template
|
||||
this.save()
|
||||
this.broadcast()
|
||||
return this.getState()
|
||||
return result
|
||||
}
|
||||
|
||||
clearEvents(): GroupExitMonitorState {
|
||||
this.ensureLoaded()
|
||||
this.events = []
|
||||
this.actionGateway.clearMemberEvents?.()
|
||||
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
|
||||
this.rewriteEventsToDisk([])
|
||||
this.lastReadAt = Date.now()
|
||||
this.save()
|
||||
this.broadcast()
|
||||
@@ -399,7 +439,7 @@ class GroupExitMonitorService {
|
||||
groups: GroupMembershipRecord[],
|
||||
scopeGeneration: number
|
||||
): Promise<number> {
|
||||
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = []
|
||||
const exits: GroupExitMonitorEvent[] = []
|
||||
let changedGroups = 0
|
||||
for (const membership of groups) {
|
||||
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
|
||||
@@ -430,10 +470,9 @@ class GroupExitMonitorService {
|
||||
if (previous && next.members.length < previous.members.length) {
|
||||
const removed = findRemovedGroupMembers(previous.members, next.members)
|
||||
for (const member of removed) {
|
||||
// 一人一条事件、一条通知 —— 保持既有产品语义,不聚合。
|
||||
const event = this.recordExit(next, member, previous.members.length, next.members.length)
|
||||
if (event && this.notificationRoomIds.has(next.roomId)) {
|
||||
notifications.push({ group: next, event })
|
||||
}
|
||||
if (event) exits.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,12 +487,11 @@ class GroupExitMonitorService {
|
||||
return changedGroups
|
||||
}
|
||||
this.lastCheckedAt = Date.now()
|
||||
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
|
||||
// 先把事件和新基线作为同一检查点落盘,再交给自动化。
|
||||
// 「退群事实已记录」与「通知发送成功」是两件独立的事。
|
||||
this.save()
|
||||
this.broadcast()
|
||||
if (notifications.length) {
|
||||
await Promise.all(notifications.map(({ group, event }) => this.notifyGroup(group, event)))
|
||||
}
|
||||
for (const event of exits) this.emitGroupExit(event)
|
||||
return changedGroups
|
||||
}
|
||||
|
||||
@@ -589,129 +627,145 @@ class GroupExitMonitorService {
|
||||
currentCount,
|
||||
delta: currentCount - previousCount,
|
||||
message,
|
||||
detectedAt,
|
||||
notificationStatus: 'not_requested'
|
||||
detectedAt
|
||||
}
|
||||
this.actionGateway.registerMemberEvent?.(event)
|
||||
this.events = [event, ...this.events].slice(0, MAX_EVENTS)
|
||||
// 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。
|
||||
// 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。
|
||||
this.events = [event, ...this.events]
|
||||
this.appendEventsToDisk([event])
|
||||
console.log(
|
||||
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
|
||||
)
|
||||
return event
|
||||
}
|
||||
|
||||
private async notifyGroup(
|
||||
group: GroupSnapshotRecord,
|
||||
event: GroupExitMonitorEvent,
|
||||
idempotencyKey = `member_left_notification:${event.id}`
|
||||
): Promise<void> {
|
||||
event.notificationStatus = 'pending'
|
||||
event.notification = { status: 'pending' }
|
||||
this.save()
|
||||
this.broadcast()
|
||||
/**
|
||||
* 把退群事件交给自动化 —— **不等待**。
|
||||
*
|
||||
* 三条约束:
|
||||
* 1. **绝不 await**:快照扫描与成员 diff 不能被微信发送耗时拖住;
|
||||
* 2. **异常必须被捕获**:`void promise` 漏掉 `.catch` 会变成 unhandled rejection;
|
||||
* 3. **不影响退群事实**:事件与快照在同一检查点已经先落盘,通知失败不回滚记录。
|
||||
*/
|
||||
private emitGroupExit(event: GroupExitMonitorEvent): void {
|
||||
const handler = this.onGroupExit
|
||||
if (!handler) return
|
||||
try {
|
||||
const result = await this.actionGateway.execute({
|
||||
idempotencyKey,
|
||||
origin: 'member_monitor',
|
||||
purpose: 'member_left_notification',
|
||||
triggerType: 'automation',
|
||||
sourceId: event.id,
|
||||
recipient: {
|
||||
type: 'group',
|
||||
id: group.roomId,
|
||||
name: group.groupName
|
||||
},
|
||||
content: {
|
||||
type: 'text',
|
||||
text: buildMemberLeftNotification(event, this.notificationTemplate)
|
||||
},
|
||||
metadata: {
|
||||
memberId: event.memberWxid,
|
||||
memberName: event.memberName,
|
||||
detectedAt: event.detectedAt,
|
||||
eventType: 'member_left',
|
||||
eventRoomId: event.roomId
|
||||
}
|
||||
})
|
||||
const notification: GroupExitNotificationState = {
|
||||
status: result.status,
|
||||
actionId: result.actionId,
|
||||
decision: result.decision,
|
||||
...(result.errorCode ? { errorCode: result.errorCode } : {}),
|
||||
...(result.reason ? { reason: result.reason } : {}),
|
||||
startedAt: result.startedAt,
|
||||
finishedAt: result.finishedAt
|
||||
}
|
||||
event.notificationStatus = result.status
|
||||
event.notification = notification
|
||||
if (result.status !== 'sent') {
|
||||
void Promise.resolve(
|
||||
handler(toGroupMemberExitedEvent(event))
|
||||
).catch((error) => {
|
||||
console.warn(
|
||||
`[GroupMonitor] 群聊通知未发送 roomId=${group.roomId} status=${result.status} code=${result.errorCode || ''}`
|
||||
`[GroupMonitor] 退群通知处理失败 eventId=${event.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
event.notificationStatus = 'failed'
|
||||
event.notification = {
|
||||
status: 'failed',
|
||||
errorCode: 'UNKNOWN',
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
// handler 同步抛出的情况(`handleGroupExit` 本身不抛,这里是防御性兜底)。
|
||||
console.warn(
|
||||
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
`[GroupMonitor] 退群通知处理异常 eventId=${event.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
} finally {
|
||||
this.save()
|
||||
this.broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
async resendEvent(eventId: string): Promise<GroupExitMonitorState> {
|
||||
const event = this.events.find((item) => item.id === eventId)
|
||||
if (!event) throw new Error('退群动态不存在')
|
||||
await this.notifyGroup(
|
||||
{ roomId: event.roomId, groupName: event.groupName } as GroupSnapshotRecord,
|
||||
event,
|
||||
`member_left_notification:${event.id}:retry:${Date.now()}`
|
||||
)
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
private filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群事件的 append-only 存储(每行一条 JSON)。
|
||||
*
|
||||
* 与状态文件分开,因为两者的写入模式完全不同:
|
||||
* - **状态**(开关 / 监控范围 / 快照 / 模板)小、且总是整体重写;
|
||||
* - **事件**只增不改,且要求**永久保留**。
|
||||
* 混在一个文件里时,每来一条事件都要把整部历史重新序列化写一遍 —— 越写越慢。
|
||||
*/
|
||||
private eventsPath(): string {
|
||||
return path.join(app.getPath('userData'), 'group-exit-monitor-events.jsonl')
|
||||
}
|
||||
|
||||
/** 读全量历史事件。单行损坏只跳过该行,不让整部历史读不出来。 */
|
||||
private readEventsFromDisk(): GroupExitMonitorEvent[] {
|
||||
let raw = ''
|
||||
try {
|
||||
raw = fs.readFileSync(this.eventsPath(), 'utf8')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const events: GroupExitMonitorEvent[] = []
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
try {
|
||||
events.push(JSON.parse(trimmed) as GroupExitMonitorEvent)
|
||||
} catch {
|
||||
// 跳过坏行
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** 只追加新增的那几行,不重写历史。 */
|
||||
private appendEventsToDisk(events: GroupExitMonitorEvent[]): void {
|
||||
if (!events.length) return
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.eventsPath()))
|
||||
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
|
||||
fs.appendFileSync(this.eventsPath(), payload, 'utf8')
|
||||
} catch (error) {
|
||||
console.warn('[GroupMonitor] 追加退群事件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 整体重写事件文件(清空、切换账号、老数据迁移时使用)。 */
|
||||
private rewriteEventsToDisk(events: GroupExitMonitorEvent[]): void {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.eventsPath()))
|
||||
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
|
||||
fs.writeFileSync(this.eventsPath(), payload, 'utf8')
|
||||
} catch (error) {
|
||||
console.warn('[GroupMonitor] 重写退群事件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
try {
|
||||
const stored = fs.readJsonSync(this.filePath()) as StoredState
|
||||
this.enabled = stored.enabled !== false
|
||||
this.events = normalizeEvents(stored.events)
|
||||
this.actionGateway.registerMemberEvents?.(this.events)
|
||||
// 事件从 append-only 文件读;状态文件不再承载它们。
|
||||
const fromDisk = this.readEventsFromDisk()
|
||||
const legacy = normalizeEvents(stored.events)
|
||||
if (legacy.length && !fromDisk.length) {
|
||||
// 老版本把事件塞在状态文件里 —— 一次性迁移过去,避免这批历史丢失。
|
||||
// 落盘按时间**升序**(旧 → 新),与之后 append 的方向一致,避免在
|
||||
// append-only 文件开头留下一段方向相反的旧历史(历史行序错乱的来源)。
|
||||
this.rewriteEventsToDisk(
|
||||
[...legacy].sort((left, right) => left.detectedAt - right.detectedAt)
|
||||
)
|
||||
this.events = sortEventsNewestFirst(legacy)
|
||||
} else {
|
||||
// 磁盘行序不保证时间有序(迁移段与追加段方向相反),读回后必须显式重建
|
||||
// 「新在前」这个内存不变量,否则列表顶部会恒为最旧的一批。
|
||||
this.events = sortEventsNewestFirst(normalizeEvents(fromDisk))
|
||||
}
|
||||
this.lastReadAt = Number(stored.lastReadAt) || 0
|
||||
this.accountRoot = String(stored.accountRoot || '')
|
||||
// 没有显式范围时按空范围处理,保留已有选择。
|
||||
this.monitorSelectionConfigured = true
|
||||
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
|
||||
this.notificationRoomIds = new Set(
|
||||
Array.from(normalizeRoomIds(stored.notificationRoomIds || [])).filter((roomId) =>
|
||||
this.monitoredRoomIds.has(roomId)
|
||||
)
|
||||
)
|
||||
this.notificationTemplate = normalizeGroupExitNotificationTemplate(
|
||||
stored.notificationTemplate
|
||||
)
|
||||
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
|
||||
} catch {
|
||||
// 首次启动或文件损坏时从空记录开始。
|
||||
this.events = []
|
||||
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
|
||||
// 不该被状态文件的问题连累,仍然读回来。
|
||||
this.events = sortEventsNewestFirst(normalizeEvents(this.readEventsFromDisk()))
|
||||
this.enabled = true
|
||||
this.lastReadAt = 0
|
||||
this.monitorSelectionConfigured = true
|
||||
this.monitoredRoomIds.clear()
|
||||
this.notificationRoomIds.clear()
|
||||
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
||||
this.snapshots.clear()
|
||||
}
|
||||
}
|
||||
@@ -725,12 +779,11 @@ class GroupExitMonitorService {
|
||||
{
|
||||
accountRoot: this.accountRoot,
|
||||
enabled: this.enabled,
|
||||
events: this.events,
|
||||
// 事件**不在这里**:它们走 append-only 的 JSONL(见 `eventsPath()`)。
|
||||
// 放进状态文件会让每新增一条事件都把整部历史重写一遍。
|
||||
lastReadAt: this.lastReadAt,
|
||||
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
||||
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
||||
notificationRoomIds: Array.from(this.notificationRoomIds),
|
||||
notificationTemplate: this.notificationTemplate,
|
||||
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
|
||||
},
|
||||
{ spaces: 2 }
|
||||
@@ -927,39 +980,28 @@ function normalizeEvents(
|
||||
? Number(value.delta)
|
||||
: currentCount - previousCount,
|
||||
message: String(value.message || `${memberName}退出了${groupName}`),
|
||||
detectedAt,
|
||||
...(value.notificationStatus
|
||||
? { notificationStatus: normalizeNotificationStatus(value.notificationStatus) }
|
||||
: {}),
|
||||
...(value.notification && typeof value.notification === 'object'
|
||||
? { notification: normalizeNotification(value.notification) }
|
||||
: {})
|
||||
detectedAt
|
||||
})
|
||||
if (normalized.length >= MAX_EVENTS) break
|
||||
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
|
||||
// 历史上的 `notificationStatus` / `notification` 字段被**丢弃**:
|
||||
// 通知状态已归 Automation 执行日志,退群监控不再持有它。
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeNotificationStatus(value: unknown): GroupExitNotificationStatus {
|
||||
const status = String(value || '').trim()
|
||||
return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed'
|
||||
? status
|
||||
: 'not_requested'
|
||||
}
|
||||
|
||||
function normalizeNotification(value: object): GroupExitNotificationState {
|
||||
const input = value as Partial<GroupExitNotificationState>
|
||||
return {
|
||||
status: normalizeNotificationStatus(input.status),
|
||||
...(input.actionId ? { actionId: String(input.actionId) } : {}),
|
||||
...(input.decision === 'allow' || input.decision === 'block'
|
||||
? { decision: input.decision }
|
||||
: {}),
|
||||
...(input.errorCode ? { errorCode: String(input.errorCode) } : {}),
|
||||
...(input.reason ? { reason: String(input.reason) } : {}),
|
||||
...(input.startedAt ? { startedAt: String(input.startedAt) } : {}),
|
||||
...(input.finishedAt ? { finishedAt: String(input.finishedAt) } : {})
|
||||
}
|
||||
/**
|
||||
* 事件在内存里恒定保持「**新在前**」。
|
||||
*
|
||||
* 这个不变量有三个依赖方:`recordExit` 的 `[event, ...this.events]` 写入方向、
|
||||
* `listEvents()` 的 `.reverse()`(它假定内存是倒序,反转后得到升序)、
|
||||
* 以及 `getState()` 的 `slice(0, MAX_EVENTS)`(要求取到的是**最新**的一批)。
|
||||
*
|
||||
* 必须显式重建它:磁盘是 append-only,行序由「迁移写入的历史 + 之后追加的新事件」
|
||||
* 决定,两段方向相反,整体不保证时间有序。直接信任文件行序会让列表顶部恒为最旧的
|
||||
* 一批,并让 `slice(0, MAX_EVENTS)` 恰好把最新的事件截掉。
|
||||
*/
|
||||
function sortEventsNewestFirst(events: GroupExitMonitorEvent[]): GroupExitMonitorEvent[] {
|
||||
return [...events].sort((left, right) => right.detectedAt - left.detectedAt)
|
||||
}
|
||||
|
||||
function isContactEvent(rawPayload: string): boolean {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { isKnowledgeFresh } from '../../shared/knowledge'
|
||||
import {
|
||||
GROUP_STATS_LIMITATION,
|
||||
GROUP_STATS_STALE_LIMITATION,
|
||||
type GroupMemberStatsActiveMember,
|
||||
type GroupMemberStatsQuery,
|
||||
type GroupMemberStatsResult,
|
||||
type GroupMemberStatsSilentMember,
|
||||
type GroupStatsFreshness
|
||||
} from '../../shared/group-stats'
|
||||
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
|
||||
import * as chat from './chat-service'
|
||||
|
||||
/**
|
||||
* 触发一次追赶同步的最小间隔。
|
||||
*
|
||||
* 面板是可以反复开关的交互入口,而追赶同步跑的是真实增量 pass(会读 WCDB)。
|
||||
* 没有这个节流,连续点击就等于连续触发索引。
|
||||
*/
|
||||
const CATCH_UP_MIN_INTERVAL_MS = 30_000
|
||||
|
||||
/**
|
||||
* 追赶的有界等待预算。
|
||||
*
|
||||
* 与 Query 侧同一取舍:全量追赶可能以分钟计,交互查询绝不能无限等。
|
||||
* 预算用完之后**如实**把结果标成不完整,而不是假装完整。
|
||||
*/
|
||||
const FRESHNESS_WAIT_BUDGET_MS = 2_000
|
||||
|
||||
/**
|
||||
* 群员统计。
|
||||
*
|
||||
* 数据两路来源,职责严格分开:
|
||||
* - **当前成员名单** 走 WCDB 的轻路径(只取 wxid + 显示名,**不** hydrate 头像);
|
||||
* - **发言聚合** 走 Knowledge 派生库的 `GROUP BY sender_id`,不读 WCDB 原始消息。
|
||||
*
|
||||
* 「未发言」= 当前成员 ∖ 窗口内有发言的 sender。这个定义**不能**简化成
|
||||
* 「整段窗口都没说话」—— 入群/退群时间在源头就不存在(整个 DB 没有该字段),
|
||||
* 所以结论必须带上 limitation。
|
||||
*/
|
||||
export class GroupStatsService {
|
||||
constructor(private readonly knowledge: KnowledgeSearchService) {}
|
||||
|
||||
async getMemberStats(query: GroupMemberStatsQuery): Promise<GroupMemberStatsResult> {
|
||||
const { userMd5, startTime, endTime } = query
|
||||
const limitations = [GROUP_STATS_LIMITATION]
|
||||
|
||||
const roomId = chat.resolveGroupRoomId(userMd5)
|
||||
if (!roomId) {
|
||||
// 不是群 / md5 解析不到:如实返回空,不编造统计。
|
||||
return this.build({
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
freshness: 'unknown',
|
||||
limitations,
|
||||
members: [],
|
||||
activeMembers: [],
|
||||
silentMembers: [],
|
||||
unattributedMessages: 0,
|
||||
excludedSystemMessages: 0,
|
||||
firstMessageTime: null
|
||||
})
|
||||
}
|
||||
|
||||
// 1) 当前群成员 —— 轻路径。只取 wxid,再按需解析显示名;绝不走
|
||||
// `getGroupSnapshotAsync`(它会 materialize 整群并 hydrate 头像,实测 8 群 ≈ 42s)。
|
||||
const membership = await chat.getGroupMemberIdsAsync(roomId)
|
||||
const memberIds = membership?.memberIds ?? []
|
||||
const members = await chat.getGroupMemberNamesAsync(userMd5, memberIds)
|
||||
|
||||
// 2) 发言聚合(只统计当前群;系统消息在 SQL 层就被排除)
|
||||
let { result, sourceLatestAt } = await this.knowledge.memberStats({
|
||||
conversationId: userMd5,
|
||||
startTime,
|
||||
endTime
|
||||
})
|
||||
let freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
|
||||
|
||||
// 3) 索引没追平 → 有界追赶一次再判。追不上就如实标 stale,
|
||||
// **不允许**把落后索引算出来的「未发言」包装成完整结论。
|
||||
if (freshness !== 'fresh') {
|
||||
this.knowledge.requestCatchUp(CATCH_UP_MIN_INTERVAL_MS)
|
||||
const settled = await this.knowledge.waitForIndexingComplete(FRESHNESS_WAIT_BUDGET_MS)
|
||||
if (settled) {
|
||||
const retried = await this.knowledge.memberStats({
|
||||
conversationId: userMd5,
|
||||
startTime,
|
||||
endTime
|
||||
})
|
||||
result = retried.result
|
||||
sourceLatestAt = retried.sourceLatestAt
|
||||
freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 差集:当前成员 × 窗口内发言
|
||||
const statBySender = new Map<string, { messageCount: number; lastMessageTime: number }>()
|
||||
for (const row of result?.senders ?? []) {
|
||||
if (row.senderId) statBySender.set(row.senderId, row)
|
||||
}
|
||||
|
||||
const knownSenderIds = new Set<string>()
|
||||
const activeMembers: GroupMemberStatsActiveMember[] = []
|
||||
const silentMembers: GroupMemberStatsSilentMember[] = []
|
||||
|
||||
for (const member of members) {
|
||||
const wxid = String(member.wxid || '').trim()
|
||||
if (!wxid) continue
|
||||
knownSenderIds.add(wxid)
|
||||
// 与 Knowledge 的 Join key 必须是 wxid,不是昵称 —— 昵称会变。
|
||||
// 显示名沿用项目既有优先级(nickname 已内含 `wechatNickname || groupNickname || username`)。
|
||||
const displayName = member.nickname || member.remark || wxid
|
||||
const groupNickname = member.groupNickname || ''
|
||||
const stat = statBySender.get(wxid)
|
||||
if (stat && stat.messageCount > 0) {
|
||||
activeMembers.push({
|
||||
senderId: wxid,
|
||||
displayName,
|
||||
groupNickname,
|
||||
messageCount: stat.messageCount,
|
||||
lastMessageTime: stat.lastMessageTime
|
||||
})
|
||||
} else {
|
||||
silentMembers.push({ senderId: wxid, displayName, groupNickname })
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口内有发言、但已不在当前成员名单里的人(退群者)。
|
||||
// 默认**不**进活跃榜:那份名单读作「当前群成员」,混入已退群的人会误导。
|
||||
let formerSenderCount = 0
|
||||
for (const senderId of statBySender.keys()) {
|
||||
if (!knownSenderIds.has(senderId)) formerSenderCount += 1
|
||||
}
|
||||
|
||||
activeMembers.sort(
|
||||
(a, b) => b.messageCount - a.messageCount || b.lastMessageTime - a.lastMessageTime
|
||||
)
|
||||
silentMembers.sort((a, b) => a.displayName.localeCompare(b.displayName, 'zh-Hans-CN'))
|
||||
|
||||
if (freshness !== 'fresh') limitations.push(GROUP_STATS_STALE_LIMITATION)
|
||||
if (formerSenderCount > 0) {
|
||||
limitations.push(
|
||||
`另有 ${formerSenderCount} 位窗口内发言者已不在当前群成员名单中,未计入活跃榜。`
|
||||
)
|
||||
}
|
||||
|
||||
return this.build({
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
freshness,
|
||||
limitations,
|
||||
members,
|
||||
activeMembers,
|
||||
silentMembers,
|
||||
unattributedMessages: result?.unattributedMessages ?? 0,
|
||||
excludedSystemMessages: result?.excludedSystemMessages ?? 0,
|
||||
firstMessageTime: result?.earliestMessageTime ?? null
|
||||
})
|
||||
}
|
||||
|
||||
private build(input: {
|
||||
userMd5: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
freshness: GroupStatsFreshness
|
||||
limitations: string[]
|
||||
members: Array<{ wxid: string }>
|
||||
activeMembers: GroupMemberStatsActiveMember[]
|
||||
silentMembers: GroupMemberStatsSilentMember[]
|
||||
unattributedMessages: number
|
||||
excludedSystemMessages: number
|
||||
firstMessageTime: number | null
|
||||
}): GroupMemberStatsResult {
|
||||
return {
|
||||
conversationId: input.userMd5,
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime,
|
||||
freshness: input.freshness,
|
||||
// 只有索引确实追平,才允许调用方把「未发言」当作完整结论。
|
||||
complete: input.freshness === 'fresh',
|
||||
memberCount: input.members.length,
|
||||
activeMemberCount: input.activeMembers.length,
|
||||
silentMemberCount: input.silentMembers.length,
|
||||
activeMembers: input.activeMembers,
|
||||
silentMembers: input.silentMembers,
|
||||
unattributedMessages: input.unattributedMessages,
|
||||
excludedSystemMessages: input.excludedSystemMessages,
|
||||
firstMessageTime: input.firstMessageTime,
|
||||
limitations: input.limitations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `isKnowledgeFresh` 返回 `boolean | null`(任一侧口径缺失即 null)。
|
||||
* 三态都要能表达:`null` 绝不能当成「新鲜」。
|
||||
*/
|
||||
function judgeFreshness(
|
||||
indexLatestAt: number | null,
|
||||
sourceLatestAt: number | null
|
||||
): GroupStatsFreshness {
|
||||
const fresh = isKnowledgeFresh({ indexLatestAt, sourceLatestAt })
|
||||
if (fresh === true) return 'fresh'
|
||||
if (fresh === false) return 'stale'
|
||||
return 'unknown'
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
isFreshImageInsight,
|
||||
isHotImageCandidate
|
||||
} from '../../shared/image-insight'
|
||||
import type { SystemOcrCapability, SystemOcrRequest, SystemOcrResult } from '../../shared/system-ocr'
|
||||
import { systemOcrService } from './system-ocr-service'
|
||||
|
||||
/**
|
||||
* 单张图片的最小信息(由 renderer 从已加载的 messages 中提取并传入 main)。
|
||||
@@ -312,6 +314,35 @@ class ImageInsightService {
|
||||
listBySession(sessionId: string, limit?: number): ImageInsight[] {
|
||||
return imageInsightsStore.listBySession(sessionId, limit)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 本地图片文字识别(System OCR)
|
||||
// ============================================================
|
||||
//
|
||||
// 与 Vision 路径的关系:
|
||||
// ImageInsightService 是统一编排入口,下面挂两条互不干扰的运行时——
|
||||
// - Vision Model Runtime(AIProviderService,走 AI Provider,可能联网)
|
||||
// - System OCR Runtime(SystemOcrService,纯本地,不联网)
|
||||
//
|
||||
// 边界与约束:
|
||||
// 1. 本地 OCR 结果属于 **派生内容**,原始消息始终是权威来源;
|
||||
// 本服务只返回识别文本,不做持久化 —— 落库与 Knowledge 回填在图片文字索引侧。
|
||||
// 2. 本地 OCR 结果 **不会** 写入 image-insights.json——那是 Vision 结果的缓存,
|
||||
// 两者的缓存键空间也不同(见 buildSystemOcrCacheKey)。
|
||||
// 3. 这里不读取也绝不修改 AI Vision Provider / 模型配置。
|
||||
|
||||
/** 本机是否支持本地图片文字识别(System OCR,引擎按平台决定)。 */
|
||||
getSystemOcrCapability(): Promise<SystemOcrCapability> {
|
||||
return systemOcrService.getCapability()
|
||||
}
|
||||
|
||||
/**
|
||||
* 只做「把图片里的文字读出来」。不发网络请求,不动 AI Provider 配置。
|
||||
* 失败不抛,返回带 errorCode 的结果。
|
||||
*/
|
||||
extractLocalText(request: SystemOcrRequest): Promise<SystemOcrResult> {
|
||||
return systemOcrService.recognize(request)
|
||||
}
|
||||
}
|
||||
|
||||
export const imageInsightService = new ImageInsightService()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* 图片文字索引的**派生存储**(与 Knowledge 派生库物理分离)。
|
||||
*
|
||||
* 为什么单独一个库而不是往 knowledge.sqlite 里加表:
|
||||
* - 清理语义干净:整个能力 = 三个文件(.sqlite/-wal/-shm),删掉即可,不留残渣。
|
||||
* - 零迁移风险:不动已发布的 knowledge schema(升级不得破坏既有派生库)。
|
||||
* - 去重语义天然:artifact 按「图片内容 + OCR 运行时指纹」唯一,binding 承担多来源。
|
||||
*
|
||||
* 账号隔离与 Knowledge 一致:路径按 accountId 摘要分目录 + 库内 account_id 自证。
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, rmSync, statSync, existsSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import {
|
||||
IMAGE_TEXT_BACKFILL_TIER_ORDER,
|
||||
IMAGE_TEXT_INDEX_SCHEMA_VERSION,
|
||||
type ImageOcrArtifact,
|
||||
type ImageOcrBinding,
|
||||
type ImageOcrPersistedState,
|
||||
type ImageTextBackfillTier,
|
||||
type ImageTextIndexStorageStats,
|
||||
type ImageTextTierRunState
|
||||
} from '../../shared/image-text-index'
|
||||
|
||||
const MAX_SAFE_ACCOUNT_SEGMENT = /^[a-f0-9]{32}$/
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
/** 派生库目录名不直接暴露 accountId。 */
|
||||
export function imageTextIndexAccountKey(accountId: string): string {
|
||||
return sha256Hex(`image-text-index-account-v1:${accountId}`).slice(0, 32)
|
||||
}
|
||||
|
||||
export function getImageTextIndexDatabasePath(databaseRoot: string, accountId: string): string {
|
||||
const accountKey = imageTextIndexAccountKey(accountId)
|
||||
if (!MAX_SAFE_ACCOUNT_SEGMENT.test(accountKey)) {
|
||||
throw new Error('Invalid image text index account key')
|
||||
}
|
||||
return join(resolve(databaseRoot), accountKey, 'image-text-index.sqlite')
|
||||
}
|
||||
|
||||
/**
|
||||
* 精确删除三件套(与 Knowledge 的 removeKnowledgeDatabase 同构)。
|
||||
*
|
||||
* **删除后必须回验**:Windows 上只要还有句柄(WAL/SHM 未关干净、别的进程打开了库),
|
||||
* `rmSync` 可能不报错却没真的删掉 —— 那就是"看似清理成功,实际没删"。
|
||||
* 这里把没删掉的路径返回给调用方,让上层能如实报告失败,而不是假装成功。
|
||||
*/
|
||||
export function removeImageTextIndexDatabase(databasePath: string): {
|
||||
removed: boolean
|
||||
leftovers: string[]
|
||||
} {
|
||||
const leftovers: string[] = []
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
const target = `${databasePath}${suffix}`
|
||||
if (!existsSync(target)) continue
|
||||
try {
|
||||
rmSync(target, { force: true })
|
||||
} catch {
|
||||
// 删除失败(典型原因是文件仍被占用)→ 由下面的回验兜住。
|
||||
}
|
||||
if (existsSync(target)) leftovers.push(target)
|
||||
}
|
||||
return { removed: leftovers.length === 0, leftovers }
|
||||
}
|
||||
|
||||
function asRows(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? (value as Record<string, unknown>[]) : []
|
||||
}
|
||||
|
||||
function artifactFromRow(row: Record<string, unknown>): ImageOcrArtifact {
|
||||
return {
|
||||
accountId: String(row.account_id),
|
||||
artifactKey: String(row.artifact_key),
|
||||
imageIdentity: String(row.image_identity),
|
||||
state: String(row.state) as ImageOcrPersistedState,
|
||||
text: String(row.text ?? ''),
|
||||
charCount: Number(row.char_count ?? 0),
|
||||
engine: String(row.engine),
|
||||
platform: String(row.platform),
|
||||
runtimeVersion: row.runtime_version ? String(row.runtime_version) : null,
|
||||
language: row.language ? String(row.language) : null,
|
||||
...(row.error_code ? { errorCode: String(row.error_code) } : {}),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
/** 会话内「消息 → OCR 文本」,供 Knowledge 索引时解析(与语音 resolver 同构)。 */
|
||||
export interface ConversationImageOcrEntry {
|
||||
state: ImageOcrPersistedState
|
||||
text: string
|
||||
}
|
||||
|
||||
export class ImageTextIndexStore {
|
||||
private readonly database: DatabaseSync
|
||||
|
||||
constructor(
|
||||
private readonly databasePath: string,
|
||||
private readonly accountId: string
|
||||
) {
|
||||
mkdirSync(dirname(databasePath), { recursive: true })
|
||||
this.database = new DatabaseSync(databasePath)
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
this.database.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
CREATE TABLE IF NOT EXISTS image_ocr_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS image_ocr_artifacts (
|
||||
artifact_key TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
image_identity TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
char_count INTEGER NOT NULL DEFAULT 0,
|
||||
engine TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
runtime_version TEXT,
|
||||
language TEXT,
|
||||
error_code TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS image_ocr_artifacts_identity
|
||||
ON image_ocr_artifacts (image_identity);
|
||||
CREATE TABLE IF NOT EXISTS image_ocr_bindings (
|
||||
conversation_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL,
|
||||
create_time INTEGER NOT NULL,
|
||||
sender_id TEXT,
|
||||
sender_name TEXT,
|
||||
image_identity TEXT NOT NULL,
|
||||
artifact_key TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (conversation_id, message_id)
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS image_ocr_bindings_identity
|
||||
ON image_ocr_bindings (image_identity);
|
||||
CREATE INDEX IF NOT EXISTS image_ocr_bindings_state
|
||||
ON image_ocr_bindings (state);
|
||||
-- 每个会话的扫描 checkpoint:重启后据此跳过已完成的会话。
|
||||
CREATE TABLE IF NOT EXISTS image_ocr_scan_state (
|
||||
conversation_id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
image_total INTEGER NOT NULL DEFAULT 0,
|
||||
image_processed INTEGER NOT NULL DEFAULT 0,
|
||||
image_max_local_id INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
`)
|
||||
|
||||
// 探测式加列(与 Knowledge 一致):旧库缺列时补上,不做版本号比较。
|
||||
const bindingColumns = new Set(
|
||||
asRows(this.database.prepare('PRAGMA table_info(image_ocr_bindings)').all()).map((row) =>
|
||||
String(row.name)
|
||||
)
|
||||
)
|
||||
if (!bindingColumns.has('artifact_key')) {
|
||||
this.database.exec(
|
||||
"ALTER TABLE image_ocr_bindings ADD COLUMN artifact_key TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
}
|
||||
|
||||
const scanColumns = new Set(
|
||||
asRows(this.database.prepare('PRAGMA table_info(image_ocr_scan_state)').all()).map((row) =>
|
||||
String(row.name)
|
||||
)
|
||||
)
|
||||
if (!scanColumns.has('image_max_local_id')) {
|
||||
// 旧库补列后默认 0:等于「水位未知」,下一次 pass 会重扫该会话并写入真实水位。
|
||||
this.database.exec(
|
||||
'ALTER TABLE image_ocr_scan_state ADD COLUMN image_max_local_id INTEGER NOT NULL DEFAULT 0'
|
||||
)
|
||||
}
|
||||
|
||||
const storedAccount = this.readMeta('account_id')
|
||||
if (storedAccount && storedAccount !== this.accountId) {
|
||||
throw new Error('Image text index account isolation check failed')
|
||||
}
|
||||
if (!storedAccount) this.writeMeta('account_id', this.accountId)
|
||||
if (!this.readMeta('schema_version')) {
|
||||
this.writeMeta('schema_version', String(IMAGE_TEXT_INDEX_SCHEMA_VERSION))
|
||||
}
|
||||
}
|
||||
|
||||
private readMeta(key: string): string | null {
|
||||
const row = this.database
|
||||
.prepare('SELECT value FROM image_ocr_meta WHERE key = ?')
|
||||
.get(key) as Record<string, unknown> | undefined
|
||||
return row ? String(row.value) : null
|
||||
}
|
||||
|
||||
private writeMeta(key: string, value: string): void {
|
||||
this.database
|
||||
.prepare(
|
||||
'INSERT INTO image_ocr_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
)
|
||||
.run(key, value)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- artifacts
|
||||
|
||||
getArtifact(artifactKey: string): ImageOcrArtifact | null {
|
||||
const row = this.database
|
||||
.prepare('SELECT * FROM image_ocr_artifacts WHERE artifact_key = ?')
|
||||
.get(artifactKey) as Record<string, unknown> | undefined
|
||||
return row ? artifactFromRow(row) : null
|
||||
}
|
||||
|
||||
putArtifact(artifact: ImageOcrArtifact): void {
|
||||
if (artifact.accountId !== this.accountId) {
|
||||
throw new Error('Image OCR artifact account does not match database')
|
||||
}
|
||||
const existing = this.getArtifact(artifact.artifactKey)
|
||||
this.database
|
||||
.prepare(
|
||||
`INSERT INTO image_ocr_artifacts (
|
||||
artifact_key, account_id, image_identity, state, text, char_count,
|
||||
engine, platform, runtime_version, language, error_code, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(artifact_key) DO UPDATE SET
|
||||
state = excluded.state,
|
||||
text = excluded.text,
|
||||
char_count = excluded.char_count,
|
||||
runtime_version = excluded.runtime_version,
|
||||
language = excluded.language,
|
||||
error_code = excluded.error_code,
|
||||
updated_at = excluded.updated_at`
|
||||
)
|
||||
.run(
|
||||
artifact.artifactKey,
|
||||
artifact.accountId,
|
||||
artifact.imageIdentity,
|
||||
artifact.state,
|
||||
artifact.text,
|
||||
artifact.charCount,
|
||||
artifact.engine,
|
||||
artifact.platform,
|
||||
artifact.runtimeVersion,
|
||||
artifact.language,
|
||||
artifact.errorCode ?? null,
|
||||
existing?.createdAt ?? artifact.createdAt,
|
||||
artifact.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- bindings
|
||||
|
||||
/** 写入绑定;同一 OCR 结果可被多个会话/消息引用。 */
|
||||
putBinding(binding: ImageOcrBinding): void {
|
||||
if (binding.accountId !== this.accountId) {
|
||||
throw new Error('Image OCR binding account does not match database')
|
||||
}
|
||||
this.database
|
||||
.prepare(
|
||||
`INSERT INTO image_ocr_bindings (
|
||||
conversation_id, message_id, account_id, create_time, sender_id, sender_name,
|
||||
image_identity, artifact_key, state, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(conversation_id, message_id) DO UPDATE SET
|
||||
create_time = excluded.create_time,
|
||||
sender_id = excluded.sender_id,
|
||||
sender_name = excluded.sender_name,
|
||||
image_identity = excluded.image_identity,
|
||||
artifact_key = excluded.artifact_key,
|
||||
state = excluded.state,
|
||||
updated_at = excluded.updated_at`
|
||||
)
|
||||
.run(
|
||||
binding.conversationId,
|
||||
binding.messageId,
|
||||
binding.accountId,
|
||||
binding.createTime,
|
||||
binding.senderId ?? null,
|
||||
binding.senderName ?? null,
|
||||
binding.imageIdentity,
|
||||
binding.artifactKey,
|
||||
binding.state,
|
||||
binding.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 某会话的「消息 → OCR 文本」映射。
|
||||
*
|
||||
* 与语音的 `withVoiceTranscript` 同构:在**主进程**把派生文本贴到消息上,
|
||||
* 再交给 Knowledge 索引,派生库不需要被 worker 打开。
|
||||
*/
|
||||
getConversationOcr(
|
||||
conversationId: string
|
||||
): Map<string, ConversationImageOcrEntry> {
|
||||
const rows = asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
`SELECT b.message_id AS message_id, b.state AS binding_state,
|
||||
a.state AS artifact_state, a.text AS text
|
||||
FROM image_ocr_bindings b
|
||||
LEFT JOIN image_ocr_artifacts a ON a.artifact_key = b.artifact_key
|
||||
WHERE b.conversation_id = ?`
|
||||
)
|
||||
.all(conversationId)
|
||||
)
|
||||
const result = new Map<string, ConversationImageOcrEntry>()
|
||||
for (const row of rows) {
|
||||
result.set(String(row.message_id), {
|
||||
state: String(row.artifact_state || row.binding_state) as ImageOcrPersistedState,
|
||||
text: String(row.text ?? '')
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- checkpoint
|
||||
|
||||
/**
|
||||
* 已持久化的图片消息总数统计。
|
||||
*
|
||||
* 必须落盘:派生库只知道自己**处理过**什么,不知道源数据里**一共**有多少图片。
|
||||
* 一旦把这个 total 只放在内存里,应用重启后 coverage 就会退化成
|
||||
* 「processed / processed」→ 把 30% 的部分索引谎报成 100% 完整覆盖。
|
||||
*/
|
||||
readCountedTotal(): { total: number; countedAt: number; complete: boolean } | null {
|
||||
const total = this.readMeta('total_image_messages')
|
||||
const countedAt = this.readMeta('total_image_counted_at')
|
||||
if (total === null || countedAt === null) return null
|
||||
const parsedTotal = Number(total)
|
||||
const parsedCountedAt = Number(countedAt)
|
||||
if (!Number.isFinite(parsedTotal) || !Number.isFinite(parsedCountedAt)) return null
|
||||
return {
|
||||
total: parsedTotal,
|
||||
countedAt: parsedCountedAt,
|
||||
// 统计时若有会话没数上(数据库不支持该统计),分母就是偏小的 →
|
||||
// 绝不能据此声称"已覆盖全部",否则少数的那些会话会被静默算进"已覆盖"。
|
||||
complete: this.readMeta('total_image_messages_complete') === '1'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描进度:所有会话累计「应扫多少张图片消息」与「实际扫过多少张」。
|
||||
*
|
||||
* 与上面的 `readCountedTotal()` 分工必须分清:
|
||||
* - `readCountedTotal()` 是 `countImageMessages()` 给出的**预估**分母(遍历消息表数出来的,
|
||||
* 会随新消息变动,且与「归档合并后流水线真正拿到的消息集合」并不完全一致);
|
||||
* - 这里是流水线**真实走过**的集合。
|
||||
*
|
||||
* 进度必须用后者。拿预估值当分母,进度会永远差最后几个百分点,
|
||||
* 让已经跑完的索引一直显示成"部分完成"。
|
||||
*/
|
||||
readScanProgress(): { total: number; processed: number } {
|
||||
const row = this.database
|
||||
.prepare(
|
||||
'SELECT SUM(image_total) AS total, SUM(image_processed) AS processed FROM image_ocr_scan_state'
|
||||
)
|
||||
.get() as Record<string, unknown> | undefined
|
||||
return {
|
||||
total: Number(row?.total ?? 0) || 0,
|
||||
processed: Number(row?.processed ?? 0) || 0
|
||||
}
|
||||
}
|
||||
|
||||
writeCountedTotal(input: { total: number; countedAt: number; complete: boolean }): void {
|
||||
this.writeMeta('total_image_messages', String(input.total))
|
||||
this.writeMeta('total_image_counted_at', String(input.countedAt))
|
||||
this.writeMeta('total_image_messages_complete', input.complete ? '1' : '0')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- recent-first 回填状态
|
||||
//
|
||||
// 全部走 `image_ocr_meta`(key/value),**不加表、不加列** —— 这是 old checkpoint
|
||||
// 兼容性的来源:旧库没有这些 key 时读出来就是"没有计划",于是下一次 pass
|
||||
// 以当前时刻为锚点重新建计划。已经处理过的图片由 terminal binding 兜住,
|
||||
// 不会因为"计划是新的"而重新 OCR。
|
||||
|
||||
/**
|
||||
* 读取回填计划状态。
|
||||
*
|
||||
* `anchorMs === null` = 从未规划过(新用户,或从旧版本升级且没有这些 key)。
|
||||
* 调用方此时必须**新建**计划,而不是假设"已完成"。
|
||||
*/
|
||||
readBackfillState(): {
|
||||
anchorMs: number | null
|
||||
tierStates: Partial<Record<ImageTextBackfillTier, ImageTextTierRunState>>
|
||||
coveredToMs: number | null
|
||||
} {
|
||||
const anchorRaw = this.readMeta('backfill_anchor_ms')
|
||||
const anchorMs = anchorRaw === null ? null : Number(anchorRaw)
|
||||
let tierStates: Partial<Record<ImageTextBackfillTier, ImageTextTierRunState>> = {}
|
||||
const statesRaw = this.readMeta('backfill_tier_states')
|
||||
if (statesRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(statesRaw) as Record<string, unknown>
|
||||
for (const tier of IMAGE_TEXT_BACKFILL_TIER_ORDER) {
|
||||
const value = parsed[tier]
|
||||
if (value === 'pending' || value === 'running' || value === 'complete') {
|
||||
tierStates[tier] = value
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 状态串损坏时按"没有分段状态"处理:最坏情况是重扫一遍,
|
||||
// 而 terminal binding 保证不会重复 OCR。
|
||||
tierStates = {}
|
||||
}
|
||||
}
|
||||
const coveredRaw = this.readMeta('backfill_covered_to_ms')
|
||||
const coveredToMs = coveredRaw === null ? null : Number(coveredRaw)
|
||||
return {
|
||||
anchorMs: anchorMs !== null && Number.isFinite(anchorMs) ? anchorMs : null,
|
||||
tierStates,
|
||||
coveredToMs: coveredToMs !== null && Number.isFinite(coveredToMs) ? coveredToMs : null
|
||||
}
|
||||
}
|
||||
|
||||
writeBackfillAnchor(anchorMs: number): void {
|
||||
this.writeMeta('backfill_anchor_ms', String(Math.floor(anchorMs)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入单个分段的运行态。
|
||||
*
|
||||
* 一个 pass 是单线程串行推进分段,所以"读-改-写"整体落在一条 meta 行里;
|
||||
* 这里额外做的只有"保留其它分段"。
|
||||
*/
|
||||
writeBackfillTierState(tier: ImageTextBackfillTier, state: ImageTextTierRunState): void {
|
||||
const current = this.readBackfillState().tierStates
|
||||
const next = { ...current, [tier]: state }
|
||||
this.writeMeta('backfill_tier_states', JSON.stringify(next))
|
||||
}
|
||||
|
||||
/** 增量补齐水位:处理到哪儿了(`create_time <= ms` 都已就绪)。只增不减。 */
|
||||
writeBackfillCoveredToMs(coveredToMs: number): void {
|
||||
const previous = this.readBackfillState().coveredToMs
|
||||
if (previous !== null && previous >= coveredToMs) return
|
||||
this.writeMeta('backfill_covered_to_ms', String(Math.floor(coveredToMs)))
|
||||
}
|
||||
|
||||
|
||||
readScanState(): Map<
|
||||
string,
|
||||
{ state: string; imageTotal: number; processed: number; maxLocalId: number }
|
||||
> {
|
||||
const rows = asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
'SELECT conversation_id, state, image_total, image_processed, image_max_local_id FROM image_ocr_scan_state'
|
||||
)
|
||||
.all()
|
||||
)
|
||||
const map = new Map<
|
||||
string,
|
||||
{ state: string; imageTotal: number; processed: number; maxLocalId: number }
|
||||
>()
|
||||
for (const row of rows) {
|
||||
map.set(String(row.conversation_id), {
|
||||
state: String(row.state),
|
||||
imageTotal: Number(row.image_total ?? 0),
|
||||
processed: Number(row.image_processed ?? 0),
|
||||
maxLocalId: Number(row.image_max_local_id ?? 0)
|
||||
})
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
writeScanState(input: {
|
||||
conversationId: string
|
||||
state: 'done' | 'partial'
|
||||
imageTotal: number
|
||||
imageProcessed: number
|
||||
/** 本会话图片消息的最大插入序(增量水位)。 */
|
||||
maxLocalId: number
|
||||
}): void {
|
||||
this.database
|
||||
.prepare(
|
||||
`INSERT INTO image_ocr_scan_state (
|
||||
conversation_id, account_id, state, image_total, image_processed,
|
||||
image_max_local_id, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(conversation_id) DO UPDATE SET
|
||||
state = excluded.state,
|
||||
image_total = excluded.image_total,
|
||||
image_processed = excluded.image_processed,
|
||||
image_max_local_id = excluded.image_max_local_id,
|
||||
updated_at = excluded.updated_at`
|
||||
)
|
||||
.run(
|
||||
input.conversationId,
|
||||
this.accountId,
|
||||
input.state,
|
||||
input.imageTotal,
|
||||
input.imageProcessed,
|
||||
input.maxLocalId,
|
||||
Date.now()
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 统计
|
||||
|
||||
/**
|
||||
* 有 OCR 派生绑定的会话集合。
|
||||
*
|
||||
* 清理时**必须**先拿到它:OCR 文本早已被灌进 Knowledge 的 chunks / FTS,
|
||||
* 只删派生病不会让那些派生文字失效 —— 用户仍会从旧索引里搜到图片里的文字。
|
||||
*/
|
||||
conversationIdsWithOcr(): string[] {
|
||||
const rows = asRows(
|
||||
this.database.prepare('SELECT DISTINCT conversation_id FROM image_ocr_bindings').all()
|
||||
)
|
||||
return rows.map((row) => String(row.conversation_id))
|
||||
}
|
||||
|
||||
/**
|
||||
* **有 OCR 派生文本**(artifact 里 char_count > 0)的会话集合。
|
||||
*
|
||||
* 派生索引修复只需要重建它们:只有这些会话的 Knowledge 里"应该"存在图片派生文字。
|
||||
* 全是 `empty` 的会话本来就没有派生文字可修,重建它们只是白读一遍 WCDB。
|
||||
*/
|
||||
conversationIdsWithIndexedOcr(): string[] {
|
||||
const rows = asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
`SELECT DISTINCT b.conversation_id AS conversation_id
|
||||
FROM image_ocr_bindings b
|
||||
JOIN image_ocr_artifacts a ON a.artifact_key = b.artifact_key
|
||||
WHERE a.char_count > 0`
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return rows.map((row) => String(row.conversation_id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置指定状态的失败记录,让它们可以被下一轮 pass 重新处理。
|
||||
*
|
||||
* 用途:**代码修好后**,把上一次 bug 造成的假失败(例如整批 `decrypt_failed`)
|
||||
* 变成可重试状态,而不是要求用户删掉整个派生库 —— 那会连已经成功的记录一起丢掉。
|
||||
*
|
||||
* 三件事一起做,缺一不可:
|
||||
* 1. 删掉这些失败绑定;
|
||||
* 2. 删掉它们所在会话的 checkpoint —— 否则 pass 会以"该会话已完成"直接跳过,
|
||||
* 表现为"点了重试但什么都没发生";
|
||||
* 3. 删掉因此变成孤儿的 artifact(**没有任何绑定再引用的**才删,成功记录一条不动)。
|
||||
*/
|
||||
resetFailures(states: ImageOcrPersistedState[]): number {
|
||||
if (!states.length) return 0
|
||||
const placeholders = states.map(() => '?').join(', ')
|
||||
const affected = asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
`SELECT DISTINCT conversation_id FROM image_ocr_bindings WHERE state IN (${placeholders})`
|
||||
)
|
||||
.all(...states)
|
||||
).map((row) => String(row.conversation_id))
|
||||
const artifactKeys = asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
`SELECT DISTINCT artifact_key FROM image_ocr_bindings WHERE state IN (${placeholders})`
|
||||
)
|
||||
.all(...states)
|
||||
).map((row) => String(row.artifact_key))
|
||||
|
||||
const info = this.database
|
||||
.prepare(`DELETE FROM image_ocr_bindings WHERE state IN (${placeholders})`)
|
||||
.run(...states)
|
||||
|
||||
const clearScan = this.database.prepare(
|
||||
'DELETE FROM image_ocr_scan_state WHERE conversation_id = ?'
|
||||
)
|
||||
for (const conversationId of affected) clearScan.run(conversationId)
|
||||
|
||||
const dropOrphan = this.database.prepare(
|
||||
`DELETE FROM image_ocr_artifacts
|
||||
WHERE artifact_key = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM image_ocr_bindings b WHERE b.artifact_key = ?)`
|
||||
)
|
||||
for (const artifactKey of artifactKeys) dropOrphan.run(artifactKey, artifactKey)
|
||||
|
||||
return Number(info.changes ?? 0)
|
||||
}
|
||||
|
||||
/** 按状态聚合绑定数 —— 覆盖度与进度都从这里取,保证与库内真实一致。 */
|
||||
countByState(): Record<string, number> { const rows = asRows(
|
||||
this.database
|
||||
.prepare('SELECT state, COUNT(*) AS total FROM image_ocr_bindings GROUP BY state')
|
||||
.all()
|
||||
)
|
||||
const counts: Record<string, number> = {}
|
||||
for (const row of rows) counts[String(row.state)] = Number(row.total ?? 0)
|
||||
return counts
|
||||
}
|
||||
|
||||
storageStats(): ImageTextIndexStorageStats {
|
||||
const counts = this.countByState()
|
||||
const textRow = this.database
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS total FROM image_ocr_artifacts WHERE state = 'indexed' AND length(text) > 0"
|
||||
)
|
||||
.get() as Record<string, unknown> | undefined
|
||||
const updatedRow = this.database
|
||||
.prepare('SELECT MAX(updated_at) AS latest FROM image_ocr_bindings')
|
||||
.get() as Record<string, unknown> | undefined
|
||||
let totalBytes = 0
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try {
|
||||
totalBytes += statSync(`${this.databasePath}${suffix}`).size
|
||||
} catch {
|
||||
// 文件可能尚未创建;忽略。
|
||||
}
|
||||
}
|
||||
const latest = updatedRow?.latest
|
||||
return {
|
||||
indexedImages: counts['indexed'] ?? 0,
|
||||
ocrTextCount: Number(textRow?.total ?? 0),
|
||||
totalBytes,
|
||||
updatedAt: latest === null || latest === undefined ? null : Number(latest)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空全部派生数据(表级清空;文件级删除由 service 负责)。 */
|
||||
clearAll(): void {
|
||||
this.database.exec(`
|
||||
DELETE FROM image_ocr_bindings;
|
||||
DELETE FROM image_ocr_artifacts;
|
||||
DELETE FROM image_ocr_scan_state;
|
||||
DELETE FROM image_ocr_meta WHERE key IN (
|
||||
'total_image_messages',
|
||||
'total_image_counted_at',
|
||||
'total_image_messages_complete',
|
||||
'backfill_anchor_ms',
|
||||
'backfill_tier_states',
|
||||
'backfill_covered_to_ms'
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空并重置检查点。
|
||||
*
|
||||
* 注意:必须同时清 `scan_state`,否则清理后再次索引会因为「会话已完成」
|
||||
* 而直接跳过 —— UI 会停在「未建立」但实际再也不跑。
|
||||
*/
|
||||
clearDerivedData(): void {
|
||||
this.clearAll()
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
// 先折叠 WAL 再关连接:否则 -wal / -shm 可能仍被持有,
|
||||
// Windows 上会导致后续 rmSync 静默失败("清理成功"但文件还在)。
|
||||
this.database.exec('PRAGMA wal_checkpoint(TRUNCATE);')
|
||||
} catch {
|
||||
// 库可能已经处于不可写状态;关闭仍然要做。
|
||||
}
|
||||
try {
|
||||
this.database.close()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
normalizeLeaveNotificationRoomIds,
|
||||
normalizeLeaveNotificationTemplate,
|
||||
type LeaveNotificationNotifyScope,
|
||||
type LeaveNotificationTarget
|
||||
} from '../../shared/automation'
|
||||
|
||||
/**
|
||||
* 旧「退群监控 → 通知群聊 / 模板」配置 → 新「自动化 → 退群通知」规则的**纯映射器**。
|
||||
*
|
||||
* 单独成文件是为了能直接单测:这是整轮迁移里**唯一会改变用户既有行为**的地方,
|
||||
* 不允许只靠"跑一遍看看"来验证。
|
||||
*
|
||||
* ## 旧语义(实测自 `group-exit-monitor-service.ts`)
|
||||
*
|
||||
* 旧版是**两层**配置,缺一不可:
|
||||
* 1. **管理群聊**(`monitoredRoomIds`)= 监测哪些群有人退出;
|
||||
* 2. **通知群聊**(`notificationRoomIds`)= 上面这批群里,**哪些要真的发通知**
|
||||
* —— 即对第 1 层的二次勾选。
|
||||
*
|
||||
* `notificationRoomIds` **不是**"统一通知接收目标列表",而是
|
||||
* **"哪些被监控群要在自己群里通报成员退出"**(per-group 多值):
|
||||
* - `applyCurrentMemberships()` 只在 `notificationRoomIds.has(roomId)` 时通知;
|
||||
* - `notifyGroup()` 的收件人**恒等于事件所在群**。
|
||||
*
|
||||
* ## 新语义
|
||||
*
|
||||
* 拆成**两个正交维度**,恰好一一对应旧版那两层:
|
||||
* - `target` ← 旧版第 2 层里"发回本群"这个事实 ⇒ 恒为 `source_chat`;
|
||||
* - `notifyScope` / `notifyRoomIds` ← 旧版第 2 层是"哪些群",逐字保留。
|
||||
*
|
||||
* ⇒ **迁移现在是无损的**。早期版本把目标压成单值、丢掉了第 2 层的"哪些群",
|
||||
* 于是部分勾选只能判成 `needs_review` 并强制停用;恢复 `notifyScope` 之后
|
||||
* 这个特例整体消失,用户不会再有"规则被无辜停用"的体验。
|
||||
*/
|
||||
|
||||
export interface LegacyLeaveNotificationState {
|
||||
monitoredRoomIds: string[]
|
||||
notificationRoomIds: string[]
|
||||
notificationTemplate?: unknown
|
||||
}
|
||||
|
||||
export type LeaveNotificationMigrationOutcome =
|
||||
/** 旧配置从不通知任何人 ⇒ 迁移成"关闭",行为完全一致。 */
|
||||
| 'lossless_off'
|
||||
/** 旧配置在**每个**被监控群里通报 ⇒ `notifyScope: 'all'`,行为完全一致。 */
|
||||
| 'lossless_all_groups'
|
||||
/** 旧配置只在**部分**群里通报 ⇒ `notifyScope: 'selected'` + 原样保留那份勾选。 */
|
||||
| 'lossless_selected_groups'
|
||||
|
||||
export interface LeaveNotificationMigrationPlan {
|
||||
outcome: LeaveNotificationMigrationOutcome
|
||||
enabled: boolean
|
||||
target: LeaveNotificationTarget
|
||||
notifyScope: LeaveNotificationNotifyScope
|
||||
notifyRoomIds: string[]
|
||||
targetNeedsReview: boolean
|
||||
template: string
|
||||
}
|
||||
|
||||
function uniqueRoomIds(values: unknown): string[] {
|
||||
return normalizeLeaveNotificationRoomIds(values)
|
||||
}
|
||||
|
||||
export function planLeaveNotificationMigration(
|
||||
legacy: LegacyLeaveNotificationState
|
||||
): LeaveNotificationMigrationPlan {
|
||||
const monitored = uniqueRoomIds(legacy.monitoredRoomIds)
|
||||
const monitoredSet = new Set(monitored)
|
||||
// 再夹一次:防止脏状态文件把范围外的群带进来。
|
||||
const notifications = uniqueRoomIds(legacy.notificationRoomIds).filter((roomId) =>
|
||||
monitoredSet.has(roomId)
|
||||
)
|
||||
// 模板永远是无损迁移的那一项:它就是用户写的那段文字。
|
||||
const template = normalizeLeaveNotificationTemplate(legacy.notificationTemplate)
|
||||
// 旧版收件人恒为事件所在群 ⇒ 无论勾了几个群,目标都是 source_chat。
|
||||
const target: LeaveNotificationTarget = { type: 'source_chat' }
|
||||
|
||||
if (!notifications.length) {
|
||||
// 旧语义是"从不通知" ⇒ 关掉,避免迁移后突然开始发消息。
|
||||
// 同时把范围写成 `selected` + 空集:配置本身也精确表达"一个都不通知"。
|
||||
return {
|
||||
outcome: 'lossless_off',
|
||||
enabled: false,
|
||||
target,
|
||||
notifyScope: 'selected',
|
||||
notifyRoomIds: [],
|
||||
targetNeedsReview: false,
|
||||
template
|
||||
}
|
||||
}
|
||||
|
||||
if (monitored.length > 0 && notifications.length === monitored.length) {
|
||||
// 每个被监控群都在自己群里通报 ⇒ 等价于"全部已监控群聊"。
|
||||
return {
|
||||
outcome: 'lossless_all_groups',
|
||||
enabled: true,
|
||||
target,
|
||||
notifyScope: 'all',
|
||||
notifyRoomIds: [],
|
||||
targetNeedsReview: false,
|
||||
template
|
||||
}
|
||||
}
|
||||
|
||||
// 部分勾选:**无损**保留那份子集,不再降级成 needs_review。
|
||||
return {
|
||||
outcome: 'lossless_selected_groups',
|
||||
enabled: true,
|
||||
target,
|
||||
notifyScope: 'selected',
|
||||
notifyRoomIds: notifications,
|
||||
targetNeedsReview: false,
|
||||
template
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||
leaveNotificationTargetLabel,
|
||||
type LeaveNotificationConfig
|
||||
} from '../../shared/automation'
|
||||
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
|
||||
import {
|
||||
automationContactDisplayName,
|
||||
filterSendableFriendContacts as filterSendable,
|
||||
isSendableFriendContact as isSendable,
|
||||
resolveAutomationTarget,
|
||||
type AutomationTargetContact,
|
||||
type AutomationTargetResolution,
|
||||
type ResolvedAutomationTarget
|
||||
} from './automation-wechat-target'
|
||||
|
||||
/**
|
||||
* 退群通知的**目标解析**(薄适配层)。
|
||||
*
|
||||
* 真正的判定在 `automation-wechat-target.ts` —— 那里才是**唯一实现**,
|
||||
* 定时日报与退群通知共用它。这里只有两件事:
|
||||
* 1. 把 `LeaveNotificationConfig` + 退群事件摊平成中性输入;
|
||||
* 2. 提供退群通知专属文案,保持既有调用方 / 测试不变。
|
||||
*
|
||||
* ⚠️ 这里**不允许**再出现一份 switch / 判定逻辑 —— 两套并存迟早分叉。
|
||||
*/
|
||||
|
||||
/** 解析所需的最小联系人结构(`FormattedContact` 结构上可赋值给它)。 */
|
||||
export type LeaveNotificationCandidateContact = AutomationTargetContact
|
||||
|
||||
export type ResolvedLeaveNotificationTarget = ResolvedAutomationTarget
|
||||
|
||||
export type LeaveNotificationTargetResolution = AutomationTargetResolution
|
||||
|
||||
/** 退群通知的目标文案(唯一一份)。 */
|
||||
export const LEAVE_NOTIFICATION_TARGET_MESSAGES = {
|
||||
options: LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||
invalidTarget: '退群通知的发送目标无效,请重新选择',
|
||||
sourceMissing: '无法确定发生退群的群聊,本次通知未发送',
|
||||
sourceRecipientName: '当前群聊',
|
||||
sourceDisplayName: '发生退群事件的群聊',
|
||||
selfMissing: '无法确定当前登录的微信账号,本次通知未发送',
|
||||
contactNotChosen: '还没有选择通知联系人,请重新选择',
|
||||
contactUnavailable: '通知联系人已不存在或当前无法发送,请重新选择'
|
||||
} as const
|
||||
|
||||
/** 联系人显示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
|
||||
export function leaveNotificationContactDisplayName(contact: AutomationTargetContact): string {
|
||||
return automationContactDisplayName(contact)
|
||||
}
|
||||
|
||||
/** 这个联系人能不能作为「指定好友」的发送目标(与定时日报共用同一条判定)。 */
|
||||
export function isSendableFriendContact(
|
||||
contact: AutomationTargetContact,
|
||||
selfWxid?: string
|
||||
): boolean {
|
||||
return isSendable(contact, selfWxid)
|
||||
}
|
||||
|
||||
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
|
||||
export function filterSendableFriendContacts<T extends AutomationTargetContact>(
|
||||
contacts: T[],
|
||||
selfWxid?: string
|
||||
): T[] {
|
||||
return filterSendable(contacts, selfWxid)
|
||||
}
|
||||
|
||||
export function resolveLeaveNotificationTarget(input: {
|
||||
config: LeaveNotificationConfig | undefined
|
||||
event: GroupMemberExitedEvent
|
||||
contacts: LeaveNotificationCandidateContact[]
|
||||
/** 当前登录账号的 wxid;拿不到时传空串。 */
|
||||
selfWxid?: string
|
||||
}): LeaveNotificationTargetResolution {
|
||||
const config = input.config
|
||||
if (!config) return { ok: false, error: '退群通知尚未配置发送目标' }
|
||||
return resolveAutomationTarget(
|
||||
{
|
||||
targetType: config.target?.type as never,
|
||||
sourceConversationId: String(input.event.conversationId || '').trim(),
|
||||
sourceDisplayName: String(input.event.groupName || '').trim(),
|
||||
...(config.target?.contactId ? { contactId: config.target.contactId } : {}),
|
||||
contacts: input.contacts,
|
||||
...(input.selfWxid ? { selfWxid: input.selfWxid } : {})
|
||||
},
|
||||
LEAVE_NOTIFICATION_TARGET_MESSAGES
|
||||
)
|
||||
}
|
||||
|
||||
/** 目标类型 → 界面短标签(保持既有导出面)。 */
|
||||
export { leaveNotificationTargetLabel }
|
||||
@@ -1,5 +1,6 @@
|
||||
import { listContactsAsync, listMessagesAsync, isReady, type FormattedContact, type FormattedMessage } from './chat-service'
|
||||
import { resolveContact } from './contact-resolution-service'
|
||||
import { sourceMessageId } from '../knowledge/message-identity'
|
||||
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
|
||||
import { inferAiSearchTimeRange } from '../../shared/ai-search'
|
||||
import { KNOWLEDGE_FRESHNESS_TOLERANCE_MS } from '../../shared/knowledge'
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
ResolvedCorpusScope,
|
||||
ResolvedTimeRange,
|
||||
QueryIndexCoverage,
|
||||
QueryImageTextCoverage,
|
||||
QuerySearchTimings,
|
||||
QueryMessagesRequest,
|
||||
SearchMessagesRequest,
|
||||
@@ -26,6 +28,14 @@ import {
|
||||
decodeMessageRef as fromRef,
|
||||
normalizeMessageIdentity
|
||||
} from '../../shared/local-query-api'
|
||||
import {
|
||||
IMAGE_TEXT_BACKFILL_TIER_LABEL,
|
||||
describeImageTextCoverage,
|
||||
describeImageTextCoveredRanges,
|
||||
imageTextCoverageState,
|
||||
type ImageTextIndexCoverage
|
||||
} from '../../shared/image-text-index'
|
||||
import { toEvidenceDisplayText } from '../../shared/knowledge'
|
||||
|
||||
const LIMIT_MAX = 200
|
||||
const CONTEXT_MAX = 50
|
||||
@@ -112,6 +122,59 @@ function buildIndexCoverage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片文字索引未完成时,必须附加的零结果诚实性约束。
|
||||
*
|
||||
* 图片 OCR 是**独立的**覆盖维度:它可能是"未建立"或"只做了 30%"。
|
||||
* 此时 0 条图片证据只是**索引缺口**,不是**事实空缺**。
|
||||
*/
|
||||
const IMAGE_OCR_ZERO_RESULT_CAUTION =
|
||||
'涉及图片、截图、海报里的文字的问题,当前不能因为没搜到就回答"没有"。'
|
||||
|
||||
/**
|
||||
* 图片文字索引覆盖度 → 可直接引用的结论句。
|
||||
*
|
||||
* 与 `buildIndexCoverage` 同思路:只给结构化数字,模型会自己换算、甚至反过来
|
||||
* 宣称"覆盖完整"。这里由 Engine 给出结论句,模型只需引用。
|
||||
*/
|
||||
export function buildImageOcrCoverage(
|
||||
coverage: ImageTextIndexCoverage | null
|
||||
): QueryImageTextCoverage | undefined {
|
||||
if (!coverage) return undefined
|
||||
const state = imageTextCoverageState(coverage)
|
||||
const countedNote = coverage.countedAt
|
||||
? `(图片数量统计于 ${formatLocalMinute(coverage.countedAt)})`
|
||||
: ''
|
||||
const base = describeImageTextCoverage(coverage)
|
||||
/**
|
||||
* recent-first 之后必须把"哪段时间能下确定性结论"一起给出。
|
||||
*
|
||||
* 否则模型只看一个总百分比:30% 时它会以为连最近一周都不可信(过度保守没坏处),
|
||||
* 但 99% 时它会以为"去年也能放心下结论"(这就把索引缺口说成了事实空缺)。
|
||||
*/
|
||||
const tiers = coverage.tiers ?? []
|
||||
const coveredRanges = tiers
|
||||
.filter((entry) => entry.state === 'complete')
|
||||
.map((entry) => IMAGE_TEXT_BACKFILL_TIER_LABEL[entry.tier])
|
||||
const rangeNote = tiers.length ? describeImageTextCoveredRanges(coverage) : ''
|
||||
return {
|
||||
state,
|
||||
totalImageMessages: coverage.totalImageMessages,
|
||||
processed: coverage.processed,
|
||||
indexed: coverage.indexed,
|
||||
empty: coverage.empty,
|
||||
missing: coverage.missing,
|
||||
failed: coverage.failed,
|
||||
pending: coverage.pending,
|
||||
...(coverage.countedAt ? { countedAtLabel: formatLocalMinute(coverage.countedAt) } : {}),
|
||||
...(coveredRanges.length ? { coveredRanges } : {}),
|
||||
summary:
|
||||
state === 'complete'
|
||||
? `${base}${countedNote}`
|
||||
: `${base}${countedNote}${rangeNote}${IMAGE_OCR_ZERO_RESULT_CAUTION}`
|
||||
}
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<QueryMessageType, string> = {
|
||||
text: '文本',
|
||||
image: '图片',
|
||||
@@ -149,7 +212,19 @@ function resolvedTimeRange(input: QueryTimeRange, now = new Date()): ResolvedTim
|
||||
const range = inferAiSearchTimeRange(phrase[input.kind], map[input.kind], now)
|
||||
return { kind: input.kind, startTime: range.startTime, endTime: range.endTime, label: range.label }
|
||||
}
|
||||
function toQueryMessage(conversationId: string, message: FormattedMessage, target: FormattedContact): QueryMessage {
|
||||
/**
|
||||
* 一条消息的展示形态。
|
||||
*
|
||||
* `imageOcr` 是可选的**派生文本**(来自本地图片文字索引,只读、不触发 OCR)。
|
||||
* 图片消息的正文永远是空的 —— 识别出的文字必须走独立字段,
|
||||
* 否则"图片里的文字"会被伪装成"群友发的文字消息"。
|
||||
*/
|
||||
function toQueryMessage(
|
||||
conversationId: string,
|
||||
message: FormattedMessage,
|
||||
target: FormattedContact,
|
||||
imageOcr?: { state: string; text: string }
|
||||
): QueryMessage {
|
||||
const kind = kindOf(message)
|
||||
const content = message.contentData
|
||||
const attachment =
|
||||
@@ -163,7 +238,16 @@ function toQueryMessage(conversationId: string, message: FormattedMessage, targe
|
||||
? { kind: 'file' as const, name: message.exportMediaName || (content?.type === 'share' ? content.title : undefined), url: content?.type === 'share' ? content.url : undefined }
|
||||
: undefined
|
||||
const text = message.content?.trim() || message.voiceTranscript?.trim() || undefined
|
||||
return { messageRef: toRef(conversationId, message.id), timestamp: (message.createTime || 0) * 1000, datetime: message.datetime, sender: message.isSender ? '我' : (message.name || target.m_nsNickName), direction: message.isSender ? 'to_target' : 'from_target', messageType: kind, sourceKind: kind, ...(attachment ? { attachment } : {}), ...(text ? { text } : {}) }
|
||||
const derived = kind === 'image' ? imageOcr : undefined
|
||||
const ocrText = derived && derived.state === 'indexed' ? derived.text.trim() : ''
|
||||
const imageTextState: QueryMessage['imageTextState'] = !derived
|
||||
? 'not_indexed'
|
||||
: ocrText
|
||||
? 'indexed'
|
||||
: derived.state === 'empty'
|
||||
? 'empty'
|
||||
: 'not_indexed'
|
||||
return { messageRef: toRef(conversationId, message.id), timestamp: (message.createTime || 0) * 1000, datetime: message.datetime, sender: message.isSender ? '我' : (message.name || target.m_nsNickName), direction: message.isSender ? 'to_target' : 'from_target', messageType: kind, sourceKind: kind, ...(attachment ? { attachment } : {}), ...(text ? { text } : {}), ...(ocrText ? { imageOcrText: ocrText, derivedSource: 'image_ocr' as const } : {}), ...(kind === 'image' ? { imageTextState } : {}) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,7 +300,49 @@ function outsideScopeError(scope: ResolvedCorpusScope, actual: string): { status
|
||||
}
|
||||
|
||||
export class LocalQueryApiService {
|
||||
constructor(private readonly knowledge?: KnowledgeSearchService, private readonly nowProvider: () => Date = () => new Date()) {}
|
||||
/**
|
||||
* 图片文字索引覆盖度提供者(同步、只读)。
|
||||
*
|
||||
* 刻意不在 Knowledge worker 里算:OCR 派生库(`image-text-index.sqlite`)与
|
||||
* `knowledge.sqlite` 物理分离,worker 不该为了一个覆盖度数字去开它。
|
||||
*/
|
||||
private imageTextCoverage: () => ImageTextIndexCoverage | null = () => null
|
||||
|
||||
/**
|
||||
* 单条图片消息的 OCR 派生文本提供者(同步、只读)。
|
||||
*
|
||||
* L4(查询层)**只读** L1(OCR artifact)—— 这里绝不允许触发 OCR、解密或读原图。
|
||||
* 之前 `query_messages` 缺这一环,导致"图片已经识别出文字"这件事在精确读消息
|
||||
* 这条路径上完全不可见:模型只拿到一个空的 `attachment`,于是把"索引缺口"
|
||||
* 说成"图片里没有文字",甚至反过来建议用户去建立已经建好的索引。
|
||||
*/
|
||||
private imageOcrEntry:
|
||||
| ((conversationId: string, messageId: string) => { state: string; text: string } | undefined)
|
||||
| undefined
|
||||
|
||||
constructor(
|
||||
private readonly knowledge?: KnowledgeSearchService,
|
||||
private readonly nowProvider: () => Date = () => new Date()
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 注入图片文字索引覆盖度提供者。
|
||||
*
|
||||
* 用 setter 而不是构造参数:避免给第二个带默认值的参数写 `undefined` 占位,
|
||||
* 也让测试可以直接注入假的覆盖度。
|
||||
*/
|
||||
setImageTextCoverageProvider(provider: () => ImageTextIndexCoverage | null): void {
|
||||
this.imageTextCoverage = provider
|
||||
}
|
||||
|
||||
/** 注入单条图片消息的 OCR 派生文本解析器(只读;见 `imageOcrEntry` 的约束)。 */
|
||||
setImageOcrEntryProvider(
|
||||
provider:
|
||||
| ((conversationId: string, messageId: string) => { state: string; text: string } | undefined)
|
||||
| undefined
|
||||
): void {
|
||||
this.imageOcrEntry = provider
|
||||
}
|
||||
capabilities(): QueryCapabilitiesResponse {
|
||||
return { version: 1, tools: { query_messages: { operation: '读取指定联系人的确定性消息', directions: ['any', 'from_target', 'to_target'], messageTypes: kinds, timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, search_messages: { operation: '受限 Knowledge 关键词检索', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, message_context: { operation: '读取消息前后文', timeRanges: ['all'], limitMax: CONTEXT_MAX }, conversation_overview: { operation: '按会话时间片提取概览证据', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX } } }
|
||||
}
|
||||
@@ -282,7 +408,18 @@ export class LocalQueryApiService {
|
||||
const raw = await listMessagesAsync(contact.md5, range.startTime, range.endTime)
|
||||
const direction = request.direction || 'any'; const allowed = new Set(request.messageTypes || kinds)
|
||||
const filtered = raw.filter((message) => !(request.excludeSystem !== false && kindOf(message) === 'system')).filter((message) => allowed.has(kindOf(message))).filter((message) => direction === 'any' || (direction === 'to_target' ? message.isSender : !message.isSender)).sort((a, b) => ((a.createTime || 0) - (b.createTime || 0)) * ((request.order || 'asc') === 'asc' ? 1 : -1)).slice(0, Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)))
|
||||
return { status: 'completed' as const, target: contactView(contact), query: { direction, messageTypes: request.messageTypes || [], order: request.order || 'asc', limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)), excludeSystem: request.excludeSystem !== false, resolvedTimeRange: range }, coverage: { state: 'complete' as const }, returnedCount: filtered.length, messages: filtered.map((message) => toQueryMessage(contact.md5, message, contact)), scope: corpus.scope }
|
||||
// 图片 OCR 派生文本:L4 只读 L1,**不触发 OCR / 解密 / 读原图**。
|
||||
// 键必须用 `sourceMessageId(message)`(binding 主键就是它),不能用裸 `message.id`,
|
||||
// 否则 `local:` 前缀会让查表静默失配 —— 与 Knowledge 用同一条身份规则。
|
||||
const messages = filtered.map((message) => {
|
||||
const ocr =
|
||||
kindOf(message) === 'image'
|
||||
? this.imageOcrEntry?.(contact.md5, sourceMessageId(message))
|
||||
: undefined
|
||||
return toQueryMessage(contact.md5, message, contact, ocr)
|
||||
})
|
||||
const imageOcrCoverage = buildImageOcrCoverage(this.imageTextCoverage())
|
||||
return { status: 'completed' as const, target: contactView(contact), query: { direction, messageTypes: request.messageTypes || [], order: request.order || 'asc', limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)), excludeSystem: request.excludeSystem !== false, resolvedTimeRange: range }, coverage: { state: 'complete' as const }, returnedCount: filtered.length, messages, scope: corpus.scope, ...(imageOcrCoverage ? { imageOcrCoverage } : {}) }
|
||||
}
|
||||
async search(request: SearchMessagesRequest) {
|
||||
const requestStartedAt = Date.now()
|
||||
@@ -359,6 +496,8 @@ export class LocalQueryApiService {
|
||||
|
||||
const covered = indexCovers(found.indexLatestAt, requestedEnd, found.sourceLatestAt)
|
||||
const indexCoverage = buildIndexCoverage(found.indexLatestAt, found.sourceLatestAt, covered)
|
||||
// 图片文字索引是**独立覆盖维度**:文字索引再完整也不代表图片里的文字搜得到。
|
||||
const imageOcrCoverage = buildImageOcrCoverage(this.imageTextCoverage())
|
||||
const timings: QuerySearchTimings = {
|
||||
totalMs: Date.now() - requestStartedAt,
|
||||
scopeMs,
|
||||
@@ -381,6 +520,7 @@ export class LocalQueryApiService {
|
||||
sourceLatestAt: found.sourceLatestAt,
|
||||
freshness: { catchUp: freshness.catchUp },
|
||||
...(indexCoverage ? { indexCoverage } : {}),
|
||||
...(imageOcrCoverage ? { imageOcrCoverage } : {}),
|
||||
timings
|
||||
}
|
||||
}
|
||||
@@ -454,7 +594,11 @@ export class LocalQueryApiService {
|
||||
timestamp: item.timestamp,
|
||||
sender: item.sender,
|
||||
sourceKind: item.sourceKind,
|
||||
text: item.text,
|
||||
// 兜底再剥一次:不管 Knowledge 侧哪条检索路径产出的文本,
|
||||
// 面向用户与模型的都不允许出现 `图片文字:` 这类引擎内部标签。
|
||||
text: toEvidenceDisplayText(item.text),
|
||||
...(item.derivedSource ? { derivedSource: item.derivedSource } : {}),
|
||||
...(item.imageOcrText ? { imageOcrText: item.imageOcrText } : {}),
|
||||
conversationName: owner ? contactView(owner).displayName : undefined,
|
||||
conversationType: owner?.type
|
||||
} satisfies QueryEvidenceItem
|
||||
@@ -592,6 +736,7 @@ export class LocalQueryApiService {
|
||||
const truncated = raw.length > OVERVIEW_SOURCE_CAP
|
||||
const evidence = selectTemporalCoverageEvidence(contact, messages, OVERVIEW_EVIDENCE_TARGET)
|
||||
const state: 'complete' | 'partial' = truncated ? 'partial' : 'complete'
|
||||
const imageOcrCoverage = buildImageOcrCoverage(this.imageTextCoverage())
|
||||
return {
|
||||
status: 'completed' as const,
|
||||
target: contactView(contact),
|
||||
@@ -603,6 +748,7 @@ export class LocalQueryApiService {
|
||||
selection: { mode: 'temporal_coverage' as const, selectedEvidenceCount: evidence.length, sampled: truncated || evidence.length < messages.length },
|
||||
evidence,
|
||||
scope: corpus.scope,
|
||||
...(imageOcrCoverage ? { imageOcrCoverage } : {}),
|
||||
origin: 'wcdb' as const
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
type PersonalWechatSendService
|
||||
} from './personal-wechat-send-service'
|
||||
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
|
||||
|
||||
/**
|
||||
* Converts the detailed sender diagnostics into a small contract that other
|
||||
* features can consume without knowing about OneBot, Hook or platform details.
|
||||
* features can consume without knowing about the macOS native runtime, the
|
||||
* Windows hook transport, or platform details.
|
||||
*
|
||||
* On macOS the capability is derived only from MacWechatRuntimeManager and its
|
||||
* validated runtime manifest. Windows continues to use the existing sender.
|
||||
*/
|
||||
export class PersonalWechatCapabilityService {
|
||||
constructor(private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>) {}
|
||||
constructor(
|
||||
private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>,
|
||||
private readonly platform: NodeJS.Platform = process.platform,
|
||||
private readonly getMacStatus: () => Promise<PersonalWechatSenderStatus> = () =>
|
||||
macWechatRuntimeManager.buildSenderStatus()
|
||||
) {}
|
||||
|
||||
async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> {
|
||||
if (this.platform === 'darwin') {
|
||||
return this.fromMacRuntime()
|
||||
}
|
||||
const senderStatus = await this.sender.getStatus()
|
||||
return this.fromSenderStatus(senderStatus)
|
||||
}
|
||||
@@ -49,6 +62,16 @@ export class PersonalWechatCapabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* macOS capability from the native runtime. Builds a senderStatus snapshot
|
||||
* aligned with the mac binding state and reuses the shared mapping, so the
|
||||
* settings header and the binding card can never disagree.
|
||||
*/
|
||||
private async fromMacRuntime(): Promise<PersonalWechatSendCapability> {
|
||||
const senderStatus = await this.getMacStatus()
|
||||
return this.fromSenderStatus(senderStatus)
|
||||
}
|
||||
|
||||
private mapState(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapabilityState {
|
||||
if (senderStatus.platform === 'win32') {
|
||||
if (senderStatus.canSend) return 'ready'
|
||||
@@ -60,6 +83,7 @@ export class PersonalWechatCapabilityService {
|
||||
return 'unsupported'
|
||||
}
|
||||
if (senderStatus.state === 'error') return 'error'
|
||||
if (senderStatus.state === 'runtime_missing') return 'unconfigured'
|
||||
const hasCurrentBinding = Boolean(
|
||||
senderStatus.endpointReady &&
|
||||
senderStatus.attachReady &&
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { execFile } from 'child_process'
|
||||
import { app, net } from 'electron'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { chmod, copyFile, cp, mkdir, mkdtemp, open, rename, rm, stat } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import type {
|
||||
PersonalWechatRuntimeDownloadResult,
|
||||
PersonalWechatRuntimeStatus
|
||||
} from '../../shared/personal-wechat-runtime'
|
||||
import { findPersonalWechatRuntime } from './personal-wechat-send-service'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const RUNTIME_VERSION = 'v0.0.18'
|
||||
const ARCHIVE_NAME = 'onebot_mac_arm64.tar.gz'
|
||||
const ARCHIVE_URL = `https://github.com/yincongcyincong/wechat_chatter/releases/download/${RUNTIME_VERSION}/${ARCHIVE_NAME}`
|
||||
const ARCHIVE_SIZE = 66_599_785
|
||||
const ARCHIVE_SHA256 = 'ee1e11bccef7cec1cf944cd8b2ac3fadaadb9376ba24cd823e3409143e107dab'
|
||||
|
||||
function patchPerSendPayload(scriptPath: string): void {
|
||||
let source = readFileSync(scriptPath, 'utf8')
|
||||
if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return
|
||||
|
||||
const activeSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||
activeTriggerX1Payload = Memory.alloc(payloadData.length);
|
||||
activeTriggerX1Payload.writeByteArray(payloadData);
|
||||
activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||
activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0));
|
||||
activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));`
|
||||
const upstreamSend = ` const payloadData = hexToByteArray(payloadHex);
|
||||
triggerX1Payload.writeByteArray(payloadData);
|
||||
triggerX1Payload.add(0x18).writePointer(info.cgiAddr);
|
||||
triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0));
|
||||
triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));`
|
||||
if (!source.includes(activeSend)) {
|
||||
throw new Error('下载的发送组件与当前应用不兼容')
|
||||
}
|
||||
source = source.replace('var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;', 'var triggerX1Payload;\nvar triggerX0;')
|
||||
.replace(activeSend, upstreamSend)
|
||||
.replace(' MMStartTask(triggerX0, activeTriggerX1Payload);', ' MMStartTask(triggerX0, triggerX1Payload);')
|
||||
.replace(' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);', ' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);')
|
||||
.replace('\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn', '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn')
|
||||
writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
function patchSendContextCapture(scriptPath: string, strict = true): void {
|
||||
let source = readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('function isLikelySendContext(')) return
|
||||
const original = `function AttachSendFunc() {
|
||||
Interceptor.attach(sendFuncAddr.add(0x10), {
|
||||
onEnter: function (args) {
|
||||
|
||||
if (triggerX1Payload) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerX0 = this.context.x0;
|
||||
triggerX1Payload = this.context.x1;
|
||||
console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
||||
}
|
||||
})
|
||||
}`
|
||||
const patched = `function isLikelySendContext(candidateX0, candidateX1) {
|
||||
try {
|
||||
if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false;
|
||||
var manager = readPointerIfReadable(candidateX0.add(0x18));
|
||||
var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18)));
|
||||
console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi);
|
||||
return !manager.equals(ptr(0));
|
||||
} catch (e) {
|
||||
console.error("[debug] StartTask candidate inspect failed: " + e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function AttachSendFunc() {
|
||||
Interceptor.attach(sendFuncAddr.add(0x10), {
|
||||
onEnter: function (args) {
|
||||
if (triggerX1Payload) return;
|
||||
var candidateX0 = this.context.x0;
|
||||
var candidateX1 = this.context.x1;
|
||||
if (!isLikelySendContext(candidateX0, candidateX1)) return;
|
||||
triggerX0 = candidateX0;
|
||||
triggerX1Payload = candidateX1;
|
||||
console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`);
|
||||
}
|
||||
})
|
||||
}`
|
||||
if (!source.includes(original)) {
|
||||
if (strict) throw new Error('下载的微信版本与当前应用不兼容')
|
||||
return
|
||||
}
|
||||
source = source.replace(original, patched)
|
||||
writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
function patchVoiceAudioBuffer(scriptPath: string, strict = true): void {
|
||||
let source = readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('voiceAudioDataAddr = Memory.alloc(audioLen + 1);')) return
|
||||
|
||||
const staticAllocation = 'voiceAudioDataAddr = Memory.alloc(5 * 1024 * 1024); // 预分配5MB'
|
||||
if (!source.includes(staticAllocation)) {
|
||||
if (strict) throw new Error('下载的语音组件与当前应用不兼容')
|
||||
return
|
||||
}
|
||||
source = source.replace(
|
||||
staticAllocation,
|
||||
'voiceAudioDataAddr = Memory.alloc(1); // 上传前按语音长度重新分配'
|
||||
)
|
||||
const audioLengthMarker = ' const audioLen = audioBytes.length;\n'
|
||||
if (!source.includes(audioLengthMarker)) {
|
||||
if (strict) throw new Error('下载的语音组件与当前应用不兼容')
|
||||
return
|
||||
}
|
||||
source = source.replace(
|
||||
audioLengthMarker,
|
||||
`${audioLengthMarker} voiceAudioDataAddr = Memory.alloc(audioLen + 1);\n`
|
||||
)
|
||||
writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
function patchImageHookReadiness(scriptPath: string): void {
|
||||
let source = readFileSync(scriptPath, 'utf8')
|
||||
if (
|
||||
source.includes('捕获到图片上传上下文,uploadGlobalX0') &&
|
||||
source.includes('图片上传 Hook Setup Complete')
|
||||
) {
|
||||
return
|
||||
}
|
||||
const original = `\t\t\tuploadGlobalX0 = this.context.x0;`
|
||||
const patched = `\t\t\tconst capturedUploadX0 = this.context.x0;
|
||||
\t\t\tif (uploadGlobalX0.equals(ptr(0)) && !capturedUploadX0.equals(ptr(0))) {
|
||||
\t\t\t\tconsole.log("[+] 捕获到图片上传上下文,uploadGlobalX0:" + capturedUploadX0);
|
||||
\t\t\t}
|
||||
\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||
if (!source.includes(original)) throw new Error('下载的媒体组件与当前应用不兼容')
|
||||
source = source.replace(original, patched)
|
||||
source = source.replace(
|
||||
' })\n}\n\n\n\nfunction patchCdnOnComplete()',
|
||||
' })\n console.log("[+] 图片上传 Hook Setup Complete.");\n}\n\n\n\nfunction patchCdnOnComplete()'
|
||||
)
|
||||
writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
function patchCdnColdStart(scriptPath: string, strict = true): void {
|
||||
let source = readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('function resolveCdnManager()')) return
|
||||
|
||||
const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
||||
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});`
|
||||
const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}});
|
||||
cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});
|
||||
// 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为)
|
||||
{{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}}
|
||||
{{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}`
|
||||
if (!source.includes(initAddresses)) {
|
||||
if (strict) throw new Error('下载的微信版本配置与当前应用不兼容')
|
||||
return
|
||||
}
|
||||
source = source.replace(initAddresses, patchedInitAddresses)
|
||||
|
||||
const downloadChunkEnd = `}
|
||||
|
||||
function fillUploadX1AndStart`
|
||||
const resolver = `}
|
||||
|
||||
// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器
|
||||
// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。
|
||||
function resolveCdnManager() {
|
||||
if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) {
|
||||
return ptr(0);
|
||||
}
|
||||
try {
|
||||
// libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。
|
||||
var strDefault = Memory.alloc(24);
|
||||
strDefault.writeUtf8String("default");
|
||||
strDefault.add(0x17).writeU8(7);
|
||||
|
||||
var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']);
|
||||
var svc = getService(strDefault);
|
||||
if (!isReadablePointer(svc)) {
|
||||
console.error("[!] GetService(\\"default\\") 返回不可读: " + svc);
|
||||
return ptr(0);
|
||||
}
|
||||
var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']);
|
||||
var ctx = getCtx(svc);
|
||||
if (!isReadablePointer(ctx)) {
|
||||
console.error("[!] CdnManager getter 返回不可读: " + ctx);
|
||||
return ptr(0);
|
||||
}
|
||||
var mgr = readPointerIfReadable(ctx.add(0x40));
|
||||
if (!isReadablePointer(mgr)) {
|
||||
console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx);
|
||||
return ptr(0);
|
||||
}
|
||||
return mgr;
|
||||
} catch (e) {
|
||||
console.error("[!] resolveCdnManager 异常: " + e);
|
||||
return ptr(0);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCdnManagerX0() {
|
||||
if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) {
|
||||
uploadGlobalX0 = downloadGlobalX0;
|
||||
console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0);
|
||||
}
|
||||
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) {
|
||||
downloadGlobalX0 = uploadGlobalX0;
|
||||
console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0);
|
||||
}
|
||||
if (uploadGlobalX0.equals(ptr(0))) {
|
||||
var mgr = resolveCdnManager();
|
||||
if (!mgr.equals(ptr(0))) {
|
||||
uploadGlobalX0 = mgr;
|
||||
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
||||
downloadGlobalX0 = mgr;
|
||||
}
|
||||
console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr);
|
||||
}
|
||||
}
|
||||
return !uploadGlobalX0.equals(ptr(0));
|
||||
}
|
||||
|
||||
function fillUploadX1AndStart`
|
||||
if (!source.includes(downloadChunkEnd)) {
|
||||
if (strict) throw new Error('无法定位 wechat_chatter 媒体上传逻辑')
|
||||
return
|
||||
}
|
||||
source = source.replace(downloadChunkEnd, resolver)
|
||||
|
||||
const declarations = 'var uploadImageAddr;\n'
|
||||
const patchedDeclarations =
|
||||
'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n'
|
||||
if (!source.includes(declarations)) {
|
||||
if (strict) throw new Error('无法定位 wechat_chatter 媒体地址声明')
|
||||
return
|
||||
}
|
||||
source = source.replace(declarations, patchedDeclarations)
|
||||
|
||||
const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {
|
||||
ensureCdnManagerX0();
|
||||
}
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
if (!source.includes(uploadGuard)) {
|
||||
if (strict) throw new Error('无法定位 wechat_chatter 媒体上传入口')
|
||||
return
|
||||
}
|
||||
source = source.replace(uploadGuard, patchedUploadGuard)
|
||||
|
||||
const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) {
|
||||
if (uploadGlobalX0.equals(ptr(0))) {
|
||||
ensureCdnManagerX0();
|
||||
}
|
||||
if (uploadGlobalX0.equals(ptr(0))) {`
|
||||
if (!source.includes(voiceGuard)) {
|
||||
if (strict) throw new Error('无法定位 wechat_chatter 语音上传入口')
|
||||
return
|
||||
}
|
||||
source = source.replace(voiceGuard, patchedVoiceGuard)
|
||||
|
||||
const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;`
|
||||
const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;
|
||||
if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) {
|
||||
downloadGlobalX0 = capturedUploadX0;
|
||||
console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0);
|
||||
}`
|
||||
if (!source.includes(uploadHook)) {
|
||||
if (strict) throw new Error('无法定位 wechat_chatter 图片 Hook')
|
||||
return
|
||||
}
|
||||
source = source.replace(uploadHook, patchedUploadHook)
|
||||
|
||||
const downloadHook = ` downloadGlobalX0 = this.context.x0;`
|
||||
const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0;
|
||||
if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) {
|
||||
uploadGlobalX0 = downloadGlobalX0;
|
||||
console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0);
|
||||
}`
|
||||
if (!source.includes(downloadHook)) {
|
||||
if (strict) throw new Error('无法定位 wechat_chatter 下载 Hook')
|
||||
return
|
||||
}
|
||||
source = source.replace(downloadHook, patchedDownloadHook)
|
||||
|
||||
const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
||||
if (!downloadGlobalX0) {`
|
||||
const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) {
|
||||
if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) {
|
||||
ensureCdnManagerX0();
|
||||
}
|
||||
if (!downloadGlobalX0) {`
|
||||
if (!source.includes(downloadGuard)) {
|
||||
if (strict) throw new Error('无法定位 wechat_chatter 媒体下载入口')
|
||||
return
|
||||
}
|
||||
source = source.replace(downloadGuard, patchedDownloadGuard)
|
||||
|
||||
writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
function patchCdnColdStartConfig(configPath: string, strict = true): void {
|
||||
let config: Record<string, unknown>
|
||||
try {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf8')) as Record<string, unknown>
|
||||
} catch {
|
||||
if (strict) throw new Error('4.1.11.53 版本配置不是有效 JSON')
|
||||
return
|
||||
}
|
||||
config.cdnGetServiceAddr = '0x50a15d0'
|
||||
config.cdnManagerGetterAddr = '0x5259290'
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function patchWechatCoreModuleBase(scriptPath: string): void {
|
||||
let source = readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('WeChat core module base:')) return
|
||||
const initMarker = 'function initAddresses() {'
|
||||
const initIndex = source.indexOf(initMarker)
|
||||
if (initIndex < 0 || !source.startsWith('var targetPath = ')) {
|
||||
throw new Error('下载的微信版本配置与当前应用不兼容')
|
||||
}
|
||||
const patchedHeader = `var targetPath = "/Applications/WeChat.app/Contents/Resources/wechat.dylib";
|
||||
var module = Process.enumerateModules().find(function(m) {
|
||||
return m.path === targetPath || m.path.endsWith("/Contents/Resources/wechat.dylib");
|
||||
});
|
||||
if (!module) {
|
||||
throw new Error("[-] Cannot find WeChat core module: " + targetPath);
|
||||
}
|
||||
var moduleBase = module.base;
|
||||
var baseAddr = moduleBase;
|
||||
console.log("[+] WeChat core module base: " + baseAddr + " path=" + module.path);
|
||||
setImmediate(initAddresses);
|
||||
|
||||
`
|
||||
source = patchedHeader + source.slice(initIndex)
|
||||
writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
function addModifiedWorkNotice(scriptPath: string): void {
|
||||
let source = readFileSync(scriptPath, 'utf8')
|
||||
if (source.includes('TraceMemo wechat_chatter compatibility modifications')) return
|
||||
source = `/*
|
||||
* TraceMemo wechat_chatter compatibility modifications
|
||||
* Modified: 2026-08-17
|
||||
* Upstream: https://github.com/yincongcyincong/wechat_chatter
|
||||
* Runtime version: v0.0.18
|
||||
* License: GNU General Public License version 3 (GPL-3.0)
|
||||
* Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers,
|
||||
* CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging.
|
||||
* These modifications are not provided by the upstream author.
|
||||
*/
|
||||
|
||||
${source}`
|
||||
writeFileSync(scriptPath, source)
|
||||
}
|
||||
|
||||
export class PersonalWechatRuntimeManager {
|
||||
private downloadController: AbortController | null = null
|
||||
private downloadPromise: Promise<PersonalWechatRuntimeDownloadResult> | null = null
|
||||
private downloadedBytes = 0
|
||||
private lastProgressAt = 0
|
||||
private progressListener: ((status: PersonalWechatRuntimeStatus) => void) | null = null
|
||||
|
||||
get directory(): string {
|
||||
return join(app.getPath('userData'), 'connectors', 'wechat-personal', 'darwin-arm64')
|
||||
}
|
||||
|
||||
private get archivePath(): string {
|
||||
return join(
|
||||
app.getPath('userData'),
|
||||
'downloads',
|
||||
`wechat-chatter-${RUNTIME_VERSION}-${ARCHIVE_NAME}`
|
||||
)
|
||||
}
|
||||
|
||||
setProgressListener(listener: ((status: PersonalWechatRuntimeStatus) => void) | null): void {
|
||||
this.progressListener = listener
|
||||
}
|
||||
|
||||
async getStatus(): Promise<PersonalWechatRuntimeStatus> {
|
||||
if (!this.isSupported()) {
|
||||
return this.buildStatus(
|
||||
'unsupported',
|
||||
0,
|
||||
process.platform === 'darwin'
|
||||
? 'Intel Mac 不支持个人微信发送组件'
|
||||
: process.platform === 'win32'
|
||||
? 'Windows 暂不支持个人微信发送组件'
|
||||
: '当前系统不支持个人微信发送组件'
|
||||
)
|
||||
}
|
||||
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
|
||||
|
||||
const runtime = findPersonalWechatRuntime()
|
||||
if (runtime) {
|
||||
try {
|
||||
// Apply compatibility fixes to runtimes installed before this version.
|
||||
patchWechatCoreModuleBase(join(runtime.workingDirectory, 'script.js'))
|
||||
patchPerSendPayload(join(runtime.workingDirectory, 'script.js'))
|
||||
patchSendContextCapture(join(runtime.workingDirectory, 'script.js'), false)
|
||||
patchVoiceAudioBuffer(join(runtime.workingDirectory, 'script.js'), false)
|
||||
patchImageHookReadiness(join(runtime.workingDirectory, 'script.js'))
|
||||
patchCdnColdStart(join(runtime.workingDirectory, 'script.js'), false)
|
||||
addModifiedWorkNotice(join(runtime.workingDirectory, 'script.js'))
|
||||
patchCdnColdStartConfig(join(runtime.root, 'wechat_version', '4_1_11_53_mac.json'), false)
|
||||
} catch {
|
||||
// Status discovery should remain available even if an old runtime is read-only.
|
||||
}
|
||||
return this.buildStatus('ready', ARCHIVE_SIZE, undefined, runtime.root)
|
||||
}
|
||||
|
||||
const hasPartialInstall = await this.hasPartialInstall()
|
||||
return this.buildStatus(
|
||||
hasPartialInstall ? 'invalid' : 'missing',
|
||||
0,
|
||||
hasPartialInstall ? '发送组件文件不完整,请重新下载' : undefined,
|
||||
hasPartialInstall ? this.directory : undefined
|
||||
)
|
||||
}
|
||||
|
||||
download(): Promise<PersonalWechatRuntimeDownloadResult> {
|
||||
if (!this.isSupported()) {
|
||||
return this.getStatus().then((status) => ({ success: false, status, error: status.error }))
|
||||
}
|
||||
if (this.downloadPromise) return this.downloadPromise
|
||||
this.downloadedBytes = 0
|
||||
this.downloadController = new AbortController()
|
||||
this.downloadPromise = this.runDownload(this.downloadController.signal).finally(() => {
|
||||
this.downloadPromise = null
|
||||
this.downloadController = null
|
||||
})
|
||||
return this.downloadPromise
|
||||
}
|
||||
|
||||
cancelDownload(): boolean {
|
||||
if (!this.downloadController) return false
|
||||
this.downloadController.abort()
|
||||
return true
|
||||
}
|
||||
|
||||
async remove(): Promise<PersonalWechatRuntimeStatus> {
|
||||
if (this.downloadPromise) return this.buildStatus('downloading', this.downloadedBytes)
|
||||
await Promise.all([
|
||||
rm(this.directory, { recursive: true, force: true }),
|
||||
rm(this.archivePath, { force: true }),
|
||||
rm(`${this.archivePath}.partial`, { force: true })
|
||||
])
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
private async runDownload(signal: AbortSignal): Promise<PersonalWechatRuntimeDownloadResult> {
|
||||
const archive = this.archivePath
|
||||
const downloadsDirectory = dirname(archive)
|
||||
const partial = `${archive}.partial`
|
||||
let extractionDirectory = ''
|
||||
let stagedDirectory = ''
|
||||
try {
|
||||
await mkdir(downloadsDirectory, { recursive: true })
|
||||
await rm(partial, { force: true })
|
||||
const response = await net.fetch(ARCHIVE_URL, { signal })
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`发送组件下载失败:HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const handle = await open(partial, 'w')
|
||||
const hash = createHash('sha256')
|
||||
try {
|
||||
const reader = response.body.getReader()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (signal.aborted) throw new DOMException('Download cancelled', 'AbortError')
|
||||
const chunk = Buffer.from(value)
|
||||
await handle.write(chunk)
|
||||
hash.update(chunk)
|
||||
this.downloadedBytes += chunk.length
|
||||
this.reportProgress(this.buildStatus('downloading', this.downloadedBytes))
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
|
||||
if (this.downloadedBytes !== ARCHIVE_SIZE || hash.digest('hex') !== ARCHIVE_SHA256) {
|
||||
throw new Error('发送组件校验失败,请重新下载')
|
||||
}
|
||||
await rm(archive, { force: true })
|
||||
await rename(partial, archive)
|
||||
|
||||
extractionDirectory = await mkdtemp(join(tmpdir(), 'wechat-chatter-extract-'))
|
||||
await execFileAsync('/usr/bin/tar', ['-xzf', archive, '-C', extractionDirectory])
|
||||
stagedDirectory = `${this.directory}.installing-${process.pid}`
|
||||
await rm(stagedDirectory, { recursive: true, force: true })
|
||||
await mkdir(dirname(stagedDirectory), { recursive: true })
|
||||
await mkdir(join(stagedDirectory, 'onebot'), { recursive: true })
|
||||
|
||||
const sourceOneBot = join(extractionDirectory, 'onebot')
|
||||
const sourceVersions = join(extractionDirectory, 'wechat_version')
|
||||
await Promise.all([
|
||||
cp(sourceVersions, join(stagedDirectory, 'wechat_version'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
}),
|
||||
copyFile(join(sourceOneBot, 'onebot'), join(stagedDirectory, 'onebot', 'onebot')),
|
||||
copyFile(join(sourceOneBot, 'script.js'), join(stagedDirectory, 'onebot', 'script.js'))
|
||||
])
|
||||
|
||||
const executable = join(stagedDirectory, 'onebot', 'onebot')
|
||||
const script = join(stagedDirectory, 'onebot', 'script.js')
|
||||
const configPath = join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json')
|
||||
patchCdnColdStartConfig(configPath)
|
||||
patchWechatCoreModuleBase(script)
|
||||
patchPerSendPayload(script)
|
||||
patchSendContextCapture(script)
|
||||
patchVoiceAudioBuffer(script)
|
||||
patchImageHookReadiness(script)
|
||||
patchCdnColdStart(script)
|
||||
addModifiedWorkNotice(script)
|
||||
await chmod(executable, 0o755)
|
||||
|
||||
for (const required of [
|
||||
executable,
|
||||
script,
|
||||
join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json')
|
||||
]) {
|
||||
if (!existsSync(required)) throw new Error('发送组件解压后文件不完整')
|
||||
}
|
||||
|
||||
await rm(this.directory, { recursive: true, force: true })
|
||||
await rename(stagedDirectory, this.directory)
|
||||
stagedDirectory = ''
|
||||
const status = this.buildStatus('ready', ARCHIVE_SIZE, undefined, this.directory)
|
||||
this.reportProgress(status, true)
|
||||
return { success: true, status }
|
||||
} catch (error) {
|
||||
const cancelled = signal.aborted
|
||||
const message = cancelled
|
||||
? '发送组件下载已取消'
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
await rm(partial, { force: true })
|
||||
const status = this.buildStatus(
|
||||
cancelled ? 'missing' : 'error',
|
||||
this.downloadedBytes,
|
||||
message
|
||||
)
|
||||
this.reportProgress(status, true)
|
||||
return { success: false, status, error: message }
|
||||
} finally {
|
||||
if (extractionDirectory) await rm(extractionDirectory, { recursive: true, force: true })
|
||||
if (stagedDirectory) await rm(stagedDirectory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
private async hasPartialInstall(): Promise<boolean> {
|
||||
try {
|
||||
await stat(this.directory)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private isSupported(): boolean {
|
||||
return process.platform === 'darwin' && process.arch === 'arm64'
|
||||
}
|
||||
|
||||
private buildStatus(
|
||||
state: PersonalWechatRuntimeStatus['state'],
|
||||
downloadedBytes: number,
|
||||
error?: string,
|
||||
directory?: string
|
||||
): PersonalWechatRuntimeStatus {
|
||||
return {
|
||||
version: RUNTIME_VERSION,
|
||||
state,
|
||||
downloadedBytes,
|
||||
totalBytes: ARCHIVE_SIZE,
|
||||
progress: ARCHIVE_SIZE ? Math.min(1, downloadedBytes / ARCHIVE_SIZE) : 0,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
supported: this.isSupported(),
|
||||
removable: directory === this.directory,
|
||||
...(directory ? { directory } : {}),
|
||||
...(error ? { error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
private reportProgress(status: PersonalWechatRuntimeStatus, force = false): void {
|
||||
const now = Date.now()
|
||||
if (!force && now - this.lastProgressAt < 100) return
|
||||
this.lastProgressAt = now
|
||||
this.progressListener?.(status)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,8 @@ import type {
|
||||
PersonalWechatVoiceRuntimeComponent
|
||||
} from '../../shared/personal-wechat-voice-runtime'
|
||||
import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime'
|
||||
import {
|
||||
buildPersonalWechatRuntimeEnvironment,
|
||||
findPersonalWechatRuntime
|
||||
} from './personal-wechat-send-service'
|
||||
import type { RuntimeLayout } from './personal-wechat-send-service'
|
||||
import { buildPersonalWechatRuntimeEnvironment } from './personal-wechat-send-service'
|
||||
import { macWechatRuntimeManager } from './mac-wechat-runtime-manager'
|
||||
import { appLogger } from '../app-logger'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
@@ -37,8 +34,8 @@ type CommandRunner = (
|
||||
interface PersonalWechatVoiceEnvironmentServiceOptions {
|
||||
platform?: NodeJS.Platform
|
||||
architecture?: string
|
||||
findRuntime?: () => RuntimeLayout | null
|
||||
buildEnvironment?: (runtimeRoot?: string) => NodeJS.ProcessEnv
|
||||
isRuntimePresent?: () => boolean
|
||||
buildEnvironment?: () => NodeJS.ProcessEnv
|
||||
runCommand?: CommandRunner
|
||||
now?: () => Date
|
||||
}
|
||||
@@ -100,18 +97,26 @@ function unsupportedEnvironment(): PersonalWechatVoiceEncodingEnvironment {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultIsRuntimePresent(): boolean {
|
||||
try {
|
||||
return macWechatRuntimeManager.isRuntimePresent()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export class PersonalWechatVoiceEnvironmentService {
|
||||
private readonly platform: NodeJS.Platform
|
||||
private readonly architecture: string
|
||||
private readonly findRuntime: () => RuntimeLayout | null
|
||||
private readonly buildEnvironment: (runtimeRoot?: string) => NodeJS.ProcessEnv
|
||||
private readonly isRuntimePresent: () => boolean
|
||||
private readonly buildEnvironment: () => NodeJS.ProcessEnv
|
||||
private readonly runCommand: CommandRunner
|
||||
private readonly now: () => Date
|
||||
|
||||
constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) {
|
||||
this.platform = options.platform || process.platform
|
||||
this.architecture = options.architecture || process.arch
|
||||
this.findRuntime = options.findRuntime || findPersonalWechatRuntime
|
||||
this.isRuntimePresent = options.isRuntimePresent || defaultIsRuntimePresent
|
||||
this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment
|
||||
this.runCommand = options.runCommand || runCommand
|
||||
this.now = options.now || (() => new Date())
|
||||
@@ -123,8 +128,8 @@ export class PersonalWechatVoiceEnvironmentService {
|
||||
}
|
||||
|
||||
logEnvironmentLine('Checking voice encoding environment')
|
||||
const runtime = this.findRuntime()
|
||||
const environment = this.buildEnvironment(runtime?.root)
|
||||
const runtimeReady = this.isRuntimePresent()
|
||||
const environment = this.buildEnvironment()
|
||||
const python = blankComponent()
|
||||
const pilk = blankComponent()
|
||||
const ffmpeg = blankComponent()
|
||||
@@ -197,26 +202,25 @@ export class PersonalWechatVoiceEnvironmentService {
|
||||
logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error })
|
||||
}
|
||||
|
||||
const ready = Boolean(runtime && python.ready && pilk.ready && ffmpeg.ready)
|
||||
const ready = Boolean(runtimeReady && python.ready && pilk.ready && ffmpeg.ready)
|
||||
const result: PersonalWechatVoiceEncodingEnvironment = {
|
||||
state: ready ? 'ready' : 'incomplete',
|
||||
ready,
|
||||
checkedAt: this.now().toISOString(),
|
||||
runtimeReady: Boolean(runtime),
|
||||
...(runtime ? { runtimeRoot: runtime.root } : {}),
|
||||
runtimeReady,
|
||||
python,
|
||||
pilk,
|
||||
ffmpeg,
|
||||
encoder: pilk.ready
|
||||
? 'pilk'
|
||||
: runtime && python.ready && ffmpeg.ready
|
||||
? 'go-silk'
|
||||
: runtimeReady && python.ready && ffmpeg.ready
|
||||
? 'silk'
|
||||
: 'unavailable',
|
||||
message: ready
|
||||
? '语音编码环境正常,可以使用 pilk 编码'
|
||||
: runtime
|
||||
? '语音编码环境不完整,OneBot 可能回退到 go-silk'
|
||||
: '微信发送组件尚未安装,请先准备 OneBot 运行时'
|
||||
: runtimeReady
|
||||
? '语音编码环境不完整,语音将回退到内置 SILK 编码'
|
||||
: '当前版本暂未提供微信消息发送功能'
|
||||
}
|
||||
logEnvironmentLine(
|
||||
ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready',
|
||||
@@ -245,7 +249,7 @@ export class PersonalWechatVoiceEnvironmentService {
|
||||
pythonExecutable,
|
||||
['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`],
|
||||
{
|
||||
env: this.buildEnvironment(before.runtimeRoot || undefined),
|
||||
env: this.buildEnvironment(),
|
||||
timeout: INSTALL_TIMEOUT_MS
|
||||
}
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,8 +27,7 @@ export interface ScheduledReportApiDependencies {
|
||||
| 'deleteTask'
|
||||
| 'setTaskEnabled'
|
||||
| 'runScheduledReportNow'
|
||||
> &
|
||||
Partial<Pick<ScheduledReportService, 'retryScheduledReportSend'>>
|
||||
>
|
||||
getCapability: () => Promise<PersonalWechatSendCapability>
|
||||
listContacts: () => FormattedContact[]
|
||||
isDatabaseReady: () => boolean
|
||||
@@ -250,16 +249,20 @@ export class ScheduledReportApiService {
|
||||
return result.data
|
||||
}
|
||||
|
||||
async retrySend(executionId: string): Promise<ScheduledReportExecution> {
|
||||
const retry = this.deps.service.retryScheduledReportSend
|
||||
if (!retry) {
|
||||
throw new ScheduledReportApiError(501, 'not_supported', '当前运行时不支持重新发送日报')
|
||||
}
|
||||
const result = await retry.call(this.deps.service, executionId)
|
||||
if (!result.data) {
|
||||
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报执行记录')
|
||||
}
|
||||
return result.data
|
||||
/**
|
||||
* 「重新发送」不再支持。
|
||||
*
|
||||
* 日报图片归日报历史所有,发送目标由规则配置决定,「把某次执行记录里那张 PNG
|
||||
* 就地再发一遍」这个语义已经不存在 —— 与其在此伪造一个只对部分规则有效的重发
|
||||
* 语义,不如**明确返回 501**。
|
||||
* 用户的替代路径:在日报历史里手动转发,或对该规则点「立即执行」重跑一次。
|
||||
*/
|
||||
async retrySend(_executionId: string): Promise<ScheduledReportExecution> {
|
||||
throw new ScheduledReportApiError(
|
||||
501,
|
||||
'not_supported',
|
||||
'当前运行时不支持重新发送日报;请在 TraceMemo 的自动化页面使用「立即执行」,或从日报历史手动转发。'
|
||||
)
|
||||
}
|
||||
|
||||
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
normalizeScheduledReportConfig,
|
||||
type ScheduledReportAutomationConfig
|
||||
} from '../../shared/automation'
|
||||
import type {
|
||||
ScheduledReportMemberNameMode,
|
||||
ScheduledReportMessageType,
|
||||
ScheduledReportRange
|
||||
} from '../../shared/scheduled-report'
|
||||
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
||||
|
||||
/**
|
||||
* 旧「定时日报任务」→ 新「AutomationRule(ruleType='scheduled_report')」的**纯映射器**。
|
||||
*
|
||||
* 单独成文件是为了能直接单测:这是整轮迁移里**唯一会改变用户既有行为**的地方,
|
||||
* 不允许只靠"跑一遍看看"来验证。
|
||||
*
|
||||
* ## 旧 target 的真实语义(实测)
|
||||
*
|
||||
* `ScheduledReportService.resolveTarget()`:
|
||||
* - `target` 以 `@chatroom` 结尾 ⇒ **直接发到那个群**(可能是另一个群);
|
||||
* - 否则 ⇒ **静默回落到来源群**。
|
||||
*
|
||||
* ⇒ 历史行为**事实上支持「生成 A 群日报 → 发 B 群」**,也存在一处静默 fallback。
|
||||
* 新产品明确不支持"指定另一个群聊",所以:
|
||||
* **能证明同群 → 无损映射成 `source_chat`;不能证明 → 停用 + 要求用户重选**,
|
||||
* 绝不自动改写、绝不偷偷丢掉目标。
|
||||
*/
|
||||
|
||||
/** 旧任务里迁移真正用得到的字段(其余字段不参与,也不需要读)。 */
|
||||
export interface LegacyScheduledReportTask {
|
||||
id?: unknown
|
||||
name?: unknown
|
||||
group?: unknown
|
||||
scheduleTime?: unknown
|
||||
reportRange?: unknown
|
||||
messageTypes?: unknown
|
||||
templateId?: unknown
|
||||
memberNameMode?: unknown
|
||||
timeoutSeconds?: unknown
|
||||
target?: unknown
|
||||
enabled?: unknown
|
||||
createdAt?: unknown
|
||||
updatedAt?: unknown
|
||||
lastRunAt?: unknown
|
||||
lastScheduledSlot?: unknown
|
||||
}
|
||||
|
||||
export type ScheduledReportMigrationOutcome =
|
||||
/** 目标与来源群是同一群 ⇒ 等价于 `source_chat`,行为完全一致。 */
|
||||
| 'lossless_source_chat'
|
||||
/** 目标是另一个群 / 无法确认是否同群 ⇒ 单值四选一表达不了,需用户重选。 */
|
||||
| 'needs_review'
|
||||
|
||||
export interface ScheduledReportMigrationPlan {
|
||||
outcome: ScheduledReportMigrationOutcome
|
||||
/** 迁移后的规则 id(确定性,可重复执行且不产生新 id)。 */
|
||||
ruleId: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
config: ScheduledReportAutomationConfig
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** 把任意输入收敛成字符串。 */
|
||||
function asText(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : String(value ?? '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定性 ruleId。
|
||||
*
|
||||
* 优先**原样复用旧 taskId**(旧 id 形如 `scheduled_report_<uuid>`,与自动化现有
|
||||
* id 空间 —— `randomUUID()` / `builtin-*` —— 不可能冲突)。
|
||||
* 旧 id 缺失时(文件被手改坏),退化成一个**由稳定字段派生**的 id,
|
||||
* 保证"重复迁移不会生成新规则"。
|
||||
*/
|
||||
export function scheduledReportMigrationRuleId(task: LegacyScheduledReportTask): string {
|
||||
const id = asText(task.id)
|
||||
if (id) return id
|
||||
const digest = createHash('sha1')
|
||||
.update(
|
||||
[asText(task.name), asText(task.group), asText(task.scheduleTime), asText(task.target)].join(
|
||||
'\u0001'
|
||||
)
|
||||
)
|
||||
.digest('hex')
|
||||
return `scheduled-report:${digest.slice(0, 32)}`
|
||||
}
|
||||
|
||||
/** 旧 `createdAt` / `updatedAt` 是 ISO 字符串;非法时回落到迁移时刻。 */
|
||||
function parseIso(value: unknown, fallback: number): number {
|
||||
const text = asText(value)
|
||||
if (!text) return fallback
|
||||
const parsed = Date.parse(text)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条旧任务 → 迁移计划。
|
||||
*
|
||||
* `resolveConversationId` 由调用方注入(main 侧是 `chat-service.resolveMd5`,
|
||||
* 单测里是假的联系人表)。**它拿不到就说明主进程还没准备好**,
|
||||
* 调用方必须**推迟整批迁移**,而不是用"解析不到"去误判成 needs_review。
|
||||
*/
|
||||
export function planScheduledReportMigration(
|
||||
task: LegacyScheduledReportTask,
|
||||
resolveConversationId: (raw: string) => string | undefined,
|
||||
now: number
|
||||
): ScheduledReportMigrationPlan {
|
||||
const sourceRaw = asText(task.group)
|
||||
const targetRaw = asText(task.target)
|
||||
const sourceResolved = resolveConversationId(sourceRaw)
|
||||
const targetResolved = resolveConversationId(targetRaw)
|
||||
|
||||
/*
|
||||
* 三种"能证明是同一个群"的情况:
|
||||
* 1. target 为空 —— 旧 `normalizeInput` 本就把空 target 填成 group;
|
||||
* 2. 两者文本完全相等 —— 无论能否解析,用户表达的就是同一个东西;
|
||||
* 3. 两者都能解析且解析结果相同 —— 旧 UI 的常态(group=会话 md5,target=roomId)。
|
||||
*/
|
||||
const sameGroup =
|
||||
!targetRaw || targetRaw === sourceRaw || Boolean(sourceResolved && targetResolved && sourceResolved === targetResolved)
|
||||
|
||||
const config = normalizeScheduledReportConfig({
|
||||
schedule: { time: asText(task.scheduleTime) },
|
||||
report: {
|
||||
// 解析得到就用稳定会话 id;解析不到**保留原值** —— 运行期会如实失败,
|
||||
// 绝不在这里"猜一个群"。
|
||||
sourceConversationId: sourceResolved ?? sourceRaw,
|
||||
range: asText(task.reportRange) as ScheduledReportRange,
|
||||
messageTypes: task.messageTypes as ScheduledReportMessageType[],
|
||||
templateId: asText(task.templateId) as SelectableReportTemplateId,
|
||||
memberNameMode: asText(task.memberNameMode) as ScheduledReportMemberNameMode,
|
||||
timeoutSeconds: Number(task.timeoutSeconds)
|
||||
},
|
||||
target: { type: 'source_chat' },
|
||||
...(sameGroup ? {} : { targetNeedsReview: true }),
|
||||
// 旧目标原文只作展示;`sameGroup` 时不保留,避免留下看起来像"还生效"的残值。
|
||||
...(sameGroup || !targetRaw ? {} : { legacyTarget: targetRaw }),
|
||||
// 来源群被改写过(md5/群名 → roomId)时留下原文,便于用户核对。
|
||||
...(sourceRaw && sourceResolved && sourceRaw !== sourceResolved
|
||||
? { legacySourceGroup: sourceRaw }
|
||||
: {}),
|
||||
...(asText(task.lastRunAt) ? { lastRunAt: asText(task.lastRunAt) } : {}),
|
||||
...(asText(task.lastScheduledSlot) ? { lastScheduledSlot: asText(task.lastScheduledSlot) } : {})
|
||||
})
|
||||
|
||||
const name = asText(task.name) || '定时日报'
|
||||
return {
|
||||
outcome: sameGroup ? 'lossless_source_chat' : 'needs_review',
|
||||
ruleId: scheduledReportMigrationRuleId(task),
|
||||
name,
|
||||
// 不自动改写、也不自动启用:无法确认目标的规则一律停用。
|
||||
enabled: sameGroup ? task.enabled !== false : false,
|
||||
config,
|
||||
createdAt: parseIso(task.createdAt, now),
|
||||
updatedAt: parseIso(task.updatedAt, now)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScheduledReportMigrationSummary {
|
||||
total: number
|
||||
migrated: number
|
||||
lossless: number
|
||||
needsReview: number
|
||||
duplicatesSkipped: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 整批任务的迁移结果:**按 id 去重**,保证幂等(同一份旧文件跑两次结果一致)。
|
||||
*/
|
||||
export function planScheduledReportMigrationBatch(
|
||||
tasks: LegacyScheduledReportTask[],
|
||||
resolveConversationId: (raw: string) => string | undefined,
|
||||
now: number
|
||||
): { plans: ScheduledReportMigrationPlan[]; summary: ScheduledReportMigrationSummary } {
|
||||
const plans: ScheduledReportMigrationPlan[] = []
|
||||
const seen = new Set<string>()
|
||||
let duplicatesSkipped = 0
|
||||
let lossless = 0
|
||||
let needsReview = 0
|
||||
for (const task of tasks) {
|
||||
if (!task || typeof task !== 'object') continue
|
||||
const plan = planScheduledReportMigration(task, resolveConversationId, now)
|
||||
if (seen.has(plan.ruleId)) {
|
||||
duplicatesSkipped += 1
|
||||
continue
|
||||
}
|
||||
seen.add(plan.ruleId)
|
||||
plans.push(plan)
|
||||
if (plan.outcome === 'lossless_source_chat') lossless += 1
|
||||
else needsReview += 1
|
||||
}
|
||||
return {
|
||||
plans,
|
||||
summary: {
|
||||
total: plans.length + duplicatesSkipped,
|
||||
migrated: plans.length,
|
||||
lossless,
|
||||
needsReview,
|
||||
duplicatesSkipped
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
SCHEDULED_REPORT_TARGET_OPTIONS,
|
||||
scheduledReportTargetLabel,
|
||||
type ScheduledReportAutomationConfig
|
||||
} from '../../shared/automation'
|
||||
import {
|
||||
resolveAutomationTarget,
|
||||
resolveGroupConversationId,
|
||||
resolveGroupDisplayName,
|
||||
type AutomationTargetContact,
|
||||
type AutomationTargetResolution
|
||||
} from './automation-wechat-target'
|
||||
|
||||
/**
|
||||
* 定时日报的**目标解析**(薄适配层)。
|
||||
*
|
||||
* 判定逻辑在 `automation-wechat-target.ts`,与退群通知**共用同一份**。
|
||||
* 差异只有一处:来源会话不是"事件所在群",而是**这条规则配置的日报来源群**。
|
||||
*/
|
||||
|
||||
/** 定时日报的目标文案(唯一一份)。 */
|
||||
export const SCHEDULED_REPORT_TARGET_MESSAGES = {
|
||||
options: SCHEDULED_REPORT_TARGET_OPTIONS,
|
||||
invalidTarget: '定时日报的发送目标无效,请重新选择',
|
||||
sourceMissing: '无法确定日报来源群,本次日报未发送',
|
||||
sourceRecipientName: '日报来源群',
|
||||
sourceDisplayName: '日报来源群',
|
||||
selfMissing: '无法确定当前登录的微信账号,本次日报未发送',
|
||||
contactNotChosen: '还没有选择发送联系人,请重新选择',
|
||||
contactUnavailable: '发送联系人已不存在或当前无法发送,请重新选择'
|
||||
} as const
|
||||
|
||||
export function resolveScheduledReportTarget(input: {
|
||||
config: ScheduledReportAutomationConfig | undefined
|
||||
contacts: AutomationTargetContact[]
|
||||
/** 当前登录账号的 wxid;拿不到时传空串。 */
|
||||
selfWxid?: string
|
||||
}): AutomationTargetResolution {
|
||||
const config = input.config
|
||||
if (!config) return { ok: false, error: '定时日报尚未配置发送目标' }
|
||||
const sourceConversationId = String(config.report?.sourceConversationId || '').trim()
|
||||
return resolveAutomationTarget(
|
||||
{
|
||||
targetType: config.target?.type as never,
|
||||
sourceConversationId,
|
||||
// 来源群的可读名从通讯录解析;解析不到时由中性层给兜底称呼。
|
||||
sourceDisplayName: resolveGroupDisplayName(sourceConversationId, input.contacts),
|
||||
...(config.target?.contactId ? { contactId: config.target.contactId } : {}),
|
||||
contacts: input.contacts,
|
||||
...(input.selfWxid ? { selfWxid: input.selfWxid } : {})
|
||||
},
|
||||
SCHEDULED_REPORT_TARGET_MESSAGES
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 日报来源群的可读显示名(执行日志的 `sourceDisplayName` 用它)。
|
||||
*
|
||||
* **绝不回落到裸 id**:解析不到就说「日报来源群」。
|
||||
*/
|
||||
export function scheduledReportSourceDisplayName(
|
||||
config: ScheduledReportAutomationConfig | undefined,
|
||||
contacts: AutomationTargetContact[]
|
||||
): string {
|
||||
const raw = String(config?.report?.sourceConversationId || '').trim()
|
||||
if (!raw) return '日报来源群'
|
||||
return resolveGroupDisplayName(raw, contacts) || '日报来源群'
|
||||
}
|
||||
|
||||
/** 日报来源群解析成稳定会话 id(执行前预检用;解析不到返回 undefined)。 */
|
||||
export function scheduledReportSourceConversationId(
|
||||
config: ScheduledReportAutomationConfig | undefined,
|
||||
contacts: AutomationTargetContact[]
|
||||
): string | undefined {
|
||||
return resolveGroupConversationId(
|
||||
String(config?.report?.sourceConversationId || ''),
|
||||
contacts
|
||||
)
|
||||
}
|
||||
|
||||
export { scheduledReportTargetLabel }
|
||||
@@ -39,9 +39,8 @@ export interface AppSettings {
|
||||
showStartupProgress: boolean
|
||||
ttsSelectedVoiceId: string
|
||||
ttsModel: TextToSpeechModel
|
||||
/** Keep a running personal-WeChat OneBot process across app restarts. */
|
||||
keepPersonalWechatProcess?: boolean
|
||||
windowsWechatPort: string
|
||||
reportImagePostfixText: string
|
||||
/**
|
||||
* Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。
|
||||
* 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关,
|
||||
@@ -130,8 +129,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
showStartupProgress: true,
|
||||
ttsSelectedVoiceId: '',
|
||||
ttsModel: 's2.1-pro-free',
|
||||
keepPersonalWechatProcess: false,
|
||||
windowsWechatPort: '',
|
||||
reportImagePostfixText: '今日日报',
|
||||
queryAgentEnabled: true
|
||||
}
|
||||
|
||||
@@ -150,7 +149,10 @@ export function loadSettings(): AppSettings {
|
||||
if (cache) return cache
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
|
||||
const stored = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings> & {
|
||||
keepPersonalWechatProcess?: unknown
|
||||
}
|
||||
const { keepPersonalWechatProcess: retiredKeepProcess, ...raw } = stored
|
||||
cache = { ...DEFAULT_SETTINGS, ...raw }
|
||||
if (raw.autoLogin === undefined) {
|
||||
const hasSavedDatabaseKey = fs.existsSync(
|
||||
@@ -176,7 +178,7 @@ export function loadSettings(): AppSettings {
|
||||
}
|
||||
// 防撤回已下线(设置入口已隐藏):历史版本可能把它持久化为 true。
|
||||
// 这里强制收敛为 false 并回写磁盘,确保旧的撤回监听与撤回日志不会继续运行。
|
||||
if (cache.recallProtectionEnabled) {
|
||||
if (cache.recallProtectionEnabled || retiredKeepProcess !== undefined) {
|
||||
cache.recallProtectionEnabled = false
|
||||
saveSettings(cache)
|
||||
}
|
||||
@@ -192,7 +194,11 @@ export function loadSettings(): AppSettings {
|
||||
export function saveSettings(next: AppSettings): AppSettings {
|
||||
// 防撤回已下线:所有写入路径(含 settings:set 补丁)统一收敛为 false,
|
||||
// 避免遗留入口或旧版本把它重新打开。
|
||||
cache = { ...next, recallProtectionEnabled: false }
|
||||
const sanitized = { ...next } as AppSettings & {
|
||||
keepPersonalWechatProcess?: unknown
|
||||
}
|
||||
delete sanitized.keepPersonalWechatProcess
|
||||
cache = { ...sanitized, recallProtectionEnabled: false }
|
||||
try {
|
||||
ensureDir()
|
||||
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user