mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:20:34 +08:00
Compare commits
106
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 | ||
|
|
e6db4de711 | ||
|
|
adff6021fc | ||
|
|
75004375df | ||
|
|
75de616527 | ||
|
|
f5158fd3e4 | ||
|
|
ed4c21a68f | ||
|
|
f35ca1778a | ||
|
|
21b2b57d28 | ||
|
|
67dc45819e | ||
|
|
ff212d594c | ||
|
|
353df30ff8 | ||
|
|
4f8435d0e2 | ||
|
|
389a853560 | ||
|
|
c437be349e | ||
|
|
488966d771 | ||
|
|
00095ae627 | ||
|
|
ffbe78f74a | ||
|
|
7e9a79486e | ||
|
|
4191d4d5f5 | ||
|
|
29c1166eca | ||
|
|
bf78055b69 | ||
|
|
550e3b104c | ||
|
|
cf9ea8a83b | ||
|
|
5efae86fcf | ||
|
|
70c55442f3 | ||
|
|
9288256a5e | ||
|
|
e5bd162a01 | ||
|
|
cef1ad493c | ||
|
|
add16885fe | ||
|
|
ab3b3731b7 | ||
|
|
0c4932d740 | ||
|
|
fe8619d56d | ||
|
|
376ea79ff6 | ||
|
|
dc6d1eb6ac | ||
|
|
532e135406 | ||
|
|
7ae0d63a7f | ||
|
|
c759bcd30e | ||
|
|
0bad419150 | ||
|
|
facc292443 | ||
|
|
7554de7d72 | ||
|
|
1bc66e33fd | ||
|
|
976aac32c5 | ||
|
|
3634f49a48 | ||
|
|
1156362c3c | ||
|
|
60528ffe53 | ||
|
|
f28480ffbe | ||
|
|
8e784d8125 | ||
|
|
3489535750 | ||
|
|
5e268cd8f4 | ||
|
|
454f449c41 | ||
|
|
517e650942 | ||
|
|
21e299942b | ||
|
|
bde3bc537f | ||
|
|
719ffa0a24 | ||
|
|
8fa4dd50e7 | ||
|
|
3462d49516 | ||
|
|
7ef991aa67 | ||
|
|
4d758b2aca | ||
|
|
27f4108cf9 | ||
|
|
e1f4ec79dd | ||
|
|
76e9e683c4 | ||
|
|
7c8b45a24a | ||
|
|
1c8bbae1f0 | ||
|
|
25b72c3d76 | ||
|
|
f65dc4ac32 | ||
|
|
e59332b514 | ||
|
|
6cbf9af662 | ||
|
|
5acfa14475 | ||
|
|
698251796f | ||
|
|
a007e110ac | ||
|
|
7cf043e3c3 | ||
|
|
949e6bc868 | ||
|
|
62abd872ee | ||
|
|
0d68217dda | ||
|
|
ee5f50db83 | ||
|
|
36e709d7a6 | ||
|
|
b26d4dd1a7 | ||
|
|
8f9a0c44da | ||
|
|
5ff67edaef |
@@ -5,6 +5,9 @@ VITE_DB_KEY=
|
||||
# Set to true/1/yes/on for local development. Default is disabled.
|
||||
VITE_AUTO_LOGIN=false
|
||||
|
||||
# Show the scheduled report debug notification test action. Default is disabled.
|
||||
VITE_SCHEDULED_REPORT_DEBUG=false
|
||||
|
||||
# AI API Configuration (Optional, can be entered in UI)
|
||||
# 注意:发布版本不再自动读取以下环境变量。
|
||||
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
|
||||
|
||||
@@ -25,16 +25,10 @@ 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 --frozen-lockfile
|
||||
run: pnpm install
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: runner.os == 'macOS'
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Type check
|
||||
@@ -52,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
|
||||
|
||||
@@ -64,6 +55,8 @@ jobs:
|
||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||
|
||||
- name: Platform visual regression
|
||||
# GitHub-hosted Windows 无法稳定容纳项目统一的 1400×800 Electron 窗口,Windows visual regression 改由可控 Windows 桌面环境执行。
|
||||
if: matrix.os != 'windows-latest'
|
||||
run: pnpm exec playwright test tests/e2e/visual.spec.ts
|
||||
env:
|
||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||
|
||||
+11
-10
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
*.tsbuildinfo
|
||||
electron.vite.config.[0-9]*.mjs
|
||||
dist
|
||||
out
|
||||
.env
|
||||
@@ -9,19 +10,19 @@ out
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
resources/connectors/wechat/
|
||||
resources/connectors/wechat-personal/
|
||||
resources/runtime/darwin-arm64
|
||||
.native-runtime-source
|
||||
.omc
|
||||
.codex/
|
||||
services/share-card-worker/.wrangler/
|
||||
services/share-card-worker/wrangler.local.jsonc
|
||||
skills-lock.json
|
||||
docs/design/
|
||||
docs/ui-redesign-plan.md
|
||||
docs/ui-redesign-spec.md
|
||||
AGENTS.md
|
||||
*__screenshots__
|
||||
|
||||
/AGENTS.md
|
||||
/CLAUDE.md
|
||||
/.claude/
|
||||
/.workbuddy/
|
||||
/.ai-local/
|
||||
|
||||
findings.md
|
||||
progress.md
|
||||
task_plan.md
|
||||
.agents
|
||||
*__screenshots__
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# 参与贡献
|
||||
|
||||
> 这份文档同时写给人和 AI Agent。文末有给 Agent 的英文硬性规则。
|
||||
> 如果你只是想把 TraceMemo 跑起来,看[第一次使用](./docs/user-guide/getting-started.md)就够了。
|
||||
|
||||
## 一句话规则
|
||||
|
||||
**从 `develop` 拉分支,把 PR 提给 `develop`。**
|
||||
|
||||
## 为什么不是 main
|
||||
|
||||
| 分支 | 是什么 | 接受 PR 吗 |
|
||||
| --------- | ------------------------------------------------------- | -------------- |
|
||||
| `main` | 稳定版。只在发版时更新,对应 GitHub Releases 里的安装包 | 不接受 |
|
||||
| `develop` | 开发主线。所有改动先进这里,**随下一个版本一起发布** | 唯一的目标分支 |
|
||||
|
||||
指向 `main` 的 PR、或者不是基于 `develop` 拉出来的分支,会被直接关闭。不是不欢迎贡献,而是因为冲突合并 提交落后较多等原因
|
||||
|
||||
## 提 PR 的完整流程
|
||||
|
||||
```bash
|
||||
# 1. 基于 develop 拉分支(不要基于 main)
|
||||
git fetch origin
|
||||
git checkout -b feat/your-change origin/develop
|
||||
|
||||
# 2. 改代码,只改与本任务相关的文件
|
||||
|
||||
# 3. 自检
|
||||
pnpm install # 需要 Node 22 与 pnpm 7.33.7
|
||||
pnpm typecheck
|
||||
pnpm test:unit # 再按改动范围补跑 component / integration
|
||||
|
||||
# 4. 提交
|
||||
git commit -m "feat: 一句话说明改了什么"
|
||||
|
||||
# 5. 提 PR,目标分支必须是 develop
|
||||
gh pr create --base develop --head feat/your-change
|
||||
```
|
||||
|
||||
## 提交信息
|
||||
|
||||
默认**只写一行标题**:类型前缀 + 一句话说明。
|
||||
|
||||
```
|
||||
feat: 新增本地查询能力
|
||||
fix: 修复查询工具时间契约
|
||||
docs: 整理开发文档
|
||||
```
|
||||
|
||||
确实包含多个功能点时,标题之后每个功能点各写一行纯文本 —— 不要用列表符号,也不要写
|
||||
「设置侧:」「测试:」这类分节标题:
|
||||
|
||||
```
|
||||
feat: 统一手动发送入口为文字转语音
|
||||
移除普通文本、图片和本地语音的手动发送入口
|
||||
保留文字转语音的生成、试听和发送能力
|
||||
```
|
||||
|
||||
不要在提交信息里堆文件名清单、测试结果或实现过程叙述,那些属于 PR 描述。
|
||||
|
||||
## 分支命名
|
||||
|
||||
`feat/…`、`fix/…`、`docs/…`、`refactor/…`,后面接简短的英文或拼音描述。
|
||||
|
||||
## PR 前自检
|
||||
|
||||
| 你的改动 | 至少跑这些 |
|
||||
| -------------------------- | ----------------------------------------------------------------- |
|
||||
| 一般代码 / 服务 / 工具函数 | `pnpm typecheck` + 相关 `pnpm test:unit` |
|
||||
| 界面 / 交互 | `pnpm typecheck` + 相关 `pnpm test:component`,或针对该功能的 E2E |
|
||||
| preload / IPC 契约 | `pnpm typecheck` + 相关 contract 测试或 `pnpm test:integration` |
|
||||
| 只改文档 | 不需要跑测试 |
|
||||
|
||||
全部测试命令见 `package.json` 的 `scripts`;本地开发环境的说明见[开发概览](./docs/development/overview.md)。
|
||||
|
||||
## 请不要提交这些东西
|
||||
|
||||
- **构建产物**:`out/`、`dist/`、`build/` 下的二进制、`node_modules/`、`test-results/`、`playwright-report/`
|
||||
- **真实数据**:微信聊天内容、真实的 wxid / 群名 / 联系人名、聊天截图
|
||||
- **密钥**:API Key、Token、数据库密钥、图片解密密钥
|
||||
- **本机绝对路径**:`/Users/…`、`C:\Users\…`
|
||||
|
||||
测试用的假数据请用一眼能看出是合成的命名,例如 `fixture-group`、`wxid_fixture_member`。
|
||||
|
||||
## 合入之后
|
||||
|
||||
PR 合进 `develop` 不会立刻出现在下载页,它随**下一个版本**发布。想提前用上,可以自行从
|
||||
`develop` 构建。
|
||||
|
||||
## 如果你是 AI Agent
|
||||
|
||||
请按顺序执行,不要凭直觉选分支:
|
||||
|
||||
1. **基线**:`git fetch origin && git checkout -b <branch> origin/develop`。永远不要基于 `main`。
|
||||
2. **PR 目标**:`gh pr create --base develop`。无法确定时默认 `develop`;任何情况下都不要把
|
||||
`main` 当 PR 目标。
|
||||
3. **改动范围**:只改与本任务相关的文件。不要顺手格式化、重排 import、升级依赖。
|
||||
4. **提交前**:跑 `pnpm typecheck` 和与本次改动相关的测试(见上表)。
|
||||
5. **提交信息**:一行标题,`type: 描述`。不要写文件清单、测试输出或过程叙述。
|
||||
6. **PR 描述**:说明改了什么、为什么改、怎么验证的;关联 Issue 用 `closes #123`。
|
||||
7. **禁止**:真实聊天数据、密钥、Token、本机绝对路径、构建产物。
|
||||
|
||||
### Hard rules for AI agents (English)
|
||||
|
||||
- Base branch: `origin/develop`. Never branch off `main`.
|
||||
- Open pull requests with base branch `develop`. PRs targeting `main` are closed without review.
|
||||
- One PR = one logical change. No drive-by reformatting, import reordering, or dependency upgrades.
|
||||
- Before opening a PR, run `pnpm typecheck` plus the tests relevant to your change.
|
||||
- Commit subject: a single line, `type: summary`. No file lists, no test logs, no process narration.
|
||||
- Never commit build output (`out/`, `dist/`, `test-results/`, `playwright-report/`), real WeChat
|
||||
data, keys, tokens, or absolute local paths.
|
||||
- Changes merged into `develop` ship with the next release.
|
||||
@@ -4,12 +4,9 @@
|
||||
<img src="./build/icon.png" width="120" alt="TraceMemo Logo" />
|
||||
</p>
|
||||
|
||||
<h2 align="center">把微信聊过的事,找回来、问清楚、留下来</h2>
|
||||
<h2 align="center">把微信里的信息,记住、理解、监控,并在需要时行动</h2>
|
||||
|
||||
<p align="center">
|
||||
本地优先的微信聊天记录工作台:查看、搜索、提问、总结和导出<br />
|
||||
查看聊天 · 找回信息 · AI 问答 · 微信群聊总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入
|
||||
</p>
|
||||
<p align="center">本地优先的微信数据、AI 分析与自动化工作台</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/stars/Wxw-Gu/TraceMemo?style=for-the-badge" alt="GitHub stars" />
|
||||
@@ -23,60 +20,71 @@
|
||||
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
|
||||
·
|
||||
<a href="./docs/README.md"><b>完整文档</b></a>
|
||||
·
|
||||
<a href="./docs/concepts/how-it-works.md"><b>TraceMemo 如何工作</b></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/software-1.png" alt="TraceMemo 主界面" />
|
||||
<img src="./public/日报.png" alt="TraceMemo 主界面" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/机器人.png" alt="TraceMemo 微信机器人" />
|
||||
<img src="./public/问问微信.png" alt="TraceMemo 问问微信" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/退群监控.png" alt="TraceMemo 退群监控" />
|
||||
</p>
|
||||
---
|
||||
|
||||
## TraceMemo(迹忆)是什么
|
||||
## 🎨 社区日报模板
|
||||
|
||||
TraceMemo(迹忆)是一款**本地优先、可追溯的 AI 微信知识与分析工作台**。
|
||||
TraceMemo 日报除了内置版式,也支持从社区模板市场安装更多样式。社区模板与默认日报读取同一份真实日报数据,只改变展示方式,适合手机长图分享、桌面归档、团队复盘等不同场景。
|
||||
|
||||
TraceMemo 原名 **WechatExplorer**,是一次从“微信聊天记录探索工具”向“可追溯的本地 AI 知识工作台”演进后的正式品牌升级。
|
||||
<p align="center">
|
||||
<a href="https://github.com/Wxw-Gu/TraceMemo-Templates"><b>浏览 TraceMemo 模板社区</b></a>
|
||||
·
|
||||
<a href="https://github.com/Wxw-Gu/TraceMemo-Templates/tree/main/skills/tracememo-template-contributor"><b>用 AI 制作并投稿模板</b></a>
|
||||
</p>
|
||||
|
||||
它可以帮你浏览、搜索和整理微信历史,也可以让 AI 帮你找回聊过的内容,并回到原始消息核对答案。
|
||||
在 TraceMemo 中打开:
|
||||
|
||||
你可以直接浏览聊天,也可以用自然语言提问:
|
||||
**日报 → 今日日报 → 日报模板 → 模板市场**
|
||||
|
||||
> “上个月我们讨论过哪些发布问题?”
|
||||
>
|
||||
> “张三之前发过的项目地址在哪里?”
|
||||
>
|
||||
> “技术交流群今天有哪些结论和待办?”
|
||||
即可查看、预览、安装和切换已发布的社区模板。
|
||||
|
||||
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。
|
||||
如果你有一张喜欢的日报长图、网页或前端项目,也可以把它交给 Codex、ChatGPT 或其他能够读取 GitHub 仓库的 AI,并让它读取 [TraceMemo Template Contributor Skill](https://github.com/Wxw-Gu/TraceMemo-Templates/tree/main/skills/tracememo-template-contributor)。AI 可以帮助你完成模板转换、真实预览,并在你确认满意后向 [TraceMemo-Templates](https://github.com/Wxw-Gu/TraceMemo-Templates) 提交 Pull Request。
|
||||
|
||||
你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
|
||||
|
||||
TraceMemo 不提供任何微信聊天数据,也不鼓励收集、上传、出售、共享或未经授权处理他人的聊天记录。使用 TraceMemo 时,请确保你对所处理的数据具有合法的访问和使用权限,并自行承担相应的数据安全与合规责任。
|
||||
模板通过审核并正式发布后,其他 TraceMemo 用户即可在模板市场中安装使用。
|
||||
|
||||
---
|
||||
|
||||
## 为什么叫 TraceMemo(迹忆)
|
||||
## TraceMemo 是什么
|
||||
|
||||
<details>
|
||||
`Trace` 代表聊天记录留下的痕迹、可以追溯的信息来源、AI 搜索过程,以及从结果回到原始聊天上下文并核对证据的能力。
|
||||
TraceMemo(迹忆)原名 **WechatExplorer** 是一款本地优先的微信数据、AI 分析与自动化工作台,把聊天变成可浏览、可搜索、可理解、可追溯的信息。
|
||||
|
||||
`Memo` 代表记忆、知识沉淀和长期保存:让聊天中产生的信息逐渐形成个人知识。
|
||||
先用档案找原话,再按需要使用 AI Search、日报、监控或 Agent。普通浏览、搜索和导出不需要 AI。
|
||||
|
||||
“迹忆”可以理解为“留下痕迹的记忆”。
|
||||
## 核心能力
|
||||
|
||||
TraceMemo 不是单纯查看微信聊天记录的工具,而是希望让聊天中产生的信息留下痕迹,并能够被再次找到、理解、验证和沉淀。
|
||||
- 💬 **聊天档案与搜索**:浏览会话,按关键词或身份信息查找。
|
||||
- 🔍 **AI Search / 问问微信**:用自然语言找回模糊记忆,并查看来源。
|
||||
- 🧠 **本地知识库**:建立索引,提升跨会话查询稳定性。
|
||||
- 📊 **群聊日报**:生成今日、昨日或近 7 天的群聊总结。
|
||||
- 👀 **群成员变化监控**:记录指定群聊的退群动态。
|
||||
- 🔊 **文字转语音**:生成语音,试听后发送到选定会话。
|
||||
- 🤖 **Agent Hub**:在微信里调用本机 TraceMemo。
|
||||
- 🔌 **外部 Agent / Local HTTP API**:让外部 Agent 查询本机微信历史。
|
||||
|
||||
> **品牌说明**
|
||||
>
|
||||
> TraceMemo(迹忆)原名 WechatExplorer。WechatExplorer 最初是一个用于查看和探索微信聊天记录的工具。随着本地搜索、AI 问答、来源追溯、知识库、日报、语音转写和 Agent 能力逐渐形成,项目已经从单纯的聊天记录查看器发展为本地 AI 知识与分析工作台,因此在 v2.2.0 正式更名为 TraceMemo(迹忆)。
|
||||
## 💻 平台支持
|
||||
|
||||
</details>
|
||||
TraceMemo 2.4.0 支持:
|
||||
|
||||
---
|
||||
- **Windows x64**
|
||||
- **macOS Apple Silicon(M 系列 / arm64)**
|
||||
- **macOS Intel(x64)**
|
||||
|
||||
Windows 与 macOS 均支持微信本地数据库连接与数据库 Key 获取。
|
||||
|
||||
## 项目缘起
|
||||
|
||||
@@ -88,11 +96,11 @@ TraceMemo 最早叫 **WechatExplorer**。
|
||||
|
||||
第一个版本完成后,项目搁置了一段时间。后来重新捡起来,我还是想继续做群聊日报,但微信已经更新到 4.x,原来的微信 3.0 数据解析方案不再适用。
|
||||
|
||||
为了支持微信 4.x,我开始重新研究数据访问。这部分工作得到了 **WeFlow** 很大的帮助。TraceMemo 目前的微信 4.x 数据连接能力,参考并使用了 **WeFlow 历史版本中的相关实现和思路**,包括数据库密钥获取、图片解密等底层能力。
|
||||
为了支持微信 4.x,我开始重新研究数据访问。这部分工作最初得到了 **WeFlow** 很大的帮助。早期 TraceMemo 曾参考 WeFlow 历史版本中的实现和思路,借此解决了数据库消息、密钥获取等微信 4.x 数据访问问题。
|
||||
|
||||
> **没有 WeFlow,就没有今天的 TraceMemo。**
|
||||
随着项目继续发展,我逐步把这部分底层能力从原有实现中抽离,并重新实现了一套独立的数据访问兼容层。目前会继续保持与 WeFlow 历史接口和行为的兼容,以减少上层业务迁移成本。
|
||||
|
||||
WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继续做后面的事情:让聊天记录可以被搜索、理解和总结,也让 AI 给出的答案能够回到原始消息核对。
|
||||
也就是说,**WeFlow 是 TraceMemo 进入微信 数据访问领域的重要起点。没有 WeFlow,就没有今天的 TraceMemo。**
|
||||
|
||||
在此基础上,项目陆续加入了:
|
||||
|
||||
@@ -105,12 +113,15 @@ WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继
|
||||
- Reader Skill
|
||||
- Agent 接入
|
||||
- 多种聊天记录导出能力
|
||||
- 退群监控
|
||||
- 文字转语音
|
||||
- 持续监控自动化能力
|
||||
|
||||
群聊日报后来被一些人看到,项目也开始有了 Star、Fork、使用反馈和功能建议。说实话,我一开始没想到,这个原本只给自己用的小工具,会得到这么多人的关注。
|
||||
|
||||
这些关注和反馈让我决定认真把项目继续做下去。WechatExplorer 就这样一步一步变成了今天的 **TraceMemo(迹忆)**。
|
||||
|
||||
感谢 WeFlow,也感谢每一位使用、关注和反馈过 TraceMemo 的人。
|
||||
感谢每一位使用、关注和反馈过的人。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -124,298 +135,54 @@ WeFlow 帮我跨过了微信 4.x 数据访问这道门槛,我才有机会继
|
||||
|
||||
## 从你的任务开始
|
||||
|
||||
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------- | ------------------------------------ |
|
||||
| 找一句记得原文或关键词的聊天 | [档案](./docs/user-guide/chat-archive.md) | 连接微信数据,不需要 AI |
|
||||
| 找一件记得大意、但不知道在哪聊过的事 | [问问微信](./docs/user-guide/ai-search.md) | 配置 AI 服务,并选择会话和时间范围 |
|
||||
| 让长期、跨群聊查找更稳定 | [问问微信 → 本地知识库](./docs/user-guide/knowledge.md) | 主动建立本地索引;不会自动创建 |
|
||||
| 快速了解一个群今天、昨天或近 7 天聊了什么 | [日报](./docs/user-guide/report.md) | 选择群聊并配置 AI 服务 |
|
||||
| 把群聊日报生成微信分享卡片(实验性) | [微信分享卡片](./docs/deployment/experimental-wechat-share-card.md) | 自备 Cloudflare、域名和微信测试号 |
|
||||
| 把微信语音变成可搜索的文字 | [设置 → 语音转文字](./docs/user-guide/voice.md) | 准备本地语音模型 |
|
||||
| 把聊天保存成 HTML、Markdown、CSV 或 JSON | [导出](./docs/user-guide/export.md) | 选择聊天、时间和格式,不需要 AI |
|
||||
| 尽量保留之后捕获到的撤回消息 | [设置 → 防撤回](./docs/user-guide/recall-protection.md) | 默认关闭;开启前先了解写入和性能边界 |
|
||||
| 直接在微信里向 TraceMemo 提问 | [微信机器人](./docs/agent/agent-hub.md) | 扫码连接机器人;总结类任务需要 AI |
|
||||
| 让 Codex 等外部 Agent 查询微信历史 | [外部 Agent](./docs/agent/overview.md) | 安装 Reader Skill 并配置本机 Token |
|
||||
|
||||
---
|
||||
|
||||
## 最核心的三个能力
|
||||
|
||||
### 生成群聊日报
|
||||
|
||||
选择群聊和时间范围后,可以让 AI 把聊天整理成报告,并保存为 HTML 与 PNG 长图。
|
||||
|
||||
报告包含:
|
||||
|
||||
- 热点
|
||||
- 重要消息
|
||||
- 资源
|
||||
- 问答
|
||||
- 待办
|
||||
- 未解决事项
|
||||
- 活跃统计
|
||||
- 图片精选
|
||||
|
||||
具体内容取决于消息、媒体是否可读以及模型能力。
|
||||
详细说明:[生成群聊日报](./docs/user-guide/report.md)
|
||||
|
||||
</details>
|
||||
|
||||
### AI 帮你找回聊过的内容
|
||||
|
||||
打开“问问微信”,选择搜索范围和时间,然后像提问一样描述你想找的内容。
|
||||
|
||||
TraceMemo 会先在本机查找候选消息,再把整理后的少量来源交给你配置的 AI 模型生成回答。
|
||||
|
||||
你可以查看答案参考了哪些聊天、来自哪个人和时间,并从来源标记跳回原始消息核对;“查看检索详情”还会展示本次查找经历了哪些阶段。
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/问一问.png" alt="问问微信与聊天来源" />
|
||||
</p>
|
||||
|
||||
详细说明:[使用 AI 查找聊天信息](./docs/user-guide/ai-search.md)
|
||||
|
||||
### 直接在微信里问你的历史聊天
|
||||
|
||||
打开应用中的“Agent”入口(页面标题为“Agent Hub”,对应微信机器人功能),扫码连接一个微信机器人账号。
|
||||
|
||||
例如,你可以直接给机器人发送:
|
||||
|
||||
- “最近 5 个会话”
|
||||
- “张三最近和我聊了什么”
|
||||
- “总结今天的技术交流群”
|
||||
|
||||
TraceMemo 会在本机读取已连接的聊天数据并把结果回复到微信。
|
||||
|
||||
这个入口不要求另外安装 Codex、Claude Code 等外部 Agent。
|
||||
|
||||
当前主要处理文字消息,不支持群发、定时任务或通用自主操作微信;总结和自然语言理解需要先配置 AI 服务。
|
||||
|
||||
详细步骤和能力边界见[在微信里向 TraceMemo 提问](./docs/agent/agent-hub.md)。
|
||||
|
||||
---
|
||||
|
||||
## 其他能力
|
||||
|
||||
### 本地知识库
|
||||
|
||||
<details>
|
||||
“问问微信”里的“本地知识库”会为当前微信账号建立一份留在本机的可检索资料。
|
||||
|
||||
它把聊天文本、附件信息和已有语音转写整理起来,让跨会话、跨时间查找更稳定。
|
||||
|
||||
它只在用户主动建立后工作,可以同步、查看占用并清理;清理不会删除微信原始数据库。
|
||||
|
||||
详细说明:[本地知识库](./docs/user-guide/knowledge.md)
|
||||
|
||||
</details>
|
||||
|
||||
### 实验性:生成微信分享卡片
|
||||
|
||||
<details>
|
||||
TraceMemo 可以把群聊日报长图上传到你自己部署的 Cloudflare Worker 和 R2,并生成可在微信中分享的临时网页、二维码及卡片信息。
|
||||
|
||||
该功能需要自备 Cloudflare 账号、域名和微信测试号,目前不属于开箱即用的稳定功能。
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/微信卡片分享.png" alt="微信卡片分享效果示例" />
|
||||
</p>
|
||||
|
||||
详细说明:[实验性微信分享卡片](./docs/deployment/experimental-wechat-share-card.md)。
|
||||
|
||||
不熟悉命令行的用户,可以把[自动部署 Skill](./docs/skill/setup-wechat-share-card/SKILL.md)直接交给 Codex 或 Claude Code。
|
||||
|
||||
</details>
|
||||
|
||||
### 转写微信语音
|
||||
|
||||
<details>
|
||||
TraceMemo 支持在本机转写单条或批量微信语音,结果可以参与本地知识库检索和 HTML 导出。
|
||||
|
||||
转写本身不要求把语音文件发送给在线 AI;随后用于 AI 问答或日报时,文字会按对应功能的规则处理。
|
||||
|
||||
详细说明:[语音转文字](./docs/user-guide/voice.md)
|
||||
|
||||
</details>
|
||||
|
||||
### 导出长期可用的聊天档案
|
||||
|
||||
<details>
|
||||
支持 HTML、CSV、JSON 和 Markdown。
|
||||
|
||||
HTML 可携带媒体、头像和可选语音转写,支持最多五个会话合并,也可以压缩为 ZIP;增量合并、媒体资源和 ZIP 只适用于 HTML,其他格式主要保留文本内容。
|
||||
|
||||
详细说明:[导出聊天](./docs/user-guide/export.md)
|
||||
|
||||
</details>
|
||||
|
||||
### 在外部 Agent 中查询微信历史
|
||||
|
||||
<details>
|
||||
通过 Reader Skill 和本机 Local HTTP API,Codex、Claude Code、OpenClaw 等外部 Agent 可以按需查询联系人、群聊和聊天记录。
|
||||
|
||||
这和微信机器人是两条不同路径:
|
||||
|
||||
- **微信机器人**:收到消息后在微信中回复。
|
||||
- **外部 Agent**:主动查询历史。
|
||||
|
||||
安装和技术说明请看[Agent 接入概览](./docs/agent/overview.md)与[Local HTTP API](./docs/agent/api.md)。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 它如何工作
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[本机微信数据] --> B[TraceMemo 读取与解析]
|
||||
B --> C[聊天档案]
|
||||
B --> D[本地知识库与搜索]
|
||||
D --> E[筛选相关聊天来源]
|
||||
E --> F[用户配置的 AI 模型]
|
||||
F --> G[带来源的回答]
|
||||
B --> H[整理日报输入]
|
||||
H --> F
|
||||
B --> I[聊天导出]
|
||||
B --> J[Local HTTP API]
|
||||
J --> K[外部 Agent]
|
||||
L[微信机器人消息] --> M[Agent Hub]
|
||||
M --> B
|
||||
M --> F
|
||||
```
|
||||
|
||||
- 微信数据库读取、聊天解析、知识库索引和离线语音识别在本机完成。
|
||||
- 普通浏览、普通搜索和导出不要求配置 AI 服务。
|
||||
- 使用“问问微信”、群聊日报或图片理解等 AI 功能时,完成任务所需的内容可能发送到你选择的模型服务;具体发送范围和确认方式以对应功能页面为准。
|
||||
- “问问微信”会先在本机缩小范围,不会默认把整个微信数据库作为一次模型请求发送。
|
||||
|
||||
完整边界见:[数据、隐私与安全](./docs/user-guide/privacy.md)
|
||||
|
||||
---
|
||||
|
||||
## 支持平台与安装包
|
||||
|
||||
| 平台 | 处理器架构 | Releases 安装包 |
|
||||
| ------- | ------------------------------ | --------------- |
|
||||
| Windows | x64 | `-setup.exe` |
|
||||
| macOS | Apple Silicon(M 系列、arm64) | `.dmg` |
|
||||
|
||||
当前版本不支持 Intel 芯片的 Mac。
|
||||
|
||||
当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
|
||||
|
||||
---
|
||||
| 想做什么 | 使用入口 |
|
||||
| ---------------------------------- | ----------------------------- |
|
||||
| 找记得原文或关键词的消息 | 档案搜索 |
|
||||
| 找记得大意、但不知道在哪聊过的内容 | AI Search / 问问微信 |
|
||||
| 长期跨群查询历史 | 本地知识库 |
|
||||
| 了解一个群今天或近 7 天聊了什么 | 群聊日报 |
|
||||
| 持续关注群成员退出 | 退群监控 |
|
||||
| 按计划生成并发送群聊日报 | 定时日报 |
|
||||
| 把文字生成微信语音 | 文字转语音 |
|
||||
| 在微信里向本机 TraceMemo 提问 | Agent Hub |
|
||||
| 让 Codex 等工具查询微信历史 | Reader Skill / Local HTTP API |
|
||||
| 把聊天保存成文件 | 导出 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载安装包。
|
||||
2. 启动 TraceMemo,按照“第一次使用”页面选择微信数据目录。
|
||||
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。
|
||||
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。
|
||||
5. 先在“档案”里搜索一句你记得的原话;这一步不需要 AI。
|
||||
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
|
||||
7. 想直接在微信里提问时,打开“Agent”扫码连接微信机器人;想让 Codex 等外部 Agent 查询时,再进入“API”。
|
||||
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载对应平台的安装包。
|
||||
2. 启动应用,按“第一次使用”页面选择微信数据目录并完成连接。
|
||||
3. 打开“档案”,确认联系人和消息已加载后开始搜索。
|
||||
4. 需要 AI 时,在“设置 → AI 模型”添加并测试 Provider。
|
||||
|
||||
Windows 安装后无法启动时,请先安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。
|
||||
|
||||
当前完整测试过的微信客户端为 Windows `4.1.9.57` 和 macOS `4.1.8.100`;下载地址与连接要求见[第一次使用](./docs/user-guide/getting-started.md)。
|
||||
|
||||
从 WechatExplorer v2.1.9 升级时,TraceMemo v2.2.0 会在首次启动检测旧设置、Knowledge、Token、AI Provider 和 Agent 数据,并在用户确认后复制到新的 TraceMemo 数据目录。
|
||||
|
||||
迁移不会覆盖已有 TraceMemo 数据,也不会删除旧目录;详情见 [v2.2.0 正式品牌身份与安全升级迁移](./docs/agent/release-notes-v2.2.0.md)。
|
||||
|
||||
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
|
||||
|
||||
完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)
|
||||
|
||||
---
|
||||
|
||||
## 配置 AI
|
||||
|
||||
需要 AI 问答、群聊日报或图片理解时,在“设置 → AI 模型”添加并测试一个服务。
|
||||
|
||||
应用支持云端服务、Ollama 等本地服务和自定义接口;具体服务商的配置、计费和数据规则由服务商决定。
|
||||
|
||||
使用本地服务可以减少数据离开电脑的路径,但本地服务的日志和配置仍由你自己负责。
|
||||
|
||||
开发者和 Agent 用户可以从[Agent 接入概览](./docs/agent/overview.md)开始,再按需要查看[Local HTTP API](./docs/agent/api.md)与[API 安全](./docs/agent/api-security.md)。
|
||||
|
||||
---
|
||||
详细步骤见[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)。
|
||||
|
||||
## 文档
|
||||
|
||||
- [文档首页](./docs/README.md)
|
||||
- [第一次使用](./docs/user-guide/getting-started.md)
|
||||
- [聊天档案与搜索](./docs/user-guide/chat-archive.md)
|
||||
- [AI 查找聊天信息](./docs/user-guide/ai-search.md)
|
||||
- [本地知识库](./docs/user-guide/knowledge.md)
|
||||
- [群聊日报](./docs/user-guide/report.md)
|
||||
- [实验性微信分享卡片](./docs/deployment/experimental-wechat-share-card.md)
|
||||
- [微信分享卡片自动部署 Skill](./docs/skill/setup-wechat-share-card/SKILL.md)
|
||||
- [语音转文字](./docs/user-guide/voice.md)
|
||||
- [导出聊天](./docs/user-guide/export.md)
|
||||
- [防撤回](./docs/user-guide/recall-protection.md)
|
||||
- [数据、隐私与安全](./docs/user-guide/privacy.md)
|
||||
- [Agent 接入](./docs/agent/overview.md)
|
||||
- [微信机器人与 Agent Hub](./docs/agent/agent-hub.md)
|
||||
- [Local HTTP API](./docs/agent/api.md)
|
||||
- [开发与测试](./docs/development/overview.md)
|
||||
- [用户指南](./docs/README.md#用户指南)
|
||||
- [AI / Knowledge](./docs/README.md#ai-与知识库)
|
||||
- [Monitor / Automation](./docs/README.md#日报与自动化)
|
||||
- [Agent / API](./docs/README.md#agent--api)
|
||||
- [开发文档](./docs/development/overview.md)
|
||||
- [隐私与安全](./docs/user-guide/privacy.md)
|
||||
|
||||
---
|
||||
完整目录由[文档首页](./docs/README.md)维护。
|
||||
|
||||
## 本地开发
|
||||
## 支持平台
|
||||
|
||||
需要 Node.js、pnpm 7+、对应平台的 Electron/native 构建环境,以及 Go(用于微信连接器)。
|
||||
| 平台 | 架构 | 微信连接 | 安装包 |
|
||||
| ------- | ------------------------------ | ------------------------------------- | ------------------------------- |
|
||||
| Windows | x64 | 支持微信 4.x | `tracememo-<version>-setup.exe` |
|
||||
| macOS | Apple Silicon(M 系列、arm64) | 自动获取数据库 Key,已适配微信 4.1.13 | `tracememo-<version>-arm64.dmg` |
|
||||
| macOS | Intel(x64) | 自动获取数据库 Key,已适配微信 4.1.13 | `tracememo-<version>-x64.dmg` |
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
## 参与贡献
|
||||
|
||||
常用检查:
|
||||
稳定版在 `main`,只在发版时更新;所有改动都先进 `develop`,随**下一个版本**一起发布。
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test:unit
|
||||
pnpm test:component
|
||||
pnpm test:integration
|
||||
pnpm test:e2e:build
|
||||
```
|
||||
**提 PR 请基于 `develop` 拉新分支,并把 PR 的目标分支设为 `develop`** —— 指向 `main` 的 PR 会被直接关闭。
|
||||
|
||||
完整说明:[开发、测试与构建](./docs/development/overview.md)
|
||||
|
||||
---
|
||||
|
||||
## 支持与反馈
|
||||
|
||||
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。
|
||||
|
||||
提交 Issue 时请提供:
|
||||
|
||||
- 操作系统
|
||||
- 微信版本
|
||||
- TraceMemo 版本
|
||||
- 复现步骤
|
||||
- 已遮挡敏感信息的截图
|
||||
|
||||
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。
|
||||
|
||||
数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
|
||||
|
||||
---
|
||||
|
||||
## 许可说明
|
||||
|
||||
TraceMemo 当前暂未提供独立的项目 `LICENSE` 文件。
|
||||
|
||||
TraceMemo 允许个人使用、学习、修改、二次开发和 Fork,也欢迎基于项目进行非商业用途的再开发和分享。
|
||||
|
||||
**但未经项目维护者书面许可,禁止将 TraceMemo 本身或基于 TraceMemo 的衍生版本用于商业用途,包括但不限于商业软件、付费服务、商业产品、SaaS 服务或其他直接或间接的商业活动。**
|
||||
|
||||
仓库中的第三方组件以及参考项目均遵循各自适用的许可证和使用条款。TraceMemo 对第三方项目的参考、使用或集成,并不意味着这些第三方项目的代码或许可证发生变化。涉及第三方代码的部分,请以对应项目的许可证和授权范围为准。
|
||||
|
||||
---
|
||||
分支流程、提交信息风格、PR 前自检,以及**给 AI Agent 的硬性规则**,都在[参与贡献指南](./CONTRIBUTING.md)。
|
||||
|
||||
## 致谢
|
||||
|
||||
@@ -423,18 +190,21 @@ TraceMemo 的诞生离不开开源社区中许多优秀项目的工作。
|
||||
|
||||
### 特别感谢 WeFlow
|
||||
|
||||
TraceMemo 在支持微信 4.x 时,参考并使用了 **[WeFlow](https://github.com/hicccc77/WeFlow)** 历史版本中的相关实现和思路,包括数据库密钥获取、图片解密等底层能力。
|
||||
TraceMemo 在早期适配微信 4.x 时,曾参考 **[WeFlow](https://github.com/hicccc77/WeFlow)** 历史版本中的相关实现和思路,包括数据库访问、密钥获取等底层能力。
|
||||
|
||||
特别感谢作者 **hicccc77** 的理解和包容。项目与 WeFlow 的具体关系见[项目缘起](#项目缘起)。
|
||||
特别感谢作者 **[hicccc77](https://github.com/hicccc77)**。项目与 WeFlow 的具体关系见[项目缘起](#项目缘起)。
|
||||
|
||||
### 其他参考项目
|
||||
|
||||
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
|
||||
- 提供了数据库解析相关思路。
|
||||
- 提供了数据解析相关思路。
|
||||
|
||||
- **[chatlog](https://github.com/sjzar/chatlog)**
|
||||
- 提供了数据处理方面的参考。
|
||||
|
||||
- **[wechat_chatter](https://github.com/yincongcyincong/wechat_chatter)**
|
||||
- 提供了发送方面的参考。
|
||||
|
||||
感谢所有开源作者,也感谢所有帮助 TraceMemo 发现问题、提出建议和持续使用它的人。
|
||||
|
||||
---
|
||||
|
||||
+48
-42
@@ -1,64 +1,70 @@
|
||||
# TraceMemo 文档
|
||||
|
||||
TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块组织。
|
||||
文档按产品任务和使用场景组织。需要集成或开发时,再看 Agent、概念和开发文档。
|
||||
|
||||
## 从这里开始
|
||||
## 档案与搜索
|
||||
|
||||
- [第一次使用](./user-guide/getting-started.md):安装、连接微信、完成第一次搜索和提问。
|
||||
- [查看和搜索聊天](./user-guide/chat-archive.md):找原话、回看上下文、处理媒体。
|
||||
- [用 AI 查找聊天信息](./user-guide/ai-search.md):理解普通搜索和 AI Search 的区别,并核对答案来源。
|
||||
- [第一次使用](./user-guide/getting-started.md):安装、连接微信并完成第一次搜索。
|
||||
- [Intel Mac 获取微信密钥](./user-guide/intel-mac-key.md):按页面检查结果准备环境并获取密钥。
|
||||
- [聊天档案与搜索](./user-guide/chat-archive.md):浏览联系人和群聊,按关键词、备注、昵称、微信号或 wxid 查找消息;也包含档案中的文字转语音入口。
|
||||
|
||||
## 你可以完成的任务
|
||||
## AI 与知识库
|
||||
|
||||
- [建立本地知识库](./user-guide/knowledge.md)
|
||||
- [生成群聊日报和总结](./user-guide/report.md)
|
||||
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
||||
- [交给 Agent 自动部署微信分享卡片](./skill/setup-wechat-share-card/SKILL.md)
|
||||
- [语音转文字](./user-guide/voice.md)
|
||||
- [导出聊天档案](./user-guide/export.md)
|
||||
- [防撤回](./user-guide/recall-protection.md)
|
||||
- [在微信里向 TraceMemo 提问](./agent/agent-hub.md)
|
||||
- [数据、隐私与安全](./user-guide/privacy.md)
|
||||
- [常见问题与排查](./user-guide/troubleshooting.md)
|
||||
- [AI Search / 问问微信](./user-guide/ai-search.md):用自然语言找回记得大意、但不知道在哪个会话里的内容,并查看 Evidence、Citation 和 Search Trace。
|
||||
- [本地知识库](./user-guide/knowledge.md):主动建立本地索引,提升跨会话、跨时间查询的稳定性。
|
||||
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):从来源回到原始消息,检查上下文和覆盖范围。
|
||||
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些 AI 功能可能调用 Provider。
|
||||
|
||||
## 如果你想了解 AI 为什么这样回答
|
||||
## 日报与自动化
|
||||
|
||||
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):用用户语言解释依据、来源标记和查找过程。
|
||||
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些步骤可能调用 Provider。
|
||||
- [群聊日报](./user-guide/report.md):手动生成今日、昨日或近 7 天的群聊报告,也可以创建定时日报。
|
||||
- 定时日报会依次生成报告、保存 Report History,再按当前微信发送能力尝试通知;发送失败时可复用已有 PNG 重试。
|
||||
- 自动发送和监控动作通过统一执行边界,并保留执行记录;简要说明见[产品工作方式](./concepts/how-it-works.md#动作执行与审计)。
|
||||
|
||||
## 微信机器人和外部 Agent
|
||||
## Monitor
|
||||
|
||||
TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以直接使用的产品能力;Reader Skill 和 Local HTTP API 面向已经在使用 Codex、Claude Code、OpenClaw 等外部 Agent 的用户。
|
||||
退群监控会比较当前成员与上一份有效快照,记录成员退出事件。它支持多群、Last Good Snapshot 和事件历史;监控关闭期间的变化不会在重新开启后补报。工作方式见[产品工作方式](./concepts/how-it-works.md#退群监控)。
|
||||
|
||||
| 你想做什么 | 应该看哪里 |
|
||||
| --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| 在微信里给机器人发消息,让本机读取数据、生成总结并回复 | [Agent Hub](./agent/agent-hub.md) |
|
||||
| 在 Codex、Claude Code、OpenClaw 等外部 Agent 中主动查询过去的微信数据 | [Reader Skill](./agent/reader-skill.md) + [Local HTTP API](./agent/api.md) |
|
||||
## 语音能力
|
||||
|
||||
### 在微信里提问
|
||||
- [语音转文字](./user-guide/voice.md):在本机转写微信语音,结果可用于搜索、Knowledge 和导出。
|
||||
- [聊天档案与搜索](./user-guide/chat-archive.md#文字转语音):把文字生成微信语音,试听后发送到当前联系人或群聊。
|
||||
|
||||
打开应用一级导航中的“Agent”,进入“Agent Hub”后扫码登录微信机器人。机器人收到文字消息后,可以查询最近会话、读取联系人聊天、生成群聊总结图片或总结群成员发言,并把结果回复给发消息的人。它需要本地微信数据库已经连接;依赖 AI 的任务还需要配置 AI 服务。
|
||||
## Agent / API
|
||||
|
||||
- [Agent Hub](./agent/agent-hub.md):连接机器人、查看运行状态和了解实时交互边界。
|
||||
Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP API 让外部 Agent 主动查询历史数据。
|
||||
|
||||
### 让外部 Agent 查询历史微信
|
||||
- [Agent 接入概览](./agent/overview.md)
|
||||
- [Agent Hub](./agent/agent-hub.md)
|
||||
- [Reader Skill](./agent/reader-skill.md)
|
||||
- [Local HTTP API](./agent/api.md)
|
||||
- [API 安全](./agent/api-security.md)
|
||||
|
||||
连接 Reader Skill 后,你可以询问:
|
||||
## 导出与隐私
|
||||
|
||||
> “总结今天技术交流群讨论了什么。”
|
||||
> “过去一周有没有人提到这个项目?”
|
||||
- [导出聊天](./user-guide/export.md):导出 HTML、Markdown、CSV 或 JSON 档案。
|
||||
- [数据、隐私与安全](./user-guide/privacy.md):本地处理、Provider、媒体和 Token 的数据边界。
|
||||
- [常见问题与排查](./user-guide/troubleshooting.md):按安装、连接、AI、媒体和 Agent 现象排查。
|
||||
|
||||
- [Agent 接入概览](./agent/overview.md):先选择适合你的接入方式。
|
||||
- [Reader Skill](./agent/reader-skill.md):安装并让外部 Agent 按需读取聊天。
|
||||
- [Local HTTP API](./agent/api.md):完整端点和请求示例。
|
||||
- [API 安全](./agent/api-security.md):Bearer Token、CORS、轮换和边界。
|
||||
## 开发文档
|
||||
|
||||
## 开发与平台
|
||||
|
||||
- [macOS 数据访问说明](./platform/macos.md)
|
||||
- [开发、测试与构建](./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)
|
||||
- [v2.2.0 正式品牌身份与安全升级迁移](./agent/release-notes-v2.2.0.md)
|
||||
- [v2.1.9 API 鉴权迁移说明](./agent/release-notes-v2.1.9.md)
|
||||
- [macOS 数据访问说明](./platform/macos.md)
|
||||
- [关闭 SIP 教程](./mac-disable-sip.md)
|
||||
|
||||
当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
||||
## 实验性功能与第三方
|
||||
|
||||
- [实验性:自托管微信分享卡片](./deployment/experimental-wechat-share-card.md)
|
||||
- [微信分享卡片自动部署 Skill](./skill/setup-wechat-share-card/SKILL.md)
|
||||
- [TraceMemo Reader Skill 文件](./skill/tracememo-reader/SKILL.md)
|
||||
|
||||
## 版本说明
|
||||
|
||||
- [v2.2.0 品牌与安全迁移](./agent/release-notes-v2.2.0.md)
|
||||
- [v2.1.9 API 鉴权迁移](./agent/release-notes-v2.1.9.md)
|
||||
|
||||
文档按当前 develop 已实现的能力维护,不在首页固定写死版本号。版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
||||
|
||||
+88
-4
@@ -36,7 +36,7 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
|
||||
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
||||
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
||||
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
||||
| GET | `/api/v1/media/{messageId}` | 获取图片消息的二进制资源 | 使用 `/chatlog` 返回的图片消息 `id` |
|
||||
| GET | `/api/v1/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 |
|
||||
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
||||
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
||||
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
||||
@@ -85,9 +85,9 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
||||
- `200`:请求成功;
|
||||
- `401`:缺少、错误或已失效的 Bearer Token;
|
||||
- `400`:参数或 JSON 请求体无效;
|
||||
- `422`:媒体 `messageId` 无效,或目标消息不是可读取的图片;
|
||||
- `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`);
|
||||
- `403`:浏览器 Origin 不在允许的 loopback 列表;
|
||||
- `404`:端点、会话或群聊不存在;
|
||||
- `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在;
|
||||
- `503`:数据库或 Agent Hub 尚未就绪;
|
||||
- `500`:服务端处理或报告渲染失败。
|
||||
|
||||
@@ -102,13 +102,97 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
||||
"media": {
|
||||
"type": "image",
|
||||
"available": true,
|
||||
"url": "/api/v1/media/msg_xxx"
|
||||
"url": "/api/v1/media/image%3A0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。
|
||||
|
||||
`media.url` 包含当前数据库连接内的独立媒体标识,不等同于消息 `id`。不同会话的消息 `id` 可能重复,调用方应原样使用返回的地址,不自行拼接或解析。重启、重连或切换账号后须重新读取 `/chatlog` 获取新地址;旧的纯消息 ID 地址仅在无歧义时兼容。`available` 只表示消息带有图片定位信息,不保证本地图片文件仍存在或可以解密。
|
||||
|
||||
## 与 MCP 的关系
|
||||
|
||||
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。
|
||||
|
||||
## LLM-friendly Query Tool API
|
||||
|
||||
这些端点提供稳定的结构化 Query primitive,不接收自然语言问题,也不会调用 AI。它们与现有 API 共用端口、Bearer Token、loopback 和 CORS 安全策略。
|
||||
|
||||
```bash
|
||||
BASE="http://127.0.0.1:6131/api/v1"
|
||||
AUTH="Authorization: Bearer ${TRACEMEMO_API_TOKEN:-$WECHATEXPLORER_API_TOKEN}"
|
||||
|
||||
# 能力目录
|
||||
curl -H "$AUTH" "$BASE/query/capabilities"
|
||||
|
||||
# BOBO 的第一条真实互动
|
||||
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/messages" \
|
||||
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"all"},"direction":"any","order":"asc","limit":1,"excludeSystem":true}'
|
||||
|
||||
# 上个月 BOBO 发来的文件
|
||||
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/messages" \
|
||||
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"previous_month"},"direction":"from_target","messageTypes":["file"],"order":"desc","limit":1}'
|
||||
|
||||
# 受限语义关键词检索(最多 4 个 variants)
|
||||
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/search" \
|
||||
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"all"},"query":"答应之后给我或者帮我完成某件事情","variants":["我给你","我发你","弄好给你"],"limit":20}'
|
||||
|
||||
# 按会话和时间范围提取可供总结的证据
|
||||
curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/conversation-overview" \
|
||||
-d '{"target":{"query":"BOBO"},"timeRange":{"kind":"previous_month"}}'
|
||||
```
|
||||
|
||||
`query/messages` 的 `messageRef` 是服务端生成的不透明引用,可直接传给 `query/message-context` 获取前后文;不要自行构造 wxid、md5 或数据库路径。
|
||||
|
||||
每条消息都会返回 `messageType`(`text`、`image`、`voice`、`video`、`file`、`link`、`sticker`、`system` 或 `other`)。非文本消息不会伪造 `text`;可识别的图片、视频、贴纸和文件会返回不含密钥或本地路径的 `attachment` 元数据。
|
||||
|
||||
`conversation-overview` 同时返回 `sourceCoverage` 与 `selection`:前者描述时间范围内源消息是否完整及 `sourceMessageCount`,后者描述从源消息中选出的 Evidence 数量及是否抽样。`evidence` 最终按 `timestamp` 升序返回,`messageRef` 是唯一推荐的消息引用。
|
||||
`conversation-overview` 另有一个 `origin` 字段:`wcdb` 表示这次证据直接来自本机聊天数据库(会话概览的事实来源),`knowledge` 表示来自本地索引。
|
||||
|
||||
### 搜索范围(scope)
|
||||
|
||||
`query/messages`、`query/search`、`query/message-context` 和 `query/conversation-overview` 都接受一个可选的 `scope`,用来把检索限制在一个确定的语料边界内:
|
||||
|
||||
| scope | 含义 |
|
||||
| ----- | ---- |
|
||||
| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) |
|
||||
| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) |
|
||||
| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 |
|
||||
| `{"kind":"current","conversationId":"…"}` | 只搜指定的那个会话(单聊或群聊) |
|
||||
|
||||
`conversationId` 是会话标识,可用 `/api/v1/resolve` 或 `/api/v1/contact` 得到。`scope` 一旦给出就是**权威边界**:`target` 落在范围之外会被拒绝(`status: "invalid_tool_arguments"`、`constraint: "target_outside_scope"`),不会静默扩大范围;范围里包含多个会话时,`query/messages` 与 `query/conversation-overview` 必须显式指定 `target`(`constraint: "target_required_for_scope"`)。
|
||||
|
||||
响应会回显实际生效的边界:
|
||||
|
||||
```json
|
||||
{ "scope": { "kind": "groups", "conversationCount": 243 } }
|
||||
```
|
||||
|
||||
跨会话检索时,`evidence` 的每一项都会带上它所属的会话,便于把结果归属到具体群 / 联系人与具体成员:
|
||||
|
||||
```json
|
||||
{
|
||||
"messageRef": "…",
|
||||
"conversationName": "某个群",
|
||||
"conversationType": "group",
|
||||
"sender": "某成员",
|
||||
"timestamp": 1789099069000,
|
||||
"text": "…"
|
||||
}
|
||||
```
|
||||
|
||||
### 索引新鲜度(freshness)
|
||||
|
||||
`query/search` 依赖本地索引,而本地索引是异步建立的派生数据,可能落后于聊天数据库。因此它的响应会显式给出覆盖口径:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| ---- | ---- |
|
||||
| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 |
|
||||
| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 |
|
||||
| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 |
|
||||
| `freshness.catchUp` | 本次为追赶索引做了什么:`none` / `reused` / `completed` / `pending` |
|
||||
|
||||
调用方**必须**把 `coverage` 当真:`coverage.state` 不是 `complete` 且 `evidence` 为空时,只能说明"这段范围暂时无法确认",**不能**下"没有找到"的结论。索引落后时服务端会自动请求一次追赶同步,但不会让请求无限等待;`freshness.catchUp` 为 `pending` 表示追赶仍在后台进行,稍后重试即可拿到更新的覆盖。
|
||||
|
||||
`query/messages` 与 `query/conversation-overview` 直读聊天数据库,不受索引新鲜度影响。
|
||||
|
||||
@@ -28,12 +28,12 @@ AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内
|
||||
|
||||
来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如:
|
||||
|
||||
- Knowledge 正在同步时,新的分析会被暂停;
|
||||
- Knowledge 还没追到最新时,跨会话检索只覆盖到索引当前的时间点,答案会标注这个范围;
|
||||
- 语音没有转写时,AI 可能只能看到消息类型;
|
||||
- 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容;
|
||||
- 你只选择了一个群,答案不会自动代表所有聊天。
|
||||
|
||||
看到“可能遗漏”或“部分覆盖”时,扩大范围、先完成同步或检查原始媒体后再问。
|
||||
Knowledge 在后台同步时**不会**暂停分析:你仍然可以提问,只是答案基于当前已可用的覆盖范围。看到“可能遗漏”或“部分覆盖”时,扩大范围、等同步追上或检查原始媒体后再问。
|
||||
|
||||
## 这不是事实保证
|
||||
|
||||
|
||||
@@ -19,8 +19,45 @@ flowchart LR
|
||||
M[微信机器人消息] --> N[Agent Hub]
|
||||
N --> B
|
||||
N --> F
|
||||
B --> O[Monitor / Snapshot]
|
||||
O --> P[Proposed Action]
|
||||
F --> P
|
||||
P --> Q[Policy]
|
||||
Q --> R[Action Gateway]
|
||||
R --> S[Personal WeChat Send Capability]
|
||||
S --> T[Action Audit / Logs]
|
||||
```
|
||||
|
||||
## Remember → Understand → Monitor → Act
|
||||
|
||||
TraceMemo 的工作方式可以概括为:
|
||||
|
||||
```text
|
||||
Remember → Understand → Monitor → Act
|
||||
```
|
||||
|
||||
先读取和整理微信信息,再由 AI、Knowledge 或日报帮助理解;Monitor 负责发现成员变化,明确的业务动作再进入执行边界。回答和动作结果都应能回到来源或记录核对。
|
||||
|
||||
## 退群监控
|
||||
|
||||
退群监控使用成员快照判断变化:
|
||||
|
||||
```text
|
||||
Current Membership → Snapshot Diff → Member Event
|
||||
```
|
||||
|
||||
上一份有效快照(Last Good Snapshot)不会被不完整读取覆盖,因此重启后仍可继续监控通知。
|
||||
|
||||
## 动作执行与审计
|
||||
|
||||
自动发送和监控动作经过统一边界:
|
||||
|
||||
```text
|
||||
Feature → Policy → Gateway → Capability → Execution → Audit
|
||||
```
|
||||
|
||||
Policy blocked 表示策略不允许,Capability unavailable 表示当前发送能力不可用,Send failed 表示已经尝试但执行失败。Action Audit / Logs 会保留执行结果;定时日报即使发送失败,也会保留已生成的报告记录。
|
||||
|
||||
## 哪些步骤在本机
|
||||
|
||||
- 微信数据库读取与解析;
|
||||
|
||||
@@ -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,41 +21,28 @@ 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 二进制缺失或下载未完成。这不是应用业务代码的启动错误。
|
||||
|
||||
项目的 [`.npmrc`](../../.npmrc) 已设置:
|
||||
**先看根因,别急着删 `node_modules` 重装。** `electron@43` 的 npm 包**不再声明 `postinstall`**(其 `package.json` 里 `scripts` 是空对象),下载改为「首次 `require('electron')` 时的懒加载」。因此:
|
||||
|
||||
```ini
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
```
|
||||
- `package.json` 里的 `pnpm.onlyBuiltDependencies: ["electron"]` 对它不起作用——上游没有脚本可执行,pnpm 无从下手;
|
||||
- `pnpm install` 跑完不会有任何二进制被下载,**只重装依赖解决不了这个问题**。
|
||||
|
||||
pnpm 会把该值传给 Electron 安装器,令其从镜像下载与 `package.json` 锁定版本匹配的二进制文件,避免默认 GitHub 下载源在受限网络中不可访问。
|
||||
项目已自动兜住这条路径:`scripts/ensure-electron-binary.cjs` 挂在 `postinstall` 与 `predev` 上,校验 `path.txt` 指向的可执行文件是否真的存在(只有 `path.txt` 而没有 `dist/` 同样算没装好),缺失时就地补下载。它读取 `.npmrc` 的 `electron_mirror`(当前为 `https://npmmirror.com/mirrors/electron/`),失败后再兜底重试一次该镜像。
|
||||
|
||||
依赖安装被中断或 Electron 目录不完整时,删除不完整的 `node_modules` 后重新安装:
|
||||
正常情况下你不需要做任何事。只有当自动步骤没有执行时(例如安装时带了 `--ignore-scripts`),才需要手动补一次:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
node scripts/ensure-electron-binary.cjs
|
||||
```
|
||||
|
||||
单次安装需要使用其他镜像时,可以临时覆盖项目默认值。PowerShell 示例:
|
||||
要换用别的镜像时,显式设置环境变量(优先于 `.npmrc`)。PowerShell 示例:
|
||||
|
||||
```powershell
|
||||
$env:ELECTRON_MIRROR = 'https://your-electron-mirror.example/'
|
||||
pnpm install --frozen-lockfile
|
||||
node scripts/ensure-electron-binary.cjs
|
||||
```
|
||||
|
||||
该环境变量只影响当前终端,不会改写仓库中的 `.npmrc`。镜像地址必须保留末尾的 `/`,并提供与 Electron 版本对应的目录结构。
|
||||
@@ -69,4 +55,4 @@ Vite 在某些 Windows 环境中只监听 IPv6 本机回环地址 `::1`。这时
|
||||
|
||||
## 仍无法启动时
|
||||
|
||||
保留首次错误的完整输出,并同时记录操作系统、Node.js、pnpm 和 Go 版本,以及 `pnpm install --frozen-lockfile` 与 `pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。
|
||||
保留首次错误的完整输出,并同时记录操作系统、Node.js 与 pnpm 版本,以及 `pnpm install --frozen-lockfile` 与 `pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
- Electron + React + TypeScript;
|
||||
- pnpm 7+;
|
||||
- Go(构建微信连接器);
|
||||
- 平台对应的 Electron/native 构建环境。
|
||||
|
||||
产品文档的事实来源优先级是:当前源码 → 当前 UI/Renderer → 测试 → package/config → README/docs → 历史资料。功能、API、版本、隐私和兼容性变更时,不要只改 README。
|
||||
@@ -18,7 +17,7 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
|
||||
本地依赖安装与 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
|
||||
|
||||
常用检查:
|
||||
|
||||
@@ -30,21 +29,21 @@ pnpm test:integration
|
||||
pnpm test:e2e:build
|
||||
```
|
||||
|
||||
完整测试入口 `pnpm test` 还会运行 Skill 安装指令、微信连接器、构建和 Playwright 测试;需要对应平台环境。
|
||||
完整测试入口 `pnpm test` 还会运行 Skill 安装指令、构建和 Playwright 测试;需要对应平台环境。
|
||||
|
||||
## 代码变更对应文档
|
||||
|
||||
| 代码区域 | 需要同步检查的文档 |
|
||||
| --------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `src/shared/ai-search.ts`、AI Search pipeline | `user-guide/ai-search.md`、`concepts/answer-sources.md` |
|
||||
| `src/shared/knowledge.ts`、`src/main/knowledge/` | `user-guide/knowledge.md`、`concepts/how-it-works.md` |
|
||||
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
|
||||
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
|
||||
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
|
||||
| `src/main/services/recall-archive-service.ts`、防撤回设置 | `user-guide/recall-protection.md`、`user-guide/privacy.md` |
|
||||
| `src/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
|
||||
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
|
||||
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
|
||||
| 代码区域 | 需要同步检查的文档 |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| `src/shared/ai-search.ts`、AI Search pipeline | `user-guide/ai-search.md`、`concepts/answer-sources.md` |
|
||||
| `src/shared/knowledge.ts`、`src/main/knowledge/` | `user-guide/knowledge.md`、`concepts/how-it-works.md` |
|
||||
| `src/shared/voice-recognition.ts` | `user-guide/voice.md` |
|
||||
| `src/shared/group-report.ts`、报告 UI | `user-guide/report.md`、API/Agent 文档 |
|
||||
| `src/shared/export.ts`、导出服务/UI | `user-guide/export.md` |
|
||||
| `src/main/services/recall-archive-service.ts` | `user-guide/privacy.md` |
|
||||
| `src/shared/local-api-test.ts`、`src/main/http-server.ts` | `agent/api.md`、`api-security.md`、打包 Skill |
|
||||
| Agent Hub service/UI | `agent/agent-hub.md`、`user-guide/privacy.md` |
|
||||
| 设置导航、连接页面 | `user-guide/getting-started.md`、`docs/README.md` |
|
||||
|
||||
## 文档检查
|
||||
|
||||
@@ -52,7 +51,7 @@ pnpm test:e2e:build
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!DOCUMENTATION_AUDIT.md' --glob '!development/overview.md'
|
||||
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!development/overview.md'
|
||||
```
|
||||
|
||||
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Query Agent POC
|
||||
|
||||
这是独立的开发测试入口,不会修改生产“问问微信”执行链。
|
||||
|
||||
先启动 TraceMemo,并在 API Center 开启 Local HTTP API。然后在仓库根目录运行:
|
||||
|
||||
```bash
|
||||
pnpm poc:query-agent "我和BOBO第一次聊了什么"
|
||||
```
|
||||
|
||||
## 两个入口
|
||||
|
||||
| 命令 | 行为 | 何时用 |
|
||||
| --- | --- | --- |
|
||||
| `pnpm poc:query-agent "问题"` | 先执行完整构建,再运行 | 首次运行,或刚改过代码 |
|
||||
| `pnpm poc:query-agent:run "问题"` | 直接运行已有构建,**不构建** | 连续迭代测试 |
|
||||
|
||||
`poc:query-agent:run` 在构建产物不存在时会明确提示先运行 `pnpm poc:query-agent`,**不会自动构建**。
|
||||
|
||||
注意:`poc:query-agent` 内部走的是完整 `electron-vite build`(main + preload + renderer),
|
||||
即使 POC 只需要一个 main entry。连续测试请使用 `poc:query-agent:run` 以免每次都重建整个 renderer。
|
||||
|
||||
## 传参
|
||||
|
||||
参数按原样转发给入口,可以被 `--` 分隔(`pnpm run` 惯例):
|
||||
|
||||
```bash
|
||||
pnpm poc:query-agent -- "BOBO上个月有没有给我发过文件"
|
||||
pnpm poc:query-agent:run "BOBO上个月有没有给我发过文件"
|
||||
```
|
||||
|
||||
入口只会移除参数列表**开头**的一个独立 `--`;问题正文中的 `--` 会原样保留。
|
||||
|
||||
## Provider
|
||||
|
||||
POC 使用设置页当前默认 AI Provider、模型、Base URL 和安全存储中的 API Key。Local Query API 仍使用现有 Bearer Token;POC 输出不会打印 Token、API Key、数据库路径或内部消息 ID。
|
||||
|
||||
## 输出
|
||||
|
||||
**stdout 是 JSON**(`poc:query-agent` 会在它前面混入构建日志,`poc:query-agent:run` 只多两行 pnpm 横幅)。
|
||||
需要机器解析时用 `--silent` 拿到纯 JSON:
|
||||
|
||||
```bash
|
||||
pnpm --silent poc:query-agent:run "我和BOBO第一次聊了什么" > result.json
|
||||
```
|
||||
|
||||
JSON 字段:
|
||||
|
||||
- `question`、`provider`、`model`
|
||||
- `modelCallCount`、`toolCallCount`
|
||||
- `modelDurationsMs`(每次模型调用耗时,含失败的那次)
|
||||
- `modelDiagnostics`(每次模型调用的请求级诊断:HTTP status、content-type、是否返回 HTML、是否超时、耗时)
|
||||
- `firstModelMs`、`toolTotalMs`、`finalModelMs`、`totalMs`
|
||||
- 每次工具调用的名称、脱敏参数、耗时、状态和结果数量
|
||||
- 最终 `answer` 或错误信息
|
||||
|
||||
**stderr 是人类可读摘要**(不参与 JSON 解析):
|
||||
|
||||
```text
|
||||
[Timing]
|
||||
Model #1 1315 ms
|
||||
TM Tools(1) 623 ms
|
||||
Model #2 1598 ms
|
||||
------------------------
|
||||
Total 3545 ms
|
||||
Model total 2913 ms (82.2%)
|
||||
TM tool total 623 ms (17.6%)
|
||||
|
||||
[Provider]
|
||||
provider DeepSeek
|
||||
model DeepSeek Chat
|
||||
host api.deepseek.com
|
||||
model calls 2
|
||||
tool calls 1
|
||||
attempt #1 elapsedMs=1298 status=200 contentType=application/json
|
||||
attempt #2 elapsedMs=1571 status=200 contentType=application/json
|
||||
```
|
||||
|
||||
`elapsedMs` 是 TTFB(收到响应头),`modelDurationsMs` 是整次调用(含读 body);502 时两者接近,
|
||||
说明等待发生在上游网关,不是本地读 body 慢。诊断只记录 host,不记录完整 URL 或任何凭据。非 2xx 响应会先记录 status / content-type / elapsedMs,再返回安全错误(例如“模型服务返回了网页而不是 JSON(HTTP 502 Bad Gateway)”),不会把 HTML 正文丢给 JSON 解析器。
|
||||
|
||||
## 约束
|
||||
|
||||
工具调用最多 5 次,只允许 `query_messages`、`search_messages`、`message_context`、`conversation_overview`。未配置 AI Provider、Local Query API 未启动或当前 Provider 协议不支持 tools 时,POC 会直接返回错误,不会回退到另一套模型配置。
|
||||
@@ -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
|
||||
```
|
||||
|
||||
然后重启电脑,恢复系统安全设置。
|
||||
|
||||
+13
-2
@@ -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#连接微信失败)处理。
|
||||
@@ -23,5 +23,16 @@ TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信
|
||||
|
||||
## Intel 与 Apple Silicon
|
||||
|
||||
从 Releases 选择与 Mac 处理器匹配的构建。不同架构、微信版本和系统授权状态可能导致连接结果不同;文档不对所有组合做兼容性保证。
|
||||
TraceMemo 同时支持两种 Mac 架构:
|
||||
|
||||
- **Apple Silicon(M 系列、`arm64`)**
|
||||
- **Intel Mac(`x64`)**
|
||||
|
||||
从 Releases 下载与你 Mac 处理器匹配的构建:
|
||||
|
||||
- Apple Silicon:`tracememo-<版本号>-arm64.dmg`
|
||||
- Intel:`tracememo-<版本号>-x64.dmg`
|
||||
|
||||
两种架构都可以通过应用内的连接流程自动获取微信数据库密钥。首次连接时,TraceMemo 会根据当前机器架构进入对应的流程,按连接页面提示操作即可。
|
||||
|
||||
Apple Silicon 和 Intel 已适配微信 macOS `4.1.13`。不同架构、微信版本和系统授权状态可能导致连接结果不同;文档不对所有组合做兼容性保证。
|
||||
|
||||
@@ -27,21 +27,70 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
|
||||
## 端点速查
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| ---- | --------------------- | ------------------------------------------------- |
|
||||
| GET | `/health` | 健康和数据库状态 |
|
||||
| GET | `/current_time` | 本机时间与时区 |
|
||||
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
|
||||
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
|
||||
| GET | `/recent_chat` | 最近会话;可传 `limit` |
|
||||
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
|
||||
| GET | `/media/{messageId}` | 获取图片消息的真实图片二进制资源 |
|
||||
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
|
||||
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
|
||||
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
|
||||
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
|
||||
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
|
||||
| POST | `/agent/send` | 已连接机器人发送测试 |
|
||||
| 方法 | 路径 | 用途 |
|
||||
| ------ | ----------------------------------- | ------------------------------------------------- |
|
||||
| GET | `/health` | 健康和数据库状态 |
|
||||
| GET | `/current_time` | 本机时间与时区 |
|
||||
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
|
||||
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
|
||||
| GET | `/recent_chat` | 最近会话;可传 `limit` |
|
||||
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
|
||||
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
|
||||
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
|
||||
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
|
||||
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
|
||||
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
|
||||
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
|
||||
| POST | `/scheduled-reports` | 创建定时日报任务 |
|
||||
| PATCH | `/scheduled-reports/:id` | 修改定时日报任务 |
|
||||
| DELETE | `/scheduled-reports/:id` | 删除定时日报任务(执行前必须获得用户确认) |
|
||||
| POST | `/scheduled-reports/:id/enable` | 启用定时日报任务 |
|
||||
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
|
||||
| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution |
|
||||
| GET | `/scheduled-reports/:id/executions` | 查询执行记录 |
|
||||
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
|
||||
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
|
||||
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
|
||||
| POST | `/agent/send` | 已连接机器人发送测试 |
|
||||
|
||||
## 定时日报管理
|
||||
|
||||
定时日报由 TraceMemo 自己持久化和调度。Agent 只负责理解自然语言、解析群聊和时间,再调用上述 API;不要创建 cron、维护任务文件、计算下一次执行时间或自行发送微信。
|
||||
|
||||
### 创建任务
|
||||
|
||||
用户提出“每天早上 9 点给技术交流群发昨天的日报”时,按以下顺序执行:
|
||||
|
||||
1. 调用 `/health`,确认 TraceMemo 和数据库可用。
|
||||
2. 调用 `/wechat-personal/send-capability`,只有 `capability.status === "ready"` 且 `capability.capabilities.image === true` 才允许继续。
|
||||
3. 用户使用“今天”“昨天”等相对日期时调用 `/current_time`;日报任务的 `schedule.time` 使用 TraceMemo 本机时区的 `HH:mm`,不要转成 UTC。
|
||||
4. 调用 `/chatroom` 或 `/contact?type=group` 查找群聊。名称匹配多个结果时,必须把候选项展示给用户并要求选择;不能猜测。
|
||||
5. 使用唯一群聊的 `talker` 创建:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "技术交流群 · 每日日报",
|
||||
"group": { "talker": "xxx@chatroom", "name": "技术交流群" },
|
||||
"schedule": { "type": "daily", "time": "09:00" },
|
||||
"reportRange": "yesterday",
|
||||
"target": { "type": "wechat_group", "talker": "xxx@chatroom" },
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态为 `unsupported`、`unconfigured`、`needs_binding`、`needs_verification` 或 `error` 时,直接说明需要先在 TraceMemo 设置中完成个人微信绑定和消息能力检测。
|
||||
|
||||
### 查看、修改和执行
|
||||
|
||||
- “我现在有哪些定时日报”调用 `GET /scheduled-reports`,使用返回的 `tasks` 展示任务名称、群聊、每天的时间、范围、目标和启停状态。
|
||||
- 修改前先查询列表并确认唯一任务,再调用 `PATCH /scheduled-reports/:id`。只提交需要修改的字段,例如 `{"schedule":{"type":"daily","time":"10:00"}}`。
|
||||
- 暂停调用 `/scheduled-reports/:id/disable`,恢复调用 `/scheduled-reports/:id/enable`。
|
||||
- “现在执行一次”调用 `/scheduled-reports/:id/run`,不要改用 `/agent/group-report` 后自行发送微信;该接口和定时执行共用同一条链路。
|
||||
- 查询执行结果调用 `/scheduled-reports/:id/executions`,根据 `status`、`startedAt`、`finishedAt`、`message` 和 `error` 向用户解释结果。
|
||||
|
||||
### 删除确认
|
||||
|
||||
删除是不可逆操作。收到删除请求后,先用任务列表找到唯一任务,向用户展示任务名称、时间、日报范围和发送目标并明确询问确认;只有用户明确确认后,才调用 `DELETE /scheduled-reports/:id`。
|
||||
|
||||
## 时间与上下文规则
|
||||
|
||||
@@ -58,7 +107,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
当 `/chatlog` 返回图片消息时:
|
||||
|
||||
1. 如果用户只是询问图片消息是否存在,不需要获取图片。
|
||||
2. 如果用户要求查看、识别、理解或分析图片,使用该消息 `media.url`(`/media/{messageId}`)获取真实图片。
|
||||
2. 如果用户要求查看、识别、理解或分析图片,原样使用该消息 `media.url` 获取真实图片;不要用消息 `id` 自行拼接。媒体标识按数据库连接隔离,重启、重连或切换账号后须重新读取 `/chatlog` 获取地址。
|
||||
3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。
|
||||
4. 获取成功后,将图片交给当前 Agent 的视觉能力。
|
||||
5. 如果图片获取失败,明确说明无法读取图片。
|
||||
@@ -71,7 +120,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
|
||||
1. 调用 `/health`;必要时调用 `/current_time`。
|
||||
2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。
|
||||
3. 调用 `/media/{messageId}`,将返回的图片交给 Vision。
|
||||
3. 请求该消息的 `media.url`,将返回的图片交给 Vision。
|
||||
4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。
|
||||
|
||||
不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。
|
||||
@@ -84,7 +133,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
|
||||
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
|
||||
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
|
||||
- `404`:先用 `/resolve` 确认会话标识。
|
||||
- `422`:`messageId` 无效,或消息不是可读取的图片。
|
||||
- `404`:会话查询失败时先用 `/resolve` 确认会话标识;媒体请求表示标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在。
|
||||
- `422`:媒体标识格式错误,或消息不是可读取的图片(`NOT_IMAGE`)。
|
||||
- `503`:用户还没有完成数据库连接或对应服务未就绪。
|
||||
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
|
||||
|
||||
-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`。
|
||||
@@ -41,6 +41,19 @@
|
||||
|
||||
你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
|
||||
|
||||
## 查找过程和跳回原消息
|
||||
|
||||
查找过程中,界面依次显示真实阶段:**理解问题 → 查找相关聊天 → 整理证据 → 生成回答**。跨会话、大范围检索更慢时,副提示会写明“正在搜索较大范围的聊天记录…”。阶段只在真正进入下一步时前进,不使用定时器或百分比伪造进度。
|
||||
|
||||
查找结束后,界面给出耗时拆解:**总耗时**,以及其中分别花在 **AI 生成** 和 **本地查询** 上的时间。这样你能判断慢在哪——是模型在写答案,还是本机还在翻聊天记录。
|
||||
|
||||
点击来源卡片的 **“跳转到原聊天”** 会真的打开对应会话并定位到那条消息:
|
||||
|
||||
- 群聊来源打开的是那个群,而不是群里某个联系人;
|
||||
- 会加载该消息前后的上下文,并滚动到它、短暂高亮;
|
||||
- 只加载目标消息附近的一段,不会把整个会话历史全部读出来;
|
||||
- 如果这条消息已经不在本地(例如已被删除),界面会明确说明“已打开对应会话,但暂时无法定位原消息”,不会假装跳转成功。
|
||||
|
||||
## 什么时候不要直接相信答案
|
||||
|
||||
- 来源很少,或时间范围与问题不一致;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 选择要看的会话
|
||||
|
||||
左侧会话列表可以浏览联系人、群聊、折叠群聊和公众号等已读取到的会话。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。
|
||||
左侧会话列表可以浏览已读取到的联系人、群聊和公众号。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。
|
||||
|
||||
如果你从 AI 回答的来源进入档案,应用会自动切换到对应会话并尽量定位到消息时间。
|
||||
|
||||
@@ -19,6 +19,22 @@
|
||||
|
||||
关键词搜索速度快、结果直观,但它不会理解“意思相近但没有相同词”的问题。
|
||||
|
||||
## 怎么找到联系人
|
||||
|
||||
档案搜索会综合多个身份字段匹配联系人或群聊,包括:
|
||||
|
||||
- 通讯录备注(remark);
|
||||
- 微信昵称;
|
||||
- 当前微信号;
|
||||
- wxid;
|
||||
- 拼音全拼和拼音首字母。
|
||||
|
||||
因此可以直接输入备注、昵称、微信号或拼音查找。搜索联系人和搜索消息是两步:先确认目标会话,再在会话内查关键词;记得大意但不知道原话时,改用[AI Search](./ai-search.md)。
|
||||
|
||||
## 文字转语音
|
||||
|
||||
在档案中选择当前联系人或群聊,输入文字后生成语音,试听确认后发送。这个入口只处理明确的文字转语音动作,不是任意文本、图片或本地语音文件发送器。
|
||||
|
||||
## 消息和媒体
|
||||
|
||||
根据微信数据中实际可用的资源,档案可以展示文本、图片、视频、语音、文件、链接、引用、小程序、表情和系统消息等类型。媒体是否能显示,取决于本机原始资源是否仍然存在、权限是否完整以及当前微信版本的存储方式。
|
||||
@@ -27,11 +43,7 @@
|
||||
|
||||
如果文字正常但图片无法打开,进入“设置 → 图片解密”查看当前状态。可以尝试自动获取,也可以在已经知道正确密钥时手动配置;原文件已经被微信清理时,仅配置密钥也无法恢复图片。
|
||||
|
||||
## 可选保留撤回消息
|
||||
|
||||
“设置 → 防撤回”提供一个默认关闭的可选功能。开启后,应用会尽量保留之后捕获到的撤回消息,并在气泡旁标记“消息已撤回”。它不能找回开启前已经消失或应用未捕获到的内容,也可能增加加载开销。
|
||||
|
||||
该功能与普通只读浏览的数据边界不同。开启前请阅读[防撤回](./recall-protection.md)。
|
||||
联系人和群聊列表会尽量显示头像、备注和昵称。头像或资料缺失时不影响消息读取;这通常表示本机没有对应资源,或微信没有返回完整资料。
|
||||
|
||||
## 保护自己不被误导
|
||||
|
||||
|
||||
@@ -8,12 +8,29 @@
|
||||
|
||||
## 1. 开始前准备
|
||||
|
||||
| 系统 | 已测试的微信客户端 | 需要注意 |
|
||||
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前,需要按连接页面提示完成授权;页面明确要求时还需要处理 SIP |
|
||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 首次使用时请确认微信数据目录;Windows 不需要关闭 SIP |
|
||||
TraceMemo 需要读取微信本地数据库。首次使用前,请确认已安装受支持的微信客户端,并按照连接页面完成数据库密钥获取。
|
||||
|
||||
- 上表是当前实际测试过的客户端版本,不代表只有这些版本可以使用。其他微信 4.x 版本可能可以连接,但尚未逐一验证。
|
||||
| 系统 | 微信客户端 | 首次连接说明 |
|
||||
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| macOS | **微信 4.1.13 系列**(推荐 [4.1.13.8](https://github.com/zsbai/wechat-versions/releases/tag/4.1.13.8))<br>备选 [4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 支持 Apple Silicon(M 系列)和 Intel Mac 自动获取数据库密钥。首次连接时,请按照 TraceMemo 页面提示完成系统授权及微信登录操作。 |
|
||||
| Windows | **微信 4.x**(推荐 [4.1.9.57](https://github.com/iibob/wechat-win-archive/releases/tag/v4.1.9.57)) | 支持自动获取数据库密钥。首次使用时请确认微信数据目录正确,无需处理 macOS 的 SIP 设置。 |
|
||||
|
||||
### macOS 用户
|
||||
|
||||
TraceMemo 已支持两种 Mac 架构:
|
||||
|
||||
- **Apple Silicon(M1 / M2 / M3 / M4 等)**
|
||||
- **Intel Mac(x64)**
|
||||
|
||||
两种架构均支持自动获取微信数据库密钥,TraceMemo 会根据当前 Mac 自动选择对应的连接方式。
|
||||
|
||||
Apple Silicon 和 Intel 均已适配微信 macOS `4.1.13` 系列。首次获取密钥时,请让微信停留在登录页面,并按照 TraceMemo 中显示的步骤操作。
|
||||
|
||||
> 不同 Mac 架构的连接流程可能略有区别,请始终以应用内「第一次使用」页面显示的提示为准。
|
||||
|
||||
### 关于微信版本
|
||||
|
||||
- 上表中的版本是当前 TraceMemo 已适配或推荐使用的版本,并不代表只有这些版本可以运行。
|
||||
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
|
||||
- 你需要有权访问要读取的微信账号和聊天数据。
|
||||
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
|
||||
@@ -26,14 +43,16 @@
|
||||
|
||||
### Windows
|
||||
|
||||
1. 从 Releases 下载 Windows x64 的 `TraceMemo-<版本号>-setup.exe` 安装包。
|
||||
1. 从 Releases 下载 Windows x64 的 `tracememo-<版本号>-setup.exe` 安装包。
|
||||
2. 双击安装包,按向导完成安装。
|
||||
3. 启动 TraceMemo。
|
||||
4. 如果安装完成后软件无法启动,请安装 Microsoft Visual C++ x64 运行库:[vc_redist.x64.exe](https://aka.ms/vc14/vc_redist.x64.exe),安装完成后重新启动 TraceMemo。
|
||||
|
||||
### macOS
|
||||
|
||||
1. 下载 Apple Silicon(M 系列、`arm64`)版本的 `.dmg`。当前版本不支持 Intel 芯片的 Mac。
|
||||
1. 从 Releases 下载与你 Mac 处理器架构匹配的 `.dmg`:
|
||||
- Apple Silicon(M 系列、`arm64`):`tracememo-<版本号>-arm64.dmg`
|
||||
- Intel(`x64`):`tracememo-<版本号>-x64.dmg`
|
||||
2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。
|
||||
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||
4. 如果系统提示应用已损坏,可在终端执行:
|
||||
@@ -42,7 +61,16 @@
|
||||
xattr -cr "/Applications/TraceMemo.app"
|
||||
```
|
||||
|
||||
5. 启动 TraceMemo。首次自动获取数据库密钥时,按连接页面显示的授权要求操作;只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
|
||||
5. 先让微信停在**未登录窗口**(如果微信已经登录,请退出当前账号,**只关闭窗口不算**),再启动 TraceMemo。
|
||||
- 连接时会按提示自动获取数据库密钥;应用会根据当前架构进入对应流程,按连接页面显示的授权要求操作即可。
|
||||
- **等第五步 明确提示可以登录后,再回到微信点击登录。** 在此之前不要先点登录,否则这次密钥获取会失败,需要重新开始。
|
||||
- 只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
|
||||
|
||||
> **注意事项:取密钥时提示监听超时(`CAPTURE_TIMEOUT`,或相关失败)**
|
||||
>
|
||||
> 这说明「先让微信停在未登录窗口」这一步操作有误——常见原因是微信当时已经登录,或者还没等连接页面提示就先点了登录
|
||||
>
|
||||
> 请先退出微信账号、回到**未登录窗口**,然后按上面的第 5 步**重新来一遍**(等连接页面明确提示可以登录后,再回到微信点击登录)。
|
||||
|
||||
更完整的权限和安全边界见 [macOS 数据访问说明](../platform/macos.md)。
|
||||
|
||||
@@ -106,7 +134,6 @@
|
||||
- [生成群聊日报或总结](./report.md)
|
||||
- [转写微信语音](./voice.md)
|
||||
- [导出聊天档案](./export.md)
|
||||
- [可选开启防撤回](./recall-protection.md)
|
||||
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
|
||||
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
||||
|
||||
@@ -142,7 +169,6 @@ Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Sk
|
||||
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
|
||||
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
|
||||
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
|
||||
- 防撤回默认关闭;首次开启会为微信消息数据库增加本地撤回日志/监听结构,详细边界见[防撤回](./recall-protection.md)。
|
||||
|
||||
完整边界见[数据、隐私与安全](./privacy.md)。
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Intel Mac 首次获取密钥
|
||||
|
||||
请先备份微信本地数据。密钥获取和数据库验证都在本机完成,不要把密钥、日志或数据库发给他人。
|
||||
|
||||
## 开始使用
|
||||
|
||||
1. 启动 TraceMemo,在首次连接页面点击“重新检查环境”。
|
||||
2. 如果页面显示需要准备连接环境,点击“准备环境”,按页面提示完成。
|
||||
3. 打开微信,让微信停在未登录页面,不要先点击登录。
|
||||
4. 回到 TraceMemo,选择微信账号,点击“开始获取密钥”。
|
||||
5. 等待页面提示可以登录后,立即回到微信点击登录。
|
||||
6. 等待 TraceMemo 显示“获取成功,验证数据库连接”。
|
||||
|
||||
获取成功后,请保存密钥。如果页面提示需要重新准备,按页面的“查看说明”操作,不要连续重试。
|
||||
|
||||
## 注意
|
||||
|
||||
- 密钥只保存在本机的安全存储中。
|
||||
- 请不要删除微信本地数据目录。
|
||||
@@ -13,7 +13,30 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信
|
||||
- **建立本地知识库**:第一次读取当前账号的可检索聊天;
|
||||
- **同步最新记录**:已有索引时,只补充新增或变化的内容。
|
||||
|
||||
同步会在后台运行,完成后页面显示已索引消息、知识片段和磁盘占用。同步期间暂不能开始新的 AI 分析;同步异常时,旧索引仍可能可以继续使用。
|
||||
同步在后台运行,**期间仍然可以正常提问和分析**,不会被禁用。索引还没追完时,答案会基于当前已经可用的部分给出,并在界面标注覆盖范围。
|
||||
|
||||
知识库卡片同时显示两组互相独立的信息:
|
||||
|
||||
- **规模**:已索引消息、知识片段、磁盘占用——说明索引有多大;
|
||||
- **状态与本轮进度**:说明索引现在处于什么状态、这一轮同步在做什么(扫了多少、真正新增了多少、处理到第几个会话)。
|
||||
|
||||
`最新索引` 只表示索引已经覆盖到聊天记录的哪个时间点,**不等于**整库已经建完;进度里的计数是**本轮**的数字,不是全部历史的总数。
|
||||
|
||||
### 状态怎么读
|
||||
|
||||
| 状态 | 含义 |
|
||||
| ---- | ---- |
|
||||
| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 |
|
||||
| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 |
|
||||
| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 |
|
||||
| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 |
|
||||
| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 |
|
||||
|
||||
只有确实追平、且没有待补齐内容时才会出现“已追至最新”。索引不可查询时不会显示“可用”。
|
||||
|
||||
### 取消和继续
|
||||
|
||||
同步过程中可以点击 **取消同步**(点击后显示“正在取消…”)。取消只结束当前这一轮,不会删除已经建立的索引,也不会回滚已完成的部分;下次同步会从上次停下的位置继续,不需要从头重扫。中断过的索引仍然可以正常搜索。
|
||||
|
||||
## 账号隔离
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有
|
||||
|
||||
应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。
|
||||
|
||||
防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。v2.1.9 的旧恢复记录会随首次启动迁移复制到 TraceMemo,旧目录仍保留。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
|
||||
|
||||
## 什么时候会请求外部服务
|
||||
|
||||
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
|
||||
@@ -58,4 +56,3 @@ Token 由应用生成,使用 Electron `safeStorage` 加密保存在本机 `loc
|
||||
- 对需要外发的 AI 功能逐项确认 Provider;
|
||||
- 定期在“设置 → 缓存与清理”清理不再需要的检索、导出和索引缓存;
|
||||
- 在共享电脑上退出应用并保护系统账户。
|
||||
- 在开启防撤回前确认你接受其数据库写入、性能和清理边界,并先用微信官方方式备份重要数据。
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# 防撤回
|
||||
|
||||
防撤回是一个默认关闭的可选功能。开启后,TraceMemo 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
|
||||
|
||||
它适合希望在本机档案中保留后续聊天上下文的用户,但不能保证找回每一条撤回消息。
|
||||
|
||||
## 如何开启
|
||||
|
||||
1. 先连接微信数据库,并确认“档案”可以正常读取聊天。
|
||||
2. 打开“设置 → 防撤回”。
|
||||
3. 阅读性能和数据提示后,开启“防撤回”。
|
||||
4. 保持 TraceMemo 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
|
||||
|
||||
防撤回不是第一次使用的必要步骤。只想浏览、搜索、提问或导出时,可以保持关闭。
|
||||
|
||||
## 当前能做什么
|
||||
|
||||
- 监听应用能够识别到的后续撤回变化;
|
||||
- 在本地保留必要的消息和撤回关系;
|
||||
- 将已识别的原消息与撤回状态一起显示在档案中;
|
||||
- 按微信账号隔离 TraceMemo 保存的恢复记录。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 不能恢复开启前已经撤回、且应用从未保存到的消息;
|
||||
- TraceMemo 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
|
||||
- 微信版本、消息表结构和数据库事件变化都可能让部分消息无法恢复或正确匹配;
|
||||
- 开启后需要为消息表增加监听,聊天很多或磁盘较慢时可能影响加载性能;
|
||||
- “消息已撤回”只说明应用识别到了撤回关系,不保证恢复内容完整。
|
||||
|
||||
## 数据写入与关闭边界
|
||||
|
||||
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除。
|
||||
|
||||
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
|
||||
|
||||
完整的数据边界见[数据、隐私与安全](./privacy.md)。
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
典型场景包括:
|
||||
|
||||
- 整理今天工作群的讨论重点;
|
||||
- 回顾昨天错过的决定和资源;
|
||||
- 整理今日工作群的讨论重点;
|
||||
- 回顾昨日错过的决定和资源;
|
||||
- 汇总近 7 天的项目进展、待办和未解决问题;
|
||||
- 把群里的图片、语音统计和重要消息放进一张长图或 HTML 页面。
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
你可以从两个入口开始:打开一级导航“日报”后新建报告,或者在“档案”中选中一个群聊并点击“生成 AI 日报”。
|
||||
|
||||
1. 选择一个群聊。当前日报入口只支持群聊,不支持单聊。
|
||||
2. 选择时间范围:今天、昨天或近 7 天。
|
||||
2. 选择时间范围:今日、昨日或近 7 天。
|
||||
3. 按需要选择参与总结的消息类型,先从文字开始最容易核对。
|
||||
4. 选择报告模板/内容模式并开始生成。
|
||||
5. 等待“整理输入 → AI 生成 → HTML/PNG 导出”完成。
|
||||
@@ -33,10 +33,22 @@
|
||||
|
||||
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
|
||||
|
||||
## 定时日报
|
||||
|
||||
在“日报 → 定时日报”中可以创建每天运行的任务。选择群聊、执行时间、日报范围、消息类型和模板后,TraceMemo 会按计划执行:
|
||||
|
||||
```text
|
||||
定时触发 → 读取群聊 → 生成报告 → 保存 Report History → 尝试发送
|
||||
```
|
||||
|
||||
生成和发送是两个阶段。当前微信发送能力不可用、未绑定或发送失败时,报告仍会保存,PNG 和执行记录也会保留;这类结果会显示为“已生成,但未发送”或“已生成,发送失败”。
|
||||
|
||||
执行记录支持查看已生成的日报。对“等待发送”或“发送失败”的记录,可以直接重试发送,重试会复用已经生成的 PNG,不会重新调用 AI 生成整份报告;完整执行状态和发送边界见[如何把聊天变成可用的信息](../concepts/how-it-works.md#动作执行与审计)。
|
||||
|
||||
## 让报告更可靠
|
||||
|
||||
- 先选正确的群和时间范围;
|
||||
- 不确定时先只选择文字消息;
|
||||
- 群太活跃时分成“今天”和“近 7 天”两次生成;
|
||||
- 群太活跃时分成“今日”和“近 7 天”两次生成;
|
||||
- 看到待办和结论后回到原消息核对上下文;
|
||||
- AI Provider 不可用时先检查模型配置和网络/本地服务状态。
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
### macOS
|
||||
|
||||
- 确认下载的构建与 Mac 处理器匹配:Apple Silicon(M 系列)用 `arm64`,Intel 用 `x64`。
|
||||
- 提示“无法打开,因为开发者无法验证”时,前往“系统设置 → 隐私与安全性”并点击“仍要打开”。
|
||||
- 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/TraceMemo.app"`。
|
||||
|
||||
@@ -25,10 +26,13 @@
|
||||
2. 微信版本是否属于当前代码面向的 4.x 数据结构;
|
||||
3. 微信是否处于页面要求的登录/退出状态;
|
||||
4. macOS 是否完成页面要求的授权;
|
||||
5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题。
|
||||
5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题;
|
||||
6. macOS 上 Apple Silicon 与 Intel 使用不同的连接流程,按连接页面提示操作;两者都可以自动获取数据库密钥。
|
||||
|
||||
重新输入密钥或断开连接不会删除微信原始数据库。macOS 的 SIP 和授权说明见[平台说明](../platform/macos.md)。
|
||||
|
||||
仍然失败时,记录 **macOS 版本、CPU 架构(Apple Silicon / Intel)、微信版本、TraceMemo 版本和页面错误提示**后 扫码 README 文档二维码进群提交消息, 或者提交 Issue。
|
||||
|
||||
## 连接成功但没有联系人或消息
|
||||
|
||||
确认账号身份和数据目录匹配。返回“设置 → 账号与数据库”查看数据库连接状态,重新加载会话后再试。若仍为空,记录系统、微信版本和错误提示后提交 Issue。
|
||||
@@ -88,7 +92,3 @@ Agent Hub 和外部 Agent 是两条路径。机器人异常时依次确认:
|
||||
5. 需要总结或自然语言理解时,AI Provider 是否可用。
|
||||
|
||||
当前机器人不支持群发、定时任务或与文字同等的图片、语音、文件和视频理解。详细边界见[Agent Hub](../agent/agent-hub.md)。
|
||||
|
||||
## 防撤回没有保留消息
|
||||
|
||||
防撤回只能尽量保留开启后且应用成功捕获到的撤回变化。确认开启时数据库已经连接、TraceMemo 在撤回发生时保持运行,并检查聊天加载是否明显变慢。开启前已经消失、应用未捕获或微信结构无法识别的消息不能保证恢复;详见[防撤回](./recall-protection.md)。
|
||||
|
||||
@@ -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/**
|
||||
@@ -63,4 +64,6 @@ publish:
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
repo: TraceMemo
|
||||
releaseType: release
|
||||
# 一律先上传为草稿 再到 GitHub 上手动 Publish。
|
||||
# 需要预发布时用 `pnpm release:beta`(EP_PRE_RELEASE 会覆盖这里的 draft)。
|
||||
releaseType: draft
|
||||
|
||||
@@ -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:
|
||||
@@ -71,4 +75,6 @@ publish:
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
repo: TraceMemo
|
||||
releaseType: release
|
||||
# 一律先上传为草稿 再到 GitHub 上手动 Publish。
|
||||
# 需要预发布时用 `pnpm release:beta`(EP_PRE_RELEASE 会覆盖这里的 draft)。
|
||||
releaseType: draft
|
||||
|
||||
@@ -8,13 +8,15 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
reportTemplateTest: resolve('src/main/report-template-test-entry.ts'),
|
||||
queryAgentPoc: resolve('src/main/query-agent-poc-entry.ts'),
|
||||
voiceRecognitionWorker: resolve('src/main/voice-pipeline/voice-recognition-worker.ts'),
|
||||
knowledgeWorker: resolve('src/main/knowledge/knowledge-worker.ts')
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].js'
|
||||
},
|
||||
external: ['koffi', 'sherpa-onnx-node']
|
||||
external: ['koffi', 'sherpa-onnx-node', '@napi-rs/system-ocr']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
# TraceMemo 日报模板示例
|
||||
|
||||
此目录是可安装的最小日报模板源文件,使用全部虚构数据进行预览。运行 `node scripts/build-report-template-example.cjs` 会生成 `examples/report-template-basic.zip`。
|
||||
|
||||
模板只能调整已有日报模块的 HTML/CSS 排版。作者不能加入 JavaScript、事件属性、外部网络资源、嵌套页面、数据库访问、AI Prompt 或新的业务分析。
|
||||
|
||||
占位符分为三类:普通文本(会被 HTML 转义)、应用生成的 HTML 片段(只能作为元素内容使用)、受限样式类(只能放进 `class` 属性,并由应用输出合法 token)。`*_MORE_NOTE` 是 HTML 片段,不是样式类。
|
||||
|
||||
允许的标签和属性由 `src/shared/report-template-package.ts` 统一定义;图片资源只能是包内 `assets/` 下的 PNG/JPEG/WebP。缺少可选模块时应用输出空字符串和 `*_EMPTY_CLASS`,模板应允许该模块隐藏。
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"protocolVersion": "1.0",
|
||||
"kind": "daily-report",
|
||||
"id": "community.github.example.basic-feed",
|
||||
"name": "基础信息流",
|
||||
"author": { "name": "example" },
|
||||
"templateVersion": "1.0.0",
|
||||
"interfaceVersion": "1",
|
||||
"entry": "template.html",
|
||||
"preview": "preview.png",
|
||||
"capture": { "width": 430, "maxWidth": 430, "maxHeight": 20000 },
|
||||
"license": { "spdx": "MIT" },
|
||||
"platform": "mobile"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>{{REPORT_TITLE}}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; width: 430px; background: #f3f5f7; color: #18202a; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
.report { padding: 18px 14px 32px; }
|
||||
.hero, .section { margin-top: 10px; padding: 16px; border-radius: 14px; background: #fff; }
|
||||
.hero { margin-top: 0; border-top: 4px solid #1769aa; }
|
||||
h1 { margin: 0 0 8px; font-size: 23px; }
|
||||
.meta, .muted { color: #64748b; font-size: 12px; line-height: 1.5; }
|
||||
.section-title { margin: 0 0 8px; font-size: 14px; }
|
||||
.topics-grid, .important-list { display: grid; gap: 8px; }
|
||||
.topic-card, .important-card { padding: 10px; border: 1px solid #dbe4ee; border-radius: 10px; }
|
||||
.empty-section { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="report {{REPORT_MODE_CLASS}}">
|
||||
<section class="hero {{REPORT_MODE_CLASS}}">
|
||||
<h1>{{REPORT_TITLE}}</h1>
|
||||
<div class="meta">{{REPORT_DATE}} · {{DATE_RANGE}}</div>
|
||||
<p>{{HERO_SUMMARY}}</p>
|
||||
<div class="muted">{{HERO_STATUS_LINE}}</div>
|
||||
</section>
|
||||
<section class="section {{TOPICS_EMPTY_CLASS}}">
|
||||
<h2 class="section-title">今日话题</h2>
|
||||
<div class="topics-grid">{{TOPIC_CARDS}}</div>
|
||||
{{TOPICS_MORE_NOTE}}
|
||||
</section>
|
||||
<section class="section {{MESSAGES_EMPTY_CLASS}}">
|
||||
<h2 class="section-title">重要消息</h2>
|
||||
<div class="important-list">{{IMPORTANT_MESSAGES}}</div>
|
||||
</section>
|
||||
<footer class="muted">{{FOOTER_NOTE}}</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+30
-20
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tracememo",
|
||||
"version": "2.2.3",
|
||||
"version": "2.4.0",
|
||||
"packageManager": "pnpm@7.33.7",
|
||||
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
|
||||
"keywords": [
|
||||
@@ -25,22 +25,27 @@
|
||||
},
|
||||
"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",
|
||||
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
|
||||
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
||||
"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-native": "node scripts/prepare-wechat-native-runtime.cjs",
|
||||
"start": "electron-vite preview",
|
||||
"predev": "node -e \"require('electron')\"",
|
||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||
"predev": "node scripts/ensure-electron-binary.cjs",
|
||||
"dev": "node scripts/ensure-env.cjs && electron-vite dev",
|
||||
"dev:update": "cross-env TRACEMEMO_UPDATE_SIMULATION=true pnpm dev",
|
||||
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
||||
"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: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",
|
||||
@@ -51,26 +56,27 @@
|
||||
"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",
|
||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
||||
"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:ffmpeg:win && 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 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
||||
"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 && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
|
||||
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
|
||||
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
|
||||
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
|
||||
"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 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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
@@ -90,6 +96,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-tree": "^3.0.1",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ffmpeg-static": "5.3.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
@@ -97,10 +104,13 @@
|
||||
"jsonrepair": "^3.15.0",
|
||||
"koffi": "^3.1.0",
|
||||
"openai": "^6.10.0",
|
||||
"parse5": "^8.0.0",
|
||||
"pinyin-pro": "^3.26.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sherpa-onnx-node": "1.13.3",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"unzipper": "^0.12.0",
|
||||
"wechat-emojis": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -159,4 +169,4 @@
|
||||
"ffmpeg-static"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+87
-5
@@ -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
|
||||
@@ -46,6 +48,7 @@ specifiers:
|
||||
class-variance-authority: ^0.7.1
|
||||
clsx: ^2.1.1
|
||||
cross-env: ^10.1.0
|
||||
css-tree: ^3.0.1
|
||||
electron: ^43.0.0
|
||||
electron-builder: ^26.0.12
|
||||
electron-updater: ^6.6.2
|
||||
@@ -61,6 +64,8 @@ specifiers:
|
||||
jsonrepair: ^3.15.0
|
||||
koffi: ^3.1.0
|
||||
openai: ^6.10.0
|
||||
parse5: ^8.0.0
|
||||
pinyin-pro: ^3.26.0
|
||||
postcss: ^8.5.26
|
||||
prettier: ^3.7.4
|
||||
qrcode: ^1.5.4
|
||||
@@ -73,6 +78,7 @@ specifiers:
|
||||
tailwindcss: 3.4.17
|
||||
tailwindcss-animate: ^1.0.7
|
||||
typescript: ^5.9.3
|
||||
unzipper: ^0.12.0
|
||||
vite: ^7.2.6
|
||||
vitest: ^4.1.10
|
||||
wechat-emojis: ^1.0.2
|
||||
@@ -82,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
|
||||
@@ -101,6 +109,7 @@ dependencies:
|
||||
class-variance-authority: 0.7.1
|
||||
clsx: 2.1.1
|
||||
cross-env: 10.1.0
|
||||
css-tree: 3.2.1
|
||||
electron-updater: 6.8.9
|
||||
ffmpeg-static: 5.3.0
|
||||
fs-extra: 11.3.2
|
||||
@@ -108,10 +117,13 @@ dependencies:
|
||||
jsonrepair: 3.15.0
|
||||
koffi: 3.1.0
|
||||
openai: 6.10.0
|
||||
parse5: 8.0.1
|
||||
pinyin-pro: 3.29.3
|
||||
qrcode: 1.5.4
|
||||
sherpa-onnx-node: 1.13.3
|
||||
silk-wasm: 3.7.1
|
||||
tailwind-merge: 3.6.0
|
||||
unzipper: 0.12.5
|
||||
wechat-emojis: 1.0.2
|
||||
|
||||
devDependencies:
|
||||
@@ -1677,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'}
|
||||
@@ -3898,6 +3948,10 @@ packages:
|
||||
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
|
||||
dev: true
|
||||
|
||||
/bluebird/3.7.2:
|
||||
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||
dev: false
|
||||
|
||||
/brace-expansion/1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
dependencies:
|
||||
@@ -4350,7 +4404,6 @@ packages:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
dev: true
|
||||
|
||||
/css.escape/1.5.1:
|
||||
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
|
||||
@@ -4570,6 +4623,12 @@ packages:
|
||||
gopd: 1.2.0
|
||||
dev: true
|
||||
|
||||
/duplexer2/0.1.4:
|
||||
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
dev: false
|
||||
|
||||
/eastasianwidth/0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
dev: true
|
||||
@@ -4697,7 +4756,6 @@ packages:
|
||||
/entities/8.0.0:
|
||||
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
dev: true
|
||||
|
||||
/env-paths/2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
@@ -5301,6 +5359,15 @@ packages:
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
|
||||
/fs-extra/11.3.1:
|
||||
resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
|
||||
engines: {node: '>=14.14'}
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
dev: false
|
||||
|
||||
/fs-extra/11.3.2:
|
||||
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
|
||||
engines: {node: '>=14.14'}
|
||||
@@ -6326,7 +6393,6 @@ packages:
|
||||
|
||||
/mdn-data/2.27.1:
|
||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||
dev: true
|
||||
|
||||
/merge2/1.4.1:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
@@ -6552,6 +6618,10 @@ packages:
|
||||
semver: 7.7.3
|
||||
dev: true
|
||||
|
||||
/node-int64/0.4.0:
|
||||
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
||||
dev: false
|
||||
|
||||
/node-releases/2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
dev: true
|
||||
@@ -6771,7 +6841,6 @@ packages:
|
||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||
dependencies:
|
||||
entities: 8.0.0
|
||||
dev: true
|
||||
|
||||
/path-exists/4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
@@ -6835,6 +6904,10 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/pinyin-pro/3.29.3:
|
||||
resolution: {integrity: sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==}
|
||||
dev: false
|
||||
|
||||
/pirates/4.0.7:
|
||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -7659,7 +7732,6 @@ packages:
|
||||
/source-map-js/1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/source-map-support/0.5.21:
|
||||
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
|
||||
@@ -8204,6 +8276,16 @@ packages:
|
||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
/unzipper/0.12.5:
|
||||
resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==}
|
||||
dependencies:
|
||||
bluebird: 3.7.2
|
||||
duplexer2: 0.1.4
|
||||
fs-extra: 11.3.1
|
||||
graceful-fs: 4.2.11
|
||||
node-int64: 0.4.0
|
||||
dev: false
|
||||
|
||||
/update-browserslist-db/1.2.2_browserslist@4.28.1:
|
||||
resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==}
|
||||
hasBin: true
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 338 KiB |
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
a35d3fa67387e049cc30bc073f9e65b077aa1db9c5c2b183125dec695fa013b9 xkey_helper_4_1_13
|
||||
+241
-5
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||
const { chmodSync, existsSync } = require('node:fs')
|
||||
const { chmodSync, existsSync, readdirSync, rmSync } = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
const asar = require('@electron/asar')
|
||||
const { readBinaryArchitectures } = require('./binary-arch.cjs')
|
||||
|
||||
const REQUIRED_RUNTIME_PACKAGES = [
|
||||
'@electron-toolkit/preload',
|
||||
@@ -15,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'
|
||||
@@ -77,11 +87,143 @@ 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
|
||||
* architectures (ffmpeg-static keeps a single "ffmpeg" per platform) and fail
|
||||
* the build instead of shipping a broken bundle.
|
||||
*/
|
||||
function validateRuntimeBinaryArchitecture(filePath, platform, arch, label) {
|
||||
if (platform !== 'darwin' && platform !== 'win32') return
|
||||
if (arch === 'universal') return
|
||||
const architectures = readBinaryArchitectures(filePath)
|
||||
if (!architectures.length || architectures.includes(arch)) return
|
||||
throw new Error(
|
||||
`${label} is ${architectures.join('/')} but this bundle targets ${arch}: ${filePath}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Intel Mac key helper is an x86_64 executable that only the x64 (or
|
||||
* universal) macOS bundle can run. Every other target — Apple Silicon macOS,
|
||||
* Windows, Linux — would otherwise ship a ~34MB binary it can never execute,
|
||||
* so it is dropped from those bundles. x64/universal builds fail fast instead
|
||||
* of silently shipping an Intel Mac app that cannot read keys.
|
||||
*/
|
||||
function pruneIntelMacKeyTool(runtimeResources, platform, arch) {
|
||||
const keyToolDirectory = path.join(runtimeResources, 'resources', 'macos-key-tool')
|
||||
const usable = platform === 'darwin' && (arch === 'x64' || arch === 'universal')
|
||||
if (!usable) {
|
||||
rmSync(keyToolDirectory, { recursive: true, force: true })
|
||||
return null
|
||||
}
|
||||
const helperPath = path.join(keyToolDirectory, 'intel_mac_key_helper')
|
||||
if (!existsSync(helperPath)) {
|
||||
throw new Error(`Missing Intel Mac key helper in a ${arch} bundle: ${helperPath}`)
|
||||
}
|
||||
return helperPath
|
||||
}
|
||||
|
||||
function validateAsarRuntimeDependencies(runtimeResources) {
|
||||
const asarPath = path.join(runtimeResources, 'app.asar')
|
||||
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
|
||||
@@ -107,22 +249,108 @@ function validateReaderSkillRuntime(runtimeResources) {
|
||||
return skillPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Native runtime packages are published once per platform-arch pair, and pnpm
|
||||
* installs all of them, so every bundle ends up carrying the native libraries
|
||||
* of every platform (measured: ~129MB of speech models plus ~16MB of koffi).
|
||||
* 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, arch) => `${platform === 'win32' ? 'win' : platform}-${arch}`
|
||||
},
|
||||
{
|
||||
modules: ['@koromix'],
|
||||
prefix: 'koffi',
|
||||
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)?$/
|
||||
}
|
||||
]
|
||||
|
||||
function pruneForeignArchNativeRuntimes(runtimeResources, platform, arch) {
|
||||
if (arch === 'universal') return []
|
||||
const unpackedRoot = path.join(runtimeResources, 'app.asar.unpacked', 'node_modules')
|
||||
if (!existsSync(unpackedRoot)) return []
|
||||
const removed = []
|
||||
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 =
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundled native directories under resources/connectors are named
|
||||
* "<platform>-<arch>". Cross-building both macOS architectures leaves both on
|
||||
* disk, but a bundle can only execute its own, so drop the foreign ones
|
||||
* instead of shipping every connector twice.
|
||||
*/
|
||||
function pruneForeignArchConnectors(runtimeResources, platform, arch) {
|
||||
if (arch === 'universal') return []
|
||||
const connectorsRoot = path.join(runtimeResources, 'resources', 'connectors')
|
||||
if (!existsSync(connectorsRoot)) return []
|
||||
const expected = `${platform}-${arch}`
|
||||
const removed = []
|
||||
for (const packageEntry of readdirSync(connectorsRoot, { withFileTypes: true })) {
|
||||
if (!packageEntry.isDirectory()) continue
|
||||
const packageRoot = path.join(connectorsRoot, packageEntry.name)
|
||||
for (const targetEntry of readdirSync(packageRoot, { withFileTypes: true })) {
|
||||
if (!targetEntry.isDirectory() || targetEntry.name === expected) continue
|
||||
if (!/^[a-z0-9]+-(arm64|x64|ia32)$/.test(targetEntry.name)) continue
|
||||
rmSync(path.join(packageRoot, targetEntry.name), { recursive: true, force: true })
|
||||
removed.push(`${packageEntry.name}/${targetEntry.name}`)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
const runtimeResources = getRuntimeResources(context)
|
||||
const arch = normalizeBuilderArch(context.arch)
|
||||
validateAsarRuntimeDependencies(runtimeResources)
|
||||
validateReaderSkillRuntime(runtimeResources)
|
||||
validateSilkWasmRuntime(runtimeResources)
|
||||
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
|
||||
validateSherpaRuntime(
|
||||
runtimeResources,
|
||||
validateRuntimeBinaryArchitecture(
|
||||
ffmpegPath,
|
||||
context.electronPlatformName,
|
||||
normalizeBuilderArch(context.arch)
|
||||
arch,
|
||||
'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)
|
||||
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
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') {
|
||||
@@ -141,7 +369,6 @@ exports.default = async function afterPack(context) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
exports.getRuntimeResources = getRuntimeResources
|
||||
@@ -150,3 +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
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('node:fs')
|
||||
|
||||
const MACHO_MAGIC_32 = 0xfeedface
|
||||
const MACHO_MAGIC_64 = 0xfeedfacf
|
||||
const FAT_MAGIC = 0xcafebabe
|
||||
const FAT_MAGIC_64 = 0xcafebabf
|
||||
const PE_SIGNATURE = 0x00004550
|
||||
const PE_MACHINE_X64 = 0x8664
|
||||
const PE_MACHINE_ARM64 = 0xaa64
|
||||
|
||||
const CPU_TYPE_IA32 = 0x00000007
|
||||
const CPU_TYPE_X86_64 = 0x01000007
|
||||
const CPU_TYPE_ARM64 = 0x0100000c
|
||||
|
||||
/** Only the headers are needed; native binaries can be tens of megabytes. */
|
||||
const HEADER_BYTES = 64 * 1024
|
||||
|
||||
function readHeader(filePath) {
|
||||
const descriptor = fs.openSync(filePath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(HEADER_BYTES)
|
||||
const bytesRead = fs.readSync(descriptor, buffer, 0, HEADER_BYTES, 0)
|
||||
return buffer.subarray(0, bytesRead)
|
||||
} finally {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMachoCpuType(cpuType) {
|
||||
if (cpuType === CPU_TYPE_X86_64) return 'x64'
|
||||
if (cpuType === CPU_TYPE_ARM64) return 'arm64'
|
||||
if (cpuType === CPU_TYPE_IA32) return 'ia32'
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every architecture contained in a Mach-O or PE binary, as
|
||||
* electron-builder arch names ("x64", "arm64"). Universal binaries report both.
|
||||
* Returns an empty array for anything that is not a native executable (scripts,
|
||||
* wasm), so callers can treat "unknown" separately from "wrong architecture".
|
||||
*/
|
||||
function readBinaryArchitectures(filePath) {
|
||||
let buffer
|
||||
try {
|
||||
buffer = readHeader(filePath)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
if (buffer.length < 8) return []
|
||||
|
||||
const fatMagic = buffer.readUInt32BE(0)
|
||||
if (fatMagic === FAT_MAGIC || fatMagic === FAT_MAGIC_64) {
|
||||
const entrySize = fatMagic === FAT_MAGIC_64 ? 32 : 20
|
||||
const count = Math.min(buffer.readUInt32BE(4), 32)
|
||||
const architectures = []
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const entryOffset = 8 + index * entrySize
|
||||
if (entryOffset + 4 > buffer.length) break
|
||||
const name = normalizeMachoCpuType(buffer.readUInt32BE(entryOffset))
|
||||
if (name && !architectures.includes(name)) architectures.push(name)
|
||||
}
|
||||
return architectures
|
||||
}
|
||||
|
||||
const thinMagic = buffer.readUInt32LE(0)
|
||||
if (thinMagic === MACHO_MAGIC_32 || thinMagic === MACHO_MAGIC_64) {
|
||||
const name = normalizeMachoCpuType(buffer.readUInt32LE(4))
|
||||
return name ? [name] : []
|
||||
}
|
||||
|
||||
if (buffer.readUInt16LE(0) === 0x5a4d) {
|
||||
const peOffset = buffer.readUInt32LE(0x3c)
|
||||
if (peOffset + 6 > buffer.length || buffer.readUInt32LE(peOffset) !== PE_SIGNATURE) return []
|
||||
const machine = buffer.readUInt16LE(peOffset + 4)
|
||||
if (machine === PE_MACHINE_X64) return ['x64']
|
||||
if (machine === PE_MACHINE_ARM64) return ['arm64']
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
module.exports = { readBinaryArchitectures }
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { ZipArchive } = require('archiver')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const source = path.join(root, 'examples', 'report-template-basic')
|
||||
const outputPath = path.join(root, 'examples', 'report-template-basic.zip')
|
||||
const output = fs.createWriteStream(outputPath)
|
||||
const archive = new ZipArchive({ zlib: { level: 9 } })
|
||||
output.on('close', () => console.log(`wrote ${outputPath} (${archive.pointer()} bytes)`))
|
||||
archive.on('error', (error) => { throw error })
|
||||
archive.pipe(output)
|
||||
archive.file(path.join(source, 'manifest.json'), { name: 'manifest.json' })
|
||||
archive.file(path.join(source, 'template.html'), { name: 'template.html' })
|
||||
// 使用 1x1 PNG 作为虚构预览占位图,避免引入真实用户媒体。
|
||||
archive.append(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), { name: 'preview.png' })
|
||||
archive.finalize()
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
/**
|
||||
* electron@43 的 npm 包不再声明 postinstall(它自己的 package.json 里 scripts 是空的),
|
||||
* 下载被改成「首次 require('electron') 时的懒加载」。后果是 `pnpm install` 之后
|
||||
* node_modules/electron 里只有一个空壳:dist/ 与 path.txt 都不在,而
|
||||
* package.json 里的 pnpm.onlyBuiltDependencies: ["electron"] 对着空气发号施令 ——
|
||||
* 上游没有脚本可跑,pnpm 自然什么也不做。新 clone 于是直到 `pnpm dev` 才炸。
|
||||
* 这里显式补上下载,并把它挂在 postinstall / predev 上。
|
||||
*/
|
||||
const MIRROR_FALLBACK = 'https://npmmirror.com/mirrors/electron/'
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
|
||||
function resolveElectronPackageRoot() {
|
||||
try {
|
||||
return path.dirname(require.resolve('electron/package.json'))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* .npmrc 的 electron_mirror 只有在 npm/pnpm 执行脚本时才会变成 npm_config_* 环境变量;
|
||||
* 直接 `node node_modules/electron/install.js` 时读不到,于是会绕开镜像去打 GitHub。
|
||||
* 这里显式读出来当 ELECTRON_MIRROR 传下去。显式设置的环境变量优先。
|
||||
*/
|
||||
function resolveMirror() {
|
||||
if (process.env.ELECTRON_MIRROR) return process.env.ELECTRON_MIRROR
|
||||
try {
|
||||
const npmrc = fs.readFileSync(path.join(projectRoot, '.npmrc'), 'utf8')
|
||||
const match = npmrc.match(/^\s*electron_mirror\s*=\s*(\S+)\s*$/m)
|
||||
return match ? match[1] : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* path.txt 只是 electron 写下的相对路径,光有它不算装好 —— 指向的可执行文件
|
||||
* 必须真的存在,否则仍会在启动时报「Electron failed to install correctly」。
|
||||
*/
|
||||
function isElectronBinaryInstalled(packageRoot) {
|
||||
if (!packageRoot) return false
|
||||
try {
|
||||
const executable = fs.readFileSync(path.join(packageRoot, 'path.txt'), 'utf8').trim()
|
||||
return executable !== '' && fs.existsSync(path.join(packageRoot, 'dist', executable))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function runInstaller(packageRoot) {
|
||||
const installer = path.join(packageRoot, 'install.js')
|
||||
if (!fs.existsSync(installer)) {
|
||||
throw new Error(`[ensure-electron] missing ${installer}; run pnpm install first`)
|
||||
}
|
||||
const mirror = resolveMirror()
|
||||
const attempts = mirror
|
||||
? [{ label: mirror, env: { ELECTRON_MIRROR: mirror } }]
|
||||
: [{ label: 'default source', env: {} }]
|
||||
// 配的镜像本身不是 npmmirror 时,再兜一层:镜像挂掉时不至于完全没退路。
|
||||
if (!mirror.includes('npmmirror.com')) {
|
||||
attempts.push({ label: MIRROR_FALLBACK, env: { ELECTRON_MIRROR: MIRROR_FALLBACK } })
|
||||
}
|
||||
let lastError
|
||||
for (let index = 0; index < attempts.length; index += 1) {
|
||||
const attempt = attempts[index]
|
||||
console.log(`[ensure-electron] downloading from ${attempt.label}`)
|
||||
try {
|
||||
execFileSync(process.execPath, [installer], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...attempt.env }
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const next = attempts[index + 1]
|
||||
if (next) {
|
||||
console.warn(`[ensure-electron] download failed, retrying from ${next.label}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
function ensureElectronBinary() {
|
||||
const packageRoot = resolveElectronPackageRoot()
|
||||
if (isElectronBinaryInstalled(packageRoot)) return false
|
||||
console.log('[ensure-electron] Electron binary is missing; downloading it now')
|
||||
runInstaller(packageRoot)
|
||||
if (!isElectronBinaryInstalled(packageRoot)) {
|
||||
throw new Error('[ensure-electron] Electron binary is still missing after installing')
|
||||
}
|
||||
console.log('[ensure-electron] Electron binary is ready')
|
||||
return true
|
||||
}
|
||||
|
||||
if (require.main === module) ensureElectronBinary()
|
||||
|
||||
module.exports = { ensureElectronBinary, isElectronBinaryInstalled, resolveElectronPackageRoot }
|
||||
@@ -98,7 +98,7 @@ const activityLine = Array.from(document.querySelectorAll('.analytics > .card'))
|
||||
const values = {
|
||||
REPORT_TITLE: title,
|
||||
REPORT_DATE: reportDate,
|
||||
DATE_RANGE: '今天',
|
||||
DATE_RANGE: '今日',
|
||||
TIME_SPAN: statValues[2] || dateTimeRange,
|
||||
HERO_SUMMARY: overview,
|
||||
HERO_TAKEAWAY: '',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
const { readBinaryArchitectures } = require('./binary-arch.cjs')
|
||||
|
||||
const runtimeNames = ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']
|
||||
|
||||
@@ -24,6 +25,31 @@ function readOption(name, fallback) {
|
||||
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback
|
||||
}
|
||||
|
||||
function ffmpegExecutableName(targetPlatform) {
|
||||
return targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg-static keeps a single binary per platform ("ffmpeg" everywhere except
|
||||
* Windows), so an arm64 and an x64 macOS checkout cannot coexist in
|
||||
* node_modules. Its installer also exits early whenever the file already
|
||||
* exists, so a binary left over from the other architecture would be packed
|
||||
* silently. Check the real architecture and drop the file when it differs, so
|
||||
* the caller re-downloads the requested one.
|
||||
*/
|
||||
function ensureFfmpegArchitecture(ffmpegPath, targetPlatform, targetArch) {
|
||||
if (!fs.existsSync(ffmpegPath)) return 'missing'
|
||||
const architectures = readBinaryArchitectures(ffmpegPath)
|
||||
if (architectures.includes(targetArch)) return 'match'
|
||||
console.log(
|
||||
`[prepare-electron-runtime] ffmpeg-static is ${
|
||||
architectures.join('/') || 'not a native binary'
|
||||
} but ${targetPlatform}-${targetArch} was requested; replacing it`
|
||||
)
|
||||
fs.rmSync(ffmpegPath, { force: true })
|
||||
return 'replaced'
|
||||
}
|
||||
|
||||
function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = process.arch) {
|
||||
let packageRoot = ''
|
||||
try {
|
||||
@@ -31,10 +57,11 @@ function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = pr
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const executable = targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const executable = ffmpegExecutableName(targetPlatform)
|
||||
const ffmpegPath = path.join(packageRoot, executable)
|
||||
const architectureState = ensureFfmpegArchitecture(ffmpegPath, targetPlatform, targetArch)
|
||||
|
||||
if (!fs.existsSync(ffmpegPath)) {
|
||||
if (architectureState !== 'match') {
|
||||
const installScript = path.join(packageRoot, 'install.js')
|
||||
console.log(
|
||||
`[prepare-electron-runtime] downloading ffmpeg-static for ${targetPlatform}-${targetArch}`
|
||||
@@ -83,4 +110,6 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
if (require.main === module) main()
|
||||
|
||||
module.exports = { ensureFfmpegArchitecture, ffmpegExecutableName, prepareFfmpegRuntime }
|
||||
|
||||
@@ -1,200 +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 declarations = 'var triggerX1Payload;\nvar triggerX0;'
|
||||
const patchedDeclarations =
|
||||
'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;'
|
||||
const originalSend = ` 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));`
|
||||
const patchedSend = ` 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));`
|
||||
|
||||
if (!source.includes(declarations) || !source.includes(originalSend)) {
|
||||
throw new Error('无法定位 wechat_chatter 连续发送补丁位置')
|
||||
}
|
||||
source = source.replace(declarations, patchedDeclarations).replace(originalSend, patchedSend)
|
||||
source = source.replace(
|
||||
' MMStartTask(triggerX0, triggerX1Payload);',
|
||||
' MMStartTask(triggerX0, activeTriggerX1Payload);'
|
||||
)
|
||||
source = source.replace(
|
||||
' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);',
|
||||
' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);'
|
||||
)
|
||||
source = source.replace(
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn',
|
||||
'\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn'
|
||||
)
|
||||
fs.writeFileSync(scriptPath, source)
|
||||
console.log('[wechat-personal] 已应用逐条发送 payload 隔离补丁')
|
||||
}
|
||||
|
||||
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 状态补丁')
|
||||
}
|
||||
|
||||
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, 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)
|
||||
patchImageHookReadiness(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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* 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 defaultRuntimeRoot = path.join(projectRoot, 'node_modules', 'sherpa-onnx-win-x64')
|
||||
const requiredFiles = ['package.json', 'sherpa-onnx.node']
|
||||
|
||||
function hasWindowsSherpaRuntime(runtimeRoot = defaultRuntimeRoot) {
|
||||
return requiredFiles.every((fileName) => fs.existsSync(path.join(runtimeRoot, fileName)))
|
||||
}
|
||||
|
||||
function ensureWindowsSherpaRuntime() {
|
||||
if (hasWindowsSherpaRuntime()) {
|
||||
console.log('[prepare-win-runtime] sherpa-onnx-win-x64 is ready')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[prepare-win-runtime] installing cross-platform optional dependencies')
|
||||
execFileSync(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
['install', '--force', '--ignore-scripts'],
|
||||
{
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
|
||||
if (!hasWindowsSherpaRuntime()) {
|
||||
throw new Error(`Missing Windows sherpa runtime: ${defaultRuntimeRoot}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) ensureWindowsSherpaRuntime()
|
||||
|
||||
module.exports = { hasWindowsSherpaRuntime, ensureWindowsSherpaRuntime }
|
||||
@@ -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,54 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Query Agent POC 快速运行入口:直接执行已构建的 out/main/queryAgentPoc.js,不做任何构建。
|
||||
*
|
||||
* 与 `pnpm poc:query-agent` 的分工:
|
||||
* - poc:query-agent : 先 electron-vite build,再运行(代码改动后使用)
|
||||
* - poc:query-agent:run : 只运行现有构建产物(连续测试使用)
|
||||
*
|
||||
* 若构建产物不存在,给出明确提示;不会偷偷触发 full build,否则 fast-run 失去意义。
|
||||
*
|
||||
* 可选环境变量:
|
||||
* TRACEMEMO_POC_ELECTRON 指定 Electron 可执行文件(默认取 node_modules 中的 electron)
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { spawnSync } = require('child_process')
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
const pocEntry = path.join(repoRoot, 'out', 'main', 'queryAgentPoc.js')
|
||||
|
||||
if (!fs.existsSync(pocEntry)) {
|
||||
process.stderr.write(
|
||||
[
|
||||
'',
|
||||
'[poc] POC build 不存在,请先运行:',
|
||||
' pnpm poc:query-agent "你的问题"',
|
||||
'',
|
||||
` 预期构建产物:${path.relative(repoRoot, pocEntry)}`,
|
||||
' (本入口有意不自动构建,以免失去快速运行的意义)',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 该入口的目标就是启动 Electron 主进程,因此必须清掉会让 Electron 退化成纯 Node 的标记。
|
||||
// 部分 IDE 集成终端会注入 ELECTRON_RUN_AS_NODE=1。
|
||||
const env = { ...process.env }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
|
||||
// 在普通 Node 中 require('electron') 返回可执行文件路径。
|
||||
const electronBinary = env.TRACEMEMO_POC_ELECTRON || require('electron')
|
||||
|
||||
const result = spawnSync(electronBinary, [pocEntry, ...process.argv.slice(2)], {
|
||||
stdio: 'inherit',
|
||||
cwd: repoRoot,
|
||||
env
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
process.stderr.write(`[poc] 启动 Electron 失败:${result.error.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(typeof result.status === 'number' ? result.status : 1)
|
||||
@@ -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,80 +0,0 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
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 := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
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) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
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] + "..."
|
||||
}
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_DEEPSEEK_API_KEY: string
|
||||
readonly VITE_SCHEDULED_REPORT_DEBUG: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -329,6 +329,32 @@ body {
|
||||
color: var(--accent);
|
||||
}
|
||||
.timeline-month small { color: inherit; }
|
||||
.timeline-month-entry { display: grid; gap: 2px; }
|
||||
.timeline-days {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
margin: 0 0 4px 11px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.timeline-days[hidden] { display: none; }
|
||||
.timeline-day {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 5px 7px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
.timeline-day:hover, .timeline-day.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.timeline-day small { color: inherit; }
|
||||
.scroll { width: 100%; max-width: 100%; overflow: auto; min-width: 0; padding: 10px 8px 36px; }
|
||||
.lazy-hint {
|
||||
width: min(100%, 820px);
|
||||
@@ -787,6 +813,23 @@ body {
|
||||
}
|
||||
.timeline-months { display: flex; gap: 6px; }
|
||||
.timeline-months[hidden] { display: none; }
|
||||
.timeline-month-entry { display: grid; gap: 2px; }
|
||||
.timeline-days {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
.timeline-days[hidden] { display: none; }
|
||||
.timeline-day {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 5px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timeline-day:hover, .timeline-day.active { border-bottom-color: var(--accent); }
|
||||
.timeline-month {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
@@ -879,6 +922,9 @@ const renderExportScript = (name: string): string => `
|
||||
let scrollLoadSuppressed = false
|
||||
let activeMonthUpdatePending = false
|
||||
let expandedTimelineYear = ''
|
||||
let expandedTimelineMonth = ''
|
||||
let selectedTimelineMonth = ''
|
||||
let selectedTimelineDate = ''
|
||||
const tabPositions = new Map()
|
||||
let lastScrollTop = 0
|
||||
let zoom = 1
|
||||
@@ -941,6 +987,13 @@ const renderExportScript = (name: string): string => `
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.getFullYear() + '-' + pad(date.getMonth() + 1)
|
||||
}
|
||||
const dateKey = (message) => {
|
||||
const timestamp = Number(message.createTime || 0)
|
||||
if (!timestamp) return 'unknown'
|
||||
const date = new Date(timestamp * 1000)
|
||||
if (Number.isNaN(date.getTime())) return 'unknown'
|
||||
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
|
||||
}
|
||||
const kindOf = (message) => {
|
||||
const data = message.contentData || {}
|
||||
const shareType = String(data.typeVal || '')
|
||||
@@ -1317,6 +1370,15 @@ const renderExportScript = (name: string): string => `
|
||||
if (months) months.hidden = !expanded
|
||||
})
|
||||
}
|
||||
const setExpandedTimelineMonth = (month) => {
|
||||
expandedTimelineMonth = month || ''
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
const expanded = button.dataset.month === expandedTimelineMonth
|
||||
button.setAttribute('aria-expanded', String(expanded))
|
||||
const days = button.nextElementSibling
|
||||
if (days) days.hidden = !expanded
|
||||
})
|
||||
}
|
||||
const renderTimeline = () => {
|
||||
if (filtered.length === 0) {
|
||||
expandedTimelineYear = ''
|
||||
@@ -1327,18 +1389,36 @@ const renderExportScript = (name: string): string => `
|
||||
for (const message of filtered) {
|
||||
const key = monthKey(message)
|
||||
if (key === 'unknown') continue
|
||||
groups.set(key, (groups.get(key) || 0) + 1)
|
||||
const date = dateKey(message)
|
||||
const group = groups.get(key) || { total: 0, days: new Map() }
|
||||
group.total += 1
|
||||
if (date !== 'unknown') group.days.set(date, (group.days.get(date) || 0) + 1)
|
||||
groups.set(key, group)
|
||||
}
|
||||
const yearGroups = new Map()
|
||||
for (const [key, total] of groups) {
|
||||
for (const [key, group] of groups) {
|
||||
const parts = key.split('-')
|
||||
if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], [])
|
||||
yearGroups.get(parts[0]).push({ key, month: Number(parts[1]), total })
|
||||
yearGroups.get(parts[0]).push({
|
||||
key,
|
||||
month: Number(parts[1]),
|
||||
total: group.total,
|
||||
days: Array.from(group.days.entries())
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([date, total]) => ({ date, day: Number(date.slice(-2)), total }))
|
||||
})
|
||||
}
|
||||
const years = Array.from(yearGroups.keys())
|
||||
const years = Array.from(yearGroups.keys()).sort((left, right) => left.localeCompare(right))
|
||||
if (!years.includes(expandedTimelineYear)) {
|
||||
expandedTimelineYear = years[years.length - 1] || ''
|
||||
}
|
||||
for (const entries of yearGroups.values()) {
|
||||
entries.sort((left, right) => left.key.localeCompare(right.key))
|
||||
}
|
||||
const allMonthKeys = Array.from(groups.keys()).sort((left, right) => left.localeCompare(right))
|
||||
if (!allMonthKeys.includes(expandedTimelineMonth)) {
|
||||
expandedTimelineMonth = allMonthKeys[allMonthKeys.length - 1] || ''
|
||||
}
|
||||
let html = ''
|
||||
for (const year of years) {
|
||||
const expanded = year === expandedTimelineYear
|
||||
@@ -1348,10 +1428,18 @@ const renderExportScript = (name: string): string => `
|
||||
'" aria-expanded="' + String(expanded) + '" aria-controls="' + esc(monthsId) + '">' +
|
||||
esc(year) + ' 年</button>' +
|
||||
'<div class="timeline-months" id="' + esc(monthsId) + '"' + (expanded ? '' : ' hidden') + '>' +
|
||||
yearGroups.get(year).map((entry) =>
|
||||
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) + '">' +
|
||||
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>'
|
||||
).join('') + '</div></section>'
|
||||
yearGroups.get(year).map((entry) => {
|
||||
const monthExpanded = entry.key === expandedTimelineMonth
|
||||
return '<div class="timeline-month-entry">' +
|
||||
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) +
|
||||
'" aria-expanded="' + String(monthExpanded) + '">' +
|
||||
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>' +
|
||||
'<div class="timeline-days" data-days-for-month="' + esc(entry.key) + '"' +
|
||||
(monthExpanded ? '' : ' hidden') + '>' + entry.days.map((day) =>
|
||||
'<button class="timeline-day" type="button" data-date="' + esc(day.date) + '">' +
|
||||
'<span>' + esc(day.date.slice(5)) + '</span><small>' + day.total + '</small></button>'
|
||||
).join('') + '</div></div>'
|
||||
}).join('') + '</div></section>'
|
||||
}
|
||||
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
|
||||
}
|
||||
@@ -1377,16 +1465,27 @@ const renderExportScript = (name: string): string => `
|
||||
const activeMessage = atBottom
|
||||
? visible[visible.length - 1] || messages[messages.length - 1]
|
||||
: anchoredMessage || visible[0] || messages[0]
|
||||
const key = activeMessage && activeMessage.dataset.month
|
||||
const key = selectedTimelineMonth || (selectedTimelineDate
|
||||
? selectedTimelineDate.slice(0, 7)
|
||||
: '') ||
|
||||
(activeMessage && activeMessage.dataset.month)
|
||||
const activeIndex = activeMessage ? Number(activeMessage.dataset.index) : -1
|
||||
const activeDate =
|
||||
selectedTimelineDate ||
|
||||
(activeIndex >= 0 && filtered[activeIndex] ? dateKey(filtered[activeIndex]) : '')
|
||||
let activeButton
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
const active = button.dataset.month === key
|
||||
button.classList.toggle('active', active)
|
||||
if (active) activeButton = button
|
||||
})
|
||||
timeline.querySelectorAll('.timeline-day').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.date === activeDate)
|
||||
})
|
||||
if (!activeButton) return
|
||||
const activeYear = key.split('-')[0]
|
||||
if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear)
|
||||
if (key !== expandedTimelineMonth) setExpandedTimelineMonth(key)
|
||||
const timelineBounds = timeline.getBoundingClientRect()
|
||||
const buttonBounds = activeButton.getBoundingClientRect()
|
||||
if (buttonBounds.top < timelineBounds.top) {
|
||||
@@ -1535,6 +1634,8 @@ const renderExportScript = (name: string): string => `
|
||||
)
|
||||
}
|
||||
const applyFilters = (restorePosition = false) => {
|
||||
selectedTimelineMonth = ''
|
||||
selectedTimelineDate = ''
|
||||
filtered = matchingMessages()
|
||||
renderTimeline()
|
||||
if (!restorePosition || !restoreTabPosition()) resetWindow(true)
|
||||
@@ -1572,18 +1673,32 @@ const renderExportScript = (name: string): string => `
|
||||
target.classList.add('located')
|
||||
window.setTimeout(() => target.classList.remove('located'), 1600)
|
||||
}
|
||||
const jumpToMonth = (key) => {
|
||||
const index = filtered.findIndex((message) => monthKey(message) === key)
|
||||
const selectTimelineMonth = (key) => {
|
||||
if (!filtered.some((message) => monthKey(message) === key)) return
|
||||
selectedTimelineMonth = key
|
||||
selectedTimelineDate = ''
|
||||
setExpandedTimelineYear(key.split('-')[0])
|
||||
setExpandedTimelineMonth(key)
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.month === key)
|
||||
})
|
||||
}
|
||||
const jumpToDate = (key) => {
|
||||
const index = filtered.findIndex((message) => dateKey(message) === key)
|
||||
if (index < 0) return
|
||||
selectedTimelineMonth = ''
|
||||
selectedTimelineDate = key
|
||||
windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4))
|
||||
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
|
||||
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
|
||||
renderWindow()
|
||||
const target = list.querySelector('.message[data-index="' + index + '"]')
|
||||
setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0)
|
||||
setExpandedTimelineYear(key.split('-')[0])
|
||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.month === key)
|
||||
const month = key.slice(0, 7)
|
||||
setExpandedTimelineYear(month.slice(0, 4))
|
||||
setExpandedTimelineMonth(month)
|
||||
timeline.querySelectorAll('.timeline-day').forEach((button) => {
|
||||
button.classList.toggle('active', button.dataset.date === key)
|
||||
})
|
||||
}
|
||||
const slideWindow = (direction) => {
|
||||
@@ -1620,6 +1735,10 @@ const renderExportScript = (name: string): string => `
|
||||
const nearTop = currentTop < 180
|
||||
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
|
||||
lastScrollTop = currentTop
|
||||
if (!scrollLoadSuppressed) {
|
||||
selectedTimelineMonth = ''
|
||||
selectedTimelineDate = ''
|
||||
}
|
||||
scheduleActiveMonthUpdate()
|
||||
if (scrollLoadSuppressed) return
|
||||
if (movingUp && nearTop) scheduleWindowSlide(-1)
|
||||
@@ -1676,7 +1795,12 @@ const renderExportScript = (name: string): string => `
|
||||
return
|
||||
}
|
||||
const button = event.target.closest('[data-month]')
|
||||
if (button) jumpToMonth(button.dataset.month)
|
||||
if (button) {
|
||||
selectTimelineMonth(button.dataset.month)
|
||||
return
|
||||
}
|
||||
const dayButton = event.target.closest('[data-date]')
|
||||
if (dayButton) jumpToDate(dayButton.dataset.date)
|
||||
})
|
||||
|
||||
const updateZoom = () => preview.style.setProperty('--zoom', zoom)
|
||||
|
||||
+175
-76
@@ -25,11 +25,12 @@ import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-c
|
||||
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
||||
import { imageFileQuality } from '../shared/image-quality'
|
||||
import { resolveMemberName } from '../shared/member-names'
|
||||
import { filesystemSafeName } from '../shared/contact-name'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const activeArchives = new Map<string, Archiver>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
||||
const safeFilePart = (value: string, fallback = '聊天档案'): string =>
|
||||
filesystemSafeName(value, fallback)
|
||||
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
|
||||
try {
|
||||
await fs.chmod(destination, 0o644)
|
||||
@@ -584,6 +585,19 @@ const detectAssetExtension = (buffer: Buffer): string | null => {
|
||||
return 'webp'
|
||||
return null
|
||||
}
|
||||
const htmlArchiveStickerResourceIsValid = async (
|
||||
outputDir: string,
|
||||
value?: string
|
||||
): Promise<boolean> => {
|
||||
const filePath = htmlArchiveResourcePath(outputDir, value)
|
||||
if (!filePath) return false
|
||||
try {
|
||||
return detectAssetExtension(await fs.readFile(filePath)) !== null
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
async function readAvatarAsset(
|
||||
source: string
|
||||
): Promise<{ extension: string; buffer: Buffer } | null> {
|
||||
@@ -630,6 +644,7 @@ const kindOf = (message: Message): ExportMessageKind => {
|
||||
return 'text'
|
||||
}
|
||||
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const csvBom = '\uFEFF'
|
||||
|
||||
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
||||
if (format === 'html') return renderExportPage(name)
|
||||
@@ -637,7 +652,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
||||
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||||
if (format === 'markdown')
|
||||
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}`
|
||||
return [
|
||||
return csvBom + [
|
||||
'时间,发送者,类型,内容,媒体路径,媒体状态',
|
||||
...messages.map((m) =>
|
||||
[
|
||||
@@ -651,7 +666,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
||||
.map(csv)
|
||||
.join(',')
|
||||
)
|
||||
].join('\n')
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
interface SingleExportOptions {
|
||||
@@ -668,6 +683,8 @@ interface AllExportManifestEntry {
|
||||
type: ExportTarget['type']
|
||||
folder: string
|
||||
messageCount: number
|
||||
status: 'completed' | 'failed'
|
||||
error?: string
|
||||
}
|
||||
|
||||
const writeAllExportManifest = async (
|
||||
@@ -1073,7 +1090,10 @@ async function runSingleExport(
|
||||
}
|
||||
const voiceService =
|
||||
request.includeMedia && chat.getChatDb()
|
||||
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
||||
? new VoiceService(
|
||||
chat.getChatDb()!.getWcdb4Client(),
|
||||
chat.getChatDb()!.getWcdb4Client().getAccountRoot()
|
||||
)
|
||||
: null
|
||||
const voiceMessages = messages.filter((message) => kindOf(message) === 'voice')
|
||||
const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media'
|
||||
@@ -1220,7 +1240,21 @@ async function runSingleExport(
|
||||
markResourceExists(voiceUrl)
|
||||
}
|
||||
message.voiceDataUrl = voiceUrl
|
||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||
// 读取 WAV 头中的采样率,避免按错误采样率计算时长(Silk 解码为 16kHz)
|
||||
const wavSampleRate =
|
||||
audioBuffer.length >= 44 ? audioBuffer.readUInt32LE(24) : 16000
|
||||
const wavChannels =
|
||||
audioBuffer.length >= 44 ? audioBuffer.readUInt16LE(22) : 1
|
||||
const pcmBytes = Math.max(0, audioBuffer.length - 44)
|
||||
// 口径统一:消息解析阶段已从 <voicemsg voicelength> 拿到微信的原始秒数(带小数),
|
||||
// 它是唯一权威来源,不要覆盖。只有拿不到时才退回用 PCM 字节数估算——
|
||||
// 那份估算是整秒、且下限 1 秒(WAV 缺失头部时的兜底),语义不同。
|
||||
if (message.voiceDuration == null) {
|
||||
message.voiceDuration = Math.max(
|
||||
1,
|
||||
Math.round(pcmBytes / (wavSampleRate * wavChannels * 2))
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
keepMediaError(
|
||||
request,
|
||||
@@ -1329,12 +1363,28 @@ async function runSingleExport(
|
||||
const reusableImageQuality =
|
||||
previous?.exportMediaQuality === 'original' ||
|
||||
(request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail')
|
||||
let reusableMediaExists = false
|
||||
if (reusableMediaType && previous?.exportMediaUrl) {
|
||||
reusableMediaExists = await resourceExists(previous.exportMediaUrl)
|
||||
if (
|
||||
reusableMediaExists &&
|
||||
reusableMediaType === 'sticker' &&
|
||||
!(await htmlArchiveStickerResourceIsValid(outputDir, previous.exportMediaUrl))
|
||||
) {
|
||||
reusableMediaExists = false
|
||||
resourceExistence.set(previous.exportMediaUrl, Promise.resolve(false))
|
||||
delete previous.exportMediaUrl
|
||||
delete previous.exportMediaType
|
||||
delete previous.exportMediaName
|
||||
delete previous.exportMediaQuality
|
||||
}
|
||||
}
|
||||
if (
|
||||
reusableMediaType &&
|
||||
previous?.exportMediaUrl &&
|
||||
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
|
||||
(reusableMediaType !== 'image' || reusableImageQuality) &&
|
||||
(await resourceExists(previous.exportMediaUrl))
|
||||
reusableMediaExists
|
||||
) {
|
||||
message.exportMediaUrl = previous.exportMediaUrl
|
||||
message.exportMediaType = reusableMediaType
|
||||
@@ -1597,6 +1647,8 @@ async function runAllExport(
|
||||
let outputDir = ''
|
||||
const manifest: AllExportManifestEntry[] = []
|
||||
let totalMessages = 0
|
||||
let failedCount = 0
|
||||
const failureMessages: string[] = []
|
||||
try {
|
||||
const targets = [...(request.targets || [])].sort((left, right) =>
|
||||
left.type === right.type ? 0 : left.type === 'group' ? -1 : 1
|
||||
@@ -1629,80 +1681,121 @@ async function runAllExport(
|
||||
const categoryName = target.type === 'group' ? '群聊' : '联系人'
|
||||
const categoryDir = join(outputDir, categoryName)
|
||||
const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name)
|
||||
await fs.mkdir(categoryDir, { recursive: true })
|
||||
const conversationOutputRoot =
|
||||
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
|
||||
const basePercent = Math.floor((targetIndex / targets.length) * 100)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'reading',
|
||||
processed: 0,
|
||||
percent: basePercent,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
const folder = `${categoryName}/${folderName}`
|
||||
let result: ExportResult
|
||||
try {
|
||||
await fs.mkdir(categoryDir, { recursive: true })
|
||||
const conversationOutputRoot =
|
||||
request.format === 'html' ? categoryDir : join(categoryDir, folderName)
|
||||
const basePercent = Math.floor((targetIndex / targets.length) * 100)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'reading',
|
||||
processed: 0,
|
||||
percent: basePercent,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
|
||||
const result = await runSingleExport(
|
||||
{
|
||||
...request,
|
||||
targets: [target],
|
||||
outputName: target.name,
|
||||
zip: false
|
||||
},
|
||||
win,
|
||||
voiceRecognition,
|
||||
{
|
||||
outputRoot: conversationOutputRoot,
|
||||
outputFolderName: request.format === 'html' ? folderName : undefined,
|
||||
manageJob: false,
|
||||
selfInfo,
|
||||
sendProgress: (childProgress) => {
|
||||
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
|
||||
const percent = Math.min(
|
||||
99,
|
||||
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
|
||||
)
|
||||
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
|
||||
const now = Date.now()
|
||||
const progressKey = `${targetIndex}:${phase}:${percent}`
|
||||
const terminal = phase === 'failed' || phase === 'cancelled'
|
||||
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
|
||||
lastProgressKey = progressKey
|
||||
lastProgressAt = now
|
||||
send({
|
||||
...childProgress,
|
||||
jobId: request.jobId,
|
||||
phase,
|
||||
percent,
|
||||
outputPath: undefined,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
result = await runSingleExport(
|
||||
{
|
||||
...request,
|
||||
targets: [target],
|
||||
outputName: target.name,
|
||||
zip: false
|
||||
},
|
||||
win,
|
||||
voiceRecognition,
|
||||
{
|
||||
outputRoot: conversationOutputRoot,
|
||||
outputFolderName: request.format === 'html' ? folderName : undefined,
|
||||
manageJob: false,
|
||||
selfInfo,
|
||||
sendProgress: (childProgress) => {
|
||||
const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0))
|
||||
const percent = Math.min(
|
||||
99,
|
||||
Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100)
|
||||
)
|
||||
const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase
|
||||
const now = Date.now()
|
||||
const progressKey = `${targetIndex}:${phase}:${percent}`
|
||||
const terminal = phase === 'failed' || phase === 'cancelled'
|
||||
if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return
|
||||
lastProgressKey = progressKey
|
||||
lastProgressAt = now
|
||||
send({
|
||||
...childProgress,
|
||||
jobId: request.jobId,
|
||||
phase,
|
||||
percent,
|
||||
outputPath: undefined,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!result.success) {
|
||||
if (result.error === '已取消') throw new Error('已取消')
|
||||
throw new Error(`${target.name}:${result.error || '导出失败'}`)
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
|
||||
result = { success: false, error: message }
|
||||
}
|
||||
|
||||
const messageCount = result.messageCount || 0
|
||||
totalMessages += messageCount
|
||||
manifest.push({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
folder: `${categoryName}/${folderName}`,
|
||||
messageCount
|
||||
})
|
||||
if (!result.success) {
|
||||
if (result.error === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消')
|
||||
const error = `${target.name}:${result.error || '导出失败'}`
|
||||
failedCount += 1
|
||||
failureMessages.push(error)
|
||||
manifest.push({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
folder,
|
||||
messageCount: 0,
|
||||
status: 'failed',
|
||||
error: result.error || '导出失败'
|
||||
})
|
||||
const failedPercent = Math.min(
|
||||
99,
|
||||
Math.floor(((targetIndex + 1) / targets.length) * 100)
|
||||
)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'failed',
|
||||
processed: totalMessages,
|
||||
total: totalMessages,
|
||||
percent: failedPercent,
|
||||
error,
|
||||
currentTargetIndex: targetIndex + 1,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: target.name,
|
||||
currentTargetType: target.type
|
||||
})
|
||||
} else {
|
||||
const messageCount = result.messageCount || 0
|
||||
totalMessages += messageCount
|
||||
manifest.push({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
folder,
|
||||
messageCount,
|
||||
status: 'completed'
|
||||
})
|
||||
}
|
||||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'running')
|
||||
}
|
||||
|
||||
await writeAllExportManifest(outputDir, manifest, totalMessages, 'completed')
|
||||
const finalStatus = failedCount ? 'failed' : 'completed'
|
||||
const finalError = failedCount
|
||||
? `全部导出完成:成功 ${targets.length - failedCount} 个,失败 ${failedCount} 个。${failureMessages.join(';')}`
|
||||
: undefined
|
||||
await writeAllExportManifest(outputDir, manifest, totalMessages, finalStatus, finalError)
|
||||
|
||||
let completedPath = outputDir
|
||||
if (request.zip) {
|
||||
@@ -1721,17 +1814,23 @@ async function runAllExport(
|
||||
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'completed',
|
||||
phase: failedCount ? 'failed' : 'completed',
|
||||
processed: totalMessages,
|
||||
total: totalMessages,
|
||||
percent: 100,
|
||||
error: finalError,
|
||||
outputPath: completedPath,
|
||||
currentTargetIndex: targets.length,
|
||||
currentTargetCount: targets.length,
|
||||
currentTargetName: targets.at(-1)?.name,
|
||||
currentTargetType: targets.at(-1)?.type
|
||||
})
|
||||
return { success: true, outputPath: completedPath, messageCount: totalMessages }
|
||||
return {
|
||||
success: failedCount === 0,
|
||||
outputPath: completedPath,
|
||||
messageCount: totalMessages,
|
||||
...(finalError ? { error: finalError } : {})
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const cancelled = !jobs.has(request.jobId) || message === '已取消'
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import crypto from 'node:crypto'
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
GroupReportExportRequest,
|
||||
GroupReportExportResult,
|
||||
@@ -10,11 +13,16 @@ import {
|
||||
GroupReportRenderSnapshotExportRequest,
|
||||
ReportHeat,
|
||||
ReportSectionMeta,
|
||||
buildReportAvatarAliasIndex,
|
||||
mergeReportAvatars,
|
||||
selectHeroParticipantNames
|
||||
} from '../shared/group-report'
|
||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||
import { imageInsightService } from './services/image-insight-service'
|
||||
import { getReportTemplate } from '../shared/report-templates'
|
||||
import type { ReportTemplateRef } from '../shared/report-template-package'
|
||||
import { injectReportTemplateFragmentContract } from '../shared/report-template-fragment-contract'
|
||||
import { reportTemplateService, validateReportTemplateHtml } from './report-template-service'
|
||||
|
||||
const LEGACY_TEMPLATE_FILES: Record<string, string> = {
|
||||
v1: 'mobile_daily_report_v1.html',
|
||||
@@ -33,6 +41,40 @@ const templatePath = (templateId?: string): string => {
|
||||
return found
|
||||
}
|
||||
|
||||
const resolveTemplateSelection = async (templateId?: string, templateRef?: ReportTemplateRef) => {
|
||||
if (!templateRef) {
|
||||
const definition = getReportTemplate(templateId)
|
||||
return {
|
||||
definition,
|
||||
entryPath: templatePath(templateId),
|
||||
captureMaxHeight: 20000,
|
||||
source: 'builtin' as const
|
||||
}
|
||||
}
|
||||
const installed = await reportTemplateService.resolve(templateRef)
|
||||
return {
|
||||
definition: {
|
||||
id: installed.id,
|
||||
order: 999,
|
||||
platform: 'default' as const,
|
||||
label: installed.name,
|
||||
name: installed.name,
|
||||
tagline: `${installed.author} · ${installed.version}`,
|
||||
fileLabel: installed.name,
|
||||
cssClass: `template-external-${installed.id.replace(/[^a-z0-9-]/gi, '-')}`,
|
||||
resourceFile: installed.entryPath,
|
||||
captureWidth: installed.capture.width,
|
||||
maxCaptureWidth: installed.capture.maxWidth
|
||||
},
|
||||
entryPath: installed.entryPath,
|
||||
captureMaxHeight: installed.capture.maxHeight,
|
||||
source: 'installed' as const
|
||||
}
|
||||
}
|
||||
|
||||
const resolveTemplate = async (request: GroupReportExportRequest) =>
|
||||
resolveTemplateSelection(request.templateId, request.templateRef)
|
||||
|
||||
const escapeHtml = (value: unknown): string =>
|
||||
String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
@@ -53,55 +95,161 @@ const hashName = (name: string): number => {
|
||||
return hash
|
||||
}
|
||||
|
||||
const fallbackAvatar = (name: string): string => {
|
||||
type RenderedAvatar = { source: string; fallback: boolean }
|
||||
|
||||
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 `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
||||
}
|
||||
|
||||
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<string> => {
|
||||
if (!source) return fallbackAvatar(name)
|
||||
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
|
||||
|
||||
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 `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`
|
||||
}
|
||||
|
||||
const localPath = source.startsWith('file://') ? new URL(source) : source
|
||||
const buffer = await fs.readFile(localPath)
|
||||
return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`
|
||||
} 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
|
||||
@@ -120,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)`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,6 +290,58 @@ const heatClass = (heat: ReportHeat): string => {
|
||||
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||
html.replaceAll(`{{${key}}}`, value)
|
||||
|
||||
const addReportCsp = (html: string, allowFileImages = true): string =>
|
||||
html.replace(
|
||||
/<head(\s[^>]*)?>/i,
|
||||
(head) =>
|
||||
`${head}<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:${allowFileImages ? ' file:' : ''}; style-src 'unsafe-inline'; font-src 'none'; base-uri 'none'; form-action 'none'">`
|
||||
)
|
||||
|
||||
const inlineTemplateAssets = async (html: string, templateRoot: string): Promise<string> => {
|
||||
const assetDataUrl = async (relative: string): Promise<string | null> => {
|
||||
const assetPath = path.resolve(templateRoot, relative)
|
||||
if (!assetPath.startsWith(`${path.resolve(templateRoot)}${path.sep}`)) return null
|
||||
const extension = path.extname(assetPath).toLowerCase()
|
||||
const mime =
|
||||
extension === '.png' ? 'image/png' : extension === '.webp' ? 'image/webp' : 'image/jpeg'
|
||||
const data = (await fs.readFile(assetPath)).toString('base64')
|
||||
return `data:${mime};base64,${data}`
|
||||
}
|
||||
let result = html
|
||||
for (const match of html.matchAll(/\bsrc=(['"])(assets\/[A-Za-z0-9._/-]+)\1/g)) {
|
||||
const dataUrl = await assetDataUrl(match[2])
|
||||
if (dataUrl) result = result.replace(match[0], `src="${dataUrl}"`)
|
||||
}
|
||||
for (const match of html.matchAll(/\burl\(\s*(['"]?)(assets\/[A-Za-z0-9._/-]+)\1\s*\)/g)) {
|
||||
const dataUrl = await assetDataUrl(match[2])
|
||||
if (dataUrl) result = result.replace(match[0], `url("${dataUrl}")`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const recordBlockedTemplateRequest = (
|
||||
details: Electron.OnBeforeRequestListenerDetails,
|
||||
reason: string
|
||||
): void => {
|
||||
// 仅测试入口设置该路径,用于保留运行时拦截证据;生产默认不记录请求明细。
|
||||
const logPath = process.env.TRACEMEMO_TEMPLATE_SECURITY_LOG
|
||||
if (!logPath) return
|
||||
try {
|
||||
appendFileSync(
|
||||
logPath,
|
||||
`${JSON.stringify({
|
||||
url: details.url,
|
||||
method: details.method,
|
||||
resourceType: details.resourceType,
|
||||
reason
|
||||
})}\n`,
|
||||
'utf8'
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[GroupReport] failed to record template security block:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const sectionMeta = (
|
||||
request: GroupReportExportRequest,
|
||||
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||
@@ -170,7 +364,8 @@ const overflowNote = (
|
||||
|
||||
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||
const { report, metadata } = request
|
||||
const template = getReportTemplate(request.templateId)
|
||||
const resolvedTemplate = await resolveTemplate(request)
|
||||
const template = resolvedTemplate.definition
|
||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
|
||||
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
|
||||
@@ -181,24 +376,42 @@ 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, string>()
|
||||
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 avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
|
||||
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,
|
||||
role: 'hero' | 'message' | 'participant' | 'ranking',
|
||||
legacyClass = '',
|
||||
alt = ''
|
||||
): string => {
|
||||
const rendered = avatar(name)
|
||||
const fallbackClass = rendered.fallback ? ' tm-avatar--fallback' : ''
|
||||
return `<img class="${legacyClass ? `${legacyClass} ` : ''}tm-avatar tm-avatar--${role}${fallbackClass}" src="${escapeHtml(rendered.source)}" alt="${escapeHtml(alt)}">`
|
||||
}
|
||||
|
||||
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
|
||||
const heroAvatars = heroNames
|
||||
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(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
|
||||
.map(
|
||||
(topic) => `<div class="card topic-card">
|
||||
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||
(topic) => `<div class="card topic-card tm-fragment tm-topic-card">
|
||||
<div class="topic-title-row tm-topic-card__title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
||||
<p>${escapeHtml(topic.summary)}</p>
|
||||
${
|
||||
@@ -237,15 +450,15 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
}
|
||||
}
|
||||
if (!imageUrl) return ''
|
||||
return `<div class="topic-inline-image"><img src="${imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||
return `<div class="topic-inline-image"><img src="${escapeHtml(imageUrl)}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||
})()
|
||||
: ''
|
||||
}
|
||||
<div class="participants">${topic.participants
|
||||
<div class="participants tm-topic-card__participants">${topic.participants
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(name) =>
|
||||
`<span class="person-chip"><img src="${avatar(name)}" alt=""><b>${escapeHtml(name)}</b></span>`
|
||||
`<span class="person-chip tm-participant">${renderAvatar(name, 'participant')}<b class="tm-participant__name">${escapeHtml(name)}</b></span>`
|
||||
)
|
||||
.join('')}</div>
|
||||
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
|
||||
@@ -262,10 +475,10 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
const importantMessages = report.importantMessages
|
||||
.map(
|
||||
(message) => `<div class="important-card">
|
||||
<img class="avatar" src="${avatar(message.sender)}" alt="">
|
||||
<div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div>
|
||||
<div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div>
|
||||
(message) => `<div class="important-card tm-fragment tm-message tm-message--important">
|
||||
${renderAvatar(message.sender, 'message', 'avatar')}
|
||||
<div class="important-body tm-message__body"><div class="important-meta tm-message__meta"><b class="tm-message__author">${escapeHtml(message.sender)}</b><span class="tm-message__time">${escapeHtml(message.time)}</span></div>
|
||||
<div class="important-text tm-message__text">${escapeHtml(message.content)}</div><div class="important-note tm-message__note">${escapeHtml(message.note)}</div></div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
@@ -273,12 +486,12 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
const quoteBlocks = report.quotes
|
||||
.map(
|
||||
(quote) =>
|
||||
`<div class="chat-block">${quote.messages
|
||||
`<div class="chat-block tm-fragment tm-quote">${quote.messages
|
||||
.map(
|
||||
(
|
||||
message
|
||||
) => `<div class="chat-msg"><img class="chat-avatar" src="${avatar(message.sender)}" alt=""><div>
|
||||
<div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div>
|
||||
) => `<div class="chat-msg tm-message tm-message--quote">${renderAvatar(message.sender, 'message', 'chat-avatar')}<div class="tm-message__body">
|
||||
<div class="chat-name tm-message__author">${escapeHtml(message.sender)}</div><div class="chat-bubble tm-message__text">${escapeHtml(message.content)}</div>
|
||||
</div></div>`
|
||||
)
|
||||
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`
|
||||
@@ -287,7 +500,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
const todoCards = (report.todos || [])
|
||||
.map(
|
||||
(item) => `<div class="action-card todo-card">
|
||||
(item) => `<div class="action-card todo-card tm-fragment tm-todo-card">
|
||||
<b>${escapeHtml(item.task)}</b>
|
||||
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
|
||||
@@ -297,7 +510,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
const unresolvedCards = (report.unresolved || [])
|
||||
.map(
|
||||
(item) => `<div class="action-card unresolved-card">
|
||||
(item) => `<div class="action-card unresolved-card tm-fragment tm-unresolved-card">
|
||||
<b>${escapeHtml(item.question)}</b>
|
||||
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||
<div class="action-note">${escapeHtml(item.note)}</div>
|
||||
@@ -324,7 +537,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
const reversalCards = (report.reversals || [])
|
||||
.map(
|
||||
(item) => `<div class="qa-card">
|
||||
(item) => `<div class="qa-card tm-fragment tm-reversal-card">
|
||||
<b>${escapeHtml(item.topic)}</b>
|
||||
<div>最初:${escapeHtml(item.initialView)}</div>
|
||||
<div>后来:${escapeHtml(item.finalView)}</div>
|
||||
@@ -349,7 +562,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
.filter((item) => item.imageUrl) // 只显示加载成功的图
|
||||
.map(
|
||||
(item) => `<div class="vision-card">
|
||||
<img class="vision-image" src="${item.imageUrl}" alt="AI 识别的图片">
|
||||
<img class="vision-image" src="${escapeHtml(item.imageUrl)}" alt="AI 识别的图片">
|
||||
<div class="vision-body">
|
||||
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||
<div class="vision-description">${escapeHtml(item.description)}</div>
|
||||
@@ -363,7 +576,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
const voiceCards = (report.media?.voiceHighlights || [])
|
||||
.map(
|
||||
(item) => `<div class="qa-card">
|
||||
(item) => `<div class="qa-card tm-fragment tm-voice-card">
|
||||
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
|
||||
<div>${escapeHtml(item.note)}</div>
|
||||
</div>`
|
||||
@@ -372,10 +585,10 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
const voiceRankCards = (report.analytics.voiceLeaderboard || [])
|
||||
.map(
|
||||
(item, index) => `<div class="rank">
|
||||
<img src="${avatar(item.sender)}" alt="">
|
||||
(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('')
|
||||
@@ -394,7 +607,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(speaker, index) =>
|
||||
`<div class="rank"><img src="${avatar(speaker.name)}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
|
||||
`<div class="rank tm-fragment tm-ranking-item">${renderAvatar(speaker.name, 'ranking')}<b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
@@ -424,7 +637,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
const qaCards = report.qa
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||
`<div class="qa-card tm-fragment tm-qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
@@ -441,7 +654,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
unresolvedCount: report.unresolved.length
|
||||
}
|
||||
|
||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
||||
let html = await fs.readFile(resolvedTemplate.entryPath, 'utf8')
|
||||
if (resolvedTemplate.source === 'installed') {
|
||||
validateReportTemplateHtml(html, path.basename(resolvedTemplate.entryPath))
|
||||
html = await inlineTemplateAssets(html, path.dirname(resolvedTemplate.entryPath))
|
||||
}
|
||||
const values: Record<string, string> = {
|
||||
TEMPLATE_CLASS: template.cssClass,
|
||||
TEMPLATE_LABEL: escapeHtml(template.label),
|
||||
@@ -547,14 +764,22 @@ 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 html
|
||||
return addReportCsp(
|
||||
injectReportTemplateFragmentContract(html),
|
||||
resolvedTemplate.source === 'builtin'
|
||||
)
|
||||
}
|
||||
|
||||
const renderReportSnapshotHtml = async (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
): Promise<string> => {
|
||||
const template = getReportTemplate(request.templateId)
|
||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
||||
const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef)
|
||||
const template = resolvedTemplate.definition
|
||||
let html = await fs.readFile(resolvedTemplate.entryPath, 'utf8')
|
||||
if (resolvedTemplate.source === 'installed') {
|
||||
validateReportTemplateHtml(html, path.basename(resolvedTemplate.entryPath))
|
||||
html = await inlineTemplateAssets(html, path.dirname(resolvedTemplate.entryPath))
|
||||
}
|
||||
const values = {
|
||||
...request.snapshot.values,
|
||||
TEMPLATE_CLASS: template.cssClass,
|
||||
@@ -565,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 html.replace(/\{\{[A-Z0-9_]+\}\}/g, '')
|
||||
return addReportCsp(
|
||||
html.replace(/\{\{[A-Z0-9_]+\}\}/g, ''),
|
||||
resolvedTemplate.source === 'builtin'
|
||||
)
|
||||
}
|
||||
|
||||
export const extractGroupReportRenderSnapshot = async (
|
||||
@@ -777,23 +1005,68 @@ export const extractGroupReportRenderSnapshot = async (
|
||||
const captureFullPage = async (
|
||||
htmlPath: string,
|
||||
pngPath: string,
|
||||
templateId?: string
|
||||
templateId?: string,
|
||||
templateOverride?: {
|
||||
captureWidth: number
|
||||
maxCaptureWidth: number
|
||||
maxCaptureHeight?: number
|
||||
}
|
||||
): Promise<string> => {
|
||||
const template = getReportTemplate(templateId)
|
||||
const template = templateOverride || getReportTemplate(templateId)
|
||||
const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth
|
||||
const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth
|
||||
const maxCaptureHeight = templateOverride?.maxCaptureHeight || 20000
|
||||
console.log(`[GroupReport] capture begin html=${htmlPath}`)
|
||||
const reportSessionPartition = `report-template-${crypto.randomUUID()}`
|
||||
const reportWindow = new BrowserWindow({
|
||||
show: false,
|
||||
opacity: 0,
|
||||
skipTaskbar: true,
|
||||
width: captureWidth,
|
||||
height: 800,
|
||||
frame: false,
|
||||
backgroundColor: '#f3f5f7',
|
||||
webPreferences: { sandbox: true }
|
||||
webPreferences: {
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
partition: reportSessionPartition
|
||||
}
|
||||
})
|
||||
const allowedDocumentPath = path.resolve(htmlPath)
|
||||
const requestHandler = (
|
||||
details: Electron.OnBeforeRequestListenerDetails,
|
||||
callback: (response: { cancel: boolean }) => void
|
||||
): void => {
|
||||
if (details.url.startsWith('file:')) {
|
||||
try {
|
||||
const requestedPath = path.resolve(fileURLToPath(details.url))
|
||||
if (requestedPath === allowedDocumentPath) {
|
||||
callback({ cancel: false })
|
||||
} else {
|
||||
recordBlockedTemplateRequest(details, 'file-path-not-allowed')
|
||||
callback({ cancel: true })
|
||||
}
|
||||
} catch {
|
||||
recordBlockedTemplateRequest(details, 'invalid-file-url')
|
||||
callback({ cancel: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
recordBlockedTemplateRequest(details, 'scheme-not-allowed')
|
||||
callback({ cancel: true })
|
||||
}
|
||||
reportWindow.webContents.session.webRequest.onBeforeRequest(
|
||||
{ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*', 'file://*/*'] },
|
||||
requestHandler
|
||||
)
|
||||
reportWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
reportWindow.webContents.on('will-navigate', (event) => event.preventDefault())
|
||||
|
||||
try {
|
||||
const readyToShow = new Promise<void>((resolve) => reportWindow.once('ready-to-show', resolve))
|
||||
await reportWindow.loadFile(htmlPath)
|
||||
await readyToShow
|
||||
console.log('[GroupReport] capture loaded html')
|
||||
await reportWindow.webContents.executeJavaScript(`Promise.all([
|
||||
document.fonts.ready,
|
||||
@@ -808,17 +1081,29 @@ const captureFullPage = async (
|
||||
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
|
||||
})`)) as { width: number; height: number }
|
||||
const width = Math.max(captureWidth, Math.min(maxCaptureWidth, Math.ceil(metrics.width)))
|
||||
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height)))
|
||||
reportWindow.setContentSize(width, height)
|
||||
const height = Math.max(800, Math.min(maxCaptureHeight, Math.ceil(metrics.height)))
|
||||
const [currentWidth, currentHeight] = reportWindow.getContentSize()
|
||||
if (currentWidth !== width || currentHeight !== height) {
|
||||
const resized = new Promise<void>((resolve) => reportWindow.once('resize', resolve))
|
||||
reportWindow.setContentSize(width, height)
|
||||
await resized
|
||||
}
|
||||
await reportWindow.webContents.executeJavaScript(
|
||||
'new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))'
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
console.log(`[GroupReport] capture native page width=${width} height=${height}`)
|
||||
const image = await reportWindow.webContents.capturePage({ x: 0, y: 0, width, height })
|
||||
const image = await reportWindow.webContents.capturePage(
|
||||
{ x: 0, y: 0, width, height },
|
||||
{ stayHidden: true }
|
||||
)
|
||||
const png = image.toPNG()
|
||||
if (png.length < 1000) throw new Error('生成的日报图片为空')
|
||||
await fs.writeFile(pngPath, png)
|
||||
console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`)
|
||||
return `data:image/png;base64,${png.toString('base64')}`
|
||||
} finally {
|
||||
reportWindow.webContents.session.webRequest.onBeforeRequest(null)
|
||||
reportWindow.destroy()
|
||||
}
|
||||
}
|
||||
@@ -830,10 +1115,14 @@ export const exportGroupReport = async (
|
||||
// === enrich 在 render 之前:从群成员快照反推真头像 ===
|
||||
await enrichAvatarsFromGroup(request.metadata)
|
||||
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
const outputDir =
|
||||
process.env.TRACEMEMO_REPORT_OUTPUT_DIR ||
|
||||
path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const templateLabel =
|
||||
request.templateId === 'v1'
|
||||
const resolvedTemplate = await resolveTemplate(request)
|
||||
const templateLabel = request.templateRef
|
||||
? resolvedTemplate.definition.fileLabel
|
||||
: request.templateId === 'v1'
|
||||
? '经典版'
|
||||
: request.templateId === 'v2'
|
||||
? '丰富版'
|
||||
@@ -846,7 +1135,10 @@ export const exportGroupReport = async (
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
})
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
@@ -877,9 +1169,12 @@ export const exportGroupReportSnapshot = async (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
): Promise<GroupReportExportResult> => {
|
||||
try {
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
const outputDir =
|
||||
process.env.TRACEMEMO_REPORT_OUTPUT_DIR ||
|
||||
path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const templateLabel = getReportTemplate(request.templateId).fileLabel
|
||||
const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef)
|
||||
const templateLabel = resolvedTemplate.definition.fileLabel
|
||||
const baseName = `${sanitizeFileName(request.snapshot.groupName)}日报_${request.snapshot.reportDate}_${templateLabel}`
|
||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||
@@ -888,7 +1183,10 @@ export const exportGroupReportSnapshot = async (
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
})
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
|
||||
+235
-5
@@ -3,6 +3,7 @@ import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
||||
import {
|
||||
isReady,
|
||||
listContacts,
|
||||
listContactsAsync,
|
||||
listMessages,
|
||||
getGroupSnapshot,
|
||||
listRecentChat,
|
||||
@@ -11,10 +12,22 @@ import {
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
import { generateAgentGroupReport } from './services/agent-group-report-service'
|
||||
import { scheduledReportService } from './services/scheduled-report-service'
|
||||
import { personalWechatCapabilityService } from './services/personal-wechat-capability-service'
|
||||
import {
|
||||
ScheduledReportApiError,
|
||||
ScheduledReportApiService,
|
||||
type ScheduledReportApiDependencies
|
||||
} from './services/scheduled-report-api-service'
|
||||
import type {
|
||||
ScheduledReportApiCreateRequest,
|
||||
ScheduledReportApiUpdateRequest
|
||||
} from '../shared/scheduled-report-api'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||
import { apiTokenStore } from './api-token-store'
|
||||
import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service'
|
||||
import { LocalQueryApiService } from './services/local-query-api-service'
|
||||
|
||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||
export const DEFAULT_HTTP_PORT = 6131
|
||||
@@ -35,6 +48,17 @@ interface RouteContext {
|
||||
export interface HttpServerOptions {
|
||||
tokenProvider?: () => string | null
|
||||
mediaProvider?: (messageId: string) => Promise<HttpImageResult>
|
||||
scheduledReportService?: ScheduledReportApiDependencies['service']
|
||||
scheduledReportCapabilityProvider?: ScheduledReportApiDependencies['getCapability']
|
||||
scheduledReportContactsProvider?: ScheduledReportApiDependencies['listContacts']
|
||||
scheduledReportDatabaseReadyProvider?: ScheduledReportApiDependencies['isDatabaseReady']
|
||||
scheduledReportPlatform?: NodeJS.Platform
|
||||
queryApiService?: LocalQueryApiService
|
||||
}
|
||||
|
||||
let configuredQueryApiService: LocalQueryApiService | undefined
|
||||
export function setLocalQueryApiService(service: LocalQueryApiService | undefined): void {
|
||||
configuredQueryApiService = service
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||
@@ -67,7 +91,7 @@ function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (!isAllowedCorsOrigin(origin)) return false
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
res.setHeader('Vary', 'Origin')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
return true
|
||||
}
|
||||
@@ -207,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(
|
||||
@@ -318,6 +347,11 @@ const routes: Record<string, RouteHandler> = {
|
||||
if (!request?.report || !request?.metadata) {
|
||||
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
||||
}
|
||||
if (request.templateRef !== undefined) {
|
||||
return sendError(res, 400, 'HTTP API 暂不支持外部日报模板,请使用内置 templateId', {
|
||||
code: 'external_template_unsupported'
|
||||
})
|
||||
}
|
||||
const result = await exportGroupReport(request)
|
||||
sendJson(res, result.success ? 200 : 500, result)
|
||||
},
|
||||
@@ -367,7 +401,199 @@ const routes: Record<string, RouteHandler> = {
|
||||
}
|
||||
}
|
||||
|
||||
const SCHEDULED_REPORTS_ROUTE = '/api/v1/scheduled-reports'
|
||||
const WECHAT_SEND_CAPABILITY_ROUTE = '/api/v1/wechat-personal/send-capability'
|
||||
|
||||
function parseJsonBody(body: unknown): unknown {
|
||||
if (typeof body !== 'string' || !body.trim()) {
|
||||
throw new ScheduledReportApiError(400, 'invalid_request', '请求体不能为空')
|
||||
}
|
||||
try {
|
||||
return JSON.parse(body)
|
||||
} catch {
|
||||
throw new ScheduledReportApiError(400, 'invalid_request', '请求体 JSON 解析失败')
|
||||
}
|
||||
}
|
||||
|
||||
function sendScheduledError(res: ServerResponse, error: unknown): void {
|
||||
if (error instanceof ScheduledReportApiError) {
|
||||
sendJson(res, error.status, {
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
...(error.details !== undefined ? { details: error.details } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
safeError('[HttpServer] scheduled report request failed:', error)
|
||||
sendJson(res, 500, {
|
||||
error: 'internal_error',
|
||||
message: '定时日报 API 执行失败'
|
||||
})
|
||||
}
|
||||
|
||||
function createScheduledReportApi(options: HttpServerOptions): ScheduledReportApiService {
|
||||
return new ScheduledReportApiService({
|
||||
service: options.scheduledReportService || scheduledReportService,
|
||||
getCapability:
|
||||
options.scheduledReportCapabilityProvider ||
|
||||
(() => personalWechatCapabilityService.getPersonalWechatSendCapability()),
|
||||
listContacts: options.scheduledReportContactsProvider || listContacts,
|
||||
isDatabaseReady: options.scheduledReportDatabaseReadyProvider || isReady,
|
||||
platform: options.scheduledReportPlatform
|
||||
})
|
||||
}
|
||||
|
||||
function createScheduledReportRoute(
|
||||
pathname: string,
|
||||
api: ScheduledReportApiService
|
||||
): RouteHandler | undefined {
|
||||
if (pathname === WECHAT_SEND_CAPABILITY_ROUTE) {
|
||||
return async ({ req, res }) => {
|
||||
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
|
||||
try {
|
||||
sendJson(res, 200, { capability: await api.getCapability() })
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === SCHEDULED_REPORTS_ROUTE) {
|
||||
return async ({ req, res, body }) => {
|
||||
try {
|
||||
if (req.method === 'GET') {
|
||||
const tasks = await api.list()
|
||||
sendJson(res, 200, { count: tasks.length, tasks })
|
||||
return
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
sendError(res, 405, '需要 GET 或 POST 请求')
|
||||
return
|
||||
}
|
||||
const task = await api.create(parseJsonBody(body) as ScheduledReportApiCreateRequest)
|
||||
sendJson(res, 201, { created: true, task })
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const retryPrefix = `${SCHEDULED_REPORTS_ROUTE}/executions/`
|
||||
if (pathname.startsWith(retryPrefix)) {
|
||||
const segments = pathname.slice(retryPrefix.length).split('/').filter(Boolean)
|
||||
if (segments.length !== 2 || segments[1] !== 'retry-send') return undefined
|
||||
let executionId: string
|
||||
try {
|
||||
executionId = decodeURIComponent(segments[0])
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return async ({ req, res }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
try {
|
||||
const execution = await api.retrySend(executionId)
|
||||
sendJson(res, 200, { success: execution.status !== 'failed', execution })
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = `${SCHEDULED_REPORTS_ROUTE}/`
|
||||
if (!pathname.startsWith(prefix)) return undefined
|
||||
const segments = pathname.slice(prefix.length).split('/').filter(Boolean)
|
||||
if (!segments.length || segments.length > 2) return undefined
|
||||
let taskId: string
|
||||
try {
|
||||
taskId = decodeURIComponent(segments[0])
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
const action = segments[1]
|
||||
|
||||
return async ({ req, res, body }) => {
|
||||
try {
|
||||
if (!action && req.method === 'GET') {
|
||||
sendJson(res, 200, { task: await api.get(taskId) })
|
||||
return
|
||||
}
|
||||
if (!action && req.method === 'PATCH') {
|
||||
const task = await api.update(
|
||||
taskId,
|
||||
parseJsonBody(body) as ScheduledReportApiUpdateRequest
|
||||
)
|
||||
sendJson(res, 200, { updated: true, task })
|
||||
return
|
||||
}
|
||||
if (!action && req.method === 'DELETE') {
|
||||
sendJson(res, 200, { deleted: true, ...(await api.delete(taskId)) })
|
||||
return
|
||||
}
|
||||
if (action === 'enable' && req.method === 'POST') {
|
||||
sendJson(res, 200, { updated: true, task: await api.setEnabled(taskId, true) })
|
||||
return
|
||||
}
|
||||
if (action === 'disable' && req.method === 'POST') {
|
||||
sendJson(res, 200, { updated: true, task: await api.setEnabled(taskId, false) })
|
||||
return
|
||||
}
|
||||
if (action === 'run' && req.method === 'POST') {
|
||||
const execution = await api.run(taskId)
|
||||
sendJson(res, 200, { success: execution.status !== 'failed', execution })
|
||||
return
|
||||
}
|
||||
if (action === 'executions' && req.method === 'GET') {
|
||||
const executions = await api.executions(taskId)
|
||||
sendJson(res, 200, { count: executions.length, executions })
|
||||
return
|
||||
}
|
||||
sendError(res, 405, '请求方法或定时日报操作不受支持')
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
|
||||
const QUERY_ROUTE_PREFIX = '/api/v1/query/'
|
||||
|
||||
function queryStatusCode(status: string): number {
|
||||
if (status === 'completed') return 200
|
||||
if (status === 'contact_not_found') return 404
|
||||
if (status === 'ambiguous_contact') return 409
|
||||
if (status === 'knowledge_unavailable') return 503
|
||||
if (status === 'retrieval_incomplete') return 206
|
||||
return 400
|
||||
}
|
||||
|
||||
function createQueryRoute(api: LocalQueryApiService): RouteHandler | undefined {
|
||||
return async ({ req, res, body }) => {
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname
|
||||
if (pathname === '/api/v1/query/capabilities') {
|
||||
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
|
||||
return sendJson(res, 200, api.capabilities())
|
||||
}
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
let payload: any
|
||||
try { payload = JSON.parse(typeof body === 'string' ? body : '') } catch { return sendError(res, 400, 'invalid_request') }
|
||||
if (!payload || typeof payload !== 'object') return sendError(res, 400, 'invalid_request')
|
||||
try {
|
||||
const result = pathname === '/api/v1/query/messages'
|
||||
? await api.messages(payload)
|
||||
: pathname === '/api/v1/query/search'
|
||||
? await api.search(payload)
|
||||
: pathname === '/api/v1/query/message-context'
|
||||
? await api.context(payload)
|
||||
: pathname === '/api/v1/query/conversation-overview'
|
||||
? await api.overview(payload)
|
||||
: undefined
|
||||
if (!result) return sendError(res, 404, `端点不存在: ${pathname}`)
|
||||
return sendJson(res, queryStatusCode(result.status), result)
|
||||
} catch (error) {
|
||||
return sendError(res, 400, error instanceof Error ? error.message : 'invalid_request')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createMediaRoute(
|
||||
mediaProvider: (messageId: string) => Promise<HttpImageResult>
|
||||
@@ -424,6 +650,8 @@ export function startHttpServer(
|
||||
): Promise<HttpServerHandle> {
|
||||
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
|
||||
const mediaProvider = options.mediaProvider || readImageMedia
|
||||
const scheduledReportApi = createScheduledReportApi(options)
|
||||
const queryApi = options.queryApiService || configuredQueryApiService || new LocalQueryApiService()
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: Server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
@@ -437,6 +665,8 @@ export function startHttpServer(
|
||||
}
|
||||
const handler =
|
||||
routes[url.pathname] ||
|
||||
createScheduledReportRoute(url.pathname, scheduledReportApi) ||
|
||||
(url.pathname.startsWith(QUERY_ROUTE_PREFIX) ? createQueryRoute(queryApi) : undefined) ||
|
||||
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
|
||||
? createMediaRoute(mediaProvider)
|
||||
: undefined)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+1021
-98
File diff suppressed because it is too large
Load Diff
+345
-69
@@ -1,11 +1,12 @@
|
||||
import { app } from 'electron'
|
||||
import { execFile } from 'child_process'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { isValidDatabaseKey } from './database-key-store'
|
||||
import crypto from 'crypto'
|
||||
import { findResource, getResourceCandidates } from './resource-paths'
|
||||
import { detectWechatVersion } from './services/connection-diagnostics'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -24,12 +25,288 @@ export interface ImageKeyResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type XkeyHelperMode = 'legacy' | 'wechat-4.1.13'
|
||||
|
||||
export function resolveXkeyHelperMode(wechatVersion: string): XkeyHelperMode {
|
||||
return /^4\.1\.13(?:\.|$)/.test(wechatVersion.trim()) ? 'wechat-4.1.13' : 'legacy'
|
||||
}
|
||||
|
||||
export function buildXkeyHelperArguments(pid: number, timeoutMs: number): string[] {
|
||||
return [String(pid), String(timeoutMs), '--profile', 'wechat-4.1.13', '--account']
|
||||
}
|
||||
|
||||
export function buildAppleSiliconXkeyInvocation(
|
||||
wechatVersion: string,
|
||||
pid: number,
|
||||
timeoutMs: number
|
||||
): {
|
||||
mode: XkeyHelperMode
|
||||
resourceName: string
|
||||
args: string[]
|
||||
waitMs: number
|
||||
timeoutSeconds: number
|
||||
execTimeoutMs: number
|
||||
} {
|
||||
const mode = resolveXkeyHelperMode(wechatVersion)
|
||||
const isWechat413 = mode === 'wechat-4.1.13'
|
||||
const waitMs = Math.max(isWechat413 ? 120_000 : 30_000, timeoutMs)
|
||||
const timeoutSeconds = Math.ceil(waitMs / 1000) + (isWechat413 ? 10 : 30)
|
||||
return {
|
||||
mode,
|
||||
resourceName: isWechat413 ? 'xkey_helper_4_1_13' : 'xkey_helper',
|
||||
args: isWechat413 ? buildXkeyHelperArguments(pid, waitMs) : [String(pid), String(waitMs)],
|
||||
waitMs,
|
||||
timeoutSeconds,
|
||||
execTimeoutMs: isWechat413 ? timeoutSeconds * 1000 + 5_000 : waitMs + 20_000
|
||||
}
|
||||
}
|
||||
|
||||
export function mapXkeyHelperFailure(
|
||||
rawError: string,
|
||||
fallbackCode = 'HELPER_RESULT_INVALID'
|
||||
): DatabaseKeyResult {
|
||||
const normalizedError = rawError.trim().toLowerCase()
|
||||
const parsedError = rawError.match(/(?:^|[\s"])(?:\\?"?)ERROR:([^:\s"}]+):?([^"}\r\n]*)/i)
|
||||
const code = parsedError?.[1]?.toUpperCase()
|
||||
const detail = parsedError?.[2]?.trim() || ''
|
||||
|
||||
if (
|
||||
code === 'CAPTURE_TIMEOUT' ||
|
||||
normalizedError.includes('timeout waiting for breakpoint hit') ||
|
||||
normalizedError.includes('timeout waiting for sink hit') ||
|
||||
normalizedError.includes('no_breakpoint_hit')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'CAPTURE_TIMEOUT',
|
||||
error:
|
||||
'已完成管理员授权,但监听期间微信没有触发账号密钥派生。请先停留在微信登录界面,在 TraceMemo 点击“自动获取密钥”,授权后点击微信“登录”;已有登录凭据时通常不需要扫码。'
|
||||
}
|
||||
}
|
||||
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error:
|
||||
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
|
||||
'建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
|
||||
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
|
||||
}
|
||||
}
|
||||
if (code === 'SCAN_FAILED') {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error: '内存扫描失败:当前微信版本或运行状态暂未适配。'
|
||||
}
|
||||
}
|
||||
if (normalizedError.includes('permission denied') || code === 'PERMISSION_DENIED') {
|
||||
return {
|
||||
success: false,
|
||||
code: code || 'PERMISSION_DENIED',
|
||||
error: '管理员授权不足,无法读取微信进程内存。'
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
code: code || fallbackCode,
|
||||
error: code
|
||||
? `密钥工具执行未完成(${code}),请确认微信仍在运行后重试。`
|
||||
: '密钥工具未返回有效密钥,请确认微信仍在运行后重试。'
|
||||
}
|
||||
}
|
||||
|
||||
export function parseXkeyHelperOutput(output: string): DatabaseKeyResult {
|
||||
const payloads: Record<string, unknown>[] = []
|
||||
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
|
||||
try {
|
||||
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
|
||||
} catch {
|
||||
// Ignore helper progress that is not JSON.
|
||||
}
|
||||
}
|
||||
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
|
||||
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
|
||||
if (isValidDatabaseKey(rawKey)) return { success: true, key: rawKey }
|
||||
|
||||
const errorPayload = payloads.find((item) => typeof item.result === 'string')
|
||||
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
|
||||
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 getHelperPath(): string {
|
||||
const helperPath = findResource('xkey_helper')
|
||||
private getMacKeyRuntimeDir(): string {
|
||||
return path.join(app.getPath('userData'), 'key-runtime')
|
||||
}
|
||||
|
||||
private async runMacKeyTool(
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
onStatus?: (message: string) => void
|
||||
): Promise<Record<string, unknown>> {
|
||||
const helperPath = findResource('macos-key-tool/intel_mac_key_helper')
|
||||
if (!helperPath) {
|
||||
return {
|
||||
type: 'result',
|
||||
success: false,
|
||||
code: 'KEY_TOOL_UNAVAILABLE',
|
||||
error: '缺少 Intel Mac 工具'
|
||||
}
|
||||
}
|
||||
|
||||
return await new Promise<Record<string, unknown>>((resolve) => {
|
||||
const child = spawn(helperPath, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let settled = false
|
||||
let pending = ''
|
||||
let lastError = ''
|
||||
let finalPayload: Record<string, unknown> | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const finish = (payload: Record<string, unknown>): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (timer) clearTimeout(timer)
|
||||
resolve(payload)
|
||||
}
|
||||
const consume = (chunk: Buffer): void => {
|
||||
pending += chunk.toString()
|
||||
const lines = pending.split(/\r?\n/)
|
||||
pending = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const payload = JSON.parse(line) as Record<string, unknown>
|
||||
if (payload.type === 'progress' && typeof payload.message === 'string') {
|
||||
onStatus?.(payload.message)
|
||||
}
|
||||
if (payload.type === 'result' || payload.type === 'status') finalPayload = payload
|
||||
} catch {
|
||||
// The helper contract is JSONL; ignore interpreter diagnostics.
|
||||
}
|
||||
}
|
||||
}
|
||||
child.stdout.on('data', consume)
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
lastError = `${lastError}\n${chunk.toString()}`.trim().slice(-1000)
|
||||
})
|
||||
child.on('error', (error) =>
|
||||
finish({
|
||||
type: 'result',
|
||||
success: false,
|
||||
code: 'KEY_TOOL_FAILED',
|
||||
error: `无法启动 Intel Mac 密钥工具:${error.message}`
|
||||
})
|
||||
)
|
||||
child.on('close', () => {
|
||||
if (pending) consume(Buffer.from('\n'))
|
||||
finish(
|
||||
finalPayload || {
|
||||
type: 'result',
|
||||
success: false,
|
||||
code: 'KEY_TOOL_FAILED',
|
||||
error: lastError || 'Intel Mac 密钥工具未返回结果'
|
||||
}
|
||||
)
|
||||
})
|
||||
timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {
|
||||
// The child may already have exited.
|
||||
}
|
||||
finish({
|
||||
type: 'result',
|
||||
success: false,
|
||||
code: 'KEY_TOOL_TIMEOUT',
|
||||
error: 'Intel Mac 密钥获取超时,请让微信回到未登录界面后重试'
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
async getIntelEnvironmentStatus(): Promise<{
|
||||
sipDisabled: boolean
|
||||
pythonAvailable: boolean
|
||||
fridaAvailable: boolean
|
||||
wechatAdhocSigned: boolean
|
||||
}> {
|
||||
const payload = await this.runMacKeyTool(
|
||||
['status', '--runtime-dir', this.getMacKeyRuntimeDir()],
|
||||
15_000
|
||||
)
|
||||
return {
|
||||
sipDisabled: payload.sipDisabled === true,
|
||||
pythonAvailable: payload.pythonAvailable === true,
|
||||
fridaAvailable: payload.fridaAvailable === true,
|
||||
wechatAdhocSigned: payload.wechatAdhocSigned === true
|
||||
}
|
||||
}
|
||||
|
||||
async installIntelKeyRuntime(
|
||||
onStatus?: (message: string) => void
|
||||
): Promise<{ success: boolean; error?: string; code?: string }> {
|
||||
if (process.platform !== 'darwin' || process.arch !== 'x64') {
|
||||
return { success: false, code: 'UNSUPPORTED_PLATFORM', error: '仅 Intel Mac 需要安装此运行环境' }
|
||||
}
|
||||
onStatus?.('正在准备连接环境,请保持网络连接…')
|
||||
const payload = await this.runMacKeyTool(
|
||||
['install', this.getMacKeyRuntimeDir()],
|
||||
5 * 60_000,
|
||||
onStatus
|
||||
)
|
||||
return {
|
||||
success: payload.success === true,
|
||||
code: typeof payload.code === 'string' ? payload.code : undefined,
|
||||
error: typeof payload.error === 'string' ? payload.error : undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async captureIntelDbKey(
|
||||
accountRoot: string | undefined,
|
||||
timeoutMs: number,
|
||||
onStatus?: (message: string) => void
|
||||
): Promise<DatabaseKeyResult> {
|
||||
const selectedRoot = String(accountRoot || '').trim()
|
||||
if (!selectedRoot) return { success: false, code: 'ACCOUNT_REQUIRED', error: '请先选择微信账号' }
|
||||
const waitMs = Math.max(30_000, timeoutMs)
|
||||
const payload = await this.runMacKeyTool(
|
||||
[
|
||||
'capture',
|
||||
'--account-root',
|
||||
selectedRoot,
|
||||
'--runtime-dir',
|
||||
this.getMacKeyRuntimeDir(),
|
||||
'--diagnostic-log',
|
||||
path.join(app.getPath('logs'), 'mac-key-diagnostic.log'),
|
||||
'--timeout-ms',
|
||||
String(waitMs)
|
||||
],
|
||||
waitMs + 15_000,
|
||||
onStatus
|
||||
)
|
||||
const key = typeof payload.key === 'string' ? payload.key.trim().toLowerCase() : ''
|
||||
if (payload.success === true && isValidDatabaseKey(key)) return { success: true, key }
|
||||
return {
|
||||
success: false,
|
||||
code: typeof payload.code === 'string' ? payload.code : 'KEY_TOOL_FAILED',
|
||||
error: typeof payload.error === 'string' ? payload.error : '未捕获到微信数据库密钥'
|
||||
}
|
||||
}
|
||||
|
||||
private getHelperPath(resourceName = 'xkey_helper'): string {
|
||||
const helperPath = findResource(resourceName)
|
||||
if (!helperPath) {
|
||||
throw new Error(
|
||||
`找不到 xkey_helper(已检查:${getResourceCandidates('xkey_helper').join(';')})`
|
||||
`找不到 ${resourceName}(已检查:${getResourceCandidates(resourceName).join(';')})`
|
||||
)
|
||||
}
|
||||
return helperPath
|
||||
@@ -38,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
|
||||
}
|
||||
@@ -61,77 +338,44 @@ export class KeyServiceMac {
|
||||
// Try the next process lookup strategy.
|
||||
}
|
||||
}
|
||||
throw new Error('未找到微信主进程,请先启动并登录微信')
|
||||
}
|
||||
|
||||
private parseHelperOutput(output: string): DatabaseKeyResult {
|
||||
const payloads: Record<string, unknown>[] = []
|
||||
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
|
||||
try {
|
||||
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
|
||||
} catch {
|
||||
// Ignore helper progress that is not JSON.
|
||||
}
|
||||
}
|
||||
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
|
||||
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
|
||||
if (!isValidDatabaseKey(rawKey)) {
|
||||
const errorPayload = payloads.find((item) => typeof item.result === 'string')
|
||||
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
|
||||
const parsedError = rawError.match(/^ERROR:([^:]+):?(.*)$/i)
|
||||
const code = parsedError?.[1]?.toUpperCase()
|
||||
const detail = parsedError?.[2]?.trim() || ''
|
||||
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error:
|
||||
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
|
||||
'建议步骤:降级微信到 4.1.8 (点击顶部"上手教程"获取下载链接) -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
|
||||
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
|
||||
}
|
||||
}
|
||||
if (code === 'SCAN_FAILED') {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error: `内存扫描失败:${detail || '未匹配到可用特征,当前微信版本可能暂未适配。'}`
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error: rawError || '密钥工具未返回有效的 64 位密钥'
|
||||
}
|
||||
}
|
||||
return { success: true, key: rawKey }
|
||||
throw new Error('未找到微信主进程,请先启动微信并停留在登录界面')
|
||||
}
|
||||
|
||||
async autoGetDbKey(
|
||||
onStatus?: (message: string) => void,
|
||||
timeoutMs = 60_000
|
||||
timeoutMs = 60_000,
|
||||
accountRoot?: string
|
||||
): Promise<DatabaseKeyResult> {
|
||||
if (onStatus) {
|
||||
const emitStatus = onStatus
|
||||
onStatus = (message: string): void =>
|
||||
emitStatus(message.replace(/Frida/gi, '连接组件'))
|
||||
}
|
||||
if (process.platform !== 'darwin') {
|
||||
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
|
||||
}
|
||||
if (await this.isSipEnabled()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'macOS 系统完整性保护(SIP)已开启,自动获取不可用,请使用手动粘贴。'
|
||||
}
|
||||
return { success: false, code: 'SIP_ENABLED', error: SIP_ENABLED_ERROR }
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.arch === 'x64') {
|
||||
onStatus?.('Intel Mac 将通过 Frida 获取微信主密钥,请让微信停留在未登录界面')
|
||||
const result = await this.captureIntelDbKey(accountRoot, timeoutMs, onStatus)
|
||||
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
|
||||
return result
|
||||
}
|
||||
const wechatVersion = await detectWechatVersion()
|
||||
onStatus?.('正在查找微信进程...')
|
||||
const pid = await this.getWeChatPid()
|
||||
const helperPath = this.getHelperPath()
|
||||
const waitMs = Math.max(30_000, timeoutMs)
|
||||
const timeoutSeconds = Math.ceil(waitMs / 1000) + 30
|
||||
const invocation = buildAppleSiliconXkeyInvocation(wechatVersion, pid, timeoutMs)
|
||||
const isWechat413 = invocation.mode === 'wechat-4.1.13'
|
||||
const helperPath = this.getHelperPath(invocation.resourceName)
|
||||
onStatus?.('正在请求管理员授权...')
|
||||
const scriptLines = [
|
||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
||||
`set timeoutSec to ${timeoutSeconds}`,
|
||||
`set cmd to quoted form of helperPath & " ${invocation.args.join(' ')}"`,
|
||||
`set timeoutSec to ${invocation.timeoutSeconds}`,
|
||||
'try',
|
||||
'with timeout of timeoutSec seconds',
|
||||
'set outText to do shell script cmd with administrator privileges',
|
||||
@@ -141,25 +385,57 @@ export class KeyServiceMac {
|
||||
'return "ERR::" & errNum & "::" & errMsg',
|
||||
'end try'
|
||||
]
|
||||
onStatus?.('授权后 需要在微信登录界面 点击登录微信')
|
||||
onStatus?.(
|
||||
isWechat413
|
||||
? '授权后请在微信登录界面点击“登录”,已有登录凭据时通常不需要扫码'
|
||||
: '授权后 需要在微信登录界面 点击登录微信'
|
||||
)
|
||||
const { stdout } = await execFileAsync(
|
||||
'/usr/bin/osascript',
|
||||
scriptLines.flatMap((line) => ['-e', line]),
|
||||
{ timeout: waitMs + 20_000 }
|
||||
{ timeout: invocation.execTimeoutMs }
|
||||
)
|
||||
const output = String(stdout).trim()
|
||||
if (output.startsWith('ERR::-128')) return { success: false, error: '已取消管理员授权' }
|
||||
if (output.startsWith('ERR::')) {
|
||||
return {
|
||||
success: false,
|
||||
error: output.split('::').slice(2).join('::') || '密钥工具执行失败'
|
||||
}
|
||||
if (output.startsWith('ERR::-128')) {
|
||||
return { success: false, error: '已取消管理员授权' }
|
||||
}
|
||||
const result = this.parseHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
|
||||
if (output.startsWith('ERR::')) {
|
||||
const [, errorNumber = 'UNKNOWN', ...errorParts] = output.split('::')
|
||||
const result = mapXkeyHelperFailure(errorParts.join('::'), `OSASCRIPT_${errorNumber}`)
|
||||
onStatus?.('密钥获取失败')
|
||||
return result
|
||||
}
|
||||
const result = parseXkeyHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
|
||||
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
|
||||
return result
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
const processError = error as NodeJS.ErrnoException & {
|
||||
killed?: boolean
|
||||
signal?: NodeJS.Signals | null
|
||||
}
|
||||
if (processError.message?.includes('未找到微信主进程')) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'WECHAT_NOT_RUNNING',
|
||||
error: '未找到微信主进程,请先启动微信并停留在登录界面。'
|
||||
}
|
||||
}
|
||||
if (
|
||||
processError.killed ||
|
||||
processError.code === 'ETIMEDOUT' ||
|
||||
processError.signal === 'SIGTERM'
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'AUTH_TIMEOUT',
|
||||
error: '管理员授权等待超时,请点击“自动获取密钥”后及时完成系统授权。'
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
code: typeof processError.code === 'string' ? processError.code : 'HELPER_EXEC_FAILED',
|
||||
error: '密钥工具执行失败,请确认微信仍在运行后重试。'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { join, dirname, delimiter } from 'path'
|
||||
import { existsSync, copyFileSync, mkdirSync } from 'fs'
|
||||
import { existsSync, copyFileSync, mkdirSync, readdirSync } from 'fs'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import os from 'os'
|
||||
import crypto from 'crypto'
|
||||
import { getResourceRoots as getSharedResourceRoots } from './resource-paths'
|
||||
import {
|
||||
deriveV4ImageKeys,
|
||||
extractKvcommCode,
|
||||
normalizeV4AccountId
|
||||
} from '../shared/wechat-image-key-derivation'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -683,7 +688,8 @@ export class KeyService {
|
||||
if (loginRequired) {
|
||||
return {
|
||||
success: false,
|
||||
error: '微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ',
|
||||
error:
|
||||
'微信可能已经启动并登录,请先在微信客户端保持未登录状态,具体点击上方“查看5分钟上手教程” ',
|
||||
logs
|
||||
}
|
||||
}
|
||||
@@ -696,8 +702,7 @@ export class KeyService {
|
||||
onProgress?: (message: string) => void,
|
||||
wxidParam?: string
|
||||
): Promise<ImageKeyResult> {
|
||||
void wxidParam
|
||||
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress)
|
||||
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress, wxidParam)
|
||||
}
|
||||
|
||||
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
||||
@@ -706,7 +711,8 @@ export class KeyService {
|
||||
|
||||
async autoGetImageKeyByMemoryScan(
|
||||
userDir: string,
|
||||
onProgress?: (message: string) => void
|
||||
onProgress?: (message: string) => void,
|
||||
wxidParam?: string
|
||||
): Promise<ImageKeyResult> {
|
||||
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
||||
|
||||
@@ -781,6 +787,18 @@ export class KeyService {
|
||||
|
||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||
|
||||
const derived = this._deriveImageKeyByLocalMetadata(
|
||||
userDir,
|
||||
wxidParam,
|
||||
ciphertext,
|
||||
xorKey,
|
||||
onProgress
|
||||
)
|
||||
if (derived) {
|
||||
onProgress?.('通过本机账号元数据推导并验证图片密钥成功')
|
||||
return { success: true, xorKey: derived.xorKey, aesKey: derived.aesKey, verified: true }
|
||||
}
|
||||
|
||||
// 2. 找微信 PID
|
||||
const pid = await this.findWeChatPid()
|
||||
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
|
||||
@@ -811,6 +829,73 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
private _deriveImageKeyByLocalMetadata(
|
||||
userDir: string,
|
||||
wxidParam: string | undefined,
|
||||
ciphertext: Buffer,
|
||||
expectedXorKey: number,
|
||||
onProgress?: (message: string) => void
|
||||
): { xorKey: number; aesKey: string } | null {
|
||||
const codes = new Set<number>()
|
||||
for (const directory of this._getKvcommCandidates(userDir)) {
|
||||
try {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue
|
||||
const code = extractKvcommCode(entry.name)
|
||||
if (code !== null) codes.add(code)
|
||||
}
|
||||
} catch {
|
||||
// Candidate paths differ across WeChat releases and installations.
|
||||
}
|
||||
}
|
||||
|
||||
const accountIds = new Set<string>()
|
||||
for (const candidate of [wxidParam, userDir]) {
|
||||
const normalized = normalizeV4AccountId(candidate || '')
|
||||
if (normalized) accountIds.add(normalized)
|
||||
}
|
||||
onProgress?.(`正在校验本机账号元数据候选(code=${codes.size}, account=${accountIds.size})...`)
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
for (const code of codes) {
|
||||
const derived = deriveV4ImageKeys(code, accountId)
|
||||
if (!derived || derived.xorKey !== expectedXorKey) continue
|
||||
if (this._verifyAesKey(Buffer.from(derived.aesKey, 'ascii'), ciphertext)) return derived
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private _getKvcommCandidates(userDir: string): string[] {
|
||||
const candidates: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const add = (candidate: string | undefined) => {
|
||||
if (!candidate) return
|
||||
const normalized = candidate.replace(/[\\/]+$/, '')
|
||||
const identity = normalized.toLowerCase()
|
||||
if (!normalized || seen.has(identity)) return
|
||||
seen.add(identity)
|
||||
candidates.push(normalized)
|
||||
}
|
||||
|
||||
const roaming = process.env.APPDATA
|
||||
const local = process.env.LOCALAPPDATA
|
||||
add(roaming && join(roaming, 'Tencent', 'xwechat', 'net', 'kvcomm'))
|
||||
add(roaming && join(roaming, 'Tencent', 'xwechat_files', 'app_data', 'net', 'kvcomm'))
|
||||
add(local && join(local, 'Tencent', 'xwechat', 'net', 'kvcomm'))
|
||||
add(local && join(local, 'Tencent', 'xwechat_files', 'app_data', 'net', 'kvcomm'))
|
||||
add(local && join(local, 'Tencent', 'WeChat', 'xwechat', 'net', 'kvcomm'))
|
||||
|
||||
let cursor = userDir
|
||||
for (let depth = 0; cursor && depth < 6; depth++) {
|
||||
add(join(cursor, 'net', 'kvcomm'))
|
||||
const parent = dirname(cursor)
|
||||
if (parent === cursor) break
|
||||
cursor = parent
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private async _findTemplateData(
|
||||
userDir: string,
|
||||
limit: number = 32
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ import type {
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchResult,
|
||||
@@ -48,6 +50,23 @@ export class KnowledgeService {
|
||||
return this.worker.status({ ...request, databaseRoot: this.databaseRoot })
|
||||
}
|
||||
|
||||
/** 每个会话已经索引到的源侧时刻(epoch ms);增量 pass 用它跳过没有变化的会话。 */
|
||||
highWaterMarks(request: Omit<KnowledgeStatusRequest, 'databaseRoot'>): Promise<Record<string, number>> {
|
||||
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()
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
return this.worker.dispose()
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -191,6 +196,7 @@ export class KnowledgeStore {
|
||||
async index(
|
||||
request: Pick<KnowledgeIndexRequest, 'conversations' | 'chunker'> & {
|
||||
sourceMessageCount?: number
|
||||
sourceLatestAt?: number
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||
@@ -242,6 +248,9 @@ export class KnowledgeStore {
|
||||
this.writeMeta('source_message_count', String(request.sourceMessageCount))
|
||||
this.refreshStatsSnapshot()
|
||||
}
|
||||
if (request.sourceLatestAt !== undefined) {
|
||||
this.writeMeta('source_latest_at', String(request.sourceLatestAt))
|
||||
}
|
||||
this.setRunState('ready')
|
||||
return {
|
||||
accountId: this.accountId,
|
||||
@@ -293,23 +302,151 @@ export class KnowledgeStore {
|
||||
}
|
||||
|
||||
getSearchStatus(): Omit<KnowledgeSearchResult, 'evidence'> {
|
||||
this.ensureStatsSnapshot()
|
||||
this.ensureStatsSnapshotForQuery()
|
||||
const indexedMessageCount = this.readStatNumber('stats_message_count')
|
||||
const indexedChunkCount = this.readStatNumber('stats_chunk_count')
|
||||
const runState = this.readMeta('run_state')
|
||||
return {
|
||||
state:
|
||||
runState === 'indexing'
|
||||
? 'indexing'
|
||||
: runState === 'ready' && indexedChunkCount > 0
|
||||
? 'ready'
|
||||
: 'unavailable',
|
||||
// READY 的含义是「这个派生库可以被查询」。一次被中断的 pass 会把 run_state 留在
|
||||
// 'indexing',但已落盘的分片仍然可用 —— 用它当 state 会让可查询的库看起来不可用。
|
||||
// 「正在同步」由 KnowledgeSearchService 依据真实索引任务表达,不靠这里的残留状态。
|
||||
state: runState === 'error' ? 'unavailable' : indexedChunkCount > 0 ? 'ready' : 'unavailable',
|
||||
indexedMessageCount,
|
||||
indexedChunkCount,
|
||||
indexLatestAt: this.readIndexLatestAt(),
|
||||
timings: emptyKnowledgeSearchTimings()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个会话在时间窗内的「按发送者聚合」统计。
|
||||
*
|
||||
* 只回聚合结果、不回消息正文:群员统计只需要「谁说了几条、最后一条是什么时候」,
|
||||
* 把消息逐条搬到主进程再统计会把几十万行推过 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)——「索引更新到哪」的权威口径。
|
||||
*
|
||||
* 优先用 per-conversation 的 `source_high_water_time` 聚合(`MAX(...)`):每个**成功处理**
|
||||
* 的会话都会立刻推进并持久化它(不是等整遍 pass 结束才写),所以增量 pass 同样能让
|
||||
* freshness 前进。
|
||||
*
|
||||
* 为什么这是源侧口径:它是会话最后活跃时间与原始消息 create_time 的最大值,对不可建模的
|
||||
* 图片/空正文也照算,不是"索引里最新一条可建模消息的时间",所以不会被不可建模消息带偏;
|
||||
* 并且它在 delta 空读可疑时**不推进**(安全阀在 KnowledgeSearchService 侧),
|
||||
* 不会把"没读到"谎报成"已覆盖"。
|
||||
*
|
||||
* 不用 `source_latest_at` meta 优先:它的写入条件要求「这一遍没有任何跳过」,
|
||||
* 而增量世界里"有跳过"是常态,于是它会冻结在最后一次全量 pass 的值上,
|
||||
* 导致 `isKnowledgeFresh()` 恒为 false。
|
||||
*
|
||||
* 回退顺序:老库没有该列(或整列为 NULL)→ `source_latest_at` meta →
|
||||
* `MAX(high_water_time)`(被建模消息的最新时间,只会偏旧,作下限是安全的)。
|
||||
*/
|
||||
private readIndexLatestAt(): number | null {
|
||||
try {
|
||||
const covered = this.database
|
||||
.prepare('SELECT MAX(source_high_water_time) AS latest FROM knowledge_index_state')
|
||||
.get() as DbRow | undefined
|
||||
const value = Number(covered?.latest)
|
||||
if (Number.isFinite(value) && value > 0) return value
|
||||
} catch {
|
||||
// 老库可能还没有 source_high_water_time 这一列(migration 之前)→ 走回退。
|
||||
}
|
||||
const recorded = Number(this.readMeta('source_latest_at'))
|
||||
if (Number.isFinite(recorded) && recorded > 0) return recorded
|
||||
try {
|
||||
const row = this.database
|
||||
.prepare('SELECT MAX(high_water_time) AS latest FROM knowledge_index_state')
|
||||
.get() as DbRow | undefined
|
||||
const value = Number(row?.latest)
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getRuntimeStatus(): KnowledgeRuntimeStatus {
|
||||
const search = this.getSearchStatus()
|
||||
const storage = this.getStorageStats()
|
||||
@@ -320,7 +457,20 @@ export class KnowledgeStore {
|
||||
const error = this.readMeta('run_error') || undefined
|
||||
return {
|
||||
accountId: this.accountId,
|
||||
state: runState === 'error' ? 'error' : search.state === 'ready' ? 'ready' : 'unavailable',
|
||||
// 注意区分三种「不是 ready」:
|
||||
// - 'error' :这一遍真的失败了;
|
||||
// - 'cancelled' :用户主动取消(已提交的会话保留、可继续,绝不留一个假的 indexing);
|
||||
// - 'unavailable':还没有任何可用分片。
|
||||
// 这里**不**用 `search.state`(那是「能不能查」):取消后派生库仍然可查,
|
||||
// 但 UI 需要区分「可用 · 已追至最新」与「可用 · 同步已取消」。
|
||||
state:
|
||||
runState === 'error'
|
||||
? 'error'
|
||||
: runState === 'cancelled'
|
||||
? 'cancelled'
|
||||
: search.state === 'ready'
|
||||
? 'ready'
|
||||
: 'unavailable',
|
||||
indexedMessageCount: search.indexedMessageCount,
|
||||
indexedChunkCount: search.indexedChunkCount,
|
||||
sourceMessageCount,
|
||||
@@ -330,7 +480,10 @@ export class KnowledgeStore {
|
||||
databaseBytes: storage.databaseBytes,
|
||||
walBytes: storage.walBytes,
|
||||
shmBytes: storage.shmBytes,
|
||||
lastError: error
|
||||
lastError: error,
|
||||
// sourceLatestAt 属于源数据(WCDB),派生库本身看不到,由 KnowledgeSearchService 补齐。
|
||||
indexLatestAt: search.indexLatestAt,
|
||||
sourceLatestAt: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +498,7 @@ export class KnowledgeStore {
|
||||
} {
|
||||
const startedAt = Date.now()
|
||||
let ftsMs = 0
|
||||
let shortTermSearchMs = 0
|
||||
let messageLoadMs = 0
|
||||
let chunkExpandMs = 0
|
||||
let rankingMs = 0
|
||||
@@ -358,6 +512,36 @@ export class KnowledgeStore {
|
||||
])
|
||||
).filter(Boolean)
|
||||
const senderIds = new Set(query.senderIds || [])
|
||||
if (query.conversationBoundary && conversationIds.length === 1) {
|
||||
const direction = query.conversationBoundary === 'first' ? 'ASC' : 'DESC'
|
||||
const row = this.database
|
||||
.prepare(
|
||||
`SELECT conversation_id, message_id, create_time, searchable_text, kind, sender_id, sender_name
|
||||
FROM knowledge_messages
|
||||
WHERE conversation_id = ? AND kind <> 'system'
|
||||
ORDER BY create_time ${direction}, message_id ${direction}
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(conversationIds[0]) as DbRow | undefined
|
||||
const count = this.database
|
||||
.prepare('SELECT COUNT(*) AS total FROM knowledge_messages WHERE conversation_id = ?')
|
||||
.get(conversationIds[0]) as DbRow | undefined
|
||||
const indexState = this.database
|
||||
.prepare('SELECT state, complete_snapshot FROM knowledge_index_state WHERE conversation_id = ?')
|
||||
.get(conversationIds[0]) as DbRow | undefined
|
||||
return {
|
||||
evidence: row ? [this.metadataEvidence(row)] : [],
|
||||
conversationRetrieval: {
|
||||
conversationId: conversationIds[0],
|
||||
totalMessages: Number(count?.total || 0),
|
||||
chunkCount: 0,
|
||||
candidateMessages: row ? 1 : 0,
|
||||
systemMessagesDeprioritized: 0,
|
||||
complete: String(indexState?.state || '') === 'ready' && Number(indexState?.complete_snapshot || 0) === 1
|
||||
},
|
||||
timings: { ...emptyKnowledgeSearchTimings(), messageLoadMs: Date.now() - startedAt, totalMs: Date.now() - startedAt }
|
||||
}
|
||||
}
|
||||
if (!terms.length) {
|
||||
const messageLoadStartedAt = Date.now()
|
||||
const metadata = this.searchByMetadata(query, conversationIds, senderIds)
|
||||
@@ -475,6 +659,7 @@ export class KnowledgeStore {
|
||||
timings: {
|
||||
...emptyKnowledgeSearchTimings(),
|
||||
ftsMs,
|
||||
shortTermSearchMs,
|
||||
messageLoadMs,
|
||||
chunkExpandMs,
|
||||
rankingMs,
|
||||
@@ -668,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
|
||||
@@ -696,14 +882,31 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
searchWithStatus(query: KnowledgeQuery): KnowledgeSearchResult {
|
||||
const startedAt = Date.now()
|
||||
const statusStartedAt = Date.now()
|
||||
const status = this.getSearchStatus()
|
||||
const statusMs = Date.now() - statusStartedAt
|
||||
const measured = status.indexedChunkCount > 0 ? this.searchMeasured(query) : null
|
||||
const voiceStartedAt = Date.now()
|
||||
const voiceCoverage = this.getVoiceCoverage(query)
|
||||
@@ -720,6 +923,7 @@ export class KnowledgeStore {
|
||||
totalMs: workerExecutionMs,
|
||||
globalCountMs: statsRefreshMs,
|
||||
voiceCoverageMs,
|
||||
statusMs,
|
||||
workerExecutionMs
|
||||
},
|
||||
conversationRetrieval: measured?.conversationRetrieval,
|
||||
@@ -803,10 +1007,20 @@ 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
|
||||
ON knowledge_messages (conversation_id, create_time);
|
||||
-- 跨会话 lexical probe 的短词回退路径是「全表 LIKE + ORDER BY create_time DESC LIMIT k」。
|
||||
-- 没有这个索引时 SQLite 只能 SCAN + TEMP B-TREE,代价随表增长线性上升;
|
||||
-- 有了它就能按时间倒序走索引并提前终止(同一 ORDER BY / 同一 LIMIT,结果集完全一致),
|
||||
-- 降到毫秒级。它不改变任何检索语义,只是让同一条 SQL 有可用的访问路径。
|
||||
CREATE INDEX IF NOT EXISTS knowledge_messages_time
|
||||
ON knowledge_messages (create_time);
|
||||
-- 语音覆盖聚合按 (kind, conversation_id[, create_time]) 过滤;没有它就只能全表扫。
|
||||
CREATE INDEX IF NOT EXISTS knowledge_messages_kind_conversation
|
||||
ON knowledge_messages (kind, conversation_id);
|
||||
CREATE TABLE IF NOT EXISTS knowledge_chunks (
|
||||
rowid INTEGER PRIMARY KEY,
|
||||
chunk_id TEXT NOT NULL UNIQUE,
|
||||
@@ -832,10 +1046,18 @@ export class KnowledgeStore {
|
||||
state TEXT NOT NULL,
|
||||
high_water_time INTEGER,
|
||||
indexed_message_count INTEGER NOT NULL DEFAULT 0,
|
||||
complete_snapshot INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
`)
|
||||
const stateColumns = new Set(asRows(this.database.prepare('PRAGMA table_info(knowledge_index_state)').all()).map((row) => String(row.name)))
|
||||
if (!stateColumns.has('complete_snapshot')) {
|
||||
this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN complete_snapshot INTEGER NOT NULL DEFAULT 0')
|
||||
}
|
||||
if (!stateColumns.has('source_high_water_time')) {
|
||||
this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN source_high_water_time INTEGER')
|
||||
}
|
||||
const messageColumns = new Set(
|
||||
asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) =>
|
||||
String(row.name)
|
||||
@@ -844,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) {
|
||||
@@ -924,7 +1154,24 @@ export class KnowledgeStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changedAt < 0) return { chunkCount: 0, updatedChunks: 0 }
|
||||
if (changedAt < 0) {
|
||||
// 内容没有变化 → 不必重建分片。但仍然要记下这一遍扫到的**源侧**边界,
|
||||
// 否则下一次增量 pass 又会因为缺少标记而重读这个会话(永远无法跳过)。
|
||||
//
|
||||
// 单调推进:checkpoint 只应该前进。让一个"看起来更旧"的值覆盖它,会把已经追到最新的
|
||||
// 会话重新打回"有新消息",于是每一遍都白读一次,还会让 freshness 误判回退。
|
||||
if (conversation.sourceHighWaterTime !== undefined) {
|
||||
this.database
|
||||
.prepare(
|
||||
`UPDATE knowledge_index_state
|
||||
SET source_high_water_time = MAX(COALESCE(source_high_water_time, 0), ?),
|
||||
updated_at = ?
|
||||
WHERE conversation_id = ?`
|
||||
)
|
||||
.run(conversation.sourceHighWaterTime, Date.now(), conversation.conversationId)
|
||||
}
|
||||
return { chunkCount: 0, updatedChunks: 0 }
|
||||
}
|
||||
|
||||
const rebuildStart = Math.max(0, changedAt - chunker.overlapMessages)
|
||||
const boundaryTime = normalized[rebuildStart]?.createTime ?? 0
|
||||
@@ -935,7 +1182,9 @@ export class KnowledgeStore {
|
||||
chunker.version,
|
||||
'indexing',
|
||||
null,
|
||||
normalized.length
|
||||
normalized.length,
|
||||
null,
|
||||
conversation.completeSnapshot
|
||||
)
|
||||
await this.writeMessageLedger(
|
||||
conversation.conversationId,
|
||||
@@ -982,7 +1231,10 @@ export class KnowledgeStore {
|
||||
chunker.version,
|
||||
'ready',
|
||||
highWater,
|
||||
normalized.length
|
||||
normalized.length,
|
||||
null,
|
||||
conversation.completeSnapshot,
|
||||
conversation.sourceHighWaterTime ?? null
|
||||
)
|
||||
this.database.exec('COMMIT')
|
||||
return { chunkCount: chunks.length, updatedChunks: chunks.length }
|
||||
@@ -1002,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,
|
||||
@@ -1013,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)
|
||||
@@ -1030,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)
|
||||
@@ -1122,22 +1377,37 @@ export class KnowledgeStore {
|
||||
state: string,
|
||||
highWater: number | null,
|
||||
messageCount: number,
|
||||
error: string | null = null
|
||||
error: string | null = null,
|
||||
completeSnapshot = false,
|
||||
/**
|
||||
* 这一遍从 WCDB 读到的**原始**最新 create_time(epoch ms,未经过滤)。
|
||||
* 与 `highWater`(索引里最后一条可建模消息的时间)不同:图片等不可建模消息会被后者漏掉,
|
||||
* 于是「最后一条恰好是图片」的会话每次都会被认为是"有新消息"。源侧边界没有这个问题。
|
||||
*/
|
||||
sourceHighWater: number | null = null
|
||||
): void {
|
||||
this.database
|
||||
.prepare(
|
||||
`INSERT INTO knowledge_index_state (
|
||||
conversation_id, account_id, chunker_version, state, high_water_time,
|
||||
indexed_message_count, last_error, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
indexed_message_count, complete_snapshot, last_error, updated_at, source_high_water_time
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(conversation_id) DO UPDATE SET
|
||||
account_id = excluded.account_id,
|
||||
chunker_version = excluded.chunker_version,
|
||||
state = excluded.state,
|
||||
high_water_time = excluded.high_water_time,
|
||||
indexed_message_count = excluded.indexed_message_count,
|
||||
complete_snapshot = excluded.complete_snapshot,
|
||||
last_error = excluded.last_error,
|
||||
updated_at = excluded.updated_at`
|
||||
updated_at = excluded.updated_at,
|
||||
-- 单调推进 + 保留 NULL 语义:新值为 NULL 时保持旧值;否则取两者较大者。
|
||||
-- 若退化成写 0,readSourceHighWaterMarks() 的 IS NOT NULL 就会把该会话
|
||||
-- 当成"有 checkpoint 但等于 0",从而每遍都误走 backfill 全量读。
|
||||
source_high_water_time = CASE
|
||||
WHEN excluded.source_high_water_time IS NULL THEN knowledge_index_state.source_high_water_time
|
||||
ELSE MAX(COALESCE(knowledge_index_state.source_high_water_time, 0), excluded.source_high_water_time)
|
||||
END`
|
||||
)
|
||||
.run(
|
||||
conversationId,
|
||||
@@ -1146,11 +1416,33 @@ export class KnowledgeStore {
|
||||
state,
|
||||
highWater,
|
||||
messageCount,
|
||||
completeSnapshot ? 1 : 0,
|
||||
error,
|
||||
Date.now()
|
||||
Date.now(),
|
||||
sourceHighWater
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个会话「已经索引到源数据的哪个时刻」(epoch ms)。
|
||||
*
|
||||
* 增量 pass 用它判断哪些会话真的需要重新读取:Session 行的 `last_timestamp` 不晚于这个值
|
||||
* 就说明没有新消息,可以直接跳过(不读 WCDB、不写索引)。
|
||||
*/
|
||||
readSourceHighWaterMarks(): Record<string, number> {
|
||||
const marks: Record<string, number> = {}
|
||||
for (const row of asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
'SELECT conversation_id, source_high_water_time FROM knowledge_index_state WHERE source_high_water_time IS NOT NULL'
|
||||
)
|
||||
.all()
|
||||
)) {
|
||||
marks[String(row.conversation_id)] = Number(row.source_high_water_time)
|
||||
}
|
||||
return marks
|
||||
}
|
||||
|
||||
private readMeta(key: string): string | null {
|
||||
const row = this.database.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as
|
||||
| DbRow
|
||||
@@ -1184,6 +1476,43 @@ export class KnowledgeStore {
|
||||
this.refreshStatsSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索热路径专用的快照读取(**不做全表聚合**)。
|
||||
*
|
||||
* 分开的原因:`markStatsStale()` 在**每次** `index()` 调用时都会执行,而
|
||||
* `KnowledgeSearchService.indexAccount` 是**逐会话**调用 `index()` 的,所以一遍后台 pass
|
||||
* 进行中 `stats_state` 几乎永远是 `'stale'`,pass 被中断后更是会一直留在 `'stale'`。
|
||||
* 如果每次搜索都因此重做三次全表聚合(messages / chunks / voice),单个 probe 的代价就是
|
||||
* 数十秒级,交互查询会被卡住。
|
||||
*
|
||||
* 规则:只有在快照**从未建立**(首库)或**明显与真实数据不符**(快照说 0 个分片、
|
||||
* 但库里确实有分片 → 会把可查询的库误报成 unavailable)时才刷新。其余情况沿用上一次完整
|
||||
* pass 写下的结论:数字可能略旧,但不会把可查询的库说成不可用,也不会把交互查询卡在
|
||||
* 全表聚合上。
|
||||
*/
|
||||
private ensureStatsSnapshotForQuery(): void {
|
||||
const state = this.readMeta('stats_state')
|
||||
if (state === 'fresh') return
|
||||
if (state === null) {
|
||||
this.refreshStatsSnapshot()
|
||||
return
|
||||
}
|
||||
if (this.readStatNumber('stats_chunk_count') === 0 && this.hasAnyChunk()) {
|
||||
this.refreshStatsSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/** 只探一行,用于避免把"有分片但快照过期"的库报成不可用。 */
|
||||
private hasAnyChunk(): boolean {
|
||||
try {
|
||||
return Boolean(
|
||||
this.database.prepare('SELECT 1 AS present FROM knowledge_chunks LIMIT 1').get()
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private markStatsStale(): void {
|
||||
this.writeMeta('stats_state', 'stale')
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchResult,
|
||||
@@ -19,6 +21,8 @@ type WorkerResult =
|
||||
| KnowledgeCapacityPreflight
|
||||
| KnowledgeSearchResult
|
||||
| KnowledgeRuntimeStatus
|
||||
| KnowledgeMemberStatsResult
|
||||
| { marks: Record<string, number> }
|
||||
| { removed: true }
|
||||
type PendingRequest = {
|
||||
resolve: (result: WorkerResult) => void
|
||||
@@ -36,6 +40,14 @@ export class KnowledgeWorkerHost {
|
||||
private child: ChildProcess | null = null
|
||||
private childStartedAt = 0
|
||||
private readonly pending = new Map<string, PendingRequest>()
|
||||
/**
|
||||
* 当前在跑的索引请求 id。
|
||||
*
|
||||
* 之前没有它,所以「取消同步」在 UI 上不存在、在主进程里也无法表达 ——
|
||||
* 唯一能停下来的方式就是退出应用。这里显式跟踪,`cancelActiveIndex()` 才能
|
||||
* 精确地只中止索引,而**不会**影响任何并发进行的查询请求。
|
||||
*/
|
||||
private activeIndexRequestId: string | null = null
|
||||
|
||||
constructor(private readonly workerPath: string) {}
|
||||
|
||||
@@ -43,7 +55,9 @@ export class KnowledgeWorkerHost {
|
||||
payload: KnowledgeIndexRequest,
|
||||
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||
): Promise<KnowledgeIndexResult> {
|
||||
return this.request('index', payload, onProgress) as Promise<KnowledgeIndexResult>
|
||||
return this.request('index', payload, onProgress, (requestId) => {
|
||||
this.activeIndexRequestId = requestId
|
||||
}) as Promise<KnowledgeIndexResult>
|
||||
}
|
||||
|
||||
preflight(payload: KnowledgeCapacityPreflightRequest): Promise<KnowledgeCapacityPreflight> {
|
||||
@@ -58,6 +72,26 @@ export class KnowledgeWorkerHost {
|
||||
return this.request('status', payload) as Promise<KnowledgeRuntimeStatus>
|
||||
}
|
||||
|
||||
/** 每个会话已经索引到的源侧时刻;用于增量 pass 跳过没有变化的会话。 */
|
||||
highWaterMarks(payload: KnowledgeStatusRequest): Promise<Record<string, number>> {
|
||||
return this.request('highWater', payload as unknown as KnowledgeWorkerRequest['payload']).then(
|
||||
(result) => ('marks' in result ? result.marks : {})
|
||||
)
|
||||
}
|
||||
|
||||
/** 单个会话内「按发送者聚合」的发言统计(群员统计用,只回聚合不回正文)。 */
|
||||
memberStats(payload: KnowledgeMemberStatsRequest): Promise<KnowledgeMemberStatsResult> {
|
||||
return this.request('memberStats', payload) as Promise<KnowledgeMemberStatsResult>
|
||||
}
|
||||
|
||||
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
|
||||
async cancelActiveIndex(): Promise<boolean> {
|
||||
const target = this.activeIndexRequestId
|
||||
if (!target) return false
|
||||
await this.cancel(target)
|
||||
return true
|
||||
}
|
||||
|
||||
remove(accountId: string, databaseRoot: string): Promise<{ removed: true }> {
|
||||
return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }>
|
||||
}
|
||||
@@ -81,13 +115,15 @@ export class KnowledgeWorkerHost {
|
||||
private request(
|
||||
type: KnowledgeWorkerRequest['type'],
|
||||
payload: KnowledgeWorkerRequest['payload'],
|
||||
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||
onProgress?: (progress: KnowledgeIndexProgress) => void,
|
||||
onRequestId?: (requestId: string) => void
|
||||
): Promise<WorkerResult> {
|
||||
const hadWorker = Boolean(this.child?.connected)
|
||||
const child = this.ensureChild()
|
||||
const requestId = randomUUID()
|
||||
const sentAt = Date.now()
|
||||
const request: KnowledgeWorkerRequest = { version: 1, type, requestId, sentAt, payload }
|
||||
onRequestId?.(requestId)
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(requestId, {
|
||||
resolve,
|
||||
@@ -145,6 +181,7 @@ export class KnowledgeWorkerHost {
|
||||
const pending = this.pending.get(requestId)
|
||||
if (!pending) return
|
||||
this.pending.delete(requestId)
|
||||
if (this.activeIndexRequestId === requestId) this.activeIndexRequestId = null
|
||||
if (error) pending.reject(error)
|
||||
else if (result) pending.resolve(this.applyTransportTimings(result, pending, transport))
|
||||
else pending.reject(new Error('Knowledge worker returned no result'))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
KnowledgeCapacityPreflightRequest,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeStatusRequest,
|
||||
@@ -114,6 +116,7 @@ async function handleSearch(
|
||||
evidence: [],
|
||||
indexedMessageCount: 0,
|
||||
indexedChunkCount: 0,
|
||||
indexLatestAt: null,
|
||||
timings: emptyKnowledgeSearchTimings()
|
||||
},
|
||||
workerReceivedAt,
|
||||
@@ -153,7 +156,9 @@ async function handleStatus(
|
||||
estimatedRemainingMs: null,
|
||||
databaseBytes: 0,
|
||||
walBytes: 0,
|
||||
shmBytes: 0
|
||||
shmBytes: 0,
|
||||
indexLatestAt: null,
|
||||
sourceLatestAt: null
|
||||
}
|
||||
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
|
||||
return
|
||||
@@ -166,6 +171,56 @@ async function handleStatus(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个会话「已经索引到源数据的哪个时刻」。
|
||||
* 增量 pass 靠它决定哪些会话可以整段跳过(见 `KnowledgeStore.readSourceHighWaterMarks`)。
|
||||
*/
|
||||
async function handleHighWater(
|
||||
request: KnowledgeWorkerRequest,
|
||||
payload: KnowledgeStatusRequest
|
||||
): Promise<void> {
|
||||
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
|
||||
const marks = existsSync(path) ? getStore(payload).readSourceHighWaterMarks() : {}
|
||||
send({
|
||||
version: 1,
|
||||
type: 'result',
|
||||
requestId: request.requestId,
|
||||
payload: { marks } as unknown as KnowledgeWorkerResponse['payload']
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 群员统计的按发送者聚合。
|
||||
*
|
||||
* 与 `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') {
|
||||
@@ -201,6 +256,14 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
|
||||
await handleStatus(request, request.payload as KnowledgeStatusRequest)
|
||||
return
|
||||
}
|
||||
if (request.type === 'highWater') {
|
||||
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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user