Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daeba3388d | ||
|
|
8fac752e2b | ||
|
|
34b86af0be | ||
|
|
a80624d6ab | ||
|
|
cf3f115124 | ||
|
|
0c1d859e1b | ||
|
|
2482c23c5e | ||
|
|
0706ba13e6 | ||
|
|
55c6fb8cd3 | ||
|
|
b6901c9d0c | ||
|
|
4d95fd7650 | ||
|
|
775b5aff18 | ||
|
|
2f2f682fa0 | ||
|
|
2354fd0766 | ||
|
|
6192e7cd35 | ||
|
|
68f0c0b5a3 | ||
|
|
7a5499f093 | ||
|
|
d1a090bb6b | ||
|
|
8a0d3b02d9 | ||
|
|
c41675b809 | ||
|
|
4cd3ea0bc0 | ||
|
|
32864ff88c | ||
|
|
291c82f0e2 | ||
|
|
666d8896ee | ||
|
|
f2c58f39b0 | ||
|
|
cd2c3cfaee | ||
|
|
a73af3b5ad | ||
|
|
0c21008ec3 | ||
|
|
96c67f5bf8 | ||
|
|
0b845db2e0 | ||
|
|
c125e85ffc | ||
|
|
43654bf0e2 | ||
|
|
e43af6f1fe | ||
|
|
3af62783dd | ||
|
|
88a6d750fb | ||
|
|
0ec2e6a0be | ||
|
|
a0e8ab278f | ||
|
|
ad4b3a8074 | ||
|
|
307d247660 | ||
|
|
e3615c0153 | ||
|
|
3c59fb64e9 | ||
|
|
c4e13ee7d2 | ||
|
|
12cae061df | ||
|
|
17cc99de37 | ||
|
|
933a87ebbb | ||
|
|
55da2e2e67 | ||
|
|
c2f9d352db | ||
|
|
7529a67f09 | ||
|
|
4e84b52cc4 | ||
|
|
66a6ee3e32 | ||
|
|
69bc6f57e7 | ||
|
|
894281fb44 | ||
|
|
2f6ab7b773 | ||
|
|
a0e8be0cdf | ||
|
|
e153ddb794 | ||
|
|
60c501e148 | ||
|
|
c23ed23bd2 | ||
|
|
0a3d930298 | ||
|
|
c6587c517a | ||
|
|
c70e49bf16 | ||
|
|
d76727875d | ||
|
|
08e1294e5d | ||
|
|
224308f0e0 | ||
|
|
3e57a8432d | ||
|
|
b4f909a597 | ||
|
|
ee7dc11e92 | ||
|
|
90bf1aed90 | ||
|
|
a3955d691d | ||
|
|
8a6d443acc | ||
|
|
f0601cdc85 | ||
|
|
77adc744e0 | ||
|
|
0adb064681 | ||
|
|
8e40487e08 | ||
|
|
d0ae9e6019 | ||
|
|
49684f3365 | ||
|
|
33aaf4d558 | ||
|
|
5ccabb3898 | ||
|
|
7db845ac7e | ||
|
|
9348c5ce4a | ||
|
|
f626d89a1a | ||
|
|
95e517eca5 | ||
|
|
e93e4554d7 | ||
|
|
9ae0c6cc47 | ||
|
|
59e609b08a | ||
|
|
9de7dcde3b | ||
|
|
cb3c2855c0 | ||
|
|
dca88e5db3 | ||
|
|
1a9f27488b | ||
|
|
3b64e18b5e | ||
|
|
554dccdb21 | ||
|
|
c674dcbc4a | ||
|
|
0f65b96da1 | ||
|
|
8488e81bb5 | ||
|
|
430a36333b | ||
|
|
474250c6c7 | ||
|
|
794880d389 | ||
|
|
d0ceeceb68 | ||
|
|
62f729d281 | ||
|
|
0d544275c0 | ||
|
|
a91b4d7a56 | ||
|
|
f9b567fba2 | ||
|
|
1f693aa3d7 | ||
|
|
8e1e166856 | ||
|
|
1275c512c4 | ||
|
|
1abaa57a0c | ||
|
|
d08db46479 | ||
|
|
eaea8d8435 | ||
|
|
1e97953d67 |
@@ -6,9 +6,12 @@ VITE_DB_KEY=
|
||||
VITE_AUTO_LOGIN=false
|
||||
|
||||
# AI API Configuration (Optional, can be entered in UI)
|
||||
# 注意:发布版本不再自动读取以下环境变量。
|
||||
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
|
||||
# 然后在「设置 → AI 模型」里手动完成"添加供应商"流程。
|
||||
VITE_DEEPSEEK_API_KEY=
|
||||
VITE_AI_BASE_URL=https://api.deepseek.com
|
||||
VITE_AI_MODEL=deepseek-v4-flash
|
||||
VITE_AI_MODEL=deepseek-chat
|
||||
|
||||
# Message types to filter out (comma separated). Empty means show all message types.
|
||||
VITE_FILTER_MSG_TYPES=
|
||||
@@ -19,3 +22,7 @@ VITE_FILTER_MSG_TYPES=
|
||||
# AES Key: 16-character string, derived from wxid and code
|
||||
VITE_IMAGE_XOR_KEY=
|
||||
VITE_IMAGE_AES_KEY=
|
||||
|
||||
# Electron E2E test window close delay in milliseconds.
|
||||
# Local default: 2000 (2 seconds). Set to 0 for immediate close.
|
||||
WXE_E2E_CLOSE_DELAY_MS=2000
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
desktop-tests:
|
||||
name: ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 7.33.7
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
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
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: runner.os == 'macOS'
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Component tests
|
||||
run: pnpm test:component
|
||||
|
||||
- name: IPC integration tests
|
||||
run: pnpm test:integration
|
||||
|
||||
- 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
|
||||
|
||||
- name: Electron E2E tests
|
||||
run: pnpm exec playwright test --grep-invert="@visual"
|
||||
env:
|
||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||
|
||||
- name: Platform visual regression
|
||||
run: pnpm exec playwright test tests/e2e/visual.spec.ts
|
||||
env:
|
||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||
|
||||
- name: Upload Playwright diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-${{ matrix.os }}
|
||||
path: |
|
||||
test-results/
|
||||
playwright-report/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -6,9 +6,16 @@ out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
resources/connectors/wechat/
|
||||
.omc
|
||||
.codex/
|
||||
docs/design/
|
||||
docs/ui-redesign-plan.md
|
||||
docs/ui-redesign-spec.md
|
||||
AGENTS.md
|
||||
findings.md
|
||||
progress.md
|
||||
task_plan.md
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
shamefully-hoist=true
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,199 +1,303 @@
|
||||
# WechatExplorer
|
||||
# TraceMemo(迹忆)
|
||||
|
||||
macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
||||
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
|
||||
<p align="center">
|
||||
<img src="./build/icon.png" width="120" alt="TraceMemo Logo" />
|
||||
</p>
|
||||
|
||||
## 项目说明
|
||||
<h2 align="center">把微信聊过的事,找回来、问清楚、留下来</h2>
|
||||
|
||||
本项目的目标,是在自己的电脑上实现“本地查看微信聊天记录 + 一键生成群聊总结”的实用能力。
|
||||
<p align="center">
|
||||
本地优先的微信聊天记录工作台:查看、搜索、提问、总结和导出<br />
|
||||
查看聊天 · 找回信息 · AI 问答 · 群聊日报总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入
|
||||
</p>
|
||||
|
||||
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/stars/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="GitHub stars" />
|
||||
<img src="https://img.shields.io/github/downloads/Wxw-Gu/WechatExplorer/total?style=for-the-badge" alt="GitHub downloads" />
|
||||
<img src="https://img.shields.io/github/v/release/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="Latest release" />
|
||||
</p>
|
||||
|
||||
> 当前版本:`v2.1.0`。macOS 支持相对稳定;Windows 已初步支持微信 4.0 数据库连接、自动获取密钥和聊天记录查看,仍在持续兼容不同微信版本与本地目录结构。
|
||||
<p align="center">
|
||||
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>下载 TraceMemo</b></a>
|
||||
·
|
||||
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
|
||||
·
|
||||
<a href="./docs/README.md"><b>完整文档</b></a>
|
||||
</p>
|
||||
|
||||
## ✨ 功能特性
|
||||
<p align="center">
|
||||
<img src="./public/software-1.png" alt="TraceMemo 主界面" />
|
||||
</p>
|
||||
|
||||
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
|
||||
- **全局搜索**: 快速搜索聊天内容。
|
||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||
- **一句话速览**: 首屏突出今日核心结论与待跟进事项。
|
||||
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
||||
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
||||
- **有趣对话或金句**: 收录群内的精彩对话。
|
||||
- **问题与解答**: 整理群内的问答内容。
|
||||
- **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
|
||||
- **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
|
||||
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||
- **词云/关键词**: 可视化展示群聊关键词。
|
||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
|
||||
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
||||
<p align="center">
|
||||
<img src="./public/机器人.png" alt="TraceMemo 主界面" />
|
||||
</p>
|
||||
|
||||
## 📸 预览
|
||||
## TraceMemo(迹忆)是什么
|
||||
|
||||
### 日报模板
|
||||
TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。
|
||||
|
||||
TraceMemo(迹忆)原名 WechatExplorer,是一次从“微信聊天记录探索工具”向“可追溯的本地 AI 知识工作台”演进后的正式品牌升级。
|
||||
|
||||
## 为什么叫 TraceMemo(迹忆)
|
||||
|
||||
`Trace` 代表聊天记录留下的痕迹、可以追溯的信息来源、AI 搜索过程,以及从结果回到原始聊天上下文并核对证据的能力。
|
||||
|
||||
`Memo` 代表记忆、知识沉淀和长期保存:让聊天中产生的信息逐渐形成个人知识。
|
||||
|
||||
“迹忆”可以理解为“留下痕迹的记忆”。TraceMemo 不是单纯查看微信聊天记录的工具,而是希望让聊天中产生的信息留下痕迹,并能够被再次找到、理解、验证和沉淀。
|
||||
|
||||
> **品牌说明**
|
||||
>
|
||||
> TraceMemo(迹忆)原名 WechatExplorer。WechatExplorer 最初是一个用于查看和探索微信聊天记录的工具。随着本地搜索、AI 问答、来源追溯、知识库、日报、语音转写和 Agent 能力逐渐形成,项目已经从单纯的聊天记录查看器发展为本地 AI 知识与分析工作台,因此在 v2.2.0 正式更名为 TraceMemo(迹忆)。
|
||||
|
||||
它可以帮你浏览、搜索和整理微信历史,也可以让 AI 帮你找回聊过的内容,并回到原始消息核对答案。
|
||||
|
||||
你可以直接浏览聊天,也可以用自然语言提问:
|
||||
|
||||
> “上个月我们讨论过哪些发布问题?”
|
||||
> “张三之前发过的项目地址在哪里?”
|
||||
> “技术交流群今天有哪些结论和待办?”
|
||||
|
||||
它和普通聊天记录查看器最大的不同,是 AI 不只是告诉你答案,还会告诉你答案来自哪里。你可以看到答案参考了哪些内容、来自哪个会话和时间,再回到原始消息确认它有没有理解错。
|
||||
|
||||
## 💬 交流与反馈
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/二维码.jpg" alt="TraceMemo 交流与售后群二维码" width="280" />
|
||||
</p>
|
||||
|
||||
## 从你的任务开始
|
||||
|
||||
| 我现在想做什么 | 在应用里打开 | 需要准备什么 |
|
||||
| ----------------------------------------- | ------------------------------------------------------- | ------------------------------------ |
|
||||
| 找一句记得原文或关键词的聊天 | [档案](./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/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 结果跳回对应聊天位置。
|
||||
|
||||
详细说明:[聊天档案与普通搜索](./docs/user-guide/chat-archive.md)
|
||||
|
||||
### 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)。
|
||||
|
||||
## 其他能力
|
||||
|
||||
### 本地知识库
|
||||
|
||||
“问问微信”里的“本地知识库”会为当前微信账号建立一份留在本机的可检索资料。它把聊天文本、附件信息和已有语音转写整理起来,让跨会话、跨时间查找更稳定。
|
||||
|
||||
它只在用户主动建立后工作,可以同步、查看占用并清理;清理不会删除微信原始数据库。
|
||||
|
||||
详细说明:[本地知识库](./docs/user-guide/knowledge.md)
|
||||
|
||||
### 生成群聊日报
|
||||
|
||||
<details>
|
||||
<summary>点击查看完整日报模板</summary>
|
||||
<br />
|
||||
<img src="./public/report-template-1.png" alt="完整日报模板" />
|
||||
<summary>查看群聊日报示例、内容和导出方式</summary>
|
||||
|
||||
选择群聊和时间范围后,可以让 AI 把聊天整理成报告,并保存为 HTML 与 PNG 长图。报告可能包含热点、重要消息、资源、问答、待办、未解决事项、活跃统计和图片精选;具体内容取决于消息、媒体是否可读以及模型能力。
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/report-template-1.png" alt="群聊日报示例" />
|
||||
</p>
|
||||
|
||||
详细说明:[生成群聊日报](./docs/user-guide/report.md)
|
||||
|
||||
</details>
|
||||
|
||||
### AI 群聊日报界面
|
||||
### 转写微信语音
|
||||
|
||||
<img src="./public/software-1.png" alt="AI 群聊日报页面" />
|
||||
TraceMemo 支持在本机转写单条或批量微信语音,结果可以参与本地知识库检索和 HTML 导出。转写本身不要求把语音文件发送给在线 AI;随后用于 AI 问答或日报时,文字会按对应功能的规则处理。
|
||||
|
||||
### 本地 API 与 Reader Skill
|
||||
详细说明:[语音转文字](./docs/user-guide/voice.md)
|
||||
|
||||
<img src="./public/software-2.png" alt="本地 API 与 Reader Skill 页面" />
|
||||
### 防撤回
|
||||
|
||||
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||
可选开启后,TraceMemo 会尽量保留开启期间捕获到的撤回消息。该能力受微信版本和应用运行状态影响,不保证找回所有内容,也不能恢复开启前已经撤回的消息。
|
||||
|
||||
## 📦 安装说明
|
||||
详细说明:[防撤回](./docs/user-guide/recall-protection.md)
|
||||
|
||||
### macOS 版本已测 4.1.8
|
||||
### 导出长期可用的聊天档案
|
||||
|
||||
> 微信 macOS `4.1.8.100` 下载地址:[wechat-versions v4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100)
|
||||
支持 HTML、CSV、JSON 和 Markdown。HTML 可携带媒体、头像和可选语音转写,支持最多五个会话合并,也可以压缩为 ZIP;增量合并、媒体资源和 ZIP 只适用于 HTML,其他格式主要保留文本内容。
|
||||
|
||||
1. 下载 Releases 中的 `xxx.dmg` 文件。
|
||||
2. 打开 DMG 并将应用拖动到 **Applications** (应用程序) 文件夹。
|
||||
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
|
||||
`系统设置 -> 隐私与安全性 -> 仍要打开`。
|
||||
4. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥
|
||||
详细说明:[导出聊天](./docs/user-guide/export.md)
|
||||
|
||||
### Windows 版本已测 4.1.9
|
||||
### 在外部 Agent 中查询微信历史
|
||||
|
||||
> 微信 Windows `4.1.9.57` 下载地址:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
|
||||
通过 Reader Skill 和本机 Local HTTP API,Codex、Claude Code、OpenClaw 等外部 Agent 可以按需查询联系人、群聊和聊天记录。这和微信机器人是两条不同路径:微信机器人收到消息后在微信中回复;外部 Agent 则主动查询历史。
|
||||
|
||||
1. 下载 Releases 中的 `xxx-setup.exe` 安装包。
|
||||
2. 双击安装,可按安装向导选择安装目录。
|
||||
3. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥。
|
||||
安装和技术说明请看[Agent 接入概览](./docs/agent/overview.md)与[Local HTTP API](./docs/agent/api.md)。
|
||||
|
||||
> Windows 支持仍处于初步阶段。软件会有些卡顿, 如果自动获取密钥失败,请先确认微信客户端已登录并保持运行;不同微信安装路径、数据目录和权限环境可能仍需要继续适配。
|
||||
## 它如何工作
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 使用前置要求
|
||||
|
||||
- **微信版本**:
|
||||
- 微信 4.0+: macOS / Windows 已支持部分能力,仍在持续迭代与兼容性验证中;如需更成熟的完整方案,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog)
|
||||
- **macOS 自动获取密钥**: 需要先关闭 SIP,操作方式见 [macOS 关闭 SIP 教程](./docs/mac-disable-sip.md);关闭 SIP 会降低系统安全性,使用完成后建议重新开启。
|
||||
- 如无法获取本地数据库密码,则无法使用当前项目
|
||||
- Node.js (推荐 v16+)
|
||||
- pnpm@7
|
||||
- 解密后的微信数据库文件 (`.db`) 和对应的密钥
|
||||
- AI API Key(支持 OpenAI 兼容 API,可选 DeepSeek/GPT/Claude/Moonshot 等)
|
||||
|
||||
### 环境变量配置 (.env)
|
||||
|
||||
可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。
|
||||
|
||||
| 变量名 | 说明 | 示例 |
|
||||
| ----------------------- | --------------------------- | --------------------------- |
|
||||
| `VITE_DB_KEY` | 微信数据库密钥 (32字节hex) | `YOUR_DB_KEY_HERE` |
|
||||
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥 (hex格式) | `0x40` |
|
||||
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥 (16字符) | `YOUR_AES_KEY_HERE` |
|
||||
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
|
||||
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
|
||||
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
||||
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
||||
|
||||
#### 图片解密密钥说明
|
||||
|
||||
微信 4.0+ 的图片以 `.dat` 文件存储,需要密钥解密:
|
||||
|
||||
- **XOR Key**: 单字节 hex 值(如 `0x40`),用于简单的字节异或解密
|
||||
- **AES Key**: 16字符字符串,用于 AES-128-ECB 解密
|
||||
|
||||
这两个密钥可以通过以下方式获取:
|
||||
|
||||
1. 在应用登录界面点击“自动获取密钥”
|
||||
2. 从 WeFlow/Chatlog 设置中导出
|
||||
3. 在软件“设置 -> 图片解密密钥”中自动获取或手动填写
|
||||
4. 如自动获取失败,可手动粘贴已获取的密钥
|
||||
|
||||
## 🤖 AI 集成(本地 HTTP API)
|
||||
|
||||
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
|
||||
|
||||
### 启用本地 API
|
||||
|
||||
API 服务在 WechatExplorer 启动时自动启用,**不需要任何配置**。只需要:
|
||||
|
||||
1. 安装并启动 WechatExplorer
|
||||
2. 完成首次密钥配置(主窗口第一步),解锁 WCDB 数据库
|
||||
3. API 即在 `http://127.0.0.1:6131` 可用
|
||||
|
||||
### 7×24 提供 API(菜单栏常驻模式)
|
||||
|
||||
默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,启用菜单栏模式:
|
||||
|
||||
```bash
|
||||
# 任选一种方式
|
||||
WXE_TRAY=1 open /Applications/WechatExplorer.app
|
||||
/Applications/WechatExplorer.app/Contents/MacOS/WechatExplorer --tray
|
||||
```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 功能时,完成任务所需的内容可能发送到你选择的模型服务;具体发送范围和确认方式以对应功能页面为准。
|
||||
- “问问微信”会先在本机缩小范围,不会默认把整个微信数据库作为一次模型请求发送。
|
||||
|
||||
- macOS dock 图标自动隐藏
|
||||
- 菜单栏出现 WechatExplorer 图标(可点击重新打开主窗口、查看 API 状态)
|
||||
- 主窗口关闭后 API 服务继续运行
|
||||
完整边界见:[数据、隐私与安全](./docs/user-guide/privacy.md)
|
||||
|
||||
### API 端点一览
|
||||
## 支持平台与安装包
|
||||
|
||||
| 端点 | 说明 |
|
||||
| ------------------------------------------------ | --------------------------------------- |
|
||||
| `GET /api/v1/health` | 健康检查 |
|
||||
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) |
|
||||
| `GET /api/v1/contact?filter=xxx` | 联系人 / 群聊列表 |
|
||||
| `GET /api/v1/chatroom?keyword=xxx` | 搜索群聊 |
|
||||
| `GET /api/v1/chatlog?talker=xxx&time=2026-07-03` | 聊天记录 |
|
||||
| `GET /api/v1/group_snapshot?md5=xxx` | 群成员快照 |
|
||||
| `GET /api/v1/resolve?q=群昵称` | 把昵称/wxid/md5 解析成 md5 |
|
||||
| 平台 | 处理器架构 | Releases 安装包 |
|
||||
| ------- | ------------------------------ | --------------- |
|
||||
| Windows | x64 | `-setup.exe` |
|
||||
| macOS | Apple Silicon(M 系列、arm64) | `.dmg` |
|
||||
|
||||
详细参数、返回结构、时间格式见 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
当前版本不支持 Intel 芯片的 Mac。当前代码面向微信 4.x 数据结构。实际连接结果仍会受到微信客户端版本、账号数据状态和系统权限影响;macOS 首次连接可能需要按页面提示完成额外授权。
|
||||
|
||||
### 安装 Reader Skill,让 Agent 读取和总结群聊
|
||||
## 快速开始
|
||||
|
||||
WechatExplorer 已内置 **Reader Skill**,无需手动复制仓库中的 `SKILL.md`:
|
||||
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载安装包。
|
||||
2. 启动 TraceMemo,按照“第一次使用”页面选择微信数据目录。
|
||||
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。
|
||||
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。
|
||||
5. 先在“档案”里搜索一句你记得的原话;这一步不需要 AI。
|
||||
6. 需要 AI 问答或日报时,在“设置 → AI 模型”添加并测试 AI 服务,再打开“问问微信”或“日报”。
|
||||
7. 想直接在微信里提问时,打开“Agent”扫码连接微信机器人;想让 Codex 等外部 Agent 查询时,再进入“API”。
|
||||
|
||||
1. 启动 WechatExplorer,并确认数据库已连接、本地 API 已运行。
|
||||
2. 打开应用内的 **API** 页面。
|
||||
3. 在“快速接入”中选择 **Codex** 或 **Claude Code**。
|
||||
4. 点击复制安装指令,将指令粘贴给对应的 Agent 执行。
|
||||
5. 安装完成后,可以直接向 Agent 提问:
|
||||
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)。
|
||||
|
||||
Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。详细接口说明仍可查看 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
如果 macOS 页面提示处理 SIP,请先阅读对应说明。具体步骤和限制见[第一次使用](./docs/user-guide/getting-started.md)。
|
||||
|
||||
### curl 调试示例(可选)
|
||||
完整步骤:[第一次使用 TraceMemo](./docs/user-guide/getting-started.md)
|
||||
|
||||
不使用 Agent 时,也可以通过 `curl` 直接调试本地 HTTP API:
|
||||
## 配置 AI
|
||||
|
||||
需要 AI 问答、群聊日报或图片理解时,在“设置 → AI 模型”添加并测试一个服务。应用支持云端服务、Ollama 等本地服务和自定义接口;具体服务商的配置、计费和数据规则由服务商决定。
|
||||
|
||||
使用本地服务可以减少数据离开电脑的路径,但本地服务的日志和配置仍由你自己负责。
|
||||
|
||||
开发者和 Agent 用户可以从[Agent 接入概览](./docs/agent/overview.md)开始,再按需要查看[Local HTTP API](./docs/agent/api.md)与[API 安全](./docs/agent/api-security.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/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)
|
||||
|
||||
## 本地开发
|
||||
|
||||
需要 Node.js、pnpm 7+、对应平台的 Electron/native 构建环境,以及 Go(用于微信连接器)。
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:6131/api/v1/health
|
||||
|
||||
# 今天 摸鱼交流群 的聊天记录
|
||||
curl -G "http://127.0.0.1:6131/api/v1/chatlog" \
|
||||
--data-urlencode "talker=摸鱼交流群" \
|
||||
--data-urlencode "time=$(date +%Y-%m-%d)"
|
||||
|
||||
# 把群昵称解析成 md5
|
||||
curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
||||
--data-urlencode "q=摸鱼交流群"
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## ⚠️ 免责声明
|
||||
常用检查:
|
||||
|
||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test:unit
|
||||
pnpm test:component
|
||||
pnpm test:integration
|
||||
pnpm test:e2e:build
|
||||
```
|
||||
|
||||
## 🔗 参考致谢
|
||||
完整说明:[开发、测试与构建](./docs/development/overview.md)
|
||||
|
||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||
- [WeFlow](https://github.com/hicccc77/WeFlow)
|
||||
- [chatlog](https://github.com/sjzar/chatlog)
|
||||
## 支持与反馈
|
||||
|
||||
遇到问题时,先查看[常见问题与排查](./docs/user-guide/troubleshooting.md)。提交 Issue 时请提供操作系统、微信版本、TraceMemo 版本、复现步骤和已遮挡敏感信息的截图。
|
||||
|
||||
请仅处理你有权访问的数据,并遵守适用的法律法规、组织政策和微信使用规则。数据库读取、解密、自动化和机器人能力都可能受平台版本与账号环境影响。
|
||||
|
||||
## 许可说明
|
||||
|
||||
仓库中的第三方组件、模型和连接器遵循各自的许可证。当前仓库根目录未提供独立的项目 `LICENSE` 文件;贡献、复制或再分发前,请先向维护者确认 TraceMemo 本身的许可范围。
|
||||
|
||||
## 致谢
|
||||
|
||||
<details>
|
||||
<summary>展开致谢与参考项目</summary>
|
||||
|
||||
TraceMemo 在开发过程中参考了多个优秀的开源项目,感谢这些项目作者的工作与分享。
|
||||
|
||||
特别感谢:
|
||||
|
||||
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
|
||||
- 提供了微信数据库解析相关思路。
|
||||
- **[WeFlow](https://github.com/hicccc77/WeFlow)**
|
||||
- 参考了数据库密钥获取、图片解密等实现思路。
|
||||
- **[chatlog](https://github.com/sjzar/chatlog)**
|
||||
- 提供了聊天记录导出与数据处理方面的参考。
|
||||
|
||||
在此基础上,TraceMemo 进行了重新设计与实现,包括:
|
||||
|
||||
- AI 问问微信
|
||||
- AI 群聊日报
|
||||
- 本地 HTTP API
|
||||
- Reader Skill
|
||||
- Agent Hub
|
||||
- 新手引导
|
||||
- Electron + React 全新界面
|
||||
- 本地优先 AI 工作流
|
||||
|
||||
感谢所有开源作者。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# TraceMemo 文档
|
||||
|
||||
TraceMemo 的文档按“你想完成什么”组织,而不是按源码模块组织。
|
||||
|
||||
## 从这里开始
|
||||
|
||||
- [第一次使用](./user-guide/getting-started.md):安装、连接微信、完成第一次搜索和提问。
|
||||
- [查看和搜索聊天](./user-guide/chat-archive.md):找原话、回看上下文、处理媒体。
|
||||
- [用 AI 查找聊天信息](./user-guide/ai-search.md):理解普通搜索和 AI Search 的区别,并核对答案来源。
|
||||
|
||||
## 你可以完成的任务
|
||||
|
||||
- [建立本地知识库](./user-guide/knowledge.md)
|
||||
- [生成群聊日报和总结](./user-guide/report.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 为什么这样回答
|
||||
|
||||
- [如何核对 AI 的回答来源](./concepts/answer-sources.md):用用户语言解释依据、来源标记和查找过程。
|
||||
- [从微信数据到回答、日报和导出](./concepts/how-it-works.md):了解哪些步骤在本机完成,哪些步骤可能调用 Provider。
|
||||
|
||||
## 微信机器人和外部 Agent
|
||||
|
||||
TraceMemo 有两种不同的接入方式。微信机器人是普通用户可以直接使用的产品能力;Reader Skill 和 Local HTTP API 面向已经在使用 Codex、Claude Code、OpenClaw 等外部 Agent 的用户。
|
||||
|
||||
| 你想做什么 | 应该看哪里 |
|
||||
| --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| 在微信里给机器人发消息,让本机读取数据、生成总结并回复 | [Agent Hub](./agent/agent-hub.md) |
|
||||
| 在 Codex、Claude Code、OpenClaw 等外部 Agent 中主动查询过去的微信数据 | [Reader Skill](./agent/reader-skill.md) + [Local HTTP API](./agent/api.md) |
|
||||
|
||||
### 在微信里提问
|
||||
|
||||
打开应用一级导航中的“Agent”,进入“Agent Hub”后扫码登录微信机器人。机器人收到文字消息后,可以查询最近会话、读取联系人聊天、生成群聊总结图片或总结群成员发言,并把结果回复给发消息的人。它需要本地微信数据库已经连接;依赖 AI 的任务还需要配置 AI 服务。
|
||||
|
||||
- [Agent Hub](./agent/agent-hub.md):连接机器人、查看运行状态和了解实时交互边界。
|
||||
|
||||
### 让外部 Agent 查询历史微信
|
||||
|
||||
连接 Reader Skill 后,你可以询问:
|
||||
|
||||
> “总结今天技术交流群讨论了什么。”
|
||||
> “过去一周有没有人提到这个项目?”
|
||||
|
||||
- [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/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)
|
||||
|
||||
当前工作区版本:**2.2.0**。文档只描述当前代码已经实现的能力;版本兼容性、AI Provider 行为和媒体读取结果可能随系统、微信客户端和服务商变化。
|
||||
@@ -0,0 +1,69 @@
|
||||
# 在微信里向 TraceMemo 提问(Agent Hub)
|
||||
|
||||
Agent Hub 是 TraceMemo 内置的微信机器人入口,也是应用一级导航中的“Agent”页面。你先扫码登录一个微信机器人账号,再用微信账号向机器人发送文字;本机 Agent Hub 会接收消息、读取已经连接的微信数据,必要时调用已配置的 AI,再把结果回复给发送者。
|
||||
|
||||
普通用户不需要安装 Reader Skill,也不需要配置 API Token。先连接微信数据库,再扫码登录机器人即可开始;需要总结或自然语言理解的任务还要配置 AI Provider。
|
||||
|
||||
它和 Reader Skill 是两条不同的路径:
|
||||
|
||||
- Reader Skill / Local HTTP API:外部 Agent 主动查询历史微信数据;
|
||||
- Agent Hub / 微信机器人:机器人收到实时消息后处理并回复。
|
||||
|
||||
## 连接器和 Agent Hub 是什么关系
|
||||
|
||||
你不需要单独部署这些组件。扫码后,后台的微信连接器负责登录机器人、保持连接、接收微信消息和发送回复;Agent Hub 负责判断消息要做什么、查询 TraceMemo 本地数据、调用 AI 并组织结果。可以把它理解为:连接器负责“和微信通信”,Hub 负责“处理任务”。
|
||||
|
||||
## 你能做什么
|
||||
|
||||
连接 Agent Hub 后,可以在微信中询问:
|
||||
|
||||
- “最近 5 个会话”;
|
||||
- “帮我看看最近跟某人聊了些什么。”
|
||||
- “生成产品交流群今天的群聊总结图片。”
|
||||
|
||||
当前已实现的实时任务包括:
|
||||
|
||||
- 查看最近会话(数量限制为 1–20);
|
||||
- 查询你和某位联系人的近期聊天;
|
||||
- 用已配置的 AI 总结你和某位联系人近 7 天的聊天;
|
||||
- 生成今天、昨天或近 7 天的群聊总结图片;
|
||||
- 总结指定群成员在群里的近期发言;
|
||||
- 对不需要读取聊天的普通文字请求返回简短 AI 回复。
|
||||
|
||||
任务完成后,回复会发送回触发这次请求的微信用户。群聊总结会先发送进度提示,完成后发送图片。
|
||||
|
||||
这些任务会在后台查询联系人、群聊和聊天记录,但当前机器人没有单独的“列出所有联系人”或“列出所有群聊”命令;需要完整浏览或按条件查询时,请使用档案页面或 Reader Skill / Local HTTP API。
|
||||
|
||||
## 连接步骤
|
||||
|
||||
1. 打开应用主导航中的“Agent”;页面标题为“Agent Hub”。
|
||||
2. 确认 Hub 显示“运行中”,数据库状态为“可查询”。
|
||||
3. 点击“扫码登录微信机器人”。
|
||||
4. 用微信扫描二维码;如果页面显示“已扫码,等待手机确认”,在手机上确认。
|
||||
5. 状态变为“在线”后,用另一个微信账号向机器人发送测试问题。
|
||||
|
||||
可以重新扫码登录或断开连接。登录凭证失效时,需要重新扫码。
|
||||
|
||||
## 运行日志
|
||||
|
||||
Agent Hub 页面会记录系统、Agent Hub 和微信连接器日志。日志支持筛选、复制和清空,并会隐藏 Token 和二维码数据,不记录微信密码。
|
||||
|
||||
## 需要满足的条件
|
||||
|
||||
- TraceMemo 的微信数据库已经连接,并且数据 API 可以查询;
|
||||
- 依赖总结或自然语言理解的任务,需要在“设置 → AI 模型”配置可用的 AI 服务;
|
||||
- TraceMemo 和 Agent Hub 需要保持运行,机器人才能接收和回复消息。
|
||||
|
||||
## 安全与边界
|
||||
|
||||
- Hub 使用本机通信,不把数据库直接暴露到公网;
|
||||
- 机器人账号和个人微信账号是不同的登录边界,请确认你连接的是正确账号;
|
||||
- 机器人回复会发送给当前发消息的人;开发者 API 另有受保护的测试发送入口,使用前必须确认接收者;
|
||||
- Hub 生成群聊总结时仍可能调用你配置的 AI Provider;
|
||||
- 当前实时自然语言入口主要处理文字消息。底层连接器可以接收图片、语音、文件和视频,但 Agent Hub 尚未为这些媒体提供同等的实时意图处理;
|
||||
- 当前没有实现群发、广播、定时任务或通用自主操作微信;
|
||||
- 本页面的“Agent Hub 状态”可以通过 Local HTTP API 查询,但不要把它误认为外部 Agent 的实时消息订阅接口或 MCP Server。
|
||||
|
||||
## 无法连接时
|
||||
|
||||
先检查 Hub、连接器和数据库三项状态,再查看日志。二维码过期、连接器不存在、凭证失效和数据 API 未就绪分别需要重新扫码、修复安装、重新登录或先完成微信数据库连接。
|
||||
@@ -0,0 +1,52 @@
|
||||
# Local HTTP API 安全
|
||||
|
||||
## 当前安全边界
|
||||
|
||||
TraceMemo 的本地 API 默认监听 `127.0.0.1:6131`。它面向同一台电脑上的 API Center、Reader Skill、CLI 和 Agent,不是公网网关,也不是带用户账户和细粒度权限 Scope 的服务。
|
||||
|
||||
## Bearer Token
|
||||
|
||||
新 Agent 配置使用 `TRACEMEMO_API_TOKEN`。v2.2.0 仍兼容读取历史变量 `WECHATEXPLORER_API_TOKEN`,优先级为新变量高于旧变量。
|
||||
|
||||
- `/api/v1/health` 是公开健康检查;
|
||||
- 其他所有端点都要求 `Authorization: Bearer <TOKEN>`;
|
||||
- Token 由应用生成,使用 32 个随机字节编码;
|
||||
- Token 由 Electron `safeStorage` 加密保存在用户数据目录的 `local-api-token.bin`;
|
||||
- 文件权限设置为 `0600`;
|
||||
- 在“API Center”中可以显示、复制和重新生成;
|
||||
- 重新生成后旧 Token 立即失效。
|
||||
|
||||
应用不会自动把 Token 写入 Codex、Claude Code、OpenClaw 或其他 Agent 配置。请把它放进 Agent 自己的本地 secret/environment,例如:
|
||||
|
||||
```bash
|
||||
export TRACEMEMO_API_TOKEN="<TOKEN>"
|
||||
```
|
||||
|
||||
## CORS 与 Origin
|
||||
|
||||
带浏览器 `Origin` 的请求只允许精确的 HTTP loopback Origin:
|
||||
|
||||
- `http://localhost` 及其端口;
|
||||
- `http://127.0.0.1` 及其端口;
|
||||
- `http://[::1]` 及其端口。
|
||||
|
||||
不带 `Origin` 的 curl、Node、本地脚本和 Agent 请求不受浏览器 CORS 规则限制,但仍必须携带 Token(health 除外)。
|
||||
|
||||
## 不要做的事
|
||||
|
||||
- 不要把 Token 放入 URL query、日志、截图、公开 Skill 或 Git;
|
||||
- 不要把服务反向代理到公网;
|
||||
- 不要把“health 能访问”误认为数据端点无需授权;
|
||||
- 不要把 Bearer Token 当成跨用户权限系统;当前服务没有细粒度 Scope;
|
||||
- 不要在共享机器上让不可信进程继承 Token 环境变量。
|
||||
|
||||
## Token 不可用时
|
||||
|
||||
如果系统安全存储不可用,API Token 会无法生成或读取,本地 API 会安全停用。先修复系统钥匙串/凭据服务,再回到 API Center 重试。不要手动编辑 `local-api-token.bin`。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Agent 接入概览](./overview.md)
|
||||
- [Reader Skill](./reader-skill.md)
|
||||
- [数据、隐私与安全](../user-guide/privacy.md)
|
||||
- [v2.1.9 鉴权迁移说明](./release-notes-v2.1.9.md)
|
||||
@@ -0,0 +1,96 @@
|
||||
# TraceMemo Local HTTP API
|
||||
|
||||
本文面向需要自己写集成的开发者。普通用户请先阅读[Agent 接入概览](./overview.md)。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- 默认地址:`http://127.0.0.1:6131`
|
||||
- API 前缀:`/api/v1`
|
||||
- 默认只监听 loopback;不要把它当作公网服务。
|
||||
- `/api/v1/health` 无需 Token;其他端点需要 `Authorization: Bearer <TOKEN>`。
|
||||
- 请求体使用 JSON;响应为 JSON。
|
||||
|
||||
## 最小请求
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:6131/api/v1/health
|
||||
|
||||
# 读取数据
|
||||
export TRACEMEMO_API_TOKEN="<从 API Center 复制的 Token>"
|
||||
curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
|
||||
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
|
||||
```
|
||||
|
||||
不要把 Token 放入 URL、Skill 文件、仓库或命令历史可被共享的脚本中。
|
||||
|
||||
新配置必须优先使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可在 v2.2.0 兼容期内继续读取 `WECHATEXPLORER_API_TOKEN`;如果两个变量都存在,以新变量为准。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 | 路径 | 作用 | 参数/请求体 |
|
||||
| ---- | ---------------------------- | -------------------------------------- | --------------------------------------------------------------- |
|
||||
| GET | `/api/v1/health` | 服务与数据库健康状态 | 无 |
|
||||
| GET | `/api/v1/current_time` | 本机时间、时区和 Unix 时间戳 | 无 |
|
||||
| GET | `/api/v1/contact` | 联系人和群聊列表 | `filter`、`type=user\|group` |
|
||||
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
|
||||
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
||||
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
||||
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
|
||||
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
||||
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
||||
| GET | `/api/v1/agent/status` | Agent Hub、连接器和数据库状态 | 无 |
|
||||
| POST | `/api/v1/agent/group-report` | 读取群聊并生成总结图片 | `{ "group": "群名或标识", "range": "today\|yesterday\|7days" }` |
|
||||
| POST | `/api/v1/agent/send` | 通过已连接机器人测试发送文字或本地图片 | `{ "to": "接收者", "text": "...", "media_url": "..." }` |
|
||||
|
||||
### 这些端点与实时机器人有什么关系
|
||||
|
||||
- `/api/v1/agent/status` 只用于查询 Agent Hub、微信连接器和数据库状态;
|
||||
- `/api/v1/agent/group-report` 由外部 Agent 或脚本主动请求生成群聊总结图片;
|
||||
- `/api/v1/agent/send` 是受 Bearer Token 保护的开发者/测试发送入口,用于通过已经连接的机器人发送文字或本地图片;它不是任意群发能力,也不是实时消息订阅接口;
|
||||
- 当前 API 没有对外暴露实时入站 webhook。微信消息由应用内部的 Agent Hub 和微信连接器接收、处理和回复。
|
||||
|
||||
## 时间查询
|
||||
|
||||
`chatlog` 的 `time` 支持:
|
||||
|
||||
- `YYYY-MM-DD`:当天;
|
||||
- `YYYY-MM-DD~YYYY-MM-DD`:日期闭区间;
|
||||
- `YYYY-MM-DD/HH:mm`:从该分钟开始的 60 秒;
|
||||
- 也可以使用 Unix 秒级 `startTime` 和 `endTime`。
|
||||
|
||||
时间按运行 TraceMemo 的本机时区解析。用户说“今天”“昨天”时,先调用 `current_time`,再根据返回的 `localDate` 计算日期,避免使用 Agent 自己的时区。
|
||||
|
||||
## 常用工作流
|
||||
|
||||
### 查找并读取一个会话
|
||||
|
||||
```bash
|
||||
BASE="http://127.0.0.1:6131/api/v1"
|
||||
AUTH="Authorization: Bearer ${TRACEMEMO_API_TOKEN:-$WECHATEXPLORER_API_TOKEN}"
|
||||
|
||||
curl -H "$AUTH" "$BASE/resolve?q=技术交流群"
|
||||
curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
||||
```
|
||||
|
||||
当标识不确定时,先用 `resolve` 或 `contact`,再调用 `chatlog`。对重要问题,先宽范围定位,再针对关键时间点读取前后文,不要只凭一次粗查回答。
|
||||
|
||||
### 生成群聊总结图片
|
||||
|
||||
优先使用 `/api/v1/agent/group-report`,因为它会读取指定群聊并按 `today`、`yesterday` 或 `7days` 生成总结。`/api/v1/report` 是更底层的渲染接口,要求调用方已经准备好 `report` 和 `metadata` 结构;完整 TypeScript 类型以 `src/shared/group-report.ts` 为准。
|
||||
|
||||
## 响应与错误
|
||||
|
||||
- `200`:请求成功;
|
||||
- `401`:缺少、错误或已失效的 Bearer Token;
|
||||
- `400`:参数或 JSON 请求体无效;
|
||||
- `403`:浏览器 Origin 不在允许的 loopback 列表;
|
||||
- `404`:端点、会话或群聊不存在;
|
||||
- `503`:数据库或 Agent Hub 尚未就绪;
|
||||
- `500`:服务端处理或报告渲染失败。
|
||||
|
||||
成功响应会返回端点对应的 JSON 对象,例如 `chatlog` 包含 `contact`、`query`、`count` 和 `messages`,`contact` 返回 `count` 与 `contacts`。
|
||||
|
||||
## 与 MCP 的关系
|
||||
|
||||
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 在微信机器人或外部 Agent 中使用 TraceMemo
|
||||
|
||||
TraceMemo 提供两条不同路径。先按你实际想做的事选择,不需要先理解 Agent、Skill 或 API 等术语。
|
||||
|
||||
| 你想做什么 | 使用方式 | 需要什么 |
|
||||
| ---------------------------------------------------- | ----------------------------- | --------------------------------------------------------- |
|
||||
| 直接在微信里发文字,让本机查询聊天并回复 | 微信机器人(Agent Hub) | 在应用“Agent”页面扫码登录机器人;部分任务需要 AI Provider |
|
||||
| 在 Codex、Claude Code、OpenClaw 等工具里查询微信历史 | Reader Skill + Local HTTP API | 安装 Skill,并配置本机 API Token |
|
||||
|
||||
## 直接在微信里提问
|
||||
|
||||
打开应用一级导航中的“Agent”,进入“Agent Hub”,扫码登录一个微信机器人账号。之后用另一个微信账号向机器人发送文字,它会调用 TraceMemo 的本机数据,必要时使用已配置的 AI,再把结果回复给发送者。
|
||||
|
||||
可以先尝试:
|
||||
|
||||
- “最近 5 个会话”;
|
||||
- “帮我看看最近跟张三聊了些什么”;
|
||||
- “生成产品交流群今天的群聊总结图片”。
|
||||
|
||||
这条路径不要求安装 Reader Skill,也不要求用户配置 API Token。它主要处理文字请求,不支持群发、定时任务或与文字同等的任意媒体理解。
|
||||
|
||||
连接步骤、当前任务清单和安全边界见[Agent Hub](./agent-hub.md)。
|
||||
|
||||
## 在外部 Agent 中查询历史微信
|
||||
|
||||
Reader Skill 是给外部 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以通过 TraceMemo Local HTTP API 按需读取联系人、群聊、最近会话、指定时间范围的聊天和群成员信息。
|
||||
|
||||
典型问题包括:
|
||||
|
||||
- “总结今天技术交流群讨论的内容。”
|
||||
- “帮我找上个月讨论过的项目地址。”
|
||||
- “过去一周有没有人提到退款?”
|
||||
|
||||
外部 Agent 不会直接打开微信数据库文件,但它能取得本机 API 返回的聊天内容。Agent 是否继续把结果发送给云端模型,取决于 Agent 自己的模型和工具配置。
|
||||
|
||||
## 外部 Agent 的安装步骤
|
||||
|
||||
1. 启动 TraceMemo 并完成微信数据库连接。
|
||||
2. 打开一级导航“API”(页面为“API Center”),确认本地 API、数据库和 Reader Skill 都可用。
|
||||
3. 选择目标 Agent,点击“复制安装指令”。
|
||||
4. 在 Agent 自己的 Skill/配置目录执行或粘贴指令。
|
||||
5. 在 API Center 复制当前 Token,并在 Agent 运行环境中设置 `TRACEMEMO_API_TOKEN`。
|
||||
6. 先让 Agent 调用 health,再尝试查询最近会话。
|
||||
|
||||
详细说明:[Reader Skill](./reader-skill.md)、[Local HTTP API](./api.md)、[API 安全](./api-security.md)。
|
||||
|
||||
## 不要混淆两条路径
|
||||
|
||||
- Agent Hub:微信机器人收到实时文字后处理并回复;
|
||||
- Reader Skill/API:外部 Agent 主动查询历史数据;
|
||||
- `127.0.0.1:6131` 是 Local HTTP API,不是 MCP Server;
|
||||
- Local HTTP API 当前没有对外提供实时入站消息订阅。
|
||||
@@ -0,0 +1,62 @@
|
||||
# Reader Skill:让外部 Agent 读取微信
|
||||
|
||||
## 先理解它能做什么
|
||||
|
||||
Reader Skill 是一份给 Agent 的操作说明。安装后,Codex、Claude Code、OpenClaw 或其他本地 Agent 可以按需调用 TraceMemo,读取联系人、群聊、最近会话、指定时间的聊天和群成员信息。
|
||||
|
||||
它使用的是 TraceMemo Local HTTP API,不是 MCP Server。
|
||||
|
||||
Reader Skill 只负责“外部 Agent 主动查询历史微信数据”。它不负责二维码登录、监听微信实时消息、接收机器人消息或管理 Agent Hub。想让机器人收到微信消息后处理并回复,请阅读[Agent Hub](./agent-hub.md)。
|
||||
|
||||
正式 Reader Skill 名称和目录是 `tracememo-reader`,新安装使用 `TRACEMEMO_API_TOKEN`。已安装的旧 `wechatexplorer-reader` 可在 v2.2.0 兼容期内继续使用旧变量。
|
||||
|
||||
## 推荐安装流程
|
||||
|
||||
1. 启动 TraceMemo 并完成数据库连接。
|
||||
2. 打开“API Center”,确认 API 服务和数据库状态正常。
|
||||
3. 在 Reader Skill 区域选择目标 Agent,点击“复制安装指令”。
|
||||
4. 把指令粘贴到对应 Agent 的 Skill/配置目录;应用会根据本机路径生成适合 Codex、Claude Code、OpenClaw 或通用 Agent 的说明。
|
||||
5. 在 API Center 复制 Token,在 Agent 自己的本地环境设置:
|
||||
|
||||
```bash
|
||||
export TRACEMEMO_API_TOKEN="<YOUR_API_TOKEN>"
|
||||
```
|
||||
|
||||
6. 先执行 health 检查,再读取数据端点。
|
||||
|
||||
TraceMemo 不会自动把 Token 写进 Agent 配置。重新生成 Token 后,必须同步更新 Agent 环境。
|
||||
|
||||
## Agent 的读取顺序
|
||||
|
||||
当用户使用“今天”“昨天”“本周”等相对时间时:
|
||||
|
||||
1. 调用 `/api/v1/current_time` 获取本机时区和日期;
|
||||
2. 将相对时间换算为 `chatlog` 支持的 `time` 或时间戳;
|
||||
3. 调用 `/api/v1/resolve`、`contact` 或 `chatroom` 确认会话;
|
||||
4. 调用 `/api/v1/chatlog` 读取目标范围;
|
||||
5. 对重要结论再读取关键消息前后文,不要只凭一次粗查。
|
||||
|
||||
## 最小请求
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:6131/api/v1/health
|
||||
|
||||
curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
|
||||
"http://127.0.0.1:6131/api/v1/recent_chat?limit=20"
|
||||
```
|
||||
|
||||
## 当前能力范围
|
||||
|
||||
Reader Skill 可以指导 Agent 使用:
|
||||
|
||||
- 联系人、群聊、最近会话和会话解析;
|
||||
- 指定会话、日期或时间戳范围的聊天记录;
|
||||
- 群成员快照;
|
||||
- 结构化日报渲染和按群聊生成总结图片;
|
||||
- Agent Hub 状态检查与已连接机器人发送测试。这里的发送接口是开发者/测试用途,不是实时机器人入口,也不会让 Reader Skill 自动监听微信消息。
|
||||
|
||||
端点、参数、错误码和鉴权细节以[Local HTTP API](./api.md)为准。Skill 文件保持短小,避免在多个文档中复制会变化的完整响应 schema。
|
||||
|
||||
## 隐私边界
|
||||
|
||||
Reader Skill 本身不会把聊天数据自动上传到其他服务器;它只是让 Agent 调用本机 API。Agent 读取结果是否继续发送给云端模型,取决于 Agent 自己的模型和工具配置。请同时阅读[数据、隐私与安全](../user-guide/privacy.md)。
|
||||
@@ -0,0 +1,12 @@
|
||||
# TraceMemo 2.1.9:Local HTTP API 鉴权迁移
|
||||
|
||||
2.1.9 为 Local HTTP API 增加 Bearer Token 鉴权。这是一次有意的兼容性变化:除健康检查外,数据接口不再接受裸请求。
|
||||
|
||||
- 历史版本中,`GET /api/v1/contact` 等数据请求可能直接返回内容;
|
||||
- 2.1.9 中,相同请求必须携带 `Authorization: Bearer <TOKEN>`,否则返回 `401`;
|
||||
- `GET /api/v1/health` 保持公开;
|
||||
- 升级后应用会生成并安全保存 Token,原有 API 启用状态、监听地址和端口设置保持不变;
|
||||
- Token 可在 TraceMemo → API Center 中显示、复制和重新生成;
|
||||
- Reader Skill、Codex、Claude Code、OpenClaw 和其他本地 Agent 需要在自己的环境中设置 `WECHATEXPLORER_API_TOKEN`。
|
||||
|
||||
如果旧 Agent 无法访问,请先从 API Center 复制当前 Token,再确认每个非 health 请求都带有 Bearer header。完整规则见[API 安全](./api-security.md)。
|
||||
@@ -0,0 +1,69 @@
|
||||
# TraceMemo 2.2.0:正式品牌身份与安全升级迁移
|
||||
|
||||
TraceMemo(迹忆)原名 WechatExplorer。v2.2.0 不只更新用户可见名称,也正式启用新的应用身份、数据目录、Reader Skill 和默认 Agent 环境变量,同时为 v2.1.9 用户提供一次安全迁移路径。
|
||||
|
||||
## 新的产品身份
|
||||
|
||||
- 产品名与 Electron runtime name:`TraceMemo`;
|
||||
- bundle/app identifier:`com.tracememo.app`;
|
||||
- macOS userData:`~/Library/Application Support/TraceMemo`;
|
||||
- macOS 日志:`~/Library/Logs/TraceMemo`;
|
||||
- Reader Skill:`tracememo-reader`;
|
||||
- Agent API Token 环境变量:`TRACEMEMO_API_TOKEN`;
|
||||
- Agent Hub 凭据目录:`~/.tracememo/wechat-connector/accounts`。
|
||||
|
||||
## v2.1.9 升级迁移
|
||||
|
||||
首次启动 TraceMemo 时,如果检测到包含有效用户资产的旧数据目录,应用会询问是否立即迁移:
|
||||
|
||||
- `WechatExplorer`;
|
||||
- v2.1.9 在区分大小写文件系统上可能使用的 `wechatexplorer`。
|
||||
|
||||
两个旧目录都有效时,应用确定性优先选择 `WechatExplorer` 并写入诊断日志,不合并目录。选择“以后迁移”不会删除或修改旧数据,下次启动仍可继续处理。
|
||||
|
||||
迁移遵循以下安全边界:
|
||||
|
||||
- 只复制明确列出的用户资产,不复制整个 Application Support;
|
||||
- TraceMemo 已存在的文件或目录绝不覆盖;
|
||||
- 每一项迁移可重复执行,已完成项会跳过;
|
||||
- 迁移失败只清理本次创建的 staging,旧目录和旧文件始终保留;
|
||||
- 不移动、不删除旧目录,不修改微信数据库或 Knowledge schema。
|
||||
|
||||
## 迁移的用户资产
|
||||
|
||||
- 设置、微信数据库连接路径和 AI Provider 元数据;
|
||||
- Knowledge 本地索引;
|
||||
- 报告历史、防撤回归档、图片理解结果和 Renderer Local Storage;
|
||||
- Local HTTP API Token;
|
||||
- AI Provider Key、微信数据库 Key 和图片解密 Key;
|
||||
- Agent Hub credential 与同步状态。
|
||||
|
||||
Chromium Cache、Code Cache、GPUCache、临时文件、语音模型和其他可重建运行缓存不会为了品牌升级强制复制。
|
||||
|
||||
## Knowledge
|
||||
|
||||
Knowledge 以完整目录为单位复制。每个账号的 `knowledge.sqlite`、`knowledge.sqlite-wal` 和 `knowledge.sqlite-shm` 会一起进入同一个 staging;复制后先核对主库及 companion 文件,再对 staging 数据库执行 SQLite `integrity_check`。只有验证通过后才放入 TraceMemo 数据根。
|
||||
|
||||
迁移过程不会打开、修改或删除真实旧 Knowledge。失败时旧索引仍可用于重新迁移,不要求用户重新建立 2.47GB 级别的索引。
|
||||
|
||||
## Token 与加密 Key
|
||||
|
||||
旧 `safeStorage` 密文不会原样复制到新数据目录。TraceMemo 会启动一个隔离的 legacy helper:macOS 使用旧 `WechatExplorer` identity,helper 只在内存中解密并校验旧 Token/Key,再通过专用进程管道交给主进程重新加密;macOS 主进程使用 TraceMemo identity. 明文不会写入磁盘、环境变量或日志。
|
||||
|
||||
Token 格式、随机熵、加密方式和 rotation 行为没有变化。如果旧 API Token 因系统安全存储限制无法迁移,应用不会静默生成替代 Token,本地 API 会安全停用并提示用户重试迁移或在 API Center 主动重新生成。AI Provider Key、数据库 Key 和图片 Key 失败时也会明确记录为部分迁移,旧密文保持不变。
|
||||
|
||||
## API、Agent 与 Skill 兼容
|
||||
|
||||
Local HTTP API 继续使用 `127.0.0.1:6131` 和 `/api/v1/*`,Bearer Token 格式不变。
|
||||
|
||||
新安装和新文档默认使用 `TRACEMEMO_API_TOKEN`。已安装的旧 Reader Skill 可以在一个兼容版本内继续使用 `WECHATEXPLORER_API_TOKEN`。正式随应用分发的 Skill 已更名为 `tracememo-reader`,资源解析仍可读取旧 `wechatexplorer-reader` 目录作为 fallback。
|
||||
|
||||
Agent Hub 新凭据写入 `~/.tracememo`。如果迁移尚未完成且新目录没有凭据,connector 会只读回退到 `~/.wechatexplorer`;新版本不会清理或删除旧目录。
|
||||
|
||||
## 日志与 Documents
|
||||
|
||||
TraceMemo 新日志写入新的日志目录,“设置 → 关于 → 打开诊断日志目录”会打开当前 TraceMemo 日志。历史 WechatExplorer 日志保持原位置,不搬迁、不重命名、不删除。
|
||||
|
||||
`Documents/TraceMemo` 用于新导出和 Emoji 数据;历史 `Documents/WechatExplorer` 不删除,并继续提供兼容读取。
|
||||
|
||||
更多安全边界见[数据、隐私与安全](../user-guide/privacy.md)和[API 安全](./api-security.md)。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 如何核对 AI 的回答来源
|
||||
|
||||
## 先记住一件事
|
||||
|
||||
AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内容来自哪个会话和时间,并跳回原始消息检查上下文。
|
||||
|
||||
这让 TraceMemo 和只给一段摘要的聊天机器人不同:答案不是终点,来源也应该能被你检查。
|
||||
|
||||
## 三类来源信息
|
||||
|
||||
在产品界面和检索详情中,你可能看到这些名称:
|
||||
|
||||
- **Evidence**:AI 回答所依据的原始聊天片段。
|
||||
- **Citation**:回答中某个结论对应的来源标记。
|
||||
- **Search Trace**:本次查找经历了哪些阶段、每一步用了多久、覆盖是否完整。
|
||||
|
||||
普通用户不需要记住英文名。判断一个回答是否可信时,按“来源 → 原消息 → 上下文”检查即可。
|
||||
|
||||
## 推荐的核对顺序
|
||||
|
||||
1. 先看回答是否明确区分事实、推断和不确定信息;
|
||||
2. 打开来源,检查发送者、会话和时间;
|
||||
3. 跳回档案,查看消息前后文,确认是否存在引用、转发或后续修正;
|
||||
4. 检查提示中是否有未转写语音、缺失媒体或只覆盖部分范围;
|
||||
5. 对重要决定、金额、日期和责任人,不要只依据 AI 摘要。
|
||||
|
||||
## 为什么来源可能不完整
|
||||
|
||||
来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如:
|
||||
|
||||
- Knowledge 正在同步时,新的分析会被暂停;
|
||||
- 语音没有转写时,AI 可能只能看到消息类型;
|
||||
- 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容;
|
||||
- 你只选择了一个群,答案不会自动代表所有聊天。
|
||||
|
||||
看到“可能遗漏”或“部分覆盖”时,扩大范围、先完成同步或检查原始媒体后再问。
|
||||
|
||||
## 这不是事实保证
|
||||
|
||||
Evidence 和 Citation 能告诉你“模型看到了什么”,不能保证模型没有误读。最终判断仍应回到原始消息,尤其是涉及隐私、法律、财务、医疗或工作决策时。
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# TraceMemo 如何把聊天变成可用的信息
|
||||
|
||||
你可以把一次任务想成下面这条路径:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[本机微信数据] --> B[读取与解析]
|
||||
B --> C[聊天档案与普通搜索]
|
||||
B --> D[本地知识索引]
|
||||
D --> E[筛选相关消息]
|
||||
E --> F[用户配置的 AI Provider]
|
||||
F --> G[回答与可核对来源]
|
||||
B --> H[聊天导出]
|
||||
B --> I[整理日报输入]
|
||||
I --> F
|
||||
F --> J[本地保存 HTML 与 PNG]
|
||||
B --> K[Local HTTP API]
|
||||
K --> L[外部 Agent]
|
||||
M[微信机器人消息] --> N[Agent Hub]
|
||||
N --> B
|
||||
N --> F
|
||||
```
|
||||
|
||||
## 哪些步骤在本机
|
||||
|
||||
- 微信数据库读取与解析;
|
||||
- 聊天档案浏览和普通搜索;
|
||||
- Knowledge 索引与增量同步;
|
||||
- 离线语音转写;
|
||||
- 聊天导出文件、日报 HTML/PNG 和本地历史记录的保存。
|
||||
|
||||
## 哪些步骤可能调用外部服务
|
||||
|
||||
当你主动使用 AI Search、群聊日报或图片理解时,应用会把完成任务所需的受控问题和上下文发送给你配置的 Provider。它不会因为打开软件就自动上传完整数据库。
|
||||
|
||||
Agent Hub 收到微信机器人的文字后,也可能为了理解请求或生成总结调用已配置的 Provider。Reader Skill 调用的是本机 API;外部 Agent 是否把读取结果继续交给云端模型,取决于外部 Agent 自己的配置。
|
||||
|
||||
如果 Provider 是 Ollama 等本机服务,请把它视为本机的另一个进程;如果是云服务,数据处理和留存规则由该服务商决定。
|
||||
|
||||
## 产品名词和用户任务的对应关系
|
||||
|
||||
| 用户想做什么 | 产品中可能看到的名称 |
|
||||
| ------------------------ | ---------------------------- |
|
||||
| 让 AI 找相关聊天 | AI Search、Retrieval |
|
||||
| 让答案能回到原消息 | Evidence、Citation |
|
||||
| 查看 AI 查找过程 | Search Trace |
|
||||
| 让跨会话查找更稳定 | Knowledge、FTS 索引 |
|
||||
| 让外部 Agent 读取聊天 | Reader Skill、Local HTTP API |
|
||||
| 让微信机器人调用本机能力 | Agent Hub |
|
||||
|
||||
先按任务使用,再在需要排查或开发集成时阅读术语。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 本地启动排障
|
||||
|
||||
本文面向运行源码开发环境的贡献者。常规启动顺序和测试入口请先阅读[开发、测试与构建](./overview.md)。
|
||||
|
||||
## 启动成功的判断标准
|
||||
|
||||
执行 `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`。
|
||||
|
||||
`6131` 是应用提供给本机集成使用的 API 端口,不是 Vite 的页面端口。
|
||||
|
||||
## 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) 已设置:
|
||||
|
||||
```ini
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
```
|
||||
|
||||
pnpm 会把该值传给 Electron 安装器,令其从镜像下载与 `package.json` 锁定版本匹配的二进制文件,避免默认 GitHub 下载源在受限网络中不可访问。
|
||||
|
||||
依赖安装被中断或 Electron 目录不完整时,删除不完整的 `node_modules` 后重新安装:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
单次安装需要使用其他镜像时,可以临时覆盖项目默认值。PowerShell 示例:
|
||||
|
||||
```powershell
|
||||
$env:ELECTRON_MIRROR = 'https://your-electron-mirror.example/'
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
该环境变量只影响当前终端,不会改写仓库中的 `.npmrc`。镜像地址必须保留末尾的 `/`,并提供与 Electron 版本对应的目录结构。
|
||||
|
||||
## 页面地址无法通过 IPv4 访问
|
||||
|
||||
Vite 在某些 Windows 环境中只监听 IPv6 本机回环地址 `::1`。这时直接访问 `http://127.0.0.1:5173/` 可能失败,但 `http://localhost:5173/` 仍然正常,Electron 也会使用后者加载页面。
|
||||
|
||||
排查时优先访问 `http://localhost:5173/`;需要显式验证 IPv6 时,使用 `http://[::1]:5173/`。不要因为 IPv4 回环地址不可用就判断 Electron 或 Vite 启动失败。
|
||||
|
||||
## 仍无法启动时
|
||||
|
||||
保留首次错误的完整输出,并同时记录操作系统、Node.js、pnpm 和 Go 版本,以及 `pnpm install --frozen-lockfile` 与 `pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。
|
||||
@@ -0,0 +1,58 @@
|
||||
# 开发、测试与构建
|
||||
|
||||
本文面向希望参与 TraceMemo 开发、验证文档或维护集成的贡献者。普通用户请从[第一次使用](../user-guide/getting-started.md)开始。
|
||||
|
||||
## 技术基线
|
||||
|
||||
- Electron + React + TypeScript;
|
||||
- pnpm 7+;
|
||||
- Go(构建微信连接器);
|
||||
- 平台对应的 Electron/native 构建环境。
|
||||
|
||||
产品文档的事实来源优先级是:当前源码 → 当前 UI/Renderer → 测试 → package/config → README/docs → 历史资料。功能、API、版本、隐私和兼容性变更时,不要只改 README。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。
|
||||
|
||||
常用检查:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test:unit
|
||||
pnpm test:component
|
||||
pnpm test:integration
|
||||
pnpm test:e2e:build
|
||||
```
|
||||
|
||||
完整测试入口 `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` |
|
||||
|
||||
## 文档检查
|
||||
|
||||
提交文档变更前至少执行:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
rg -n "v2\.1\.7|TraceMemo|迹忆|mcpServers|无鉴权" README.md docs --glob '*.md' --glob '!DOCUMENTATION_AUDIT.md' --glob '!development/overview.md'
|
||||
```
|
||||
|
||||
历史迁移说明可以出现旧版本号;正式使用指南不要把过时版本写成当前版本。负向澄清“6131 不是 MCP Server”可以保留,以防用户照抄错误配置。
|
||||
@@ -1,47 +1,5 @@
|
||||
# macOS 关闭 SIP 教程
|
||||
# macOS 数据访问说明(兼容入口)
|
||||
|
||||
SIP(System Integrity Protection,系统完整性保护)是 macOS 的系统安全机制。关闭 SIP 会降低系统安全性,只建议在确实需要读取或调试本地微信数据时临时关闭;操作完成后,建议重新开启。
|
||||
完整内容已移到[macOS 数据访问与系统权限](./platform/macos.md)。
|
||||
|
||||
## 准备
|
||||
|
||||
- 一台 Mac 电脑,Intel 芯片和 Apple Silicon 芯片均可。
|
||||
- 需要进入 macOS 恢复模式。
|
||||
- 请先保存正在编辑的文件,并预留一次重启时间。
|
||||
|
||||
## 关闭 SIP
|
||||
|
||||
### Intel Mac
|
||||
|
||||
1. 关机。
|
||||
2. 按下开机键后,立刻按住 `Command + R`。
|
||||
3. 保持按住,直到进入 macOS 恢复模式。
|
||||
|
||||
### Apple Silicon Mac(M1/M2/M3/M4)
|
||||
|
||||
1. 关机。
|
||||
2. 长按开机键不放。
|
||||
3. 直到出现启动选项界面后松开。
|
||||
4. 选择“选项”,进入 macOS 恢复模式。
|
||||
|
||||
### 在恢复模式中执行命令
|
||||
|
||||
1. 进入恢复模式后,点击顶部菜单栏的 **Utilities(实用工具)**。
|
||||
2. 选择 **Terminal(终端)**。
|
||||
3. 在终端中输入:
|
||||
|
||||
```bash
|
||||
csrutil disable
|
||||
```
|
||||
|
||||
4. 按回车执行。
|
||||
5. 看到关闭成功提示后,重启电脑。
|
||||
|
||||
## 重新开启 SIP
|
||||
|
||||
如果后续不再需要关闭 SIP,建议重新进入恢复模式,在终端中执行:
|
||||
|
||||
```bash
|
||||
csrutil enable
|
||||
```
|
||||
|
||||
然后重启电脑。
|
||||
保留此文件是为了兼容应用内已经发布的帮助链接。请不要把“关闭 SIP”当作默认安装步骤;只有当当前连接页面明确要求时才处理,并在完成后恢复系统安全设置。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# macOS 数据访问与系统权限
|
||||
|
||||
## 你什么时候会看到这些提示
|
||||
|
||||
TraceMemo 需要读取微信本地数据。macOS 会根据系统版本、微信状态和安全设置,要求应用完成授权;自动获取数据库密钥时,页面可能提示暂时调整系统安全设置。
|
||||
|
||||
## 推荐步骤
|
||||
|
||||
1. 先启动 TraceMemo,阅读连接页面显示的当前前置条件。
|
||||
2. 确认微信数据目录指向当前账号。
|
||||
3. 只在页面明确要求时处理系统授权或 SIP;按页面提示完成密钥获取后,恢复你平时使用的安全设置。
|
||||
4. 返回应用重新检测账号、数据库和图片资源状态。
|
||||
|
||||
不要直接复制网上针对其他微信版本的命令。系统授权失败时,记录 macOS 版本、微信版本和页面错误,再按[排障文档](../user-guide/troubleshooting.md#连接微信失败)处理。
|
||||
|
||||
## SIP 风险
|
||||
|
||||
关闭 System Integrity Protection 会降低 macOS 对系统文件和进程的保护。它不是日常使用 TraceMemo 的功能开关,也不应长期保持关闭。只有在你理解风险、确认页面要求且完成必要操作时才处理;完成后按 Apple 官方方式重新启用。
|
||||
|
||||
## 应用无法打开
|
||||
|
||||
如果 macOS 阻止未验证的应用,使用系统“隐私与安全性”中的“仍要打开”选项。不要为了绕过提示下载来历不明的补丁或替换应用文件。
|
||||
|
||||
## Intel 与 Apple Silicon
|
||||
|
||||
从 Releases 选择与 Mac 处理器匹配的构建。不同架构、微信版本和系统授权状态可能导致连接结果不同;文档不对所有组合做兼容性保证。
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: tracememo-reader
|
||||
description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据。当用户要求查看微信消息、查找联系人或群聊、总结聊天、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。
|
||||
---
|
||||
|
||||
# TraceMemo Reader
|
||||
|
||||
你是一个通过本机 TraceMemo 读取微信历史的 Agent。先确认用户已经在 TraceMemo 中完成数据库连接,再按需调用 API;不要假设数据库已就绪,也不要声称读取了没有调用过的消息。
|
||||
|
||||
## 连接信息
|
||||
|
||||
- Base URL 默认是 `http://127.0.0.1:6131/api/v1`。
|
||||
- `GET /health` 不需要 Token。
|
||||
- 其他端点必须带 `Authorization: Bearer $TRACEMEMO_API_TOKEN`。
|
||||
- 新配置优先读取 `TRACEMEMO_API_TOKEN`;为兼容已安装的旧 Reader,可在新变量缺失时回退到 `WECHATEXPLORER_API_TOKEN`。
|
||||
- Token 由用户在 TraceMemo → API Center 显示/复制,并放在 Agent 自己的本地环境中。
|
||||
- 不要把 Token 放到 URL、回答、日志、Skill 文件或仓库。
|
||||
- 6131 是普通 Local HTTP API,不是 MCP Server;不要生成 `mcpServers` 配置。
|
||||
|
||||
## 每次任务前
|
||||
|
||||
1. 调用 `/health`,确认服务和数据库状态。
|
||||
2. 用户说“今天”“昨天”“本周”等相对时间时,先调用 `/current_time`,按返回的本机时区换算日期。
|
||||
3. 用 `/resolve`、`/contact` 或 `/chatroom` 确认会话标识。
|
||||
4. 用 `/chatlog` 读取最小必要的时间范围。
|
||||
5. 对重要结论读取关键消息前后文;不要只凭一次宽范围粗查回答。
|
||||
|
||||
## 端点速查
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| ---- | --------------------- | ------------------------------------------------- |
|
||||
| GET | `/health` | 健康和数据库状态 |
|
||||
| GET | `/current_time` | 本机时间与时区 |
|
||||
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
|
||||
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
|
||||
| GET | `/recent_chat` | 最近会话;可传 `limit` |
|
||||
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
|
||||
| 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` | 已连接机器人发送测试 |
|
||||
|
||||
## 时间与上下文规则
|
||||
|
||||
`/chatlog` 的 `time` 支持 `YYYY-MM-DD`、日期闭区间和分钟范围;也可以使用 Unix 秒级 `startTime`/`endTime`。时间按 TraceMemo 所在机器的本机时区解释。
|
||||
|
||||
当用户问“某个话题是谁说的、后来结论是什么”时,先定位会话和时间,再读取关键消息前后文。回答时区分:
|
||||
|
||||
- 原消息明确写出的内容;
|
||||
- 根据多条消息整理出的总结;
|
||||
- 没有来源支持的推断。
|
||||
|
||||
## 隐私和安全
|
||||
|
||||
只读取用户请求所需的会话和时间范围。不要把完整聊天数据库、密钥或 Token 暴露给用户。Reader API 本身不自动把聊天转发到外部服务器,但当前 Agent 可能会把工具结果交给其配置的模型;如有疑问,提醒用户检查 Agent 的数据策略。
|
||||
|
||||
## 常见错误
|
||||
|
||||
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
|
||||
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
|
||||
- `404`:先用 `/resolve` 确认会话标识。
|
||||
- `503`:用户还没有完成数据库连接或对应服务未就绪。
|
||||
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
|
||||
@@ -1,355 +0,0 @@
|
||||
---
|
||||
name: wechatexplorer-reader
|
||||
description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊天数据(本地服务由 WechatExplorer.app 提供)。当用户提到微信聊天记录、群消息、看看群里说了什么、查一下微信、分析微信对话、总结群聊等场景时,使用此技能。注意:此技能的数据源是用户本机 WechatExplorer app。
|
||||
---
|
||||
|
||||
# WechatExplorer Reader
|
||||
|
||||
通过本地 HTTP API(`http://127.0.0.1:6131`)读取 WechatExplorer 已经解锁的微信数据库内容。
|
||||
|
||||
## 数据源
|
||||
|
||||
- **本服务由 WechatExplorer.app 提供**,数据完全在本地处理,不会上传任何服务器
|
||||
- 用户必须在 WechatExplorer 主窗口完成**首次密钥配置**(解锁 WCDB 数据库)
|
||||
- 默认监听 `127.0.0.1:6131`,仅本机可访问,无需鉴权
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **安装并启动 WechatExplorer.app**(从项目 release 页面下载)
|
||||
2. **首次启动时完成密钥配置**:在主界面第一步输入微信数据库密钥(64 位 hex),完成 WCDB 初始化
|
||||
3. **如需 7×24 提供 API**:用 `WXE_TRAY=1` 或 `--tray` 参数启动 app,启用菜单栏常驻模式(主窗口关闭后服务仍在)
|
||||
|
||||
## API 列表
|
||||
|
||||
GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图)。所有端点返回 JSON。
|
||||
|
||||
| 端点 | 用途 | 关键参数 |
|
||||
|------|------|---------|
|
||||
| `GET /api/v1/health` | 健康检查 + 是否已初始化 | — |
|
||||
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) | — |
|
||||
| `GET /api/v1/contact` | 联系人 / 群聊列表 | `filter`(昵称模糊)、`type`(`user` \| `group`) |
|
||||
| `GET /api/v1/chatroom` | 群聊列表(等同 contact?type=group) | `keyword` |
|
||||
| `GET /api/v1/recent_chat` | 最近会话 | `limit`(默认 50) |
|
||||
| `GET /api/v1/chatlog` | 聊天记录 | `talker`、`time` 或 `startTime`/`endTime` |
|
||||
| `GET /api/v1/group_snapshot` | 群成员快照 | `md5` |
|
||||
| `GET /api/v1/resolve` | 把昵称/wxid/md5 解析成 md5 | `q` |
|
||||
| `POST /api/v1/report` | 生成群聊日报 HTML + 长图 PNG | JSON body(见下文,推荐传 `metadata.talker` 让服务端自动反推真头像) |
|
||||
|
||||
### `talker` 参数可接受的值
|
||||
|
||||
`chatlog` 和 `recent_chat` 的 `talker` / 列表项 ID 支持以下三种形式,服务端会按 `nickname → wxid → md5` 顺序匹配:
|
||||
|
||||
1. **群昵称 / 好友备注**(模糊匹配,如 `技术交流`、`摸鱼群`)
|
||||
2. **微信 wxid**(如 `wxid_abc123`、`gh_xxxxx@chatroom`)
|
||||
3. **会话 md5**(如 `49023470180@chatroom` 的 md5 哈希,可在 `contact` 接口里看到)
|
||||
|
||||
不确定时先调 `GET /api/v1/resolve?q=<输入>` 校验,返回 `{ md5, m_nsUsrName, m_nsNickName, type, ... }`。
|
||||
|
||||
### `chatroom` 与 `contact?type=group` 字段一致性
|
||||
|
||||
`/chatroom` 和 `/contact?type=group` 返回的是**同一个集合**(都是 `listContacts().filter(type==='group')`),字段也完全一致:
|
||||
|
||||
```json
|
||||
{
|
||||
"m_nsUsrName": "49023470180@chatroom", // wxid, 用作 chatlog 的 talker
|
||||
"m_nsNickName": { "buffer": "...", "type": "Buffer" }, // nickname 原 buffer
|
||||
"type": "group",
|
||||
"md5": "..."
|
||||
}
|
||||
```
|
||||
|
||||
需要 `displayName` 时从 `m_nsNickName` 里解析;需要拉消息就传 `m_nsUsrName` 当 talker。
|
||||
|
||||
## 时间范围格式(`time` 参数)
|
||||
|
||||
支持以下格式:
|
||||
|
||||
| 输入 | 含义 |
|
||||
|------|------|
|
||||
| `2026-07-03` | 单日 00:00:00 ~ 23:59:59 |
|
||||
| `2026-07-01~2026-07-03` | 日期范围(闭区间) |
|
||||
| `2026-07-03/14:30` | 单分钟(从 14:30:00 起 60 秒) |
|
||||
| `2026-07-03/14:30~2026-07-03/15:30` | 精确到分钟的范围 |
|
||||
|
||||
也可以直接传 unix 秒级时间戳作为 `startTime` 和 `endTime`。
|
||||
|
||||
### "今天 / 昨天 / 本周" 的时区语义
|
||||
|
||||
所有 `time` / `startTime` / `endTime` 都按**用户本机时区**解析(由 `current_time` 里的 `timezone` 字段给出,典型为 `Asia/Shanghai`)。含义如下:
|
||||
|
||||
- "今天 2026-07-03" → 本机 2026-07-03 00:00:00 ~ 23:59:59(北京时间 24 小时),**不是** UTC 当天
|
||||
- "昨天" → 本机昨天 0 点 ~ 23:59:59
|
||||
- "本周" → 本周一 0 点 ~ 当前时刻(按本机时区所在周的周一)
|
||||
|
||||
跨时区时(如用户在国外):仍以本机时区为准,需要按 UTC 处理时显式传 unix 时间戳。
|
||||
|
||||
## 时间预检工作流(Time-Aware Workflow)
|
||||
|
||||
**重要**:只要用户请求中包含"今天"、"昨天"、"本周"、"刚才"等相对时间概念,**禁止**直接生成日期字符串。
|
||||
|
||||
**步骤 1**:先调用 `current_time` 工具获取本地 RFC3339 时间。
|
||||
**步骤 2**:根据返回的时间计算对应的 `time` 参数。
|
||||
**步骤 3**:用计算后的参数调 `chatlog`。
|
||||
|
||||
示例:
|
||||
- 用户: "今天 摸鱼交流群 聊了啥?"
|
||||
- AI: 先 `GET /api/v1/current_time` → 得到 `2026-07-03T14:30:00+08:00` → 计算 `time=2026-07-03` → `GET /api/v1/chatlog?talker=摸鱼交流群&time=2026-07-03`
|
||||
|
||||
## 多步上下文检索(强制)
|
||||
|
||||
当查询特定话题或特定发送者发言时,**必须**按以下流程操作:
|
||||
|
||||
1. **初步定位**:用 `contact` 或 `chatroom` 端点确定群聊 md5 / wxid
|
||||
2. **粗查**:用 `chatlog` + 较宽时间范围找到相关消息时间点
|
||||
3. **精查**:对每个关键时间点分别查前后 15-30 分钟(不带任何 keyword 过滤),用完整上下文分析
|
||||
|
||||
**禁止**:仅凭一次粗查结果直接回答用户。
|
||||
|
||||
## 生成群日报(POST /api/v1/report)
|
||||
|
||||
当用户希望输出**可视化群日报**(长图 PNG + HTML 邮件版)时,用这个端点。WechatExplorer 内置 `mobile_daily_report.html` 模板,渲染后会同时落盘 `htmlPath` 和 `pngPath`,并返回 `imageDataUrl` 可直接预览。
|
||||
|
||||
### 请求体(`GroupReportExportRequest`)
|
||||
|
||||
```json
|
||||
{
|
||||
"report": {
|
||||
"overview": "一句话总览,20-80 字",
|
||||
"topics": [
|
||||
{
|
||||
"title": "话题标题",
|
||||
"timeRange": "10:00-12:30",
|
||||
"heat": "高", // "高" | "中" | "低"
|
||||
"participants": ["张三", "李四"],
|
||||
"summary": "本话题讨论了什么",
|
||||
"conclusion": "可选,达成的结论",
|
||||
"keywords": ["关键词1", "关键词2"]
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{ "title": "链接/文件标题", "description": "为什么重要", "sender": "张三" }
|
||||
],
|
||||
"importantMessages": [
|
||||
{ "sender": "张三", "time": "10:23", "content": "原消息文本", "note": "为什么重要" }
|
||||
],
|
||||
"quotes": [
|
||||
{
|
||||
"messages": [{ "sender": "李四", "content": "原话1" }, { "sender": "王五", "content": "原话2" }],
|
||||
"note": "为什么这些话值得引用"
|
||||
}
|
||||
],
|
||||
"qa": [
|
||||
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
|
||||
],
|
||||
"unresolved": [
|
||||
{ "question": "待跟进问题", "owner": "相关人(可选)", "status": "待跟进", "note": "为什么还没结束" }
|
||||
],
|
||||
"storylines": [
|
||||
{ "title": "剧情线", "stages": [{ "time": "10:12", "event": "提出问题" }], "result": "可选结果" }
|
||||
],
|
||||
"reversals": [
|
||||
{ "topic": "某话题", "initialView": "最初判断", "finalView": "最终判断", "note": "可选说明" }
|
||||
],
|
||||
"participantChains": [
|
||||
{ "topic": "某话题", "chain": ["A 提出", "B 补充", "C 收尾"], "note": "可选说明" }
|
||||
],
|
||||
"analytics": {
|
||||
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
|
||||
"activeTimeline": "10:00-12:00 为最活跃时段",
|
||||
"topSpeakers": [{ "name": "张三", "count": 58 }],
|
||||
"voiceLeaderboard": [{ "sender": "张三", "count": 3, "durationSec": 97 }]
|
||||
},
|
||||
"keywords": ["高频词1", "高频词2"],
|
||||
"hero": {
|
||||
"headline": "一句抓重点的日报标题",
|
||||
"summary": "一句概览",
|
||||
"keyTakeaway": "最重要结论",
|
||||
"pendingNote": "待跟进事项"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"groupName": "技术交流",
|
||||
"reportDate": "2026-07-03",
|
||||
"dateRange": "2026-07-03 全天",
|
||||
"messageCount": 1234,
|
||||
"activeUsers": 56,
|
||||
"timeSpan": "00:00-23:59",
|
||||
"generatedAt": "2026-07-03 22:00",
|
||||
"recordNote": "本日报由 WechatExplorer 自动生成",
|
||||
"footerNote": "底部附加说明",
|
||||
"heroParticipants": ["张三", "李四"],
|
||||
"avatars": {},
|
||||
"talker": "技术交流",
|
||||
"timeRange": "2026-07-03"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应(`GroupReportExportResult`)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"htmlPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.html",
|
||||
"pngPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.png",
|
||||
"imageDataUrl": "data:image/png;base64,iVBORw0K..."
|
||||
}
|
||||
```
|
||||
|
||||
成功返回 200;失败返回 500 + `{ success: false, error: "..." }`。HTML 和 PNG 用 `mobile_daily_report.html` 模板渲染,长图宽度自适应移动端预览。
|
||||
|
||||
### 典型工作流
|
||||
|
||||
1. 调 `current_time` + `chatlog` 拉取当天/目标时间段消息
|
||||
2. LLM 总结生成 `report` + `metadata`(直接走 AI 总结即可,无需自己造数据)
|
||||
3. POST 到 `/api/v1/report` 拿到 `htmlPath` / `pngPath`,把文件路径告诉用户即可在 Finder 打开
|
||||
4. **不要**自己拼 HTML/PNG,模板已内置,只需组织好 report/metadata 字段
|
||||
|
||||
### 必填字段与隐式约束(踩坑提示)
|
||||
|
||||
`metadata` 的以下字段**必填**,缺一返回 500:
|
||||
|
||||
- `groupName`、`reportDate`、`dateRange`、`generatedAt`
|
||||
- `heroParticipants`:数组,模板会把每个名字当 key 去 `metadata.avatars[name]` 取头像图
|
||||
- `avatars`:对象,**每个 `heroParticipants` 里的名字都必须有这个 key**(没有就传 `""`,**不要省略整段**),否则模板渲染会抛 `Cannot read properties of undefined (reading '<名字>')` 报 500
|
||||
|
||||
`report` 的以下字段**必须存在**(空就传 `[]`,**不能省略**),否则模板遍历时会抛 `Cannot read properties of undefined (reading 'map')` 报 500:
|
||||
|
||||
- `report.topics`(至少 1 个,完全没话题就改用纯文本总结,不要硬生成空日报)
|
||||
- `report.resources`
|
||||
- `report.importantMessages`
|
||||
- `report.quotes`
|
||||
- `report.qa`
|
||||
- `report.analytics.topicHeat`
|
||||
- `report.analytics.topSpeakers`(至少 1 个)
|
||||
- `report.keywords`
|
||||
|
||||
最小安全示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"report": {
|
||||
"overview": "...",
|
||||
"topics": [],
|
||||
"resources": [],
|
||||
"importantMessages": [],
|
||||
"quotes": [],
|
||||
"qa": [],
|
||||
"analytics": { "topicHeat": [], "activeTimeline": "", "topSpeakers": [] },
|
||||
"keywords": []
|
||||
},
|
||||
"metadata": {
|
||||
"groupName": "技术交流",
|
||||
"reportDate": "2026-07-07",
|
||||
"dateRange": "2026-07-07 全天",
|
||||
"heroParticipants": ["张三", "李四"],
|
||||
"avatars": { "张三": "", "李四": "" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`report.importantMessages[].time` 用 `HH:mm` 格式(不要 ISO 时间戳);`report.analytics.topicHeat[].score` 数字 0-10。
|
||||
|
||||
### 4 个数字格子的内容必须紧凑(避免塌陷)
|
||||
|
||||
模板顶部的 4 个统计格(`消息数 / 活跃人数 / 时间跨度 / 主要话题`)宽度均分,内容过长会被截断或换行:
|
||||
|
||||
| 字段 | 推荐格式 | 反例(会撑爆格子) |
|
||||
|------|---------|----------------|
|
||||
| `metadata.messageCount` | 纯数字 `"1234"` | `"约 1.2k 条"` |
|
||||
| `metadata.activeUsers` | 纯数字 `"56"` | `"大约 50 多人"` |
|
||||
| `metadata.timeSpan` | **持续时长紧凑半角** `"1 h"` / `"30 min"` / `"2 d"` | `"1 小时"` / `"7 小时"` / `"1天3小时"` |
|
||||
| `metadata.topicCount` 等 | 数字 / 短中文 | 长句子 |
|
||||
|
||||
`timeSpan` 是**首条到末条消息的持续时长**,不是时间区间。**单位用半角空格分隔**:
|
||||
|
||||
- `< 1 h` → `"30 min"`
|
||||
- `1~24 h` → `"1 h"` / `"7 h"`(整数,向上取整)
|
||||
- `> 24 h` → `"2 d"`(整数,向上取整)
|
||||
|
||||
**首末条消息的具体时间点**:`dateRange` 字段会显示完整日期 + 起止时间(无长度限制),模板里 dateRange 是 hero 区的副标题,跟 stat 格子分开。
|
||||
|
||||
**区间叙事**(如"主要集中在上午 10 点-12 点")放 `report.analytics.activeTimeline`,那是模板里单独一段的描述,不被 stat 格子限制。
|
||||
|
||||
**不传 timeSpan**:服务端会用空字符串渲染(stat 格会空),subagent 应当总是算好时长填进来,或者 renderer 端会自动算(见 renderer 源码)。
|
||||
|
||||
### 头像:服务端自动反推(推荐)
|
||||
|
||||
**v1.4 起无需手动拼 `avatars` 字典**。在 `metadata` 里加 `talker`(群昵称/wxid/md5 都行),服务端会用 `getGroupSnapshot` 拉全量群成员,按 `nickname → avatar` 自动反推填进 `metadata.avatars`。LLM 总结里出现的 `heroParticipants` / `topics[].participants` / `topSpeakers[].name` 等所有名字都会被覆盖。
|
||||
|
||||
**优先级**:客户端传的 `avatars[name]`(非空字符串) > 服务端反推 > 占位 SVG(姓名首字母 + 随机色块)。
|
||||
|
||||
**回退**:不传 `talker` 时按 `metadata.avatars` 字典取;还取不到则生成 SVG 占位(`fallbackAvatar`),**不会变空白方块**(v1.4 修了 data URL 正则,SVG 占位能正常嵌入)。
|
||||
|
||||
**手动覆盖**:仍可传 `avatars` 字典强制使用自定义头像,例如 `{"张三": "data:image/jpeg;base64,..."}`。
|
||||
|
||||
**P2 风险**:群里有两人同名(如"杨伟")时,服务端只取首条;客户端可手动覆盖。
|
||||
|
||||
## 隐私安全原则
|
||||
|
||||
1. **最小化原则**:只返回用户明确请求的内容,不过度展开无关聊天
|
||||
2. **本地处理**:所有数据来自用户本机,API 不缓存、不转发
|
||||
3. **摘要优先**:对于大量聊天记录,先提供摘要而非完整 dump
|
||||
4. **用户确认**:涉及敏感内容时,先展示摘要,让用户决定是否继续深入
|
||||
|
||||
## 典型工作流示例
|
||||
|
||||
**示例 1:今日群聊总结(纯文本)**
|
||||
1. `GET /api/v1/current_time` → 获取今天日期
|
||||
2. `GET /api/v1/chatroom?keyword=技术交流` → 找到目标群 md5
|
||||
3. `GET /api/v1/chatlog?talker=技术交流&time=2026-07-03` → 拉取今天的聊天
|
||||
4. AI 用 LLM 生成总结报告(话题 TOP N、最活跃发言者等)
|
||||
|
||||
**示例 2:搜索特定消息上下文**
|
||||
1. `GET /api/v1/chatlog?talker=摸鱼群&time=2026-07-01~2026-07-03` → 粗查近 3 天
|
||||
2. 在返回的消息中定位关键词出现的时间点 T1, T2, ...
|
||||
3. 对每个 Ti 分别查 `chatlog?talker=摸鱼群&time=Ti-15min~Ti+15min`,分析上下文
|
||||
|
||||
**示例 3:群日报(可视化长图)**
|
||||
1. `GET /api/v1/chatlog?talker=技术交流&time=2026-07-03` → 拉今天聊天
|
||||
2. LLM 按上方 `GroupDailyReport` schema 总结出 `report` + `metadata`
|
||||
3. `POST /api/v1/report` body = 上述 JSON → 拿到 `htmlPath` / `pngPath` / `imageDataUrl`
|
||||
4. 把 `imageDataUrl` 给用户预览,把 `pngPath` 路径告诉用户用 Finder 打开
|
||||
|
||||
## 错误处理
|
||||
|
||||
- `503` → WechatExplorer 未初始化(密钥未配置),提示用户在主窗口完成配置
|
||||
- `404 talker not found` → talker 不存在,先调 `contact` 或 `resolve` 确认 md5/wxid
|
||||
- `400 missing required parameter` → 检查必填参数(talker / md5 / q)
|
||||
- `200` 但 `result.warnings: ['enrich skipped: talker "X" not found']` → `/report` 的 `metadata.talker` 解析失败,头像走 SVG fallback(不阻断生成)
|
||||
- `200` 但 `result.warnings: ['enriched N member avatars from snapshot (M members)']` → enrich 成功(诊断用)
|
||||
- `400 请求体为空 / 需包含 report 和 metadata` → 调用 `/report` 时 body 必须是非空 JSON,且有这两个顶层字段
|
||||
- `500 success=false` → 模板渲染失败,通常因 `report` 字段缺失或 `metadata.groupName/reportDate` 为空,检查后重试
|
||||
|
||||
## 配置 Claude Desktop
|
||||
|
||||
把以下加入 `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"wechatexplorer": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@wechatexplorer/mcp-bridge"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(待 P3 实现 — MCP bridge 包,在此之前可直接用 `curl` 调用 HTTP API,或通过 mcp-remote 桥接。)
|
||||
|
||||
## 配置 Claude Code / Codex
|
||||
|
||||
在 `~/.claude/settings.json` 或项目级 `.claude/settings.local.json` 中:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"wechatexplorer": {
|
||||
"url": "http://127.0.0.1:6131"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(视 MCP over HTTP 支持情况调整)
|
||||
@@ -0,0 +1,61 @@
|
||||
# 用 AI 查找你以前聊过的信息
|
||||
|
||||
## AI Search 是什么
|
||||
|
||||
你可以把它理解成“会帮你翻聊天记录的 AI”。
|
||||
|
||||
普通搜索需要你猜关键词;AI Search 更适合这些问题:
|
||||
|
||||
- “我们上个月为什么决定延期?”
|
||||
- “谁提过这个项目,后来结论是什么?”
|
||||
- “过去一周有哪些待跟进事项?”
|
||||
|
||||
它会先在本机查找相关聊天,再把受控范围内的内容交给你选择的 AI Provider 生成回答。它不是凭空记忆,也不是把整库聊天一次性上传。
|
||||
|
||||
## 第一次使用
|
||||
|
||||
1. 进入“设置 → AI 模型”,添加一个 Provider,填写服务地址、模型和认证信息,然后测试连接。
|
||||
2. 打开“问问微信”。
|
||||
3. 选择所有聊天、群聊、单聊或当前会话,并选择今天、近 7 天、近 30 天或不限时间;范围越明确,答案越容易核对。
|
||||
4. 输入问题并开始分析。
|
||||
|
||||
如果知识库尚未建立,页面会提示你建立或同步;你也可以先直接使用当前可用的搜索路径。
|
||||
|
||||
## 怎么提问更容易得到好结果
|
||||
|
||||
把“谁、什么时候、在哪个群、想找什么结果”写出来。例如:
|
||||
|
||||
> “在产品交流群里,查找 2026 年 7 月讨论发布延期的消息,列出结论和待办。”
|
||||
|
||||
尽量避免只写“总结一下”。如果你只记得模糊含义,也可以先提问,再根据来源缩小范围继续追问。
|
||||
|
||||
## AI 回答后先看什么
|
||||
|
||||
不要只看结论。回答区域通常还会展示:
|
||||
|
||||
- 参考了哪些聊天内容;
|
||||
- 来源来自哪个会话、发送者和时间;
|
||||
- 哪一段回答对应哪条来源;
|
||||
- 本次查找经过了哪些阶段、耗时和覆盖情况;
|
||||
- 是否存在未转写语音、媒体不可用或结果不完整的提示。
|
||||
|
||||
你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
|
||||
|
||||
## 什么时候不要直接相信答案
|
||||
|
||||
- 来源很少,或时间范围与问题不一致;
|
||||
- 回答提到了来源中没有的细节;
|
||||
- 关键内容来自未转写语音、无法读取的图片或转发消息;
|
||||
- 页面提示只覆盖了部分聊天。
|
||||
|
||||
这些情况下,打开原消息,扩大或缩小范围,再重新提问。必要时把问题改成“只列出原文明确说过的内容”。
|
||||
|
||||
## 取消、失败和降级
|
||||
|
||||
分析过程中可以取消当前任务。检索或模型请求失败时,页面可能保留已找到的来源或切换到备用路径;这不代表一定得到了完整答案。请查看提示、检索详情和[排障文档](./troubleshooting.md#ai-没有结果或回答失败)。
|
||||
|
||||
## 数据会发到哪里
|
||||
|
||||
本地解析、索引和候选消息查找在本机完成。只有完成 AI 任务所需的用户问题、受控检索上下文和最终用于总结的来源内容,才可能发送到你配置的 Provider;具体边界见[数据、隐私与安全](./privacy.md)。
|
||||
|
||||
使用远程 Provider 时,当前界面会在本次请求发出前显示接收方和发送范围,等待你确认。当前实现最多发送 8 条最终来源,不会发送完整微信数据库、数据库密钥、绝对文件路径或内部会话/消息引用 ID;这次确认不会自动授权之后的其他请求。
|
||||
@@ -0,0 +1,57 @@
|
||||
# 查看和搜索聊天
|
||||
|
||||
“档案”是你直接阅读微信历史的地方。适合查原文、回看上下文、确认 AI 来源,也适合在你已经知道关键词时快速定位。
|
||||
|
||||
## 选择要看的会话
|
||||
|
||||
左侧会话列表可以浏览联系人、群聊、折叠群聊和公众号等已读取到的会话。选中会话后,右侧显示消息时间线;滚动到较早位置可以继续加载历史。
|
||||
|
||||
如果你从 AI 回答的来源进入档案,应用会自动切换到对应会话并尽量定位到消息时间。
|
||||
|
||||
## 普通关键词搜索什么时候最好用
|
||||
|
||||
当你记得以下任意信息时,优先使用档案搜索:
|
||||
|
||||
- 一段原话或关键词;
|
||||
- 人名、群名、项目名;
|
||||
- 链接、文件名或订单号;
|
||||
- 大致知道在哪个联系人或群里。
|
||||
|
||||
关键词搜索速度快、结果直观,但它不会理解“意思相近但没有相同词”的问题。
|
||||
|
||||
## 消息和媒体
|
||||
|
||||
根据微信数据中实际可用的资源,档案可以展示文本、图片、视频、语音、文件、链接、引用、小程序、表情和系统消息等类型。媒体是否能显示,取决于本机原始资源是否仍然存在、权限是否完整以及当前微信版本的存储方式。
|
||||
|
||||
不要把“消息类型已读取”理解成“所有媒体都一定能解码”。遇到图片或视频空白时,请先检查[媒体与导出排查](./troubleshooting.md#媒体显示或导出异常)。
|
||||
|
||||
如果文字正常但图片无法打开,进入“设置 → 图片解密”查看当前状态。可以尝试自动获取,也可以在已经知道正确密钥时手动配置;原文件已经被微信清理时,仅配置密钥也无法恢复图片。
|
||||
|
||||
## 可选保留撤回消息
|
||||
|
||||
“设置 → 防撤回”提供一个默认关闭的可选功能。开启后,应用会尽量保留之后捕获到的撤回消息,并在气泡旁标记“消息已撤回”。它不能找回开启前已经消失或应用未捕获到的内容,也可能增加加载开销。
|
||||
|
||||
该功能与普通只读浏览的数据边界不同。开启前请阅读[防撤回](./recall-protection.md)。
|
||||
|
||||
## 保护自己不被误导
|
||||
|
||||
档案中的原始消息是核对 AI 结果的最终依据。看到 AI 的总结、日报或来源时,建议:
|
||||
|
||||
1. 打开来源对应的会话;
|
||||
2. 查看消息前后几条上下文;
|
||||
3. 注意消息时间、发送者和是否存在转发/引用;
|
||||
4. 对未转写的语音、无法读取的图片保持不确定判断。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 会话列表为空
|
||||
|
||||
确认数据库连接成功、连接的是正确微信账号,并重新加载数据。若仍为空,查看[连接微信失败](./troubleshooting.md#连接微信失败)。
|
||||
|
||||
### 搜索不到明明存在的消息
|
||||
|
||||
先缩小到正确会话,再尝试更短的关键词或原文片段。对于“以前讨论过什么”这类语义问题,改用[AI Search](./ai-search.md)。
|
||||
|
||||
### 想跨多个会话查找
|
||||
|
||||
使用“问问微信”,并在问题中写清时间范围、人物或群聊范围。需要更稳定的跨会话查找时,先建立[本地知识库](./knowledge.md)。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 导出聊天档案
|
||||
|
||||
导出适合把微信里的重要讨论保存成可阅读、可分享或可继续处理的文件。
|
||||
|
||||
## 支持的格式
|
||||
|
||||
| 格式 | 适合什么任务 | 当前边界 |
|
||||
| -------- | ------------------------ | ---------------------------------------------------------- |
|
||||
| HTML | 完整阅读和长期归档 | 可包含媒体、头像和可选语音转写;支持多会话、增量合并和 ZIP |
|
||||
| Markdown | 笔记、版本管理和再次编辑 | 主要保留文本内容,不复制 HTML 资源文件 |
|
||||
| CSV | 表格分析 | 主要保留文本内容,不复制 HTML 资源文件 |
|
||||
| JSON | 程序处理和数据归档 | 主要保留文本内容,不复制 HTML 资源文件 |
|
||||
|
||||
ZIP 是 HTML 资源包的压缩选项,不是第五种内容格式。
|
||||
|
||||
## 导出步骤
|
||||
|
||||
可以打开一级导航“导出”,也可以在“档案”的聊天顶部点击“导出”并选择时间范围。
|
||||
|
||||
1. 选择一个或多个联系人/群聊。
|
||||
2. 选择时间范围和消息类型。
|
||||
3. 选择格式;只有 HTML 可以配置媒体资源、语音转写和 ZIP。
|
||||
4. 按需要设置头像、原图/缩略图和缺失资源处理。
|
||||
5. 设置文件名并开始导出。
|
||||
6. 在导出任务中心查看读取、解析、媒体处理、转写、写入和压缩进度;完成后打开文件位置。
|
||||
|
||||
## 多会话和增量导出
|
||||
|
||||
HTML 支持把最多五个会话合并到一个档案中;选择多个会话后,其他格式会不可用。再次使用相同名称导出 HTML 时,可以把新消息增量合并到已有档案;这不会删除之前已导出的消息。
|
||||
|
||||
## 媒体怎么处理
|
||||
|
||||
原图、缩略图、缺失资源和头像都可能影响导出大小与可读性。想要小文件时关闭媒体或选择缩略图;想要长期保存时,确认原始媒体目录仍可访问,并考虑 ZIP 归档。
|
||||
|
||||
HTML 导出可以选择在任务中执行本地语音转写,并把成功结果显示在语音气泡下方。语音模型不可用或识别失败时,导出不会把失败内容当成已转写文本。
|
||||
|
||||
## 导出和原始数据的关系
|
||||
|
||||
导出是复制/整理结果,不会修改微信原始数据库。删除导出文件也不会影响应用内聊天记录或本地知识库。
|
||||
@@ -0,0 +1,151 @@
|
||||
# 第一次使用 TraceMemo
|
||||
|
||||
如果你刚下载 TraceMemo,只需要完成一条主线:
|
||||
|
||||
> 安装应用 → 连接微信数据 → 确认聊天已加载 → 搜索或提问。
|
||||
|
||||
这篇文档不要求你先学习内部术语;先把第一个问题问出来,之后再按需要深入了解产品名称和进阶功能。
|
||||
|
||||
## 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 |
|
||||
|
||||
- 上表是当前实际测试过的客户端版本,不代表只有这些版本可以使用。其他微信 4.x 版本可能可以连接,但尚未逐一验证。
|
||||
- TraceMemo 必须取得当前微信账号对应的数据库密钥,才能读取聊天记录。
|
||||
- 你需要有权访问要读取的微信账号和聊天数据。
|
||||
- 如果要使用 AI 问答、群聊日报或图片理解,还需要在应用中配置一个 AI 服务。
|
||||
|
||||
当前代码按微信 4.x 数据结构处理。不同微信客户端版本、系统权限和数据迁移状态可能影响自动连接;遇到问题时请查看[常见问题与排查](./troubleshooting.md)。
|
||||
|
||||
## 2. 安装并启动
|
||||
|
||||
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载。
|
||||
|
||||
### Windows
|
||||
|
||||
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。
|
||||
2. 打开 DMG,将 TraceMemo 拖入“应用程序”文件夹。
|
||||
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||
4. 如果系统提示应用已损坏,可在终端执行:
|
||||
|
||||
```bash
|
||||
xattr -cr "/Applications/TraceMemo.app"
|
||||
```
|
||||
|
||||
5. 启动 TraceMemo。首次自动获取数据库密钥时,按连接页面显示的授权要求操作;只有页面明确提示时才按[关闭 SIP 教程](../mac-disable-sip.md)处理。关闭 SIP 会降低系统安全性,完成密钥配置后应重新开启。
|
||||
|
||||
更完整的权限和安全边界见 [macOS 数据访问说明](../platform/macos.md)。
|
||||
|
||||
## 3. 让应用读取微信数据
|
||||
|
||||
首次启动会自动进入“第一次使用”页面。页面会根据当前系统显示连接方式和注意事项:
|
||||
|
||||
<p align="center">
|
||||
<img src="../../public/setup-page.png" alt="第一次使用连接页面" width="820" />
|
||||
</p>
|
||||
|
||||
通常按下面三步操作即可:
|
||||
|
||||
1. **确认微信数据目录**:自动识别不准确时,打开微信设置中的缓存/存储管理,复制实际路径并在页面中修改。
|
||||
2. **让微信停在登录页面**:如果微信已经登录,先退出当前微信账号,不只是关闭微信窗口。
|
||||
3. **点击“开始连接”并按提示获取密钥**:软件准备好连接组件后会提示你登录微信;回到微信完成登录,再等待数据库、账号和联系人检查完成。
|
||||
|
||||
只有已经通过其他方式取得当前账号数据库密钥的高级用户,才需要选择“手动连接”。Windows 不需要关闭 SIP;macOS 是否需要额外授权或处理 SIP,以当前连接页面提示为准。
|
||||
|
||||
连接页面会显示微信状态、数据库状态和诊断结果。连接失败时先不要反复删除数据,优先查看[连接问题排查](./troubleshooting.md#连接微信失败)。
|
||||
|
||||
## 4. 确认第一次连接成功
|
||||
|
||||
连接成功后会进入“档案”页面。你可以用下面三个信号确认已经准备好:
|
||||
|
||||
- 左侧出现联系人或群聊列表;
|
||||
- 选中一个会话后,右侧能看到历史消息;
|
||||
- 搜索框可以在当前会话中定位文字。
|
||||
|
||||
如果联系人列表为空,先检查是否连到了正确账号和数据目录,再重新加载会话。
|
||||
|
||||
文字消息正常但图片打不开时,不代表数据库连接失败。打开“设置 → 图片解密”查看状态并尝试自动获取;图片原文件缺失、权限不足或密钥不匹配时,部分图片仍可能无法显示。
|
||||
|
||||
## 5. 完成你的第一个任务
|
||||
|
||||
### 只是想找一句话
|
||||
|
||||
进入“档案”,选择联系人或群聊,在会话内搜索关键词。适合你记得原话、姓名、链接或大致关键词的情况。
|
||||
|
||||
### 想找一个模糊的结论
|
||||
|
||||
先在“设置 → AI 模型”添加并测试一个 Provider,再进入“问问微信”描述问题,例如:
|
||||
|
||||
- “上个月技术群讨论过哪些发布问题?”
|
||||
- “张三之前发过的项目地址在哪里?”
|
||||
- “过去一周有没有人提到退款?”
|
||||
|
||||
这就是 AI Search:它会先帮你从本机聊天中找出相关内容,再让你配置的模型组织答案。你不需要知道关键词在哪,但问题越具体,结果越容易核对。
|
||||
|
||||
### 想让 AI 的答案可核对
|
||||
|
||||
回答生成后,打开来源或检索详情,查看它参考的聊天内容、会话、时间和原始消息。你可以从来源直接跳回“档案”检查上下文。
|
||||
|
||||
产品把这些来源信息分别称为 Evidence、Citation 和 Search Trace;普通使用时只需要记住“答案可以回到原消息核对”即可。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。
|
||||
|
||||
## 6. 接下来可以做什么
|
||||
|
||||
- [查看和搜索聊天](./chat-archive.md)
|
||||
- [使用 AI 查找聊天信息](./ai-search.md)
|
||||
- [建立本地知识库,让后续查找更稳定](./knowledge.md)
|
||||
- [生成群聊日报或总结](./report.md)
|
||||
- [转写微信语音](./voice.md)
|
||||
- [导出聊天档案](./export.md)
|
||||
- [可选开启防撤回](./recall-protection.md)
|
||||
- [在微信里向 TraceMemo 提问](../agent/agent-hub.md)
|
||||
- [让外部 Agent 查询微信历史](../agent/overview.md)
|
||||
|
||||
## 7. 想直接在微信里提问
|
||||
|
||||
如果你希望直接在微信里向 TraceMemo 提问,而不是另外配置 Codex 等外部 Agent,请使用 Agent Hub:
|
||||
|
||||
1. 先完成上面的微信数据库连接,并确认“档案”里能看到聊天。
|
||||
2. 打开应用主导航中的“Agent”;页面标题为“Agent Hub”。
|
||||
3. 确认 Agent Hub 显示“运行中”,数据 API/数据库状态可以查询。
|
||||
4. 点击“扫码登录微信机器人”,用微信扫描二维码,并在手机上确认登录。
|
||||
5. 状态变为“在线”后,向这个机器人发送文字消息。
|
||||
|
||||
可以先试试这些真实支持的请求:
|
||||
|
||||
- “最近 5 个会话”;
|
||||
- “帮我看看最近跟张三聊了些什么”;
|
||||
- “生成产品交流群今天的群聊总结图片”。
|
||||
|
||||
机器人会把处理结果回复给发消息的人。联系人聊天总结、群聊总结和需要理解自然语言的请求依赖“设置 → AI 模型”中已经配置好的 AI 服务。当前实时入口主要处理文字消息;它不是支持任意图片、语音、文件理解、群发或定时任务的通用机器人。机器人账号扫码登录与读取你微信数据库是两条独立流程,都需要分别确认账号和权限。
|
||||
|
||||
Agent Hub 是普通用户可以直接使用的入口,不需要安装 Reader Skill 或配置 API Token。Reader Skill 和 API 只用于让外部 Agent 主动查询历史微信。
|
||||
|
||||
## 8. 需要配置 AI 吗?
|
||||
|
||||
不一定。浏览聊天、普通关键词搜索、建立本地知识库和导出不要求在线 AI 服务。
|
||||
|
||||
使用“问问微信”、群聊日报或图片理解时,需要在“设置 → AI 模型”中添加并测试 AI 服务。你主动开始并确认远程 AI 功能后,完成任务所需的内容才可能发送给该服务;计费、留存和地区规则由对应服务商决定。
|
||||
|
||||
## 9. 数据和隐私的最低须知
|
||||
|
||||
- 微信数据库、聊天解析和本地索引默认留在本机。
|
||||
- 离线语音转写使用本地模型;它与在线 AI 请求是两条不同的数据路径。
|
||||
- 你主动开始并确认 AI 问答或日报后,完成任务所需的受控上下文才可能发送给你选择的 AI 服务;打开应用不会自动上传全部聊天。
|
||||
- 应用内 Local HTTP API 默认只监听 `127.0.0.1:6131`,受保护接口需要 Token。
|
||||
- 防撤回默认关闭;首次开启会为微信消息数据库增加本地撤回日志/监听结构,详细边界见[防撤回](./recall-protection.md)。
|
||||
|
||||
完整边界见[数据、隐私与安全](./privacy.md)。
|
||||
|
||||
## 10. 如果你卡住了
|
||||
|
||||
按现象进入[常见问题与排查](./troubleshooting.md):连接失败、聊天为空、AI 没有结果、语音模型不可用、导出失败和 Agent 无法访问分别有不同处理方式。
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 214 KiB |
@@ -0,0 +1,38 @@
|
||||
# 把聊天变成更容易再次找到的本地资料
|
||||
|
||||
## 你为什么需要 Knowledge
|
||||
|
||||
如果你经常查同一批工作群、项目讨论或长期联系人,只靠每次临时翻聊天会越来越慢。Knowledge 会在本机建立一份可重复查找的索引,让“以前聊过什么”这类问题更容易跨会话、跨时间找到相关内容。
|
||||
|
||||
它不是另一个聊天窗口,也不会替你修改微信原始数据库;它是 TraceMemo 为当前账号维护的本地加速资料。
|
||||
|
||||
## 建立和同步
|
||||
|
||||
Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信”后,在“本地知识库”区域点击:
|
||||
|
||||
- **建立本地知识库**:第一次读取当前账号的可检索聊天;
|
||||
- **同步最新记录**:已有索引时,只补充新增或变化的内容。
|
||||
|
||||
同步会在后台运行,完成后页面显示已索引消息、知识片段和磁盘占用。同步期间暂不能开始新的 AI 分析;同步异常时,旧索引仍可能可以继续使用。
|
||||
|
||||
## 账号隔离
|
||||
|
||||
每个微信账号使用独立的本地索引。切换账号时,应用不会把一个账号的索引混入另一个账号的搜索结果。
|
||||
|
||||
## 什么时候值得建立
|
||||
|
||||
- 你要跨多个群查过去几个月的内容;
|
||||
- 你反复查同一个项目、客户或主题;
|
||||
- 你希望 AI 先从更稳定的本地资料中找来源;
|
||||
- 你想减少每次搜索都重新读取大量原始记录的等待。
|
||||
|
||||
只偶尔查一条原话时,直接使用档案搜索通常更快。
|
||||
|
||||
## 清理和重建
|
||||
|
||||
在“设置 → 缓存与清理”中可以清理本地知识库索引、检索记录和导出任务缓存。清理索引不会删除微信原始聊天记录或数据库密钥;之后可以回到“问问微信”重新建立。
|
||||
|
||||
## 产品术语(可选)
|
||||
|
||||
源码和日志中可能出现 SQLite、FTS、Chunk、索引等词。它们描述的是本地存储和检索实现,不是你开始使用 TraceMemo 的前置知识。
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# 数据、隐私与安全
|
||||
|
||||
TraceMemo 的核心路径是本地优先,但“本地优先”不等于所有功能都完全离线。是否有数据离开电脑,取决于你是否启用了对应的 AI、Agent 或机器人能力。
|
||||
|
||||
## 默认留在本机的内容
|
||||
|
||||
以下处理由应用在本机完成:
|
||||
|
||||
- 读取和解析微信数据库;
|
||||
- 聊天档案浏览和普通关键词搜索;
|
||||
- 本地 Knowledge 索引及其账号隔离;
|
||||
- 离线语音转写;
|
||||
- 导出文件生成和本地日报历史。
|
||||
|
||||
应用不会因为你打开 TraceMemo 就自动把整份微信数据库上传。
|
||||
|
||||
防撤回默认关闭,并且和上面的普通读取路径不同。用户第一次明确开启时,当前实现会在微信消息数据库中安装本地撤回日志/监听结构,同时在 TraceMemo 用户数据目录保存必要的恢复记录。v2.1.9 的旧恢复记录会随首次启动迁移复制到 TraceMemo,旧目录仍保留。关闭开关不等于移除已经安装的结构或清空既有记录;当前 UI 没有对应的清理入口。详见[防撤回](./recall-protection.md)。
|
||||
|
||||
## 什么时候会请求外部服务
|
||||
|
||||
当你主动使用 AI Search、群聊日报或图片理解,并配置了远程 Provider 时,完成任务所需的内容可能发送给该 Provider。当前设置页给出的边界是:
|
||||
|
||||
- 当前用户问题;
|
||||
- 受控检索所需的有限上下文;
|
||||
- 最终用于总结的 Evidence。
|
||||
|
||||
不会发送完整微信数据库、全量聊天记录、未选中的聊天范围、数据库密钥、内部索引结构或内部会话/消息引用 ID。Provider 的日志、保留、计费和跨境规则不由 TraceMemo 控制,请查看你所选服务商的政策。
|
||||
|
||||
Ollama 等本机 Provider 可以把模型请求留在本机,但本机服务的日志和配置仍由你负责。
|
||||
|
||||
## 语音和媒体
|
||||
|
||||
离线语音转写在本机进行。图片理解属于 AI 功能:只有你主动启用并使用相关报告/分析路径时,图片才可能按该 Provider 的请求规则被处理。无法读取的媒体不会被自动“猜出来”。
|
||||
|
||||
## Local HTTP API
|
||||
|
||||
- 默认监听地址为 `127.0.0.1:6131`,不是公网服务;
|
||||
- `/api/v1/health` 为公开健康检查;
|
||||
- 其他端点需要 `Authorization: Bearer <TOKEN>`;
|
||||
- 浏览器 CORS 只允许 HTTP 的 `localhost`、`127.0.0.1` 和 `[::1]` Origin;
|
||||
- 不带 Origin 的本地 CLI/Agent 请求可以使用 Token 访问;
|
||||
- API 不适合直接转发到公网或绑定到不受信任的网络接口。
|
||||
|
||||
Token 由应用生成,使用 Electron `safeStorage` 加密保存在本机 `local-api-token.bin`,文件权限为仅当前用户可读写。你可以在“API Center”中显示、复制或重新生成 Token;重新生成会立即使旧 Token 失效。具体配置见[API 安全](../agent/api-security.md)。
|
||||
|
||||
## Agent 访问时发生什么
|
||||
|
||||
外部 Agent 通过 Reader Skill 调用本机 API,按需读取联系人、会话或时间范围内的聊天;它不会因此获得数据库文件路径或任意文件系统权限。Agent 是否把读取结果再次发送给模型,取决于 Agent 本身及其配置。
|
||||
|
||||
应用内 Agent Hub 是另一条路径:微信机器人通过本机 Hub 调用 TraceMemo,并且可能使用已配置的 AI 来理解问题。请把机器人账号、发送权限和日志视为独立的安全边界。
|
||||
|
||||
机器人收到的文字会先进入本机 Agent Hub;如果任务需要总结或自然语言理解,受控上下文可能发送给你配置的 AI Provider。机器人账号扫码登录、个人微信数据库连接和外部 Agent/API Token 是不同的边界,使用前请分别确认账号与权限。
|
||||
|
||||
## 你可以主动做的事
|
||||
|
||||
- 不要把 API Token 放进 Git、截图、URL 或公开 Skill 文件;
|
||||
- 只连接你有权访问的微信数据;
|
||||
- 对需要外发的 AI 功能逐项确认 Provider;
|
||||
- 定期在“设置 → 缓存与清理”清理不再需要的检索、导出和索引缓存;
|
||||
- 在共享电脑上退出应用并保护系统账户。
|
||||
- 在开启防撤回前确认你接受其数据库写入、性能和清理边界,并先用微信官方方式备份重要数据。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 防撤回
|
||||
|
||||
防撤回是一个默认关闭的可选功能。开启后,TraceMemo 会尽量保留它能够捕获到的撤回消息,并在聊天气泡旁标记“消息已撤回”。
|
||||
|
||||
它适合希望在本机档案中保留后续聊天上下文的用户,但不能保证找回每一条撤回消息。
|
||||
|
||||
## 如何开启
|
||||
|
||||
1. 先连接微信数据库,并确认“档案”可以正常读取聊天。
|
||||
2. 打开“设置 → 防撤回”。
|
||||
3. 阅读性能和数据提示后,开启“防撤回”。
|
||||
4. 保持 TraceMemo 与当前微信数据连接;之后捕获到的撤回消息会尽量保留并标记。
|
||||
|
||||
防撤回不是第一次使用的必要步骤。只想浏览、搜索、提问或导出时,可以保持关闭。
|
||||
|
||||
## 当前能做什么
|
||||
|
||||
- 监听应用能够识别到的后续撤回变化;
|
||||
- 在本地保留必要的消息和撤回关系;
|
||||
- 将已识别的原消息与撤回状态一起显示在档案中;
|
||||
- 按微信账号隔离 TraceMemo 保存的恢复记录。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 不能恢复开启前已经撤回、且应用从未保存到的消息;
|
||||
- TraceMemo 未运行、数据库未连接或没有捕获到撤回变化时,消息可能无法保留;
|
||||
- 微信版本、消息表结构和数据库事件变化都可能让部分消息无法恢复或正确匹配;
|
||||
- 开启后需要为消息表增加监听,聊天很多或磁盘较慢时可能影响加载性能;
|
||||
- “消息已撤回”只说明应用识别到了撤回关系,不保证恢复内容完整。
|
||||
|
||||
## 数据写入与关闭边界
|
||||
|
||||
普通浏览、搜索和 Knowledge 不会修改微信原始聊天数据库;防撤回是一个例外。用户第一次明确开启时,当前实现会在微信消息数据库中安装用于记录撤回的本地日志/监听结构,并在 TraceMemo 的用户数据目录保存必要的本地恢复记录。v2.1.9 的旧恢复记录会在用户确认迁移后复制到 TraceMemo,旧目录不会删除。
|
||||
|
||||
关闭设置中的开关,不等同于删除已经安装的日志结构或清空此前保存的恢复记录。当前版本没有在 UI 中提供“移除防撤回日志结构”或“清空防撤回记录”的独立操作。对数据库写入、磁盘占用或完全回滚有要求时,应在开启前先确认这一边界,并使用微信官方方式备份重要数据。
|
||||
|
||||
完整的数据边界见[数据、隐私与安全](./privacy.md)。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 生成群聊日报和总结
|
||||
|
||||
如果你每天在多个群里聊天,晚上不想重新翻几十个群,可以让 TraceMemo 根据一个群的聊天内容整理出一份可阅读、可保存的报告。
|
||||
|
||||
## 报告适合做什么
|
||||
|
||||
典型场景包括:
|
||||
|
||||
- 整理今天工作群的讨论重点;
|
||||
- 回顾昨天错过的决定和资源;
|
||||
- 汇总近 7 天的项目进展、待办和未解决问题;
|
||||
- 把群里的图片、语音统计和重要消息放进一张长图或 HTML 页面。
|
||||
|
||||
## 生成步骤
|
||||
|
||||
你可以从两个入口开始:打开一级导航“日报”后新建报告,或者在“档案”中选中一个群聊并点击“生成 AI 日报”。
|
||||
|
||||
1. 选择一个群聊。当前日报入口只支持群聊,不支持单聊。
|
||||
2. 选择时间范围:今天、昨天或近 7 天。
|
||||
3. 按需要选择参与总结的消息类型,先从文字开始最容易核对。
|
||||
4. 选择报告模板/内容模式并开始生成。
|
||||
5. 等待“整理输入 → AI 生成 → HTML/PNG 导出”完成。
|
||||
|
||||
报告可能包含主题、重要消息、问答、资源、待办、未解决事项、关键词、活跃统计,以及可用媒体的精选内容。具体展示内容会随消息类型、资源可用性和模型能力变化。
|
||||
|
||||
## 如何检查报告
|
||||
|
||||
报告中的重点结论会关联来源消息。对于重要决定、金额、时间和责任人,打开对应原消息核对,不要把 AI 生成的摘要当成新的事实来源。
|
||||
|
||||
图片无法读取时,报告可能只保留消息类型和上下文;模型未通过图片理解验证时,图片精选会被跳过。语音在日报中可参与数量和活跃度统计,但不要把统计当成语音内容已经被完整转写。
|
||||
|
||||
## 保存、查看和删除
|
||||
|
||||
生成成功后会保存本地 HTML 与 PNG,并出现在日报历史中。你可以复制图片、打开文件位置或重新生成。删除历史日报只删除本地生成的报告文件,不会影响微信聊天数据库。
|
||||
|
||||
## 让报告更可靠
|
||||
|
||||
- 先选正确的群和时间范围;
|
||||
- 不确定时先只选择文字消息;
|
||||
- 群太活跃时分成“今天”和“近 7 天”两次生成;
|
||||
- 看到待办和结论后回到原消息核对上下文;
|
||||
- AI Provider 不可用时先检查模型配置和网络/本地服务状态。
|
||||
@@ -0,0 +1,94 @@
|
||||
# 常见问题与排查
|
||||
|
||||
先按现象定位,不要为了“重置”而直接删除微信数据库或整个应用目录。
|
||||
|
||||
## 安装后软件无法打开
|
||||
|
||||
### Windows
|
||||
|
||||
1. 确认下载的是 GitHub Releases 中的 Windows x64 `-setup.exe`,并已完成安装。
|
||||
2. 安装 [Microsoft Visual C++ x64 运行库](https://aka.ms/vc14/vc_redist.x64.exe)。
|
||||
3. 安装完成后重新启动 TraceMemo;如果仍无响应,再重新运行安装包进行覆盖安装。
|
||||
|
||||
### macOS
|
||||
|
||||
- 提示“无法打开,因为开发者无法验证”时,前往“系统设置 → 隐私与安全性”并点击“仍要打开”。
|
||||
- 提示应用已损坏时,确认应用位于“应用程序”目录,再执行 `xattr -cr "/Applications/TraceMemo.app"`。
|
||||
|
||||
完整安装步骤见[第一次使用 TraceMemo](./getting-started.md#2-安装并启动)。
|
||||
|
||||
## 连接微信失败
|
||||
|
||||
依次检查:
|
||||
|
||||
1. 数据目录是否指向当前登录账号,而不是旧备份或迁移前目录;
|
||||
2. 微信版本是否属于当前代码面向的 4.x 数据结构;
|
||||
3. 微信是否处于页面要求的登录/退出状态;
|
||||
4. macOS 是否完成页面要求的授权;
|
||||
5. 连接页面的诊断项是否明确指出密钥、账号或数据库问题。
|
||||
|
||||
重新输入密钥或断开连接不会删除微信原始数据库。macOS 的 SIP 和授权说明见[平台说明](../platform/macos.md)。
|
||||
|
||||
## 连接成功但没有联系人或消息
|
||||
|
||||
确认账号身份和数据目录匹配。返回“设置 → 账号与数据库”查看数据库连接状态,重新加载会话后再试。若仍为空,记录系统、微信版本和错误提示后提交 Issue。
|
||||
|
||||
## AI 没有结果或回答失败
|
||||
|
||||
- 先在“设置 → AI 模型”测试 Provider;
|
||||
- 检查问题的时间范围和会话范围是否过窄;
|
||||
- 确认 Knowledge 没有正在同步;
|
||||
- 打开检索详情,查看是本地查找为空、Provider 失败还是来源被过滤;
|
||||
- 把问题改成要求“只根据来源原文回答”。
|
||||
|
||||
AI Search 失败时可能仍保留部分来源;不要把部分结果当成完整覆盖。
|
||||
|
||||
## AI 答案看起来不对
|
||||
|
||||
打开来源和原始消息,检查发送者、时间和上下文。若来源不支持结论,扩大或缩小范围后重问。涉及未转写语音、缺失图片、转发和引用时,优先以原消息为准。
|
||||
|
||||
## Knowledge 一直在同步
|
||||
|
||||
首次建立或增量同步会在后台运行。查看“已索引消息、知识片段、磁盘占用”和同步详情;同步期间暂不能开始新的 AI 分析。若出现错误,旧索引可能仍可用,重启应用或在“缓存与清理”清理后重新建立。
|
||||
|
||||
## 语音转写失败
|
||||
|
||||
检查本地模型是否已准备、磁盘空间是否足够、单条语音是否仍有原始资源。批量任务可能部分成功;先处理失败项,不必重复转写已缓存内容。
|
||||
|
||||
## 媒体显示或导出异常
|
||||
|
||||
原图/缩略图目录缺失、权限不足或微信资源已被清理都会导致图片、视频或语音不可用。导出时可以切换缩略图、关闭媒体或保留缺失项,先确认文本档案是否正常。
|
||||
|
||||
文字正常但图片打不开时,进入“设置 → 图片解密”查看状态并尝试自动获取。密钥正确也不能恢复已经被微信清理的原图文件。
|
||||
|
||||
## 日报生成失败
|
||||
|
||||
日报只支持群聊。确认已选择群聊、时间范围内确实有消息、Provider 可用,并尝试先只选择文字消息。图片理解失败不会自动变成图片内容;报告可能跳过图片精选但仍生成文字日报。
|
||||
|
||||
## Agent 无法读取
|
||||
|
||||
确认:
|
||||
|
||||
1. TraceMemo 正在运行且 API Center 显示本地服务在线;
|
||||
2. Agent 使用的是当前 Reader Skill,而不是旧的 MCP 配置;
|
||||
3. 请求地址为 `http://127.0.0.1:6131`;
|
||||
4. 非 health 请求带有最新 `Authorization: Bearer <TOKEN>`;
|
||||
5. Token 重新生成后,Agent 配置已同步更新。
|
||||
|
||||
详细步骤见[Agent 接入概览](../agent/overview.md)和[API 安全](../agent/api-security.md)。
|
||||
|
||||
## 微信机器人无法连接或不回复
|
||||
|
||||
Agent Hub 和外部 Agent 是两条路径。机器人异常时依次确认:
|
||||
|
||||
1. “Agent”页面中的 Agent Hub、微信连接器和数据库状态是否正常;
|
||||
2. 二维码是否过期,手机是否已经确认登录;
|
||||
3. 是否由另一个微信账号向已登录的机器人账号发送文字;
|
||||
4. 请求是否属于当前支持的最近会话、联系人聊天、近 7 天联系人总结、群聊总结或群成员发言总结;
|
||||
5. 需要总结或自然语言理解时,AI Provider 是否可用。
|
||||
|
||||
当前机器人不支持群发、定时任务或与文字同等的图片、语音、文件和视频理解。详细边界见[Agent Hub](../agent/agent-hub.md)。
|
||||
|
||||
## 防撤回没有保留消息
|
||||
|
||||
防撤回只能尽量保留开启后且应用成功捕获到的撤回变化。确认开启时数据库已经连接、TraceMemo 在撤回发生时保持运行,并检查聊天加载是否明显变慢。开启前已经消失、应用未捕获或微信结构无法识别的消息不能保证恢复;详见[防撤回](./recall-protection.md)。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 语音转文字
|
||||
|
||||
TraceMemo 可以把微信语音转换成可搜索的文字,适合你不想逐条播放、希望把语音内容带入后续查找或导出的场景。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
1. 打开“设置 → 语音识别”。
|
||||
2. 按页面提示准备或下载本地语音模型。
|
||||
3. 等待模型状态显示可用。
|
||||
|
||||
语音识别使用本地 SenseVoice/sherpa-onnx 运行时。首次准备模型可能需要下载文件和占用额外磁盘空间;模型文件可以从设置中删除,之后需要重新准备。
|
||||
|
||||
## 转写单条语音
|
||||
|
||||
在聊天档案中找到语音消息,点击转写入口。完成后,转写文本会与该消息关联,并可用于后续查看或检索。失败时查看消息提示和模型状态。
|
||||
|
||||
## 批量转写
|
||||
|
||||
在语音设置中选择联系人或群聊,再选择范围:
|
||||
|
||||
- 最近 30 天;
|
||||
- 当前年份;
|
||||
- 选择的历史范围。
|
||||
|
||||
开始前页面会显示语音条数、已缓存数量、待处理数量和预计耗时。批量任务支持进度、取消、缓存复用,并可能以“部分失败”结束;部分失败时可以根据列表重新处理未成功内容。
|
||||
|
||||
## 和 AI、知识库、导出的关系
|
||||
|
||||
- 本地转写结果可以参与本地知识库检索;
|
||||
- 导出时可选择是否包含已有语音转写;
|
||||
- AI Search 可能提示某些语音尚未转写,这意味着答案覆盖不完整;
|
||||
- 群聊日报默认会统计语音数量和时长,但不等于已经理解了每条语音的具体内容。
|
||||
|
||||
## 隐私提示
|
||||
|
||||
离线转写本身在本机完成。若你主动把转写结果用于 AI Search、日报或其他 AI 功能,受控文本可能按对应功能的规则发送给你配置的 Provider;详见[数据、隐私与安全](./privacy.md)。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
appId: com.wechatexplorer.app
|
||||
productName: WechatExplorer
|
||||
appId: com.tracememo.app
|
||||
productName: TraceMemo
|
||||
afterPack: scripts/after-pack.cjs
|
||||
directories:
|
||||
buildResources: build
|
||||
@@ -15,20 +15,25 @@ extraMetadata:
|
||||
main: out/main/index.js
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- node_modules/ffmpeg-static/**
|
||||
- node_modules/silk-wasm/**
|
||||
- node_modules/sherpa-onnx-node/**
|
||||
- node_modules/sherpa-onnx-*/**
|
||||
extraResources:
|
||||
# Includes the optional WeChat connector binary for the target platform.
|
||||
- from: resources
|
||||
to: resources
|
||||
filter:
|
||||
- '**/*'
|
||||
- from: docs/skill/wechatexplorer-reader
|
||||
to: skill/wechatexplorer-reader
|
||||
- from: docs/skill/tracememo-reader
|
||||
to: skill/tracememo-reader
|
||||
filter:
|
||||
- '**/*'
|
||||
win:
|
||||
icon: icon.ico
|
||||
# WCDB's Windows runtime checks the host executable name. The dev runtime is
|
||||
# electron.exe, so keep the packaged executable compatible while preserving
|
||||
# WechatExplorer as the product/shortcut name.
|
||||
# electron.exe, so keep the packaged executable compatible while using
|
||||
# TraceMemo as the product/shortcut name.
|
||||
executableName: electron
|
||||
nsis:
|
||||
oneClick: false
|
||||
@@ -41,10 +46,10 @@ mac:
|
||||
icon: icon.icns
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
# The bundled WCDB bridge accepts Electron as its internal host name. The
|
||||
# public app name and bundle identifier remain WechatExplorer-specific.
|
||||
# The bundled WCDB bridge still uses Electron as its internal executable
|
||||
# compatibility name; the public product and bundle identity are TraceMemo.
|
||||
CFBundleName: Electron
|
||||
CFBundleDisplayName: WechatExplorer
|
||||
CFBundleDisplayName: TraceMemo
|
||||
NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
@@ -67,4 +72,4 @@ publish:
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
repo: WechatExplorer
|
||||
releaseType: draft
|
||||
releaseType: release
|
||||
|
||||
@@ -7,12 +7,14 @@ export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts')
|
||||
index: resolve('src/main/index.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']
|
||||
external: ['koffi', 'sherpa-onnx-node']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"use strict";
|
||||
const electron = require("electron");
|
||||
const preload = require("@electron-toolkit/preload");
|
||||
const api = {
|
||||
writeAppLog: (entry) => electron.ipcRenderer.invoke("app-log:write", entry),
|
||||
getAppLogPath: () => electron.ipcRenderer.invoke("app-log:getPath"),
|
||||
revealAppLog: () => electron.ipcRenderer.invoke("app-log:reveal"),
|
||||
getAppUpdateState: () => electron.ipcRenderer.invoke("app-update:getState"),
|
||||
checkAppUpdate: () => electron.ipcRenderer.invoke("app-update:check"),
|
||||
downloadAppUpdate: () => electron.ipcRenderer.invoke("app-update:download"),
|
||||
installAppUpdate: () => electron.ipcRenderer.invoke("app-update:install"),
|
||||
onAppUpdateState: (callback) => {
|
||||
const listener = (_event, state) => callback(state);
|
||||
electron.ipcRenderer.on("app-update:state", listener);
|
||||
return () => electron.ipcRenderer.removeListener("app-update:state", listener);
|
||||
},
|
||||
getCacheSummary: () => electron.ipcRenderer.invoke("cache:getSummary"),
|
||||
clearCache: (scope) => electron.ipcRenderer.invoke("cache:clear", scope),
|
||||
initDb: (key) => electron.ipcRenderer.invoke("db:init", key),
|
||||
getBootstrapCache: () => electron.ipcRenderer.invoke("db:getBootstrapCache"),
|
||||
getStartupCache: () => electron.ipcRenderer.invoke("db:getStartupCache"),
|
||||
getContacts: (filter) => electron.ipcRenderer.invoke("db:getContacts", filter),
|
||||
getContactAvatars: (usernames) => electron.ipcRenderer.invoke("db:getContactAvatars", usernames),
|
||||
getCachedMessages: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessages", userMd5, startTime, endTime),
|
||||
getCachedMessagePage: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessagePage", userMd5, startTime, endTime),
|
||||
getMessages: (userMd5, startTime, endTime, options) => electron.ipcRenderer.invoke("db:getMessages", userMd5, startTime, endTime, options),
|
||||
getGroupSnapshot: (userMd5) => electron.ipcRenderer.invoke("db:getGroupSnapshot", userMd5),
|
||||
search: (keyword) => electron.ipcRenderer.invoke("db:search", keyword),
|
||||
aiChat: (messages, options) => electron.ipcRenderer.invoke("ai:chat", messages, options),
|
||||
listAIProviders: () => electron.ipcRenderer.invoke("ai:listProviders"),
|
||||
getAIRuntimeConfig: () => electron.ipcRenderer.invoke("ai:getRuntimeConfig"),
|
||||
saveAIProvider: (provider) => electron.ipcRenderer.invoke("ai:saveProvider", provider),
|
||||
deleteAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:deleteProvider", providerId),
|
||||
setDefaultAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:setDefaultProvider", providerId),
|
||||
testAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:testProvider", providerId),
|
||||
testAIVision: (request) => electron.ipcRenderer.invoke("ai:testVision", request),
|
||||
migrateLegacyAIConfig: (config) => electron.ipcRenderer.invoke("ai:migrateLegacy", config),
|
||||
copyImage: (base64String) => electron.ipcRenderer.invoke("copy-image", base64String),
|
||||
getVoiceData: (sessionId, localId, createTime, svrId) => electron.ipcRenderer.invoke("db:getVoiceData", sessionId, localId, createTime, svrId),
|
||||
parseMessage: (content, messageType) => electron.ipcRenderer.invoke("db:parseMessage", content, messageType),
|
||||
getImage: (imageMd5, imageDatNameOrThumb, sessionId, options) => electron.ipcRenderer.invoke("db:getImage", imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||
getVideo: (hashes) => electron.ipcRenderer.invoke("db:getVideo", hashes),
|
||||
getSticker: (cdnUrl, md5) => electron.ipcRenderer.invoke("db:getSticker", cdnUrl, md5),
|
||||
startExport: (request) => electron.ipcRenderer.invoke("export:start", request),
|
||||
cancelExport: (jobId) => electron.ipcRenderer.invoke("export:cancel", jobId),
|
||||
revealExport: (path) => electron.ipcRenderer.invoke("export:reveal", path),
|
||||
onExportProgress: (callback) => {
|
||||
const listener = (_event, progress) => callback(progress);
|
||||
electron.ipcRenderer.on("export:progress", listener);
|
||||
return () => electron.ipcRenderer.removeListener("export:progress", listener);
|
||||
},
|
||||
exportGroupReport: (request) => electron.ipcRenderer.invoke("report:export", request),
|
||||
listGeneratedReports: () => electron.ipcRenderer.invoke("report:listGenerated"),
|
||||
saveGeneratedReport: (request) => electron.ipcRenderer.invoke("report:saveGenerated", request),
|
||||
deleteGeneratedReport: (reportId) => electron.ipcRenderer.invoke("report:deleteGenerated", reportId),
|
||||
revealGroupReport: (filePath) => electron.ipcRenderer.invoke("report:reveal", filePath),
|
||||
getSavedDbKey: () => electron.ipcRenderer.invoke("key:getSavedDbKey"),
|
||||
getDatabaseKeyEnvironment: () => electron.ipcRenderer.invoke("key:getEnvironment"),
|
||||
readDatabaseKeyClipboard: () => electron.ipcRenderer.invoke("key:readClipboardDbKey"),
|
||||
autoGetDbKey: (options) => electron.ipcRenderer.invoke("key:autoGetDbKey", options),
|
||||
autoGetImageKey: (options) => electron.ipcRenderer.invoke("key:autoGetImageKey", options),
|
||||
getImageKeyConfig: () => electron.ipcRenderer.invoke("image:getConfig"),
|
||||
getImageDecryptionStatus: () => electron.ipcRenderer.invoke("image:getStatus"),
|
||||
saveImageKeyConfig: (request) => electron.ipcRenderer.invoke("image:saveConfig", request),
|
||||
testImageDecryption: (request) => electron.ipcRenderer.invoke("image:testConfig", request),
|
||||
clearImageKeyConfig: () => electron.ipcRenderer.invoke("image:clearConfig"),
|
||||
pasteAndSaveDbKey: () => electron.ipcRenderer.invoke("key:pasteAndSaveDbKey"),
|
||||
saveDbKey: (key) => electron.ipcRenderer.invoke("key:saveDbKey", key),
|
||||
clearSavedDbKey: () => electron.ipcRenderer.invoke("key:clearSavedDbKey"),
|
||||
onWcdbChange: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
electron.ipcRenderer.on("wcdb-change", listener);
|
||||
return () => electron.ipcRenderer.removeListener("wcdb-change", listener);
|
||||
},
|
||||
onDbKeyStatus: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
electron.ipcRenderer.on("key:dbKeyStatus", listener);
|
||||
return () => electron.ipcRenderer.removeListener("key:dbKeyStatus", listener);
|
||||
},
|
||||
onImageKeyStatus: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
electron.ipcRenderer.on("key:imageKeyStatus", listener);
|
||||
return () => electron.ipcRenderer.removeListener("key:imageKeyStatus", listener);
|
||||
},
|
||||
getSettings: () => electron.ipcRenderer.invoke("settings:get"),
|
||||
setSettings: (patch) => electron.ipcRenderer.invoke("settings:set", patch),
|
||||
getSelf: () => electron.ipcRenderer.invoke("settings:getSelf"),
|
||||
testConnection: (key, accountRoot) => electron.ipcRenderer.invoke("db:testConnection", key, accountRoot),
|
||||
reopenWithRoot: (accountRoot) => electron.ipcRenderer.invoke("db:reopenWithRoot", accountRoot),
|
||||
selectDbRoot: () => electron.ipcRenderer.invoke("settings:selectDbRoot"),
|
||||
openAccountRoot: () => electron.ipcRenderer.invoke("settings:openAccountRoot"),
|
||||
disconnectDb: (options) => electron.ipcRenderer.invoke("db:disconnect", options),
|
||||
apiStatus: () => electron.ipcRenderer.invoke("api:getStatus"),
|
||||
apiStart: (host, port) => electron.ipcRenderer.invoke("api:start", host, port),
|
||||
apiStop: () => electron.ipcRenderer.invoke("api:stop"),
|
||||
apiToggle: (enabled) => electron.ipcRenderer.invoke("api:toggle", enabled),
|
||||
getReaderSkillStatus: () => electron.ipcRenderer.invoke("api:skillStatus"),
|
||||
readReaderSkill: () => electron.ipcRenderer.invoke("api:readSkill"),
|
||||
revealReaderSkill: () => electron.ipcRenderer.invoke("api:revealSkill"),
|
||||
openReaderSkillGithub: () => electron.ipcRenderer.invoke("api:openSkillGithub"),
|
||||
testLocalApiRequest: (request) => electron.ipcRenderer.invoke("api:testLocalRequest", request),
|
||||
copyText: (text) => electron.ipcRenderer.invoke("api:copyText", text),
|
||||
// ============================================================
|
||||
// AI 图片理解基础设施(ImageInsightService)
|
||||
// ============================================================
|
||||
imageListCandidates: (query) => electron.ipcRenderer.invoke("image:listCandidates", query),
|
||||
imageAnalyze: (request) => electron.ipcRenderer.invoke("image:analyze", request),
|
||||
getImageInsight: (imageHash) => electron.ipcRenderer.invoke("image:getInsight", imageHash),
|
||||
listImageInsights: (sessionId, limit) => electron.ipcRenderer.invoke("image:listInsights", sessionId, limit),
|
||||
getAgentHubStatus: () => electron.ipcRenderer.invoke("agent-hub:getStatus"),
|
||||
getAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:getLogs"),
|
||||
clearAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:clearLogs"),
|
||||
startAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:startLogin"),
|
||||
cancelAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:cancelLogin"),
|
||||
reconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:reconnect"),
|
||||
disconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:disconnect"),
|
||||
selectAgentHubTestImage: () => electron.ipcRenderer.invoke("agent-hub:selectTestImage"),
|
||||
onAgentHubStatus: (callback) => {
|
||||
const listener = (_event, status) => callback(status);
|
||||
electron.ipcRenderer.on("agent-hub:status", listener);
|
||||
return () => electron.ipcRenderer.removeListener("agent-hub:status", listener);
|
||||
},
|
||||
onAgentHubLog: (callback) => {
|
||||
const listener = (_event, entry) => callback(entry);
|
||||
electron.ipcRenderer.on("agent-hub:log", listener);
|
||||
return () => electron.ipcRenderer.removeListener("agent-hub:log", listener);
|
||||
}
|
||||
};
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
electron.contextBridge.exposeInMainWorld("electron", preload.electronAPI);
|
||||
electron.contextBridge.exposeInMainWorld("api", api);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
window.electron = preload.electronAPI;
|
||||
window.api = api;
|
||||
}
|
||||
@@ -1,22 +1,30 @@
|
||||
{
|
||||
"name": "wechatexplorer",
|
||||
"version": "2.1.1",
|
||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||
"name": "tracememo",
|
||||
"version": "2.2.0",
|
||||
"packageManager": "pnpm@7.33.7",
|
||||
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、AI 总结和 Agent 助手。",
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"chat",
|
||||
"wechat chat",
|
||||
"wechat history",
|
||||
"mac微信",
|
||||
"windows微信",
|
||||
"微信聊天记录",
|
||||
"AI群聊总结助手"
|
||||
"微信聊天记录搜索",
|
||||
"微信AI",
|
||||
"微信机器人",
|
||||
"AI聊天搜索",
|
||||
"AI群聊总结",
|
||||
"本地AI"
|
||||
],
|
||||
"author": "Qingmao",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Wxw-Gu/WechatExplorer.git"
|
||||
"url": "https://github.com/Wxw-Gu/TraceMemo.git"
|
||||
},
|
||||
"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",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache .",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
@@ -25,25 +33,52 @@
|
||||
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
|
||||
"cp:env": "node scripts/ensure-env.cjs",
|
||||
"prepare:env": "node scripts/ensure-env.cjs",
|
||||
"predev": "node scripts/ensure-env.cjs",
|
||||
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
||||
"test:unit": "vitest run --config vitest.unit.config.ts",
|
||||
"test:component": "vitest run --config vitest.component.config.ts",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"benchmark:knowledge": "vitest run --config vitest.knowledge-benchmark.config.ts --reporter=verbose",
|
||||
"benchmark:knowledge:capacity": "cross-env KNOWLEDGE_CAPACITY=1 vitest run --config vitest.knowledge-benchmark.config.ts --reporter=verbose",
|
||||
"test:e2e:build": "electron-vite build",
|
||||
"test:knowledge-worker": "pnpm test:e2e:build && node scripts/test-knowledge-worker.cjs",
|
||||
"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,arm64",
|
||||
"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:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
|
||||
"build:win": "npm run build && electron-builder --config electron-builder.yml --win",
|
||||
"build:mac:x64": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
|
||||
"build:mac:arm64": "electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
||||
"release:mac": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
|
||||
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.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",
|
||||
"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.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",
|
||||
"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",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@tanstack/react-virtual": "^3.14.6",
|
||||
"archiver": "^8.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ffmpeg-static": "5.3.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"jsonrepair": "^3.15.0",
|
||||
"koffi": "^3.1.0",
|
||||
"openai": "^6.10.0",
|
||||
"sherpa-onnx-node": "1.13.3",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"wechat-emojis": "^1.0.2"
|
||||
},
|
||||
@@ -51,12 +86,19 @@
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@rollup/rollup-darwin-arm64": "^4.62.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/archiver": "^8.0.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"electron": "^43.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
@@ -64,16 +106,30 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"jsdom": "^30.0.1",
|
||||
"prettier": "^3.7.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"sass": "^1.102.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"pnpm": {
|
||||
"supportedArchitectures": {
|
||||
"os": [
|
||||
"current",
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"current",
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"esbuild"
|
||||
"esbuild",
|
||||
"ffmpeg-static"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testMatch: /.*\.spec\.ts/,
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 8_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI
|
||||
? [['line'], ['html', { outputFolder: 'playwright-report', open: 'never' }]]
|
||||
: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
|
||||
outputDir: 'test-results',
|
||||
snapshotPathTemplate: 'tests/e2e/__screenshots__/{platform}/{testFilePath}/{arg}{ext}',
|
||||
use: {
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure'
|
||||
}
|
||||
})
|
||||
|
After Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 238 KiB |
@@ -76,6 +76,17 @@
|
||||
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;
|
||||
}
|
||||
.avatar-grid.empty-section {
|
||||
display: none;
|
||||
}
|
||||
.avatar-grid img,
|
||||
.avatar {
|
||||
width: 100%;
|
||||
@@ -523,7 +534,7 @@
|
||||
<div class="overview">{{OVERVIEW}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||
<div class="avatar-grid {{HERO_AVATAR_CLASS}}">{{HERO_AVATARS}}</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||
@@ -583,7 +594,7 @@
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
数据来源:WechatExplorer · 微信群聊记录<br />
|
||||
数据来源:TraceMemo · 微信群聊记录<br />
|
||||
生成时间:{{GENERATED_AT}}<br />
|
||||
{{FOOTER_NOTE}}
|
||||
</footer>
|
||||
|
||||
@@ -69,6 +69,17 @@
|
||||
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;
|
||||
}
|
||||
.avatar-grid.empty-section {
|
||||
display: none;
|
||||
}
|
||||
.avatar-grid img,
|
||||
.avatar {
|
||||
width: 100%;
|
||||
@@ -695,7 +706,7 @@
|
||||
<h1>{{GROUP_NAME}}日报</h1>
|
||||
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||
</div>
|
||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||
<div class="avatar-grid {{HERO_AVATAR_CLASS}}">{{HERO_AVATARS}}</div>
|
||||
</div>
|
||||
<div class="hero-headline">
|
||||
<b>{{HERO_HEADLINE}}</b>
|
||||
@@ -820,7 +831,7 @@
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
数据来源:WechatExplorer · 微信群聊记录<br />
|
||||
数据来源:TraceMemo · 微信群聊记录<br />
|
||||
生成时间:{{GENERATED_AT}}<br />
|
||||
{{FOOTER_NOTE}}
|
||||
</footer>
|
||||
|
||||
@@ -1,15 +1,153 @@
|
||||
const { existsSync, renameSync } = require('node:fs')
|
||||
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||
const { chmodSync, existsSync, renameSync } = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
const asar = require('@electron/asar')
|
||||
|
||||
const COMPATIBILITY_NAME = 'Electron'
|
||||
const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)']
|
||||
const REQUIRED_RUNTIME_PACKAGES = [
|
||||
'@electron-toolkit/preload',
|
||||
'@electron-toolkit/utils',
|
||||
'archiver',
|
||||
'electron-updater',
|
||||
'ffmpeg-static',
|
||||
'fs-extra',
|
||||
'jsonrepair',
|
||||
'koffi'
|
||||
]
|
||||
|
||||
function getRuntimeResources(context) {
|
||||
const productName = context.packager.appInfo.productFilename
|
||||
return context.electronPlatformName === 'darwin'
|
||||
? path.join(context.appOutDir, `${productName}.app`, 'Contents', 'Resources')
|
||||
: path.join(context.appOutDir, 'resources')
|
||||
}
|
||||
|
||||
function validateSilkWasmRuntime(runtimeResources) {
|
||||
const packagePath = path.join(runtimeResources, 'app.asar.unpacked', 'node_modules', 'silk-wasm')
|
||||
const requiredFiles = [
|
||||
path.join(packagePath, 'package.json'),
|
||||
path.join(packagePath, 'lib', 'index.cjs'),
|
||||
path.join(packagePath, 'lib', 'silk.wasm')
|
||||
]
|
||||
const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath))
|
||||
if (missingFiles.length > 0) {
|
||||
throw new Error(`Missing unpacked silk-wasm runtime: ${missingFiles.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateFfmpegRuntime(runtimeResources, platform = process.platform) {
|
||||
const executable = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const ffmpegPath = path.join(
|
||||
runtimeResources,
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
'ffmpeg-static',
|
||||
executable
|
||||
)
|
||||
if (!existsSync(ffmpegPath)) {
|
||||
throw new Error(`Missing unpacked ffmpeg-static runtime: ${ffmpegPath}`)
|
||||
}
|
||||
if (platform !== 'win32') chmodSync(ffmpegPath, 0o755)
|
||||
return ffmpegPath
|
||||
}
|
||||
|
||||
function validateSherpaRuntime(runtimeResources, platform, arch) {
|
||||
const platformName = platform === 'win32' ? 'win' : platform
|
||||
const basePath = path.join(
|
||||
runtimeResources,
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
'sherpa-onnx-node'
|
||||
)
|
||||
const nativePath = path.join(
|
||||
runtimeResources,
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
`sherpa-onnx-${platformName}-${arch}`
|
||||
)
|
||||
const requiredFiles = [
|
||||
path.join(basePath, 'package.json'),
|
||||
path.join(basePath, 'sherpa-onnx.js'),
|
||||
path.join(nativePath, 'package.json'),
|
||||
path.join(nativePath, 'sherpa-onnx.node')
|
||||
]
|
||||
const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath))
|
||||
if (missingFiles.length > 0) {
|
||||
throw new Error(`Missing unpacked sherpa-onnx 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 validateAsarRuntimeDependencies(runtimeResources) {
|
||||
const asarPath = path.join(runtimeResources, 'app.asar')
|
||||
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
|
||||
|
||||
// @electron/asar returns platform-native separators. Normalize to POSIX
|
||||
// paths so validation behaves consistently on Windows and macOS/Linux.
|
||||
const entries = new Set(asar.listPackage(asarPath).map((entry) => entry.replaceAll('\\', '/')))
|
||||
const missingPackages = REQUIRED_RUNTIME_PACKAGES.filter(
|
||||
(packageName) => !entries.has(`/node_modules/${packageName}/package.json`)
|
||||
)
|
||||
if (missingPackages.length > 0) {
|
||||
throw new Error(
|
||||
`Missing packaged runtime dependencies: ${missingPackages.join(', ')}. ` +
|
||||
'Use pnpm 7.33.7 so electron-builder can read pnpm-lock.yaml.'
|
||||
)
|
||||
}
|
||||
}
|
||||
function setPlistValue(plistPath, key, value) {
|
||||
execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plistPath])
|
||||
}
|
||||
|
||||
function validateReaderSkillRuntime(runtimeResources) {
|
||||
const skillPath = path.join(runtimeResources, 'skill', 'tracememo-reader', 'SKILL.md')
|
||||
if (!existsSync(skillPath)) {
|
||||
throw new Error(`Missing bundled TraceMemo Reader Skill: ${skillPath}`)
|
||||
}
|
||||
return skillPath
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
const runtimeResources = getRuntimeResources(context)
|
||||
validateAsarRuntimeDependencies(runtimeResources)
|
||||
validateReaderSkillRuntime(runtimeResources)
|
||||
validateSilkWasmRuntime(runtimeResources)
|
||||
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
|
||||
validateSherpaRuntime(
|
||||
runtimeResources,
|
||||
context.electronPlatformName,
|
||||
normalizeBuilderArch(context.arch)
|
||||
)
|
||||
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
}
|
||||
|
||||
if (context.electronPlatformName === 'win32') {
|
||||
const koffiNative = path.join(
|
||||
context.appOutDir,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
'@koromix',
|
||||
'koffi-win32-x64',
|
||||
'win32_x64',
|
||||
'koffi.node'
|
||||
)
|
||||
if (!existsSync(koffiNative)) {
|
||||
throw new Error(`Missing Windows Koffi native module: ${koffiNative}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (context.electronPlatformName !== 'darwin') return
|
||||
|
||||
const productName = context.packager.appInfo.productFilename
|
||||
@@ -52,3 +190,10 @@ exports.default = async function afterPack(context) {
|
||||
setPlistValue(plistPath, 'CFBundleName', targetName)
|
||||
}
|
||||
}
|
||||
|
||||
exports.getRuntimeResources = getRuntimeResources
|
||||
exports.validateAsarRuntimeDependencies = validateAsarRuntimeDependencies
|
||||
exports.validateReaderSkillRuntime = validateReaderSkillRuntime
|
||||
exports.validateFfmpegRuntime = validateFfmpegRuntime
|
||||
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
|
||||
exports.validateSherpaRuntime = validateSherpaRuntime
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* 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,116 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# TraceMemo v2.2.0 Local HTTP API 手动验收脚本
|
||||
# 仅用于 macOS Terminal;不会写入或输出真实 API Token。
|
||||
|
||||
set -u
|
||||
|
||||
API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:6131}"
|
||||
API_BASE_URL="${API_BASE_URL%/}"
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tracememo-api-test.XXXXXX")"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
SKIP_COUNT=0
|
||||
|
||||
pass() { PASS_COUNT=$((PASS_COUNT + 1)); printf 'PASS %s\n' "$1"; }
|
||||
fail() { FAIL_COUNT=$((FAIL_COUNT + 1)); printf 'FAIL %s%s\n' "$1" "${2:+ ($2)}"; }
|
||||
skip() { SKIP_COUNT=$((SKIP_COUNT + 1)); printf 'SKIP %s\n' "$1"; }
|
||||
|
||||
printf 'TraceMemo Local HTTP API 手动测试\n'
|
||||
printf 'API 地址: %s\n\n' "$API_BASE_URL"
|
||||
read -r -s -p '请输入 API Token(不会显示): ' API_TOKEN
|
||||
printf '\n'
|
||||
if [[ -z "$API_TOKEN" ]]; then
|
||||
printf 'Token 不能为空。\n'
|
||||
exit 2
|
||||
fi
|
||||
|
||||
request() {
|
||||
local method="$1" path="$2" auth="$3" origin="$4" body="${5:-}"
|
||||
local out="$TMP_DIR/body" headers="$TMP_DIR/headers" err="$TMP_DIR/error"
|
||||
local -a args=(--silent --show-error --max-time 10 -X "$method" -D "$headers" -o "$out" -w '%{http_code}')
|
||||
[[ "$auth" == 1 ]] && args+=(-H "Authorization: Bearer $API_TOKEN")
|
||||
[[ "$auth" == invalid ]] && args+=(-H 'Authorization: Bearer invalid')
|
||||
[[ "$auth" == malformed ]] && args+=(-H 'Authorization: abc')
|
||||
[[ "$auth" == bearer-only ]] && args+=(-H 'Authorization: Bearer')
|
||||
[[ -n "$origin" ]] && args+=(-H "Origin: $origin")
|
||||
if [[ -n "$body" ]]; then args+=(-H 'Content-Type: application/json' --data "$body"); fi
|
||||
: >"$out"
|
||||
: >"$headers"
|
||||
: >"$err"
|
||||
local status
|
||||
status="$(curl "${args[@]}" "$API_BASE_URL$path" 2>"$err")"
|
||||
CURL_STATUS="$status"
|
||||
CURL_BODY="$(<"$out")"
|
||||
CURL_HEADERS="$(<"$headers")"
|
||||
}
|
||||
|
||||
expect_status() {
|
||||
local name="$1" expected="$2" actual="$3"
|
||||
if [[ "$actual" == "$expected" ]]; then pass "$name ($actual)"; else fail "$name" "期望 ${expected},实际 ${actual:-000}"; fi
|
||||
}
|
||||
|
||||
printf '%s\n' '--- 基础鉴权 ---'
|
||||
request GET /api/v1/health 0 ''
|
||||
expect_status 'health 无 Token' 200 "$CURL_STATUS"
|
||||
|
||||
request GET /api/v1/current_time 0 ''
|
||||
expect_status '受保护 endpoint 无 Token' 401 "$CURL_STATUS"
|
||||
|
||||
request GET /api/v1/current_time invalid ''
|
||||
expect_status '错误 Token' 401 "$CURL_STATUS"
|
||||
|
||||
request GET /api/v1/current_time 1 ''
|
||||
expect_status '正确 Token' 200 "$CURL_STATUS"
|
||||
|
||||
request GET /api/v1/current_time malformed ''
|
||||
expect_status 'Authorization: abc' 401 "$CURL_STATUS"
|
||||
|
||||
request GET /api/v1/current_time bearer-only ''
|
||||
expect_status 'Authorization: Bearer' 401 "$CURL_STATUS"
|
||||
|
||||
printf '%s\n' '--- CORS ---'
|
||||
request OPTIONS /api/v1/health 0 http://localhost
|
||||
expect_status 'OPTIONS / CORS localhost' 204 "$CURL_STATUS"
|
||||
if [[ "$CURL_HEADERS" == *'Access-Control-Allow-Origin: http://localhost'* && "$CURL_HEADERS" == *'Access-Control-Allow-Headers: Content-Type, Authorization'* ]]; then
|
||||
pass 'localhost Origin 响应头'
|
||||
else
|
||||
fail 'localhost Origin 响应头'
|
||||
fi
|
||||
|
||||
request OPTIONS /api/v1/health 0 http://evil.example.com
|
||||
expect_status 'evil Origin 被拒绝' 403 "$CURL_STATUS"
|
||||
|
||||
request GET /api/v1/health 0 ''
|
||||
if [[ "$CURL_STATUS" == 200 ]]; then pass '无 Origin 的 curl 请求'; else fail '无 Origin 的 curl 请求' "实际 ${CURL_STATUS:-000}"; fi
|
||||
|
||||
printf '%s\n' '--- API stop 后连接测试 ---'
|
||||
RUN_STOP_CHECK="${RUN_STOP_CHECK:-0}"
|
||||
if [[ -t 0 && "$RUN_STOP_CHECK" != 1 ]]; then
|
||||
read -r -p '现在请在 API Center 停止 API;完成后输入 y 验证连接失败,其他键跳过: ' STOP_CONFIRM
|
||||
[[ "$STOP_CONFIRM" == y || "$STOP_CONFIRM" == Y ]] && RUN_STOP_CHECK=1
|
||||
fi
|
||||
if [[ "$RUN_STOP_CHECK" == 1 ]]; then
|
||||
request GET /api/v1/health 0 ''
|
||||
if [[ "$CURL_STATUS" == 000 ]]; then
|
||||
pass 'API 已停止后连接失败'
|
||||
else
|
||||
fail 'API stop 后连接失败' "仍收到 HTTP ${CURL_STATUS:-000}"
|
||||
fi
|
||||
else
|
||||
skip '未执行 stop 验证;也可在停止 API 后使用 RUN_STOP_CHECK=1 重新运行'
|
||||
fi
|
||||
|
||||
printf '\n%s\n' '--- 人工验证项目(脚本不会自动操作) ---'
|
||||
printf '%s\n' '1. API Center 默认隐藏 Token,点击“显示 Token”后可见,再点击隐藏。'
|
||||
printf '%s\n' '2. 点击“复制 Token”,粘贴到安全位置确认复制成功;终端不要回显 Token。'
|
||||
printf '%s\n' '3. 点击“重新生成 Token”并确认二次确认提示。'
|
||||
printf '%s\n' '4. rotation 后,用旧 Token 请求 /api/v1/current_time 应立即返回 401。'
|
||||
printf '%s\n' '5. 重启 App 后 Token 应保持不变。'
|
||||
printf '%s\n' '6. 将 apiEnabled=false 后,API 应不再监听(可重新运行本脚本的 stop 测试)。'
|
||||
|
||||
printf '\n结果:PASS=%d FAIL=%d SKIP=%d\n' "$PASS_COUNT" "$FAIL_COUNT" "$SKIP_COUNT"
|
||||
if (( FAIL_COUNT > 0 )); then exit 1; fi
|
||||
exit 0
|
||||
@@ -1,12 +1,8 @@
|
||||
const fs = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
const runtimeNames = [
|
||||
'msvcp140.dll',
|
||||
'msvcp140_1.dll',
|
||||
'vcruntime140.dll',
|
||||
'vcruntime140_1.dll'
|
||||
]
|
||||
const runtimeNames = ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']
|
||||
|
||||
function copyIfDifferent(sourcePath, targetPath) {
|
||||
const source = fs.statSync(sourcePath)
|
||||
@@ -23,7 +19,49 @@ function copyIfDifferent(sourcePath, targetPath) {
|
||||
return true
|
||||
}
|
||||
|
||||
function readOption(name, fallback) {
|
||||
const index = process.argv.indexOf(`--${name}`)
|
||||
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback
|
||||
}
|
||||
|
||||
function prepareFfmpegRuntime(targetPlatform = process.platform, targetArch = process.arch) {
|
||||
let packageRoot = ''
|
||||
try {
|
||||
packageRoot = path.dirname(require.resolve('ffmpeg-static/package.json'))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const executable = targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const ffmpegPath = path.join(packageRoot, executable)
|
||||
|
||||
if (!fs.existsSync(ffmpegPath)) {
|
||||
const installScript = path.join(packageRoot, 'install.js')
|
||||
console.log(
|
||||
`[prepare-electron-runtime] downloading ffmpeg-static for ${targetPlatform}-${targetArch}`
|
||||
)
|
||||
execFileSync(process.execPath, [installScript], {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_platform: targetPlatform,
|
||||
npm_config_arch: targetArch
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!fs.existsSync(ffmpegPath)) {
|
||||
throw new Error(`ffmpeg-static runtime download failed: ${ffmpegPath}`)
|
||||
}
|
||||
if (targetPlatform === 'win32') return
|
||||
|
||||
fs.chmodSync(ffmpegPath, 0o755)
|
||||
if (process.platform === 'darwin') {
|
||||
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], { stdio: 'ignore' })
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
prepareFfmpegRuntime(readOption('platform', process.platform), readOption('arch', process.arch))
|
||||
if (process.platform !== 'win32') return
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'url'
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const templatePath = path.join(root, 'resources', 'mobile_daily_report.html')
|
||||
const outputDir = path.join(os.tmpdir(), 'wechatexplorer-report-fixtures')
|
||||
const outputDir = path.join(os.tmpdir(), 'tracememo-report-fixtures')
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value ?? '')
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
const { fork } = require('node:child_process')
|
||||
const { existsSync, mkdtempSync } = require('node:fs')
|
||||
const { rm } = require('node:fs/promises')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join } = require('node:path')
|
||||
const { randomUUID, createHash } = require('node:crypto')
|
||||
|
||||
const workerPath = join(__dirname, '..', 'out', 'main', 'knowledgeWorker.js')
|
||||
if (!existsSync(workerPath)) throw new Error(`Knowledge worker build is missing: ${workerPath}`)
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'wxe-knowledge-worker-'))
|
||||
const child = fork(workerPath, [], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||
serialization: 'advanced',
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
const pending = new Map()
|
||||
|
||||
function request(type, payload) {
|
||||
const requestId = randomUUID()
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(requestId, { resolve, reject })
|
||||
child.send({ version: 1, type, requestId, payload }, (error) => {
|
||||
if (error) reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
child.on('message', (message) => {
|
||||
if (!message || message.type === 'progress') return
|
||||
const current = pending.get(message.requestId)
|
||||
if (!current) return
|
||||
pending.delete(message.requestId)
|
||||
if (message.type === 'error') current.reject(new Error(message.error))
|
||||
else current.resolve(message.payload)
|
||||
})
|
||||
|
||||
function fts(profileId) {
|
||||
return {
|
||||
profileId,
|
||||
tokenizer: 'trigram',
|
||||
contentMode: 'external',
|
||||
detail: 'full',
|
||||
columnsize: 1
|
||||
}
|
||||
}
|
||||
|
||||
function conversation(accountId, id) {
|
||||
return {
|
||||
conversationId: `conversation-${id}`,
|
||||
completeSnapshot: true,
|
||||
messages: [
|
||||
{
|
||||
accountId,
|
||||
conversationId: `conversation-${id}`,
|
||||
messageId: `message-${id}`,
|
||||
createTime: 1,
|
||||
senderId: 'fixture-member',
|
||||
senderName: '脱敏成员',
|
||||
kind: 'text',
|
||||
text: `脱敏索引内容 ${id}`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function accountPath(accountId) {
|
||||
const key = createHash('sha256')
|
||||
.update(`knowledge-account-v1:${accountId}`)
|
||||
.digest('hex')
|
||||
.slice(0, 32)
|
||||
return join(root, key, 'knowledge.sqlite')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const chunker = {
|
||||
version: 'conversation-v1',
|
||||
maxGapMs: 600000,
|
||||
maxMessages: 12,
|
||||
maxCharacters: 1200,
|
||||
overlapMessages: 3
|
||||
}
|
||||
const accountA = 'worker-fixture-a'
|
||||
const accountB = 'worker-fixture-b'
|
||||
const first = await request('index', {
|
||||
accountId: accountA,
|
||||
databaseRoot: root,
|
||||
conversations: [conversation(accountA, 'a')],
|
||||
chunker,
|
||||
fts: fts('worker-a')
|
||||
})
|
||||
await request('index', {
|
||||
accountId: accountB,
|
||||
databaseRoot: root,
|
||||
conversations: [conversation(accountB, 'b')],
|
||||
chunker,
|
||||
fts: fts('worker-b')
|
||||
})
|
||||
if (
|
||||
!first ||
|
||||
first.cancelled ||
|
||||
!existsSync(accountPath(accountA)) ||
|
||||
!existsSync(accountPath(accountB))
|
||||
) {
|
||||
throw new Error('Knowledge worker did not create isolated derived databases')
|
||||
}
|
||||
const search = await request('search', {
|
||||
accountId: accountA,
|
||||
databaseRoot: root,
|
||||
fts: fts('worker-a'),
|
||||
text: '查询脱敏索引内容 a',
|
||||
terms: ['脱敏索引内容', 'a'],
|
||||
limit: 10
|
||||
})
|
||||
const evidence = search?.evidence?.[0]
|
||||
if (
|
||||
search?.state !== 'ready' ||
|
||||
!evidence ||
|
||||
evidence.messageId !== 'message-a' ||
|
||||
evidence.conversationId !== 'conversation-a' ||
|
||||
evidence.sender !== '脱敏成员' ||
|
||||
typeof evidence.timestamp !== 'number'
|
||||
) {
|
||||
throw new Error('Knowledge worker search did not return message-level evidence')
|
||||
}
|
||||
await request('remove', { accountId: accountA, databaseRoot: root })
|
||||
if (existsSync(accountPath(accountA)) || !existsSync(accountPath(accountB))) {
|
||||
throw new Error('Knowledge worker removal crossed an account boundary')
|
||||
}
|
||||
const unavailable = await request('search', {
|
||||
accountId: accountA,
|
||||
databaseRoot: root,
|
||||
fts: fts('worker-a'),
|
||||
text: '查询脱敏索引内容 a',
|
||||
terms: ['脱敏索引内容'],
|
||||
limit: 10
|
||||
})
|
||||
if (unavailable?.state !== 'unavailable' || unavailable.evidence?.length) {
|
||||
throw new Error('Knowledge worker did not report unavailable index after removal')
|
||||
}
|
||||
await request('close', {})
|
||||
console.log('Knowledge worker integration check passed')
|
||||
} finally {
|
||||
child.kill()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -15,30 +15,55 @@ const filePath = path.join(
|
||||
'buildSkillInstallInstruction.ts'
|
||||
)
|
||||
const source = fs.readFileSync(filePath, 'utf8')
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const output = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
const moduleExports = {}
|
||||
new Function('exports', 'require', 'module', output)(moduleExports, require, { exports: moduleExports })
|
||||
new Function('exports', 'require', 'module', output)(moduleExports, require, {
|
||||
exports: moduleExports
|
||||
})
|
||||
|
||||
const { buildSkillInstallInstruction } = moduleExports
|
||||
const local = { type: 'local', directoryPath: 'C:/skill/wechatexplorer-reader', skillPath: 'C:/skill/wechatexplorer-reader/SKILL.md', version: 'v1.0' }
|
||||
const local = {
|
||||
type: 'local',
|
||||
directoryPath: 'C:/skill/tracememo-reader',
|
||||
skillPath: 'C:/skill/tracememo-reader/SKILL.md',
|
||||
version: 'v1.0'
|
||||
}
|
||||
|
||||
for (const [target, expected] of [
|
||||
['codex', 'Codex 项目或用户 Skill 目录'],
|
||||
['claude-code', '按照 SKILL\.md 调用本地 HTTP API'],
|
||||
['openclaw', '作为 WechatExplorer Reader Skill 安装'],
|
||||
['openclaw', '作为 TraceMemo Reader Skill 安装'],
|
||||
['generic', '读取并安装']
|
||||
]) {
|
||||
const text = buildSkillInstallInstruction({ target, source: local, apiBaseUrl: { host: '127.0.0.1', port: 6131 } })
|
||||
const text = buildSkillInstallInstruction({
|
||||
target,
|
||||
source: local,
|
||||
apiBaseUrl: { host: '127.0.0.1', port: 6131 }
|
||||
})
|
||||
assert.match(text, new RegExp(expected))
|
||||
assert.match(text, /http:\/\/127\.0\.0\.1:6131\/api\/v1\/health/)
|
||||
assert.match(text, /TRACEMEMO_API_TOKEN/)
|
||||
assert.match(text, /WECHATEXPLORER_API_TOKEN/)
|
||||
assert.match(text, /Authorization: Bearer/)
|
||||
assert.doesNotMatch(text, /mcpServers/)
|
||||
}
|
||||
|
||||
assert.match(
|
||||
buildSkillInstallInstruction({ target: 'codex', source: local, apiBaseUrl: { host: '0.0.0.0', port: 7000 } }),
|
||||
buildSkillInstallInstruction({
|
||||
target: 'codex',
|
||||
source: local,
|
||||
apiBaseUrl: { host: '0.0.0.0', port: 7000 }
|
||||
}),
|
||||
/http:\/\/127\.0\.0\.1:7000\/api\/v1\/health/
|
||||
)
|
||||
assert.match(
|
||||
buildSkillInstallInstruction({ target: 'generic', source: { type: 'remote', installUrl: 'https://example.com/skill', version: 'v1.0' }, apiBaseUrl: { host: 'localhost', port: 6131 } }),
|
||||
buildSkillInstallInstruction({
|
||||
target: 'generic',
|
||||
source: { type: 'remote', installUrl: 'https://example.com/skill', version: 'v1.0' },
|
||||
apiBaseUrl: { host: 'localhost', port: 6131 }
|
||||
}),
|
||||
/https:\/\/example\.com\/skill/
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,135 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
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=
|
||||
@@ -0,0 +1,236 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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] + "..."
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import crypto from 'crypto'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type {
|
||||
ApiTokenActionResult,
|
||||
ApiTokenRevealResult,
|
||||
ApiTokenStatus
|
||||
} from '../shared/local-api-auth'
|
||||
|
||||
const MASKED_TOKEN = '••••••••••••••••'
|
||||
const TOKEN_BYTES = 32
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
|
||||
|
||||
interface TokenReadResult {
|
||||
success: boolean
|
||||
token?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class ApiTokenStore {
|
||||
private cachedToken: string | null = null
|
||||
private automaticGenerationBlockedReason: string | null = null
|
||||
|
||||
constructor(private readonly filePathOverride?: string) {}
|
||||
|
||||
private get filePath(): string {
|
||||
return this.filePathOverride || path.join(app.getPath('userData'), 'local-api-token.bin')
|
||||
}
|
||||
|
||||
getStatus(): ApiTokenStatus {
|
||||
const available = safeStorage.isEncryptionAvailable()
|
||||
if (!available) {
|
||||
return {
|
||||
available: false,
|
||||
hasToken: false,
|
||||
maskedToken: MASKED_TOKEN,
|
||||
error: '系统安全存储不可用,本地 API 已安全停用。请检查系统钥匙串或凭据服务后重试。'
|
||||
}
|
||||
}
|
||||
const result = this.read()
|
||||
return {
|
||||
available: true,
|
||||
hasToken: result.success && Boolean(result.token),
|
||||
maskedToken: MASKED_TOKEN,
|
||||
...(result.success
|
||||
? result.token || !this.automaticGenerationBlockedReason
|
||||
? {}
|
||||
: { error: this.automaticGenerationBlockedReason }
|
||||
: { error: result.error })
|
||||
}
|
||||
}
|
||||
|
||||
setAutomaticGenerationBlocked(reason?: string): void {
|
||||
this.automaticGenerationBlockedReason = reason?.trim() || null
|
||||
}
|
||||
|
||||
ensureToken(): ApiTokenActionResult {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return {
|
||||
success: false,
|
||||
available: false,
|
||||
hasToken: false,
|
||||
maskedToken: MASKED_TOKEN,
|
||||
error: '系统安全存储不可用,本地 API 已安全停用。请检查系统钥匙串或凭据服务后重试。'
|
||||
}
|
||||
}
|
||||
const current = this.read()
|
||||
if (!current.success) return this.actionError(current.error)
|
||||
if (current.token) return this.actionSuccess()
|
||||
if (this.automaticGenerationBlockedReason) {
|
||||
return this.actionError(this.automaticGenerationBlockedReason)
|
||||
}
|
||||
return this.persist(this.generateToken())
|
||||
}
|
||||
|
||||
revealToken(): ApiTokenRevealResult {
|
||||
const ensured = this.ensureToken()
|
||||
if (!ensured.success) return ensured
|
||||
return { ...ensured, token: this.cachedToken || undefined }
|
||||
}
|
||||
|
||||
rotateToken(): ApiTokenActionResult {
|
||||
if (!safeStorage.isEncryptionAvailable()) return this.ensureToken()
|
||||
const result = this.persist(this.generateToken())
|
||||
if (result.success) this.automaticGenerationBlockedReason = null
|
||||
return result
|
||||
}
|
||||
|
||||
getTokenForAuthentication(): string | null {
|
||||
if (this.cachedToken) return this.cachedToken
|
||||
const result = this.read()
|
||||
return result.success ? result.token || null : null
|
||||
}
|
||||
|
||||
private generateToken(): string {
|
||||
return crypto.randomBytes(TOKEN_BYTES).toString('base64url')
|
||||
}
|
||||
|
||||
private read(): TokenReadResult {
|
||||
if (this.cachedToken) return { success: true, token: this.cachedToken }
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
if (!fs.existsSync(this.filePath)) return { success: true }
|
||||
try {
|
||||
const token = safeStorage.decryptString(fs.readFileSync(this.filePath))
|
||||
if (!TOKEN_PATTERN.test(token)) throw new Error('invalid token data')
|
||||
this.cachedToken = token
|
||||
return { success: true, token }
|
||||
} catch {
|
||||
return { success: false, error: '已保存的 API Token 无法从系统安全存储读取' }
|
||||
}
|
||||
}
|
||||
|
||||
private persist(token: string): ApiTokenActionResult {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.filePath))
|
||||
fs.writeFileSync(this.filePath, safeStorage.encryptString(token), { mode: 0o600 })
|
||||
fs.chmodSync(this.filePath, 0o600)
|
||||
this.cachedToken = token
|
||||
return this.actionSuccess()
|
||||
} catch {
|
||||
return this.actionError('API Token 无法保存到系统安全存储')
|
||||
}
|
||||
}
|
||||
|
||||
private actionSuccess(): ApiTokenActionResult {
|
||||
return {
|
||||
success: true,
|
||||
available: true,
|
||||
hasToken: true,
|
||||
maskedToken: MASKED_TOKEN
|
||||
}
|
||||
}
|
||||
|
||||
private actionError(error?: string): ApiTokenActionResult {
|
||||
return {
|
||||
success: false,
|
||||
available: safeStorage.isEncryptionAvailable(),
|
||||
hasToken: false,
|
||||
maskedToken: MASKED_TOKEN,
|
||||
error: error || 'API Token 安全存储不可用'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const apiTokenStore = new ApiTokenStore()
|
||||
@@ -0,0 +1,56 @@
|
||||
import { app } from 'electron'
|
||||
import path from 'path'
|
||||
import { getUserDataRoots, LEGACY_USER_DATA_NAME, TRACE_MEMO_RUNTIME_NAME } from './app-data-paths'
|
||||
|
||||
export const LEGACY_MIGRATION_HELPER_ENV = 'TRACEMEMO_LEGACY_MIGRATION_HELPER'
|
||||
export const LEGACY_MIGRATION_SOURCE_ENV = 'TRACEMEMO_LEGACY_MIGRATION_SOURCE'
|
||||
export const LEGACY_MIGRATION_USER_DATA_ENV = 'TRACEMEMO_LEGACY_MIGRATION_USER_DATA'
|
||||
export const LEGACY_MIGRATION_RESULT_FD_ENV = 'TRACEMEMO_LEGACY_MIGRATION_RESULT_FD'
|
||||
|
||||
// This module must remain the first main-process import. Static imports in
|
||||
// settings/cache services can otherwise resolve Electron paths before the
|
||||
// TraceMemo userData is installed before any consumers. macOS uses the old
|
||||
// safeStorage identity only inside the helper; Windows keeps in both
|
||||
// processes because WCDB requires that technical runtime name.
|
||||
const isLegacyMigrationHelper = process.env[LEGACY_MIGRATION_HELPER_ENV] === '1'
|
||||
const legacyRuntimeName = process.platform === 'win32' ? 'WeFlow' : LEGACY_USER_DATA_NAME
|
||||
const runtimeName =
|
||||
process.platform === 'win32'
|
||||
? 'WeFlow'
|
||||
: isLegacyMigrationHelper
|
||||
? legacyRuntimeName
|
||||
: TRACE_MEMO_RUNTIME_NAME
|
||||
app.setName(runtimeName)
|
||||
|
||||
const isolatedUserData = process.env['WXE_USER_DATA']
|
||||
const isUserDataIsolated = !isLegacyMigrationHelper && Boolean(isolatedUserData?.trim())
|
||||
const roots = getUserDataRoots(app.getPath('appData'))
|
||||
const helperUserData = process.env[LEGACY_MIGRATION_USER_DATA_ENV]
|
||||
const selectedUserData = isLegacyMigrationHelper
|
||||
? path.resolve(helperUserData?.trim() || path.join(roots.current, '.legacy-migration-helper'))
|
||||
: isolatedUserData?.trim()
|
||||
? path.resolve(isolatedUserData)
|
||||
: roots.current
|
||||
|
||||
app.setPath('userData', selectedUserData)
|
||||
app.setPath('sessionData', selectedUserData)
|
||||
|
||||
// Logs are intentionally independent from userData. New TraceMemo logs go to
|
||||
// the new visible directory while historical WechatExplorer logs remain in
|
||||
// place and are never moved or renamed.
|
||||
if (isLegacyMigrationHelper) {
|
||||
app.setPath('logs', path.join(selectedUserData, 'logs'))
|
||||
} else if (process.platform === 'darwin') {
|
||||
app.setPath('logs', path.join(app.getPath('home'), 'Library', 'Logs', 'TraceMemo'))
|
||||
} else if (process.platform === 'win32') {
|
||||
app.setPath('logs', path.join(selectedUserData, 'logs'))
|
||||
}
|
||||
|
||||
export {
|
||||
isLegacyMigrationHelper,
|
||||
isUserDataIsolated,
|
||||
legacyRuntimeName,
|
||||
runtimeName,
|
||||
roots,
|
||||
selectedUserData
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
import { app, BrowserWindow, dialog, safeStorage } from 'electron'
|
||||
import { spawn } from 'child_process'
|
||||
import { constants as fsConstants } from 'fs'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import {
|
||||
LEGACY_MIGRATION_HELPER_ENV,
|
||||
LEGACY_MIGRATION_RESULT_FD_ENV,
|
||||
LEGACY_MIGRATION_SOURCE_ENV,
|
||||
LEGACY_MIGRATION_USER_DATA_ENV
|
||||
} from './app-data-bootstrap'
|
||||
import {
|
||||
hasValidUserAssets,
|
||||
selectUserDataRoot,
|
||||
type UserDataRoots,
|
||||
type UserDataSelection
|
||||
} from './app-data-paths'
|
||||
import type { LegacySecretBundle } from './legacy-safe-storage-helper'
|
||||
import { SqliteTranscriptRepository } from './voice-pipeline/transcript-repository'
|
||||
|
||||
const MIGRATION_STATE_FILE = 'tracememo-migration-v1.json'
|
||||
const VOICE_TRANSCRIPT_CACHE_ITEM = 'cache/voice-transcripts.sqlite'
|
||||
const TOKEN_MIGRATION_BLOCK_MESSAGE =
|
||||
'检测到旧版 API Token 尚未完成迁移。请重试数据迁移,或在 API Center 主动重新生成 Token。'
|
||||
|
||||
const FILE_ASSETS = [
|
||||
'settings.json',
|
||||
'ai-providers.json',
|
||||
'image-insights.json',
|
||||
'group-exit-monitor.json'
|
||||
] as const
|
||||
|
||||
const DIRECTORY_ASSETS = [
|
||||
'knowledge',
|
||||
'reports',
|
||||
'recall-archive',
|
||||
'Local Storage',
|
||||
'digital-twin',
|
||||
'group-exit-monitor'
|
||||
] as const
|
||||
|
||||
export type MigrationItemStatus = 'migrated' | 'skipped' | 'missing' | 'failed'
|
||||
export type MigrationStatus = 'deferred' | 'in-progress' | 'partial' | 'completed'
|
||||
|
||||
export interface MigrationState {
|
||||
version: 1
|
||||
status: MigrationStatus
|
||||
sourceRoot: string
|
||||
updatedAt: string
|
||||
items: Record<string, MigrationItemStatus>
|
||||
secretFailures: string[]
|
||||
}
|
||||
|
||||
export interface MigrationAssessment {
|
||||
selection: UserDataSelection
|
||||
sourceRoot?: string
|
||||
currentHasAssets: boolean
|
||||
state?: MigrationState
|
||||
shouldPrompt: boolean
|
||||
reason:
|
||||
| 'clean-install'
|
||||
| 'legacy-empty'
|
||||
| 'current-data-present'
|
||||
| 'migration-completed'
|
||||
| 'legacy-assets-detected'
|
||||
| 'migration-resumable'
|
||||
}
|
||||
|
||||
export interface MigrationExecutionResult {
|
||||
state: MigrationState
|
||||
tokenGenerationBlocked: boolean
|
||||
tokenBlockReason?: string
|
||||
}
|
||||
|
||||
export interface MigrationFlowResult {
|
||||
assessment: MigrationAssessment
|
||||
action: 'none' | 'deferred' | 'migrated'
|
||||
execution?: MigrationExecutionResult
|
||||
tokenGenerationBlocked: boolean
|
||||
tokenBlockReason?: string
|
||||
}
|
||||
|
||||
export interface MigrationDependencies {
|
||||
decryptLegacySecrets: (sourceRoot: string) => Promise<LegacySecretBundle>
|
||||
agentRoots: () => { legacy: string; current: string }
|
||||
now: () => Date
|
||||
onProgress?: (message: string) => void
|
||||
}
|
||||
|
||||
function migrationStatePath(targetRoot: string): string {
|
||||
return path.join(targetRoot, MIGRATION_STATE_FILE)
|
||||
}
|
||||
|
||||
function readMigrationState(targetRoot: string): MigrationState | undefined {
|
||||
try {
|
||||
const state = fs.readJsonSync(migrationStatePath(targetRoot)) as MigrationState
|
||||
if (
|
||||
state.version !== 1 ||
|
||||
!['deferred', 'in-progress', 'partial', 'completed'].includes(state.status) ||
|
||||
typeof state.sourceRoot !== 'string'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return state
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function writeMigrationState(targetRoot: string, state: MigrationState): Promise<void> {
|
||||
await fs.ensureDir(targetRoot)
|
||||
const statePath = migrationStatePath(targetRoot)
|
||||
const tempPath = `${statePath}.tmp-${process.pid}-${randomUUID()}`
|
||||
try {
|
||||
await fs.writeJson(tempPath, state, { spaces: 2, mode: 0o600 })
|
||||
await fs.chmod(tempPath, 0o600)
|
||||
await fs.move(tempPath, statePath, { overwrite: true })
|
||||
} finally {
|
||||
await fs.remove(tempPath).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function isLegacySelection(selection: UserDataSelection): boolean {
|
||||
return selection.selectedKind === 'legacy-display' || selection.selectedKind === 'legacy-package'
|
||||
}
|
||||
|
||||
export function assessMigration(roots: UserDataRoots): MigrationAssessment {
|
||||
const selection = selectUserDataRoot(roots)
|
||||
const state = readMigrationState(roots.current)
|
||||
const selectedLegacyRoot = isLegacySelection(selection) ? selection.selected : undefined
|
||||
const stateSource = state?.sourceRoot
|
||||
const resumableStateSource =
|
||||
stateSource &&
|
||||
[roots.legacy, roots.legacyPackage].includes(stateSource) &&
|
||||
hasValidUserAssets(stateSource)
|
||||
? stateSource
|
||||
: undefined
|
||||
const sourceRoot = resumableStateSource || selectedLegacyRoot
|
||||
const currentHasAssets = hasValidUserAssets(roots.current)
|
||||
|
||||
if (state?.status === 'completed') {
|
||||
return {
|
||||
selection,
|
||||
sourceRoot,
|
||||
currentHasAssets,
|
||||
state,
|
||||
shouldPrompt: false,
|
||||
reason: 'migration-completed'
|
||||
}
|
||||
}
|
||||
if (sourceRoot && state && ['deferred', 'in-progress', 'partial'].includes(state.status)) {
|
||||
return {
|
||||
selection,
|
||||
sourceRoot,
|
||||
currentHasAssets,
|
||||
state,
|
||||
shouldPrompt: true,
|
||||
reason: 'migration-resumable'
|
||||
}
|
||||
}
|
||||
if (!sourceRoot) {
|
||||
return {
|
||||
selection,
|
||||
currentHasAssets,
|
||||
state,
|
||||
shouldPrompt: false,
|
||||
reason:
|
||||
selection.directories.legacy || selection.directories.legacyPackage
|
||||
? 'legacy-empty'
|
||||
: 'clean-install'
|
||||
}
|
||||
}
|
||||
if (currentHasAssets) {
|
||||
return {
|
||||
selection,
|
||||
sourceRoot,
|
||||
currentHasAssets,
|
||||
state,
|
||||
shouldPrompt: false,
|
||||
reason: 'current-data-present'
|
||||
}
|
||||
}
|
||||
return {
|
||||
selection,
|
||||
sourceRoot,
|
||||
currentHasAssets,
|
||||
state,
|
||||
shouldPrompt: true,
|
||||
reason: 'legacy-assets-detected'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyFileWithoutOverwrite(
|
||||
sourceRoot: string,
|
||||
targetRoot: string,
|
||||
relativePath: string,
|
||||
stagingRoot: string
|
||||
): Promise<MigrationItemStatus> {
|
||||
const sourcePath = path.join(sourceRoot, relativePath)
|
||||
const targetPath = path.join(targetRoot, relativePath)
|
||||
if (!(await fs.pathExists(sourcePath))) return 'missing'
|
||||
if (await fs.pathExists(targetPath)) return 'skipped'
|
||||
const stagedPath = path.join(stagingRoot, relativePath)
|
||||
await fs.ensureDir(path.dirname(stagedPath))
|
||||
await fs.copy(sourcePath, stagedPath, {
|
||||
overwrite: false,
|
||||
errorOnExist: true,
|
||||
preserveTimestamps: true
|
||||
})
|
||||
if (await fs.pathExists(targetPath)) return 'skipped'
|
||||
await fs.ensureDir(path.dirname(targetPath))
|
||||
await fs.move(stagedPath, targetPath, { overwrite: false })
|
||||
return 'migrated'
|
||||
}
|
||||
|
||||
async function integrityCheckKnowledgeDatabase(databasePath: string): Promise<void> {
|
||||
const script = `
|
||||
const { DatabaseSync } = require('node:sqlite')
|
||||
const database = new DatabaseSync(process.argv[1], { readOnly: true })
|
||||
try {
|
||||
const rows = database.prepare('PRAGMA integrity_check').all()
|
||||
const result = String(rows[0]?.integrity_check || rows[0]?.['integrity_check(1)'] || '').toLowerCase()
|
||||
if (rows.length !== 1 || result !== 'ok') process.exitCode = 2
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
`
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['-e', script, databasePath], {
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
if (stderr.length < 4096) stderr += chunk.toString('utf8')
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code) => {
|
||||
if (code === 0) resolve()
|
||||
else reject(new Error(`Knowledge SQLite integrity_check failed (${code}): ${stderr.trim()}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function verifyKnowledgeCopy(sourcePath: string, stagedPath: string): Promise<void> {
|
||||
const accounts = await fs.readdir(sourcePath, { withFileTypes: true })
|
||||
for (const account of accounts) {
|
||||
if (!account.isDirectory()) continue
|
||||
const sourceDatabase = path.join(sourcePath, account.name, 'knowledge.sqlite')
|
||||
if (!(await fs.pathExists(sourceDatabase))) continue
|
||||
const stagedDatabase = path.join(stagedPath, account.name, 'knowledge.sqlite')
|
||||
if (!(await fs.pathExists(stagedDatabase)))
|
||||
throw new Error('Knowledge database copy is missing')
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
const sourceFile = `${sourceDatabase}${suffix}`
|
||||
if (!(await fs.pathExists(sourceFile))) continue
|
||||
const stagedFile = `${stagedDatabase}${suffix}`
|
||||
if (!(await fs.pathExists(stagedFile)))
|
||||
throw new Error(`Knowledge companion missing: ${suffix}`)
|
||||
const [sourceStat, stagedStat] = await Promise.all([fs.stat(sourceFile), fs.stat(stagedFile)])
|
||||
if (sourceStat.size !== stagedStat.size)
|
||||
throw new Error(`Knowledge copy size mismatch: ${suffix}`)
|
||||
}
|
||||
await integrityCheckKnowledgeDatabase(stagedDatabase)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDirectoryWithoutOverwrite(
|
||||
sourceRoot: string,
|
||||
targetRoot: string,
|
||||
relativePath: string,
|
||||
stagingRoot: string
|
||||
): Promise<MigrationItemStatus> {
|
||||
const sourcePath = path.join(sourceRoot, relativePath)
|
||||
const targetPath = path.join(targetRoot, relativePath)
|
||||
if (!(await fs.pathExists(sourcePath))) return 'missing'
|
||||
if (await fs.pathExists(targetPath)) return 'skipped'
|
||||
const stagedPath = path.join(stagingRoot, relativePath)
|
||||
await fs.ensureDir(path.dirname(stagedPath))
|
||||
await fs.copy(sourcePath, stagedPath, {
|
||||
overwrite: false,
|
||||
errorOnExist: true,
|
||||
preserveTimestamps: true
|
||||
})
|
||||
if (relativePath === 'knowledge') await verifyKnowledgeCopy(sourcePath, stagedPath)
|
||||
if (await fs.pathExists(targetPath)) return 'skipped'
|
||||
await fs.move(stagedPath, targetPath, { overwrite: false })
|
||||
return 'migrated'
|
||||
}
|
||||
|
||||
export async function migrateLegacyVoiceTranscripts(
|
||||
sourceRoot: string,
|
||||
targetRoot: string
|
||||
): Promise<MigrationItemStatus> {
|
||||
const relativePath = path.join('cache', 'voice-transcripts.sqlite')
|
||||
const sourcePath = path.join(sourceRoot, relativePath)
|
||||
const targetPath = path.join(targetRoot, relativePath)
|
||||
if (!(await fs.pathExists(sourcePath))) return 'missing'
|
||||
if (path.resolve(sourcePath) === path.resolve(targetPath)) return 'skipped'
|
||||
|
||||
const repository = new SqliteTranscriptRepository(targetPath)
|
||||
try {
|
||||
return repository.mergeFrom(sourcePath) > 0 ? 'migrated' : 'skipped'
|
||||
} finally {
|
||||
repository.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function writeEncryptedWithoutOverwrite(
|
||||
targetRoot: string,
|
||||
relativePath: string,
|
||||
plainText: string
|
||||
): Promise<MigrationItemStatus> {
|
||||
const targetPath = path.join(targetRoot, relativePath)
|
||||
if (await fs.pathExists(targetPath)) return 'skipped'
|
||||
if (!safeStorage.isEncryptionAvailable()) throw new Error('系统安全存储不可用')
|
||||
const tempPath = `${targetPath}.tmp-${process.pid}-${randomUUID()}`
|
||||
try {
|
||||
await fs.ensureDir(path.dirname(targetPath))
|
||||
await fs.writeFile(tempPath, safeStorage.encryptString(plainText), { mode: 0o600 })
|
||||
await fs.chmod(tempPath, 0o600)
|
||||
if (await fs.pathExists(targetPath)) return 'skipped'
|
||||
await fs.move(tempPath, targetPath, { overwrite: false })
|
||||
return 'migrated'
|
||||
} finally {
|
||||
await fs.remove(tempPath).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyMissingTree(
|
||||
sourceRoot: string,
|
||||
targetRoot: string
|
||||
): Promise<MigrationItemStatus> {
|
||||
if (!(await fs.pathExists(sourceRoot))) return 'missing'
|
||||
let copied = false
|
||||
const visit = async (sourceDirectory: string, targetDirectory: string): Promise<void> => {
|
||||
await fs.ensureDir(targetDirectory)
|
||||
for (const entry of await fs.readdir(sourceDirectory, { withFileTypes: true })) {
|
||||
const sourcePath = path.join(sourceDirectory, entry.name)
|
||||
const targetPath = path.join(targetDirectory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await visit(sourcePath, targetPath)
|
||||
} else if (entry.isFile() && !(await fs.pathExists(targetPath))) {
|
||||
await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_EXCL)
|
||||
const stat = await fs.stat(sourcePath)
|
||||
await fs.chmod(targetPath, stat.mode & 0o777)
|
||||
copied = true
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit(sourceRoot, targetRoot)
|
||||
return copied ? 'migrated' : 'skipped'
|
||||
}
|
||||
|
||||
export function getAgentCredentialRoots(homePath = os.homedir()): {
|
||||
legacy: string
|
||||
current: string
|
||||
} {
|
||||
return {
|
||||
legacy: path.join(homePath, '.wechatexplorer', 'wechat-connector', 'accounts'),
|
||||
current: path.join(homePath, '.tracememo', 'wechat-connector', 'accounts')
|
||||
}
|
||||
}
|
||||
|
||||
async function hasEncryptedLegacyAssets(sourceRoot: string): Promise<boolean> {
|
||||
for (const relativePath of [
|
||||
'local-api-token.bin',
|
||||
'ai-provider-keys.bin',
|
||||
'wechat-image-keys.bin',
|
||||
'wechat-db-key.bin'
|
||||
]) {
|
||||
if (await fs.pathExists(path.join(sourceRoot, relativePath))) return true
|
||||
}
|
||||
const databaseKeyRoot = path.join(sourceRoot, 'database-keys')
|
||||
if (!(await fs.pathExists(databaseKeyRoot))) return false
|
||||
return (await fs.readdir(databaseKeyRoot, { withFileTypes: true })).some(
|
||||
(entry) => entry.isFile() && entry.name.endsWith('.bin')
|
||||
)
|
||||
}
|
||||
|
||||
export async function runLegacySecretHelper(sourceRoot: string): Promise<LegacySecretBundle> {
|
||||
const helperUserData = await fs.mkdtemp(
|
||||
path.join(app.getPath('temp'), 'tracememo-legacy-safe-storage-')
|
||||
)
|
||||
const helperArgs = app.isPackaged ? [] : process.argv.slice(1)
|
||||
try {
|
||||
return await new Promise<LegacySecretBundle>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, helperArgs, {
|
||||
env: {
|
||||
...process.env,
|
||||
[LEGACY_MIGRATION_HELPER_ENV]: '1',
|
||||
[LEGACY_MIGRATION_SOURCE_ENV]: sourceRoot,
|
||||
[LEGACY_MIGRATION_USER_DATA_ENV]: helperUserData,
|
||||
[LEGACY_MIGRATION_RESULT_FD_ENV]: '3'
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
const chunks: Buffer[] = []
|
||||
let totalBytes = 0
|
||||
let settled = false
|
||||
const finish = (error?: Error, value?: LegacySecretBundle): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
if (error) reject(error)
|
||||
else resolve(value!)
|
||||
}
|
||||
const resultPipe = child.stdio[3]
|
||||
resultPipe?.on('data', (chunk: Buffer) => {
|
||||
totalBytes += chunk.length
|
||||
if (totalBytes > 5 * 1024 * 1024) {
|
||||
child.kill()
|
||||
finish(new Error('legacy secret helper result is too large'))
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
child.once('error', (error) => finish(error))
|
||||
child.once('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
finish(new Error(`legacy secret helper exited with code ${code}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
finish(
|
||||
undefined,
|
||||
JSON.parse(Buffer.concat(chunks).toString('utf8')) as LegacySecretBundle
|
||||
)
|
||||
} catch {
|
||||
finish(new Error('legacy secret helper returned invalid data'))
|
||||
}
|
||||
})
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill()
|
||||
finish(new Error('legacy secret helper timed out'))
|
||||
}, 30_000)
|
||||
})
|
||||
} finally {
|
||||
await fs.remove(helperUserData).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeMigration(
|
||||
sourceRoot: string,
|
||||
targetRoot: string,
|
||||
dependencies: MigrationDependencies = {
|
||||
decryptLegacySecrets: runLegacySecretHelper,
|
||||
agentRoots: () => getAgentCredentialRoots(),
|
||||
now: () => new Date()
|
||||
}
|
||||
): Promise<MigrationExecutionResult> {
|
||||
const items: Record<string, MigrationItemStatus> = {}
|
||||
const secretFailures: string[] = []
|
||||
const timestamp = (): string => dependencies.now().toISOString()
|
||||
const state: MigrationState = {
|
||||
version: 1,
|
||||
status: 'in-progress',
|
||||
sourceRoot,
|
||||
updatedAt: timestamp(),
|
||||
items,
|
||||
secretFailures
|
||||
}
|
||||
await writeMigrationState(targetRoot, state)
|
||||
const stagingRoot = path.join(targetRoot, '.tracememo-migration-staging-v1')
|
||||
await fs.remove(stagingRoot).catch(() => undefined)
|
||||
|
||||
const runItem = async (name: string, task: () => Promise<MigrationItemStatus>): Promise<void> => {
|
||||
dependencies.onProgress?.(
|
||||
name === 'knowledge' ? '正在安全迁移 Knowledge,数据较大时需要几分钟…' : `正在迁移 ${name}…`
|
||||
)
|
||||
try {
|
||||
items[name] = await task()
|
||||
} catch {
|
||||
items[name] = 'failed'
|
||||
}
|
||||
state.updatedAt = timestamp()
|
||||
await writeMigrationState(targetRoot, state)
|
||||
}
|
||||
|
||||
try {
|
||||
for (const relativePath of FILE_ASSETS) {
|
||||
await runItem(relativePath, () =>
|
||||
copyFileWithoutOverwrite(sourceRoot, targetRoot, relativePath, stagingRoot)
|
||||
)
|
||||
}
|
||||
for (const relativePath of DIRECTORY_ASSETS) {
|
||||
await runItem(relativePath, () =>
|
||||
copyDirectoryWithoutOverwrite(sourceRoot, targetRoot, relativePath, stagingRoot)
|
||||
)
|
||||
}
|
||||
await runItem(VOICE_TRANSCRIPT_CACHE_ITEM, () =>
|
||||
migrateLegacyVoiceTranscripts(sourceRoot, targetRoot)
|
||||
)
|
||||
|
||||
const agentRoots = dependencies.agentRoots()
|
||||
await runItem('agent-credentials', () => copyMissingTree(agentRoots.legacy, agentRoots.current))
|
||||
|
||||
let secrets: LegacySecretBundle = { databaseKeys: {}, failures: [] }
|
||||
if (await hasEncryptedLegacyAssets(sourceRoot)) {
|
||||
try {
|
||||
secrets = await dependencies.decryptLegacySecrets(sourceRoot)
|
||||
secretFailures.push(...secrets.failures.map((failure) => failure.asset))
|
||||
} catch {
|
||||
secretFailures.push('safeStorage-helper')
|
||||
}
|
||||
}
|
||||
|
||||
const migrateSecret = async (
|
||||
relativePath: string,
|
||||
plainText: string | undefined
|
||||
): Promise<void> => {
|
||||
const sourceExists = await fs.pathExists(path.join(sourceRoot, relativePath))
|
||||
if (!sourceExists) {
|
||||
items[relativePath] = 'missing'
|
||||
return
|
||||
}
|
||||
if (plainText === undefined) {
|
||||
items[relativePath] = 'failed'
|
||||
if (!secretFailures.includes(relativePath)) secretFailures.push(relativePath)
|
||||
return
|
||||
}
|
||||
await runItem(relativePath, () =>
|
||||
writeEncryptedWithoutOverwrite(targetRoot, relativePath, plainText)
|
||||
)
|
||||
}
|
||||
|
||||
await migrateSecret('local-api-token.bin', secrets.token)
|
||||
await migrateSecret(
|
||||
'ai-provider-keys.bin',
|
||||
secrets.aiProviderKeys ? JSON.stringify(secrets.aiProviderKeys) : undefined
|
||||
)
|
||||
await migrateSecret(
|
||||
'wechat-image-keys.bin',
|
||||
secrets.imageKeys ? JSON.stringify(secrets.imageKeys) : undefined
|
||||
)
|
||||
await migrateSecret('wechat-db-key.bin', secrets.legacyDatabaseKey)
|
||||
|
||||
const databaseKeySource = path.join(sourceRoot, 'database-keys')
|
||||
if (await fs.pathExists(databaseKeySource)) {
|
||||
for (const entry of await fs.readdir(databaseKeySource, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !/^[0-9a-f]{64}\.bin$/i.test(entry.name)) continue
|
||||
const relativePath = path.join('database-keys', entry.name)
|
||||
await migrateSecret(relativePath, secrets.databaseKeys[entry.name])
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await fs.remove(stagingRoot).catch(() => undefined)
|
||||
}
|
||||
|
||||
const failed = Object.entries(items).some(
|
||||
([name, status]) => name !== VOICE_TRANSCRIPT_CACHE_ITEM && status === 'failed'
|
||||
)
|
||||
state.status = failed || secretFailures.length > 0 ? 'partial' : 'completed'
|
||||
state.updatedAt = timestamp()
|
||||
state.secretFailures = Array.from(new Set(secretFailures))
|
||||
await writeMigrationState(targetRoot, state)
|
||||
const tokenGenerationBlocked =
|
||||
(await fs.pathExists(path.join(sourceRoot, 'local-api-token.bin'))) &&
|
||||
!(await fs.pathExists(path.join(targetRoot, 'local-api-token.bin')))
|
||||
return {
|
||||
state,
|
||||
tokenGenerationBlocked,
|
||||
...(tokenGenerationBlocked ? { tokenBlockReason: TOKEN_MIGRATION_BLOCK_MESSAGE } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async function createMigrationProgressWindow(): Promise<BrowserWindow | null> {
|
||||
if (process.platform !== 'darwin') return null
|
||||
const window = new BrowserWindow({
|
||||
width: 460,
|
||||
height: 260,
|
||||
show: false,
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
closable: false,
|
||||
title: 'TraceMemo 数据迁移',
|
||||
backgroundColor: '#f5f5f7',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true
|
||||
}
|
||||
})
|
||||
const html = `<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><style>
|
||||
html,body{height:100%;margin:0}body{display:grid;place-items:center;background:#f5f5f7;color:#202124;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center}
|
||||
main{width:360px}.spinner{width:30px;height:30px;margin:0 auto 20px;border:3px solid #d9dddf;border-top-color:#00796b;border-radius:50%;animation:spin .9s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
|
||||
h1{font-size:18px;margin:0 0 12px}p{line-height:1.6;margin:0}.hint{margin-top:14px;color:#697177;font-size:12px}
|
||||
</style></head><body><main><div class="spinner"></div><h1>正在迁移 WechatExplorer 数据</h1><p id="status">正在准备迁移…</p><p class="hint">请不要退出 TraceMemo。旧数据不会被删除。</p></main></body></html>`
|
||||
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
|
||||
window.setProgressBar(2, { mode: 'indeterminate' })
|
||||
window.show()
|
||||
return window
|
||||
}
|
||||
|
||||
function updateMigrationProgress(window: BrowserWindow | null, message: string): void {
|
||||
if (!window || window.isDestroyed()) return
|
||||
void window.webContents
|
||||
.executeJavaScript(
|
||||
`document.getElementById('status').textContent = ${JSON.stringify(message)}`,
|
||||
true
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function closeMigrationProgress(window: BrowserWindow | null): void {
|
||||
if (!window || window.isDestroyed()) return
|
||||
window.setProgressBar(-1)
|
||||
window.setClosable(true)
|
||||
window.destroy()
|
||||
}
|
||||
|
||||
export async function runFirstLaunchMigration(roots: UserDataRoots): Promise<MigrationFlowResult> {
|
||||
const assessment = assessMigration(roots)
|
||||
const voiceMigrationStatus = assessment.state?.items[VOICE_TRANSCRIPT_CACHE_ITEM]
|
||||
const needsVoiceBackfill = !voiceMigrationStatus
|
||||
if (
|
||||
assessment.reason === 'migration-completed' &&
|
||||
assessment.sourceRoot &&
|
||||
assessment.state &&
|
||||
needsVoiceBackfill
|
||||
) {
|
||||
const state: MigrationState = {
|
||||
...assessment.state,
|
||||
items: { ...assessment.state.items },
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
try {
|
||||
state.items[VOICE_TRANSCRIPT_CACHE_ITEM] = await migrateLegacyVoiceTranscripts(
|
||||
assessment.sourceRoot,
|
||||
roots.current
|
||||
)
|
||||
} catch {
|
||||
state.items[VOICE_TRANSCRIPT_CACHE_ITEM] = 'failed'
|
||||
}
|
||||
await writeMigrationState(roots.current, state)
|
||||
return {
|
||||
assessment: { ...assessment, state },
|
||||
action: state.items[VOICE_TRANSCRIPT_CACHE_ITEM] === 'migrated' ? 'migrated' : 'none',
|
||||
execution: {
|
||||
state,
|
||||
tokenGenerationBlocked: false
|
||||
},
|
||||
tokenGenerationBlocked: false
|
||||
}
|
||||
}
|
||||
if (!assessment.shouldPrompt || !assessment.sourceRoot) {
|
||||
return { assessment, action: 'none', tokenGenerationBlocked: false }
|
||||
}
|
||||
|
||||
const sourceLabel = path.basename(assessment.sourceRoot)
|
||||
const conflictNote = assessment.selection.legacyConflict
|
||||
? '\n\n检测到两个旧数据目录,将确定性使用 WechatExplorer;另一个目录不会修改。'
|
||||
: ''
|
||||
const response = await dialog.showMessageBox({
|
||||
type: 'question',
|
||||
title: '迁移 WechatExplorer 数据',
|
||||
message: '检测到 WechatExplorer 数据',
|
||||
detail:
|
||||
`TraceMemo 可以从 ${sourceLabel} 迁移设置、Knowledge、本地索引、API Token、AI Provider、报告和 Agent 配置。` +
|
||||
'\n\n迁移只复制缺失的用户资产,不会覆盖 TraceMemo 已有数据,也不会删除旧目录。' +
|
||||
conflictNote,
|
||||
buttons: ['立即迁移', '以后迁移'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
noLink: true
|
||||
})
|
||||
|
||||
if (response.response !== 0) {
|
||||
const state: MigrationState = {
|
||||
version: 1,
|
||||
status: 'deferred',
|
||||
sourceRoot: assessment.sourceRoot,
|
||||
updatedAt: new Date().toISOString(),
|
||||
items: assessment.state?.items || {},
|
||||
secretFailures: assessment.state?.secretFailures || []
|
||||
}
|
||||
await writeMigrationState(roots.current, state)
|
||||
const tokenGenerationBlocked = await fs.pathExists(
|
||||
path.join(assessment.sourceRoot, 'local-api-token.bin')
|
||||
)
|
||||
return {
|
||||
assessment,
|
||||
action: 'deferred',
|
||||
tokenGenerationBlocked,
|
||||
...(tokenGenerationBlocked ? { tokenBlockReason: TOKEN_MIGRATION_BLOCK_MESSAGE } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const progressWindow = await createMigrationProgressWindow()
|
||||
let execution: MigrationExecutionResult
|
||||
try {
|
||||
execution = await executeMigration(assessment.sourceRoot, roots.current, {
|
||||
decryptLegacySecrets: runLegacySecretHelper,
|
||||
agentRoots: () => getAgentCredentialRoots(),
|
||||
now: () => new Date(),
|
||||
onProgress: (message) => updateMigrationProgress(progressWindow, message)
|
||||
})
|
||||
updateMigrationProgress(progressWindow, '迁移完成,正在启动 TraceMemo…')
|
||||
const messageBoxOptions = {
|
||||
type: execution.state.status === 'completed' ? ('info' as const) : ('warning' as const),
|
||||
title: 'TraceMemo 数据迁移',
|
||||
message:
|
||||
execution.state.status === 'completed' ? 'WechatExplorer 数据迁移完成' : '部分数据未能迁移',
|
||||
detail:
|
||||
execution.state.status === 'completed'
|
||||
? '核心用户资产已复制到 TraceMemo。旧目录仍完整保留。'
|
||||
: '旧目录没有被修改。请保留旧数据并在下次启动时重试;无法迁移的 API Token 不会被静默替换。',
|
||||
buttons: ['好']
|
||||
}
|
||||
if (progressWindow && !progressWindow.isDestroyed()) {
|
||||
await dialog.showMessageBox(progressWindow, messageBoxOptions)
|
||||
} else {
|
||||
await dialog.showMessageBox(messageBoxOptions)
|
||||
}
|
||||
} finally {
|
||||
closeMigrationProgress(progressWindow)
|
||||
}
|
||||
return {
|
||||
assessment,
|
||||
action: 'migrated',
|
||||
execution,
|
||||
tokenGenerationBlocked: execution.tokenGenerationBlocked,
|
||||
tokenBlockReason: execution.tokenBlockReason
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export const LEGACY_USER_DATA_NAME = 'WechatExplorer'
|
||||
export const LEGACY_PACKAGE_USER_DATA_NAME = 'wechatexplorer'
|
||||
export const CURRENT_USER_DATA_NAME = 'TraceMemo'
|
||||
export const TRACE_MEMO_RUNTIME_NAME = 'TraceMemo'
|
||||
|
||||
export interface UserDataRoots {
|
||||
legacy: string
|
||||
legacyPackage: string
|
||||
current: string
|
||||
}
|
||||
|
||||
export interface UserDataSelectionInput extends UserDataRoots {
|
||||
isolated?: string
|
||||
}
|
||||
|
||||
export type UserDataRootKind = 'isolated' | 'legacy-display' | 'legacy-package' | 'current'
|
||||
|
||||
export type UserDataSelectionReason =
|
||||
| 'isolated-override'
|
||||
| 'legacy-display-assets'
|
||||
| 'legacy-package-assets'
|
||||
| 'legacy-shared-assets'
|
||||
| 'legacy-conflict-display-preferred'
|
||||
| 'current-assets'
|
||||
| 'clean-install'
|
||||
|
||||
export interface UserDataSelection {
|
||||
selected: string
|
||||
selectedKind: UserDataRootKind
|
||||
reason: UserDataSelectionReason
|
||||
directories: {
|
||||
legacy: boolean
|
||||
legacyPackage: boolean
|
||||
current: boolean
|
||||
}
|
||||
assets: {
|
||||
legacy: boolean
|
||||
legacyPackage: boolean
|
||||
current: boolean
|
||||
}
|
||||
legacyRootsEquivalent: boolean
|
||||
legacyConflict: boolean
|
||||
}
|
||||
|
||||
export interface UserDataSelectionDependencies {
|
||||
directoryExists: (root: string) => boolean
|
||||
hasAssets: (root: string) => boolean
|
||||
areSameDirectory: (first: string, second: string) => boolean
|
||||
}
|
||||
|
||||
function isNonEmptyFile(filePath: string): boolean {
|
||||
try {
|
||||
const stat = fs.statSync(filePath)
|
||||
return stat.isFile() && stat.size > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function hasPersistentEntries(directoryPath: string): boolean {
|
||||
try {
|
||||
return fs.readdirSync(directoryPath, { withFileTypes: true }).some((entry) => {
|
||||
if (entry.name === '.DS_Store') return false
|
||||
if (entry.name === 'LOCK' || entry.name === 'LOG' || entry.name === 'LOG.old') return false
|
||||
return entry.isFile() || entry.isDirectory()
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function hasDatabaseKey(directoryPath: string): boolean {
|
||||
try {
|
||||
return fs.readdirSync(directoryPath, { withFileTypes: true }).some((entry) => {
|
||||
return (
|
||||
entry.isFile() &&
|
||||
entry.name.endsWith('.bin') &&
|
||||
isNonEmptyFile(path.join(directoryPath, entry.name))
|
||||
)
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function hasKnowledgeDatabase(root: string): boolean {
|
||||
const knowledgeRoot = path.join(root, 'knowledge')
|
||||
try {
|
||||
return fs.readdirSync(knowledgeRoot, { withFileTypes: true }).some((entry) => {
|
||||
if (!entry.isDirectory()) return false
|
||||
return isNonEmptyFile(path.join(knowledgeRoot, entry.name, 'knowledge.sqlite'))
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isExistingDirectory(directoryPath: string): boolean {
|
||||
try {
|
||||
return fs.statSync(directoryPath).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function areSameExistingDirectory(firstPath: string, secondPath: string): boolean {
|
||||
try {
|
||||
const first = fs.statSync(firstPath)
|
||||
const second = fs.statSync(secondPath)
|
||||
if (!first.isDirectory() || !second.isDirectory()) return false
|
||||
if (first.ino && first.dev === second.dev && first.ino === second.ino) return true
|
||||
return fs.realpathSync.native(firstPath) === fs.realpathSync.native(secondPath)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-only Chromium files are deliberately excluded. A directory is a
|
||||
* valid data root only when it contains at least one user-owned marker.
|
||||
*/
|
||||
export function hasValidUserAssets(root: string): boolean {
|
||||
const markers = [
|
||||
'settings.json',
|
||||
'ai-providers.json',
|
||||
'ai-provider-keys.bin',
|
||||
'local-api-token.bin',
|
||||
'wechat-db-key.bin',
|
||||
'wechat-image-keys.bin',
|
||||
'image-insights.json',
|
||||
'wechat-share-service.bin',
|
||||
path.join('cache', 'voice-transcripts.sqlite')
|
||||
]
|
||||
if (markers.some((marker) => isNonEmptyFile(path.join(root, marker)))) return true
|
||||
if (hasKnowledgeDatabase(root)) return true
|
||||
if (hasDatabaseKey(path.join(root, 'database-keys'))) return true
|
||||
if (hasPersistentEntries(path.join(root, 'reports'))) return true
|
||||
if (hasPersistentEntries(path.join(root, 'recall-archive'))) return true
|
||||
if (hasPersistentEntries(path.join(root, 'digital-twin'))) return true
|
||||
if (hasPersistentEntries(path.join(root, 'group-exit-monitor'))) return true
|
||||
if (hasPersistentEntries(path.join(root, 'Local Storage', 'leveldb'))) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function getUserDataRoots(appDataPath: string): UserDataRoots {
|
||||
return {
|
||||
legacy: path.join(appDataPath, LEGACY_USER_DATA_NAME),
|
||||
legacyPackage: path.join(appDataPath, LEGACY_PACKAGE_USER_DATA_NAME),
|
||||
current: path.join(appDataPath, CURRENT_USER_DATA_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select exactly one root. This intentionally does not copy, merge, delete or
|
||||
* modify any directory. The visible-name legacy root remains first priority;
|
||||
* the lowercase v2.1.9 package-name root is the fallback on case-sensitive
|
||||
* filesystems. If both distinct legacy roots contain assets, the visible-name
|
||||
* root wins deterministically and the caller receives conflict diagnostics.
|
||||
*/
|
||||
export function selectUserDataRoot(
|
||||
input: UserDataSelectionInput,
|
||||
dependencies: UserDataSelectionDependencies = {
|
||||
directoryExists: isExistingDirectory,
|
||||
hasAssets: hasValidUserAssets,
|
||||
areSameDirectory: areSameExistingDirectory
|
||||
}
|
||||
): UserDataSelection {
|
||||
const isolated = input.isolated?.trim()
|
||||
if (isolated) {
|
||||
return {
|
||||
selected: path.resolve(isolated),
|
||||
selectedKind: 'isolated',
|
||||
reason: 'isolated-override',
|
||||
directories: { legacy: false, legacyPackage: false, current: false },
|
||||
assets: { legacy: false, legacyPackage: false, current: false },
|
||||
legacyRootsEquivalent: false,
|
||||
legacyConflict: false
|
||||
}
|
||||
}
|
||||
|
||||
const directories = {
|
||||
legacy: dependencies.directoryExists(input.legacy),
|
||||
legacyPackage: dependencies.directoryExists(input.legacyPackage),
|
||||
current: dependencies.directoryExists(input.current)
|
||||
}
|
||||
const assets = {
|
||||
legacy: dependencies.hasAssets(input.legacy),
|
||||
legacyPackage: dependencies.hasAssets(input.legacyPackage),
|
||||
current: dependencies.hasAssets(input.current)
|
||||
}
|
||||
const legacyRootsEquivalent =
|
||||
directories.legacy &&
|
||||
directories.legacyPackage &&
|
||||
dependencies.areSameDirectory(input.legacy, input.legacyPackage)
|
||||
|
||||
if (assets.legacy) {
|
||||
const legacyConflict = assets.legacyPackage && !legacyRootsEquivalent
|
||||
return {
|
||||
selected: input.legacy,
|
||||
selectedKind: 'legacy-display',
|
||||
reason: legacyConflict
|
||||
? 'legacy-conflict-display-preferred'
|
||||
: legacyRootsEquivalent
|
||||
? 'legacy-shared-assets'
|
||||
: 'legacy-display-assets',
|
||||
directories,
|
||||
assets,
|
||||
legacyRootsEquivalent,
|
||||
legacyConflict
|
||||
}
|
||||
}
|
||||
|
||||
if (assets.legacyPackage) {
|
||||
return {
|
||||
selected: input.legacyPackage,
|
||||
selectedKind: 'legacy-package',
|
||||
reason: 'legacy-package-assets',
|
||||
directories,
|
||||
assets,
|
||||
legacyRootsEquivalent: false,
|
||||
legacyConflict: false
|
||||
}
|
||||
}
|
||||
|
||||
if (assets.current) {
|
||||
return {
|
||||
selected: input.current,
|
||||
selectedKind: 'current',
|
||||
reason: 'current-assets',
|
||||
directories,
|
||||
assets,
|
||||
legacyRootsEquivalent: false,
|
||||
legacyConflict: false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selected: input.current,
|
||||
selectedKind: 'current',
|
||||
reason: 'clean-install',
|
||||
directories,
|
||||
assets,
|
||||
legacyRootsEquivalent: false,
|
||||
legacyConflict: false
|
||||
}
|
||||
}
|
||||
|
||||
export function chooseUserDataRoot(input: UserDataSelectionInput): string {
|
||||
return selectUserDataRoot(input).selected
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { app, shell } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import { isPackagedRuntime } from './runtime-mode'
|
||||
|
||||
const MAX_LOG_BYTES = 5 * 1024 * 1024
|
||||
const REDACTED_KEY = /(?:api[-_]?key|authorization|token|secret|password|database[-_]?key)/i
|
||||
|
||||
const sanitize = (value: unknown, depth = 0): unknown => {
|
||||
if (depth > 4) return '[depth-limited]'
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
|
||||
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
|
||||
.replace(/\b(?:0x)?[a-f0-9]{64}\b/gi, '***')
|
||||
.slice(0, 2000)
|
||||
}
|
||||
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1))
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
||||
key,
|
||||
REDACTED_KEY.test(key) ? '***' : sanitize(item, depth + 1)
|
||||
])
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export class AppLogger {
|
||||
private get logDir(): string {
|
||||
return app.getPath('logs')
|
||||
}
|
||||
|
||||
get logPath(): string {
|
||||
return path.join(this.logDir, 'tracememo.log')
|
||||
}
|
||||
|
||||
private rotateIfNeeded(): void {
|
||||
try {
|
||||
if (!fs.existsSync(this.logPath) || fs.statSync(this.logPath).size < MAX_LOG_BYTES) return
|
||||
const previous = `${this.logPath}.1`
|
||||
if (fs.existsSync(previous)) fs.removeSync(previous)
|
||||
fs.moveSync(this.logPath, previous)
|
||||
} catch {
|
||||
// Logging must never interrupt the application.
|
||||
}
|
||||
}
|
||||
|
||||
write(entry: AppLogEntry): void {
|
||||
try {
|
||||
fs.ensureDirSync(this.logDir)
|
||||
this.rotateIfNeeded()
|
||||
const record = {
|
||||
timestamp: new Date().toISOString(),
|
||||
mode: isPackagedRuntime() ? 'packaged' : 'development',
|
||||
level: entry.level,
|
||||
scope: String(entry.scope || 'app').slice(0, 80),
|
||||
message: String(sanitize(entry.message || '')).slice(0, 500),
|
||||
details: sanitize(entry.details || {})
|
||||
}
|
||||
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
|
||||
if (!isPackagedRuntime()) {
|
||||
const method =
|
||||
entry.level === 'error'
|
||||
? console.error
|
||||
: entry.level === 'warn'
|
||||
? console.warn
|
||||
: console.log
|
||||
method(`[${record.scope}] ${record.message}`, record.details)
|
||||
}
|
||||
} catch {
|
||||
// Logging must never interrupt the application.
|
||||
}
|
||||
}
|
||||
|
||||
reveal(): void {
|
||||
fs.ensureDirSync(this.logDir)
|
||||
if (!fs.existsSync(this.logPath)) fs.writeFileSync(this.logPath, '', 'utf8')
|
||||
shell.showItemInFolder(this.logPath)
|
||||
}
|
||||
}
|
||||
|
||||
export const appLogger = new AppLogger()
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import type { DatabaseKeyStorageResult } from '../shared/database-key'
|
||||
|
||||
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '')
|
||||
@@ -9,32 +10,42 @@ export const isValidDatabaseKey = (value: string): boolean =>
|
||||
/^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value))
|
||||
|
||||
export class DatabaseKeyStore {
|
||||
private get filePath(): string {
|
||||
private get legacyFilePath(): string {
|
||||
return path.join(app.getPath('userData'), 'wechat-db-key.bin')
|
||||
}
|
||||
|
||||
async getStatus(): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
|
||||
private get directoryPath(): string {
|
||||
return path.join(app.getPath('userData'), 'database-keys')
|
||||
}
|
||||
|
||||
private filePath(accountRoot: string): string {
|
||||
const normalized = path.resolve(accountRoot).toLowerCase()
|
||||
const id = crypto.createHash('sha256').update(normalized).digest('hex')
|
||||
return path.join(this.directoryPath, `${id}.bin`)
|
||||
}
|
||||
|
||||
async getStatus(accountRoot: string): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
|
||||
return {
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: Boolean(accountRoot) && (await fs.pathExists(this.filePath(accountRoot))),
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<DatabaseKeyStorageResult> {
|
||||
async load(accountRoot: string): Promise<DatabaseKeyStorageResult> {
|
||||
try {
|
||||
const status = await this.getStatus()
|
||||
const status = await this.getStatus(accountRoot)
|
||||
if (!status.saved) return { success: true, ...status }
|
||||
if (!status.encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', ...status }
|
||||
}
|
||||
const encrypted = await fs.readFile(this.filePath)
|
||||
const encrypted = await fs.readFile(this.filePath(accountRoot))
|
||||
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
|
||||
if (!isValidDatabaseKey(key)) {
|
||||
return { success: false, error: '已保存的密钥格式无效', ...status }
|
||||
}
|
||||
return { success: true, key, ...status }
|
||||
} catch (error) {
|
||||
const status = await this.getStatus()
|
||||
const status = await this.getStatus(accountRoot)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -43,13 +54,49 @@ export class DatabaseKeyStore {
|
||||
}
|
||||
}
|
||||
|
||||
async save(rawKey: string): Promise<DatabaseKeyStorageResult> {
|
||||
async loadLegacy(): Promise<DatabaseKeyStorageResult> {
|
||||
const saved = await fs.pathExists(this.legacyFilePath)
|
||||
const encryptionAvailable = safeStorage.isEncryptionAvailable()
|
||||
if (!saved) return { success: true, saved, encryptionAvailable }
|
||||
if (!encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', saved, encryptionAvailable }
|
||||
}
|
||||
try {
|
||||
const key = normalizeDatabaseKey(
|
||||
safeStorage.decryptString(await fs.readFile(this.legacyFilePath))
|
||||
)
|
||||
return isValidDatabaseKey(key)
|
||||
? { success: true, key, saved, encryptionAvailable }
|
||||
: { success: false, error: '旧版密钥格式无效', saved, encryptionAvailable }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
saved,
|
||||
encryptionAvailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clearLegacy(): Promise<void> {
|
||||
await fs.remove(this.legacyFilePath)
|
||||
}
|
||||
|
||||
async save(accountRoot: string, rawKey: string): Promise<DatabaseKeyStorageResult> {
|
||||
const key = normalizeDatabaseKey(rawKey)
|
||||
if (!accountRoot.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
error: '请先选择微信账号',
|
||||
saved: false,
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
if (!isValidDatabaseKey(key)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '密钥必须是 64 位十六进制字符',
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
@@ -57,29 +104,30 @@ export class DatabaseKeyStore {
|
||||
return {
|
||||
success: false,
|
||||
error: '系统安全存储不可用',
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||
encryptionAvailable: false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.ensureDir(path.dirname(this.filePath))
|
||||
await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 })
|
||||
await fs.chmod(this.filePath, 0o600)
|
||||
const filePath = this.filePath(accountRoot)
|
||||
await fs.ensureDir(this.directoryPath)
|
||||
await fs.writeFile(filePath, safeStorage.encryptString(key), { mode: 0o600 })
|
||||
await fs.chmod(filePath, 0o600)
|
||||
return { success: true, key, saved: true, encryptionAvailable: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||
encryptionAvailable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<{ success: boolean; error?: string }> {
|
||||
async clear(accountRoot: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await fs.remove(this.filePath)
|
||||
if (accountRoot) await fs.remove(this.filePath(accountRoot))
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
export type FileAssetResult = {
|
||||
success: boolean
|
||||
filePath?: string
|
||||
fileName?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const monthName = (timestamp?: number): string => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp * 1000)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export class FileAssetService {
|
||||
private index:
|
||||
| {
|
||||
root: string
|
||||
files: { filePath: string; fileName: string; month: string; mtimeMs: number }[]
|
||||
}
|
||||
| undefined
|
||||
|
||||
constructor(private readonly client: Pick<Wcdb4Client, 'getAccountRoot'>) {}
|
||||
|
||||
resolve(fileTitle: string, createTime?: number): FileAssetResult {
|
||||
const normalizedTitle = path.basename(
|
||||
String(fileTitle || '')
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
)
|
||||
if (!normalizedTitle) return { success: false, error: '文件名为空,无法定位本地附件' }
|
||||
|
||||
const configuredRoot = path.resolve(this.client.getAccountRoot(), 'msg', 'file')
|
||||
if (!fs.existsSync(configuredRoot)) {
|
||||
return { success: false, error: '本地文件附件目录不存在' }
|
||||
}
|
||||
const root = fs.realpathSync(configuredRoot)
|
||||
|
||||
const preferredMonth = monthName(createTime)
|
||||
const extension = path.extname(normalizedTitle)
|
||||
const stem = normalizedTitle.slice(0, normalizedTitle.length - extension.length)
|
||||
const duplicatePattern = new RegExp(
|
||||
`^${escapeRegExp(stem)}(?:\\(\\d+\\))?${escapeRegExp(extension)}$`,
|
||||
'i'
|
||||
)
|
||||
const expectedTime = createTime ? createTime * 1000 : 0
|
||||
const candidates = this.getIndex(root).filter(({ fileName }) => duplicatePattern.test(fileName))
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
const leftPreferred = left.month === preferredMonth ? 1 : 0
|
||||
const rightPreferred = right.month === preferredMonth ? 1 : 0
|
||||
if (leftPreferred !== rightPreferred) return rightPreferred - leftPreferred
|
||||
const leftExact = left.fileName === normalizedTitle ? 1 : 0
|
||||
const rightExact = right.fileName === normalizedTitle ? 1 : 0
|
||||
if (leftExact !== rightExact) return rightExact - leftExact
|
||||
if (expectedTime) {
|
||||
const timeDifference =
|
||||
Math.abs(left.mtimeMs - expectedTime) - Math.abs(right.mtimeMs - expectedTime)
|
||||
if (timeDifference) return timeDifference
|
||||
}
|
||||
return left.fileName.localeCompare(right.fileName)
|
||||
})
|
||||
|
||||
const selected = candidates[0]
|
||||
if (!selected) return { success: false, error: `本地未找到文件附件:${normalizedTitle}` }
|
||||
return { success: true, filePath: selected.filePath, fileName: selected.fileName }
|
||||
}
|
||||
|
||||
private isSafeChild(root: string, candidate: string): boolean {
|
||||
return candidate === root || candidate.startsWith(`${root}${path.sep}`)
|
||||
}
|
||||
|
||||
private getIndex(
|
||||
root: string
|
||||
): { filePath: string; fileName: string; month: string; mtimeMs: number }[] {
|
||||
if (this.index?.root === root) return this.index.files
|
||||
const files: { filePath: string; fileName: string; month: string; mtimeMs: number }[] = []
|
||||
for (const month of fs.readdirSync(root)) {
|
||||
const monthPath = path.resolve(root, month)
|
||||
if (!this.isSafeChild(root, monthPath) || !this.isDirectory(monthPath)) continue
|
||||
for (const fileName of fs.readdirSync(monthPath)) {
|
||||
const candidate = path.resolve(monthPath, fileName)
|
||||
if (!this.isSafeChild(root, candidate) || !this.isFile(candidate)) continue
|
||||
const filePath = fs.realpathSync(candidate)
|
||||
if (!this.isSafeChild(root, filePath)) continue
|
||||
files.push({ filePath, fileName, month, mtimeMs: this.mtimeMs(filePath) })
|
||||
}
|
||||
}
|
||||
this.index = { root, files }
|
||||
return files
|
||||
}
|
||||
|
||||
private isDirectory(filePath: string): boolean {
|
||||
try {
|
||||
return fs.statSync(filePath).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private isFile(filePath: string): boolean {
|
||||
try {
|
||||
return fs.statSync(filePath).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private mtimeMs(filePath: string): number {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
GroupReportExportResult,
|
||||
GroupReportMetadata,
|
||||
ReportHeat,
|
||||
ReportSectionMeta
|
||||
ReportSectionMeta,
|
||||
selectHeroParticipantNames
|
||||
} from '../shared/group-report'
|
||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||
import { imageInsightService } from './services/image-insight-service'
|
||||
@@ -74,7 +75,7 @@ const embedAvatar = async (source: string | undefined, name: string): Promise<st
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const response = await fetch(source, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 WechatExplorer',
|
||||
'User-Agent': 'Mozilla/5.0 TraceMemo',
|
||||
Referer: 'https://weixin.qq.com/'
|
||||
},
|
||||
signal: AbortSignal.timeout(8000)
|
||||
@@ -186,11 +187,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
)
|
||||
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
|
||||
|
||||
const heroNames = metadata.heroParticipants.slice(0, 4)
|
||||
while (heroNames.length < 4) heroNames.push(metadata.groupName)
|
||||
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
|
||||
const heroAvatars = heroNames
|
||||
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
|
||||
.join('')
|
||||
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
|
||||
|
||||
const topicCards = report.topics
|
||||
.map(
|
||||
@@ -218,10 +219,18 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
if (insight) {
|
||||
// insight 不含 imageUrl,需要按 md5/datName 重新拿;这里通过 ImageDecryptService 间接获取
|
||||
// 走 ImageDecryptService.findImageFile + decryptImageToBase64
|
||||
const decryptService = (globalThis as { __imageDecrypt?: { findImageFile: (md5?: string, dat?: string) => string | null; decryptImageToBase64: (p: string) => string | null } }).__imageDecrypt
|
||||
const decryptService = (
|
||||
globalThis as {
|
||||
__imageDecrypt?: {
|
||||
findImageFile: (md5?: string, dat?: string) => string | null
|
||||
decryptImageToBase64: (p: string) => string | null
|
||||
}
|
||||
}
|
||||
).__imageDecrypt
|
||||
if (decryptService) {
|
||||
const filePath = decryptService.findImageFile(insight.md5, insight.datName)
|
||||
if (filePath) imageUrl = decryptService.decryptImageToBase64(filePath) || undefined
|
||||
if (filePath)
|
||||
imageUrl = decryptService.decryptImageToBase64(filePath) || undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,7 +460,9 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||
RECORD_NOTE: escapeHtml(metadata.recordNote),
|
||||
// v1 模板使用的 OVERVIEW(经典版以概览段落呈现)
|
||||
OVERVIEW: escapeHtml(report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'),
|
||||
OVERVIEW: escapeHtml(
|
||||
report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'
|
||||
),
|
||||
// v2 模板使用的 hero-*
|
||||
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
|
||||
HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
|
||||
@@ -462,6 +473,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
HERO_TAKEAWAY_EMPTY_CLASS: report.hero?.keyTakeaway ? '' : 'empty-section',
|
||||
HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section',
|
||||
HERO_AVATARS: heroAvatars,
|
||||
HERO_AVATAR_CLASS: heroAvatarClass,
|
||||
MESSAGE_COUNT: String(summaryStats.messageCount),
|
||||
ACTIVE_USERS: String(summaryStats.activeUsers),
|
||||
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
|
||||
@@ -473,13 +485,21 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
RESOURCES_EMPTY_CLASS: sectionClass(request, 'resources', report.resources.length > 0),
|
||||
RESOURCE_ITEMS: resourceItems,
|
||||
RESOURCES_MORE_NOTE: overflowNote(request, 'resources'),
|
||||
MESSAGES_EMPTY_CLASS: sectionClass(request, 'importantMessages', report.importantMessages.length > 0),
|
||||
MESSAGES_EMPTY_CLASS: sectionClass(
|
||||
request,
|
||||
'importantMessages',
|
||||
report.importantMessages.length > 0
|
||||
),
|
||||
IMPORTANT_MESSAGES: importantMessages,
|
||||
MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
|
||||
QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
|
||||
QUOTE_BLOCKS: quoteBlocks,
|
||||
QUOTES_MORE_NOTE: overflowNote(request, 'moments'),
|
||||
ACTIONS_EMPTY_CLASS: sectionClass(request, 'actions', report.todos.length + report.unresolved.length > 0),
|
||||
ACTIONS_EMPTY_CLASS: sectionClass(
|
||||
request,
|
||||
'actions',
|
||||
report.todos.length + report.unresolved.length > 0
|
||||
),
|
||||
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
|
||||
TODO_CARDS: todoCards,
|
||||
UNRESOLVED_EMPTY_CLASS: report.unresolved?.length ? '' : 'empty-section',
|
||||
@@ -511,7 +531,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
VOICE_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0),
|
||||
VOICE_CARDS: voiceCards,
|
||||
VOICE_MORE_NOTE: overflowNote(request, 'voices'),
|
||||
VOICE_RANK_EMPTY_CLASS: sectionClass(request, 'voices', report.analytics.voiceLeaderboard?.length > 0),
|
||||
VOICE_RANK_EMPTY_CLASS: sectionClass(
|
||||
request,
|
||||
'voices',
|
||||
report.analytics.voiceLeaderboard?.length > 0
|
||||
),
|
||||
VOICE_RANK_CARDS: voiceRankCards,
|
||||
BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0),
|
||||
BADGE_CARDS: badgeCards,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from 'crypto'
|
||||
import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
||||
import {
|
||||
isReady,
|
||||
@@ -9,7 +10,10 @@ import {
|
||||
} from './services/chat-service'
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
import { generateAgentGroupReport } from './services/agent-group-report-service'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||
import { apiTokenStore } from './api-token-store'
|
||||
|
||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||
export const DEFAULT_HTTP_PORT = 6131
|
||||
@@ -27,6 +31,10 @@ interface RouteContext {
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export interface HttpServerOptions {
|
||||
tokenProvider?: () => string | null
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||
@@ -34,12 +42,50 @@ function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store'
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
function isAllowedCorsOrigin(origin: string): boolean {
|
||||
if (!/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(origin)) return false
|
||||
try {
|
||||
const parsed = new URL(origin)
|
||||
if (parsed.protocol !== 'http:') return false
|
||||
if (parsed.username || parsed.password) return false
|
||||
return ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname.toLowerCase())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
const origin = req.headers.origin
|
||||
if (!origin) return true
|
||||
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-Headers', 'Content-Type, Authorization')
|
||||
return true
|
||||
}
|
||||
|
||||
function isAuthorized(req: IncomingMessage, expectedToken: string | null): boolean {
|
||||
const header = req.headers.authorization
|
||||
const match = typeof header === 'string' ? /^Bearer ([A-Za-z0-9_-]+)$/.exec(header) : null
|
||||
if (!match || !expectedToken) return false
|
||||
const actualDigest = crypto.createHash('sha256').update(match[1], 'utf8').digest()
|
||||
const expectedDigest = crypto.createHash('sha256').update(expectedToken, 'utf8').digest()
|
||||
return crypto.timingSafeEqual(actualDigest, expectedDigest)
|
||||
}
|
||||
|
||||
function sendUnauthorized(res: ServerResponse): void {
|
||||
sendJson(res, 401, {
|
||||
error: 'unauthorized',
|
||||
message: 'Valid API token required'
|
||||
})
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
|
||||
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
|
||||
}
|
||||
@@ -121,7 +167,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
ready: isReady(),
|
||||
service: 'WechatExplorer Reader',
|
||||
service: 'TraceMemo Reader',
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
@@ -140,7 +186,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
},
|
||||
|
||||
'/api/v1/contact': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
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)
|
||||
@@ -151,27 +197,29 @@ const routes: Record<string, RouteHandler> = {
|
||||
},
|
||||
|
||||
'/api/v1/chatroom': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const keyword = url.searchParams.get('keyword') || ''
|
||||
let groups = listContacts().filter((c) => c.type === 'group')
|
||||
if (keyword) {
|
||||
const lower = keyword.toLowerCase()
|
||||
groups = groups.filter(
|
||||
(c) => c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
|
||||
(c) =>
|
||||
c.m_nsNickName.toLowerCase().includes(lower) ||
|
||||
c.m_nsUsrName.toLowerCase().includes(lower)
|
||||
)
|
||||
}
|
||||
sendJson(res, 200, { count: groups.length, chatrooms: groups })
|
||||
},
|
||||
|
||||
'/api/v1/recent_chat': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const limit = parseNumeric(url.searchParams.get('limit'), 50)
|
||||
const items = listRecentChat(limit)
|
||||
sendJson(res, 200, { count: items.length, items })
|
||||
},
|
||||
|
||||
'/api/v1/chatlog': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const talker = url.searchParams.get('talker')
|
||||
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
|
||||
|
||||
@@ -209,7 +257,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
},
|
||||
|
||||
'/api/v1/group_snapshot': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const md5 = url.searchParams.get('md5')
|
||||
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
|
||||
const snapshot = getGroupSnapshot(md5)
|
||||
@@ -218,7 +266,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
},
|
||||
|
||||
'/api/v1/resolve': ({ res, url }) => {
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
const q = url.searchParams.get('q')
|
||||
if (!q) return sendError(res, 400, '缺少必要参数 q')
|
||||
const contact = resolveMd5(q)
|
||||
@@ -228,7 +276,7 @@ const routes: Record<string, RouteHandler> = {
|
||||
|
||||
'/api/v1/report': async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
if (typeof body !== 'string' || !body.trim()) {
|
||||
return sendError(res, 400, '请求体为空,需 POST GroupReportExportRequest JSON')
|
||||
}
|
||||
@@ -236,36 +284,89 @@ const routes: Record<string, RouteHandler> = {
|
||||
try {
|
||||
request = JSON.parse(body) as GroupReportExportRequest
|
||||
} catch (error) {
|
||||
return sendError(res, 400, '请求体 JSON 解析失败', error instanceof Error ? error.message : String(error))
|
||||
return sendError(
|
||||
res,
|
||||
400,
|
||||
'请求体 JSON 解析失败',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
if (!request?.report || !request?.metadata) {
|
||||
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
||||
}
|
||||
const result = await exportGroupReport(request)
|
||||
sendJson(res, result.success ? 200 : 500, result)
|
||||
},
|
||||
|
||||
'/api/v1/agent/group-report': async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
if (!isReady()) return sendError(res, 503, 'TraceMemo 数据库未初始化')
|
||||
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
|
||||
try {
|
||||
request = JSON.parse(typeof body === 'string' ? body : '{}')
|
||||
} catch {
|
||||
return sendError(res, 400, '请求体 JSON 解析失败')
|
||||
}
|
||||
const result = await generateAgentGroupReport({
|
||||
group: request.group || '',
|
||||
range: request.range
|
||||
})
|
||||
sendJson(res, result.success ? 200 : 400, result)
|
||||
},
|
||||
|
||||
'/api/v1/agent/status': ({ res }) => {
|
||||
const status = agentHubService.getStatus()
|
||||
sendJson(res, 200, {
|
||||
ok: status.hub === 'online' && status.connector === 'online',
|
||||
hub: status.hub,
|
||||
connector: status.connector,
|
||||
dataApi: status.dataApi,
|
||||
databaseReady: status.databaseReady,
|
||||
accountId: status.accountId
|
||||
})
|
||||
},
|
||||
|
||||
'/api/v1/agent/send': async ({ req, res, body }) => {
|
||||
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||
let request: { to?: string; text?: string; media_url?: string }
|
||||
try {
|
||||
request = JSON.parse(typeof body === 'string' ? body : '{}')
|
||||
} catch {
|
||||
return sendError(res, 400, '请求体 JSON 解析失败')
|
||||
}
|
||||
const result = await agentHubService.testSend({
|
||||
to: request.to,
|
||||
text: request.text,
|
||||
mediaUrl: request.media_url
|
||||
})
|
||||
sendJson(res, result.success ? 200 : result.status === 'token_expired' ? 401 : 503, result)
|
||||
}
|
||||
}
|
||||
|
||||
export function startHttpServer(
|
||||
host: string = DEFAULT_HTTP_HOST,
|
||||
port: number = DEFAULT_HTTP_PORT
|
||||
port: number = DEFAULT_HTTP_PORT,
|
||||
options: HttpServerOptions = {}
|
||||
): Promise<HttpServerHandle> {
|
||||
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: Server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', `http://${host}:${port}`)
|
||||
if (!applyCorsHeaders(req, res)) {
|
||||
return sendError(res, 403, 'Origin 不允许访问本地 API')
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*'
|
||||
})
|
||||
res.writeHead(204)
|
||||
return res.end()
|
||||
}
|
||||
const handler = routes[url.pathname]
|
||||
if (!handler) {
|
||||
return sendError(res, 404, `端点不存在: ${url.pathname}`)
|
||||
}
|
||||
if (url.pathname !== '/api/v1/health' && !isAuthorized(req, tokenProvider())) {
|
||||
return sendUnauthorized(res)
|
||||
}
|
||||
let body: string | undefined
|
||||
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
body = await readBody(req)
|
||||
@@ -311,7 +412,11 @@ export interface ApiServerState {
|
||||
}
|
||||
|
||||
let singleton: HttpServerHandle | null = null
|
||||
let singletonState: ApiServerState = { running: false, host: DEFAULT_HTTP_HOST, port: DEFAULT_HTTP_PORT }
|
||||
let singletonState: ApiServerState = {
|
||||
running: false,
|
||||
host: DEFAULT_HTTP_HOST,
|
||||
port: DEFAULT_HTTP_PORT
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
@@ -326,16 +431,32 @@ export const apiServer = {
|
||||
return { ...singletonState }
|
||||
},
|
||||
|
||||
async start(host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT): Promise<ApiServerState> {
|
||||
async start(
|
||||
host: string = DEFAULT_HTTP_HOST,
|
||||
port: number = DEFAULT_HTTP_PORT
|
||||
): Promise<ApiServerState> {
|
||||
if (singleton) {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
const token = apiTokenStore.ensureToken()
|
||||
if (!token.success) {
|
||||
singletonState = {
|
||||
running: false,
|
||||
host,
|
||||
port,
|
||||
error: token.error || 'API Token 安全存储不可用'
|
||||
}
|
||||
return { ...singletonState }
|
||||
}
|
||||
|
||||
const maxAttempts = 4
|
||||
let lastError: (NodeJS.ErrnoException & { friendlyMessage?: string }) | null = null
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
singleton = await startHttpServer(host, port)
|
||||
singleton = await startHttpServer(host, port, {
|
||||
tokenProvider: () => apiTokenStore.getTokenForAuthentication()
|
||||
})
|
||||
singletonState = {
|
||||
running: true,
|
||||
host: singleton.host,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// @ts-nocheck
|
||||
// Windows native bridge adapted from WeFlow. Koffi Win32 callbacks and optional
|
||||
// helpers are intentionally dynamic; keep this file isolated from strict TS.
|
||||
import { join, dirname, delimiter } from 'path'
|
||||
import { existsSync, copyFileSync, mkdirSync } from 'fs'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import os from 'os'
|
||||
import crypto from 'crypto'
|
||||
@@ -12,14 +9,19 @@ import { getResourceRoots as getSharedResourceRoots } from './resource-paths'
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||
type ImageKeyResult = {
|
||||
success: boolean
|
||||
xorKey?: number
|
||||
aesKey?: string
|
||||
verified?: boolean
|
||||
error?: string
|
||||
}
|
||||
type DbKeyPollResult =
|
||||
| { status: 'success'; key: string; loginRequiredDetected: boolean }
|
||||
| { status: 'process-ended'; loginRequiredDetected: boolean }
|
||||
| { status: 'timeout'; loginRequiredDetected: boolean }
|
||||
|
||||
export class KeyService {
|
||||
private readonly isMac = process.platform === 'darwin'
|
||||
private koffi: any = null
|
||||
private lib: any = null
|
||||
private initialized = false
|
||||
@@ -28,19 +30,15 @@ export class KeyService {
|
||||
private getStatusMessage: any = null
|
||||
private cleanupHook: any = null
|
||||
private getLastErrorMsg: any = null
|
||||
private getImageKeyDll: any = null
|
||||
private lastLoadError = ''
|
||||
|
||||
// Win32 APIs
|
||||
private kernel32: any = null
|
||||
private user32: any = null
|
||||
private advapi32: any = null
|
||||
|
||||
// Kernel32
|
||||
private OpenProcess: any = null
|
||||
private CloseHandle: any = null
|
||||
private TerminateProcess: any = null
|
||||
private QueryFullProcessImageNameW: any = null
|
||||
|
||||
// User32
|
||||
private EnumWindows: any = null
|
||||
@@ -50,22 +48,9 @@ export class KeyService {
|
||||
private GetWindowThreadProcessId: any = null
|
||||
private IsWindowVisible: any = null
|
||||
private EnumChildWindows: any = null
|
||||
private PostMessageW: any = null
|
||||
private WNDENUMPROC_PTR: any = null
|
||||
|
||||
// Advapi32
|
||||
private RegOpenKeyExW: any = null
|
||||
private RegQueryValueExW: any = null
|
||||
private RegCloseKey: any = null
|
||||
|
||||
// Constants
|
||||
private readonly PROCESS_ALL_ACCESS = 0x1F0FFF
|
||||
private readonly PROCESS_TERMINATE = 0x0001
|
||||
private readonly KEY_READ = 0x20019
|
||||
private readonly HKEY_LOCAL_MACHINE = 0x80000002
|
||||
private readonly HKEY_CURRENT_USER = 0x80000001
|
||||
private readonly ERROR_SUCCESS = 0
|
||||
private readonly WM_CLOSE = 0x0010
|
||||
private readonly DB_KEY_PROCESS_CHECK_INTERVAL_MS = 1000
|
||||
|
||||
private getResourceRoots(): string[] {
|
||||
@@ -156,11 +141,11 @@ export class KeyService {
|
||||
this.lib = this.koffi.load(dllPath)
|
||||
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
|
||||
this.pollKeyData = this.lib.func('bool PollKeyData(_Out_ char *keyBuffer, int bufferSize)')
|
||||
this.getStatusMessage = this.lib.func('bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)')
|
||||
this.getStatusMessage = this.lib.func(
|
||||
'bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)'
|
||||
)
|
||||
this.cleanupHook = this.lib.func('bool CleanupHook()')
|
||||
this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()')
|
||||
this.getImageKeyDll = this.lib.func('bool GetImageKey(_Out_ char *resultBuffer, int bufferSize)')
|
||||
|
||||
this.initialized = true
|
||||
return true
|
||||
} catch (e) {
|
||||
@@ -186,8 +171,6 @@ export class KeyService {
|
||||
this.kernel32 = this.koffi.load('kernel32.dll')
|
||||
this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32'])
|
||||
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*'])
|
||||
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['void*', 'uint32'])
|
||||
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['void*', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
@@ -211,12 +194,26 @@ export class KeyService {
|
||||
this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC)
|
||||
|
||||
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
|
||||
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
|
||||
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
|
||||
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
||||
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', [
|
||||
'void*',
|
||||
this.WNDENUMPROC_PTR,
|
||||
'intptr_t'
|
||||
])
|
||||
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', [
|
||||
'void*',
|
||||
this.koffi.out('uint16*'),
|
||||
'int'
|
||||
])
|
||||
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
|
||||
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
||||
this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', ['void*', this.koffi.out('uint32*')])
|
||||
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', [
|
||||
'void*',
|
||||
this.koffi.out('uint16*'),
|
||||
'int'
|
||||
])
|
||||
this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', [
|
||||
'void*',
|
||||
this.koffi.out('uint32*')
|
||||
])
|
||||
this.IsWindowVisible = this.user32.func('IsWindowVisible', 'bool', ['void*'])
|
||||
|
||||
return true
|
||||
@@ -226,26 +223,6 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAdvapi32(): boolean {
|
||||
if (this.advapi32) return true
|
||||
try {
|
||||
this.koffi = require('koffi')
|
||||
this.advapi32 = this.koffi.load('advapi32.dll')
|
||||
|
||||
const HKEY = this.koffi.alias('HKEY', 'intptr_t')
|
||||
const HKEY_PTR = this.koffi.pointer(HKEY)
|
||||
|
||||
this.RegOpenKeyExW = this.advapi32.func('RegOpenKeyExW', 'long', [HKEY, 'uint16*', 'uint32', 'uint32', this.koffi.out(HKEY_PTR)])
|
||||
this.RegQueryValueExW = this.advapi32.func('RegQueryValueExW', 'long', [HKEY, 'uint16*', 'uint32*', this.koffi.out('uint32*'), this.koffi.out('uint8*'), this.koffi.out('uint32*')])
|
||||
this.RegCloseKey = this.advapi32.func('RegCloseKey', 'long', [HKEY])
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('初始化 advapi32 失败:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private decodeCString(ptr: any): string {
|
||||
try {
|
||||
if (typeof ptr === 'string') return ptr
|
||||
@@ -255,123 +232,19 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// --- WeChat Process & Path Finding ---
|
||||
|
||||
private readRegistryString(rootKey: number, subKey: string, valueName: string): string | null {
|
||||
if (!this.ensureAdvapi32()) return null
|
||||
const subKeyBuf = Buffer.from(subKey + '\0', 'ucs2')
|
||||
const valueNameBuf = valueName ? Buffer.from(valueName + '\0', 'ucs2') : null
|
||||
const phkResult = Buffer.alloc(8)
|
||||
|
||||
if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) return null
|
||||
|
||||
const hKey = this.koffi.decode(phkResult, 'uintptr_t')
|
||||
|
||||
try {
|
||||
const lpcbData = Buffer.alloc(4)
|
||||
lpcbData.writeUInt32LE(0, 0)
|
||||
|
||||
let ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, null, lpcbData)
|
||||
if (ret !== this.ERROR_SUCCESS) return null
|
||||
|
||||
const size = lpcbData.readUInt32LE(0)
|
||||
if (size === 0) return null
|
||||
|
||||
const dataBuf = Buffer.alloc(size)
|
||||
ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, dataBuf, lpcbData)
|
||||
if (ret !== this.ERROR_SUCCESS) return null
|
||||
|
||||
let str = dataBuf.toString('ucs2')
|
||||
if (str.endsWith('\0')) str = str.slice(0, -1)
|
||||
return str
|
||||
} finally {
|
||||
this.RegCloseKey(hKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async getProcessExecutablePath(pid: number): Promise<string | null> {
|
||||
if (!this.ensureKernel32()) return null
|
||||
const hProcess = this.OpenProcess(0x1000, false, pid)
|
||||
if (!hProcess) return null
|
||||
|
||||
try {
|
||||
const sizeBuf = Buffer.alloc(4)
|
||||
sizeBuf.writeUInt32LE(1024, 0)
|
||||
const pathBuf = Buffer.alloc(1024 * 2)
|
||||
|
||||
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
|
||||
if (ret) {
|
||||
const len = sizeBuf.readUInt32LE(0)
|
||||
return pathBuf.toString('ucs2', 0, len * 2)
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
console.error('获取进程路径失败:', e)
|
||||
return null
|
||||
} finally {
|
||||
this.CloseHandle(hProcess)
|
||||
}
|
||||
}
|
||||
|
||||
private async findWeChatInstallPath(): Promise<string | null> {
|
||||
try {
|
||||
const pid = await this.findWeChatPid()
|
||||
if (pid) {
|
||||
const runPath = await this.getProcessExecutablePath(pid)
|
||||
if (runPath && existsSync(runPath)) return runPath
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('尝试获取运行中微信路径失败:', e)
|
||||
}
|
||||
|
||||
const uninstallKeys = [
|
||||
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||
'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'
|
||||
]
|
||||
const roots = [this.HKEY_LOCAL_MACHINE, this.HKEY_CURRENT_USER]
|
||||
const tencentKeys = [
|
||||
'Software\\Tencent\\WeChat',
|
||||
'Software\\WOW6432Node\\Tencent\\WeChat',
|
||||
'Software\\Tencent\\Weixin',
|
||||
]
|
||||
|
||||
for (const root of roots) {
|
||||
for (const key of tencentKeys) {
|
||||
const path = this.readRegistryString(root, key, 'InstallPath')
|
||||
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
|
||||
if (path && existsSync(join(path, 'WeChat.exe'))) return join(path, 'WeChat.exe')
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
for (const parent of uninstallKeys) {
|
||||
const path = this.readRegistryString(root, parent + '\\WeChat', 'InstallLocation')
|
||||
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
|
||||
}
|
||||
}
|
||||
|
||||
const drives = ['C', 'D', 'E', 'F']
|
||||
const commonPaths = [
|
||||
'Program Files\\Tencent\\WeChat\\WeChat.exe',
|
||||
'Program Files (x86)\\Tencent\\WeChat\\WeChat.exe',
|
||||
'Program Files\\Tencent\\Weixin\\Weixin.exe',
|
||||
'Program Files (x86)\\Tencent\\Weixin\\Weixin.exe'
|
||||
]
|
||||
|
||||
for (const drive of drives) {
|
||||
for (const p of commonPaths) {
|
||||
const full = join(drive + ':\\', p)
|
||||
if (existsSync(full)) return full
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async findPidsByImageName(imageName: string): Promise<number[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH'])
|
||||
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
||||
const { stdout } = await execFileAsync('tasklist', [
|
||||
'/FI',
|
||||
`IMAGENAME eq ${imageName}`,
|
||||
'/FO',
|
||||
'CSV',
|
||||
'/NH'
|
||||
])
|
||||
const lines = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const pids: number[] = []
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('INFO:')) continue
|
||||
@@ -418,7 +291,7 @@ export class KeyService {
|
||||
const fallbackPid = await this.waitForWeChatWindow(250)
|
||||
if (fallbackPid) return fallbackPid
|
||||
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -428,10 +301,10 @@ export class KeyService {
|
||||
}
|
||||
|
||||
private async pollDbKeyFromHook(
|
||||
pid: number,
|
||||
deadline: number,
|
||||
logs: string[],
|
||||
onStatus?: (message: string, level: number) => void
|
||||
pid: number,
|
||||
deadline: number,
|
||||
logs: string[],
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<DbKeyPollResult> {
|
||||
const keyBuffer = Buffer.alloc(128)
|
||||
let loginRequiredDetected = false
|
||||
@@ -441,7 +314,7 @@ export class KeyService {
|
||||
const now = Date.now()
|
||||
if (now >= nextProcessCheckAt) {
|
||||
nextProcessCheckAt = now + this.DB_KEY_PROCESS_CHECK_INTERVAL_MS
|
||||
if (!await this.isWeChatPidActive(pid)) {
|
||||
if (!(await this.isWeChatPidActive(pid))) {
|
||||
return { status: 'process-ended', loginRequiredDetected }
|
||||
}
|
||||
}
|
||||
@@ -477,13 +350,17 @@ export class KeyService {
|
||||
private cleanupDbKeyHook(): void {
|
||||
try {
|
||||
this.cleanupHook()
|
||||
} catch { }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private buildInitHookError(): string {
|
||||
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
||||
if (error) {
|
||||
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
|
||||
if (
|
||||
error.includes('0xC0000022') ||
|
||||
error.includes('ACCESS_DENIED') ||
|
||||
error.includes('打开目标进程失败')
|
||||
) {
|
||||
return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||
}
|
||||
return error
|
||||
@@ -491,13 +368,17 @@ export class KeyService {
|
||||
|
||||
const statusBuffer = Buffer.alloc(256)
|
||||
const levelOut = [0]
|
||||
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
|
||||
const status =
|
||||
this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
|
||||
? this.decodeUtf8(statusBuffer)
|
||||
: ''
|
||||
return status || '初始化失败'
|
||||
}
|
||||
|
||||
private async waitForNextDbKeyPid(deadline: number, onStatus?: (message: string, level: number) => void): Promise<number | null> {
|
||||
private async waitForNextDbKeyPid(
|
||||
deadline: number,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<number | null> {
|
||||
while (this.getRemainingMs(deadline) > 0) {
|
||||
onStatus?.('正在查找微信进程...', 0)
|
||||
const pid = await this.waitForWeChatPid(Math.min(this.getRemainingMs(deadline), 30_000))
|
||||
@@ -514,17 +395,23 @@ export class KeyService {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
private async waitForProcessRestart(deadline: number, onStatus?: (message: string, level: number) => void): Promise<number | null> {
|
||||
private async waitForProcessRestart(
|
||||
deadline: number,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<number | null> {
|
||||
if (!this.shouldRetryAfterProcessLost(deadline)) return null
|
||||
onStatus?.('检测到微信已退出,已清理 Hook,等待重新打开微信...', 0)
|
||||
await this.delayBeforeRetry()
|
||||
return this.waitForNextDbKeyPid(deadline, onStatus)
|
||||
}
|
||||
|
||||
private async detectLoginRequiredForLastPid(pid: number | null, loginRequiredDetected: boolean): Promise<boolean> {
|
||||
private async detectLoginRequiredForLastPid(
|
||||
pid: number | null,
|
||||
loginRequiredDetected: boolean
|
||||
): Promise<boolean> {
|
||||
if (loginRequiredDetected) return true
|
||||
if (!pid) return false
|
||||
if (!await this.isWeChatPidActive(pid)) return false
|
||||
if (!(await this.isWeChatPidActive(pid))) return false
|
||||
return await this.detectWeChatLoginRequired(pid)
|
||||
}
|
||||
|
||||
@@ -535,56 +422,6 @@ export class KeyService {
|
||||
return fallbackPid ?? null
|
||||
}
|
||||
|
||||
private async waitForWeChatExit(timeoutMs = 8000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const runningPids = await this.findWeChatPids()
|
||||
if (runningPids.length === 0) return true
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async closeWeChatWindows(): Promise<boolean> {
|
||||
if (!this.ensureUser32()) return false
|
||||
let requested = false
|
||||
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
|
||||
if (!this.IsWindowVisible(hWnd)) return true
|
||||
const title = this.getWindowTitle(hWnd)
|
||||
const className = this.getClassName(hWnd)
|
||||
const classLower = (className || '').toLowerCase()
|
||||
const isWeChatWindow = this.isWeChatWindowTitle(title) || classLower.includes('wechat') || classLower.includes('weixin')
|
||||
if (!isWeChatWindow) return true
|
||||
|
||||
requested = true
|
||||
try {
|
||||
this.PostMessageW?.(hWnd, this.WM_CLOSE, 0, 0)
|
||||
} catch { }
|
||||
return true
|
||||
}, this.WNDENUMPROC_PTR)
|
||||
|
||||
this.EnumWindows(enumWindowsCallback, 0)
|
||||
this.koffi.unregister(enumWindowsCallback)
|
||||
|
||||
return requested
|
||||
}
|
||||
|
||||
private async killWeChatProcesses(): Promise<boolean> {
|
||||
const requested = await this.closeWeChatWindows()
|
||||
if (requested) {
|
||||
const gracefulOk = await this.waitForWeChatExit(1500)
|
||||
if (gracefulOk) return true
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
|
||||
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
|
||||
} catch (e) { }
|
||||
|
||||
return await this.waitForWeChatExit(5000)
|
||||
}
|
||||
|
||||
// --- Window Detection ---
|
||||
|
||||
private getWindowTitle(hWnd: any): string {
|
||||
@@ -614,7 +451,7 @@ export class KeyService {
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
let foundPid: number | null = null
|
||||
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => {
|
||||
if (!this.IsWindowVisible(hWnd)) return true
|
||||
const title = this.getWindowTitle(hWnd)
|
||||
if (!this.isWeChatWindowTitle(title)) return true
|
||||
@@ -633,14 +470,14 @@ export class KeyService {
|
||||
this.koffi.unregister(enumWindowsCallback)
|
||||
|
||||
if (foundPid) return foundPid
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private collectChildWindowInfos(parent: any): Array<{ title: string; className: string }> {
|
||||
const children: Array<{ title: string; className: string }> = []
|
||||
const enumChildCallback = this.koffi.register((hChild: any, lp: any) => {
|
||||
const enumChildCallback = this.koffi.register((hChild: any, _lp: any) => {
|
||||
const title = this.getWindowTitle(hChild).trim()
|
||||
const className = this.getClassName(hChild).trim()
|
||||
children.push({ title, className })
|
||||
@@ -655,7 +492,16 @@ export class KeyService {
|
||||
if (children.length === 0) return false
|
||||
|
||||
const readyTexts = ['聊天', '登录', '账号']
|
||||
const readyClassMarkers = ['WeChat', 'Weixin', 'TXGuiFoundation', 'Qt5', 'ChatList', 'MainWnd', 'BrowserWnd', 'ListView']
|
||||
const readyClassMarkers = [
|
||||
'WeChat',
|
||||
'Weixin',
|
||||
'TXGuiFoundation',
|
||||
'Qt5',
|
||||
'ChatList',
|
||||
'MainWnd',
|
||||
'BrowserWnd',
|
||||
'ListView'
|
||||
]
|
||||
const readyChildCountThreshold = 14
|
||||
|
||||
let classMatchCount = 0
|
||||
@@ -665,12 +511,12 @@ export class KeyService {
|
||||
for (const child of children) {
|
||||
const normalizedTitle = child.title.replace(/\s+/g, '')
|
||||
if (normalizedTitle) {
|
||||
if (readyTexts.some(marker => normalizedTitle.includes(marker))) return true
|
||||
if (readyTexts.some((marker) => normalizedTitle.includes(marker))) return true
|
||||
titleMatchCount += 1
|
||||
}
|
||||
const className = child.className
|
||||
if (className) {
|
||||
if (readyClassMarkers.some(marker => className.includes(marker))) return true
|
||||
if (readyClassMarkers.some((marker) => className.includes(marker))) return true
|
||||
if (className.length > 5) {
|
||||
classMatchCount += 1
|
||||
hasValidClassName = true
|
||||
@@ -685,7 +531,9 @@ export class KeyService {
|
||||
}
|
||||
|
||||
private isLoginRelatedText(value: string): boolean {
|
||||
const normalized = String(value || '').replace(/\s+/g, '').toLowerCase()
|
||||
const normalized = String(value || '')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase()
|
||||
if (!normalized) return false
|
||||
const keywords = [
|
||||
'登录',
|
||||
@@ -741,7 +589,7 @@ export class KeyService {
|
||||
const startTime = Date.now()
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
let ready = false
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => {
|
||||
if (!this.IsWindowVisible(hWnd)) return true
|
||||
const title = this.getWindowTitle(hWnd)
|
||||
if (!this.isWeChatWindowTitle(title)) return true
|
||||
@@ -763,7 +611,7 @@ export class KeyService {
|
||||
this.koffi.unregister(enumWindowsCallback)
|
||||
|
||||
if (ready) return true
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -771,8 +619,8 @@ export class KeyService {
|
||||
// --- DB Key Logic (core hook/poll flow unchanged) ---
|
||||
|
||||
async autoGetDbKey(
|
||||
timeoutMs = 60_000,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
timeoutMs = 60_000,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<DbKeyResult> {
|
||||
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
||||
if (!this.ensureLoaded()) return { success: false, error: this.getLoadError() }
|
||||
@@ -794,14 +642,14 @@ export class KeyService {
|
||||
onStatus?.('正在检测微信界面组件...', 0)
|
||||
await this.waitForWeChatWindowComponents(pid, Math.min(15000, this.getRemainingMs(deadline)))
|
||||
|
||||
if (!await this.isWeChatPidActive(pid)) {
|
||||
if (!(await this.isWeChatPidActive(pid))) {
|
||||
pid = await this.waitForProcessRestart(deadline, onStatus)
|
||||
continue
|
||||
}
|
||||
|
||||
const ok = this.initHook(pid)
|
||||
if (!ok) {
|
||||
if (!await this.isWeChatPidActive(pid)) {
|
||||
if (!(await this.isWeChatPidActive(pid))) {
|
||||
this.cleanupDbKeyHook()
|
||||
pid = await this.waitForProcessRestart(deadline, onStatus)
|
||||
continue
|
||||
@@ -828,7 +676,10 @@ export class KeyService {
|
||||
break
|
||||
}
|
||||
|
||||
const loginRequired = await this.detectLoginRequiredForLastPid(pid, lastAttemptLoginRequiredDetected)
|
||||
const loginRequired = await this.detectLoginRequiredForLastPid(
|
||||
pid,
|
||||
lastAttemptLoginRequiredDetected
|
||||
)
|
||||
if (loginRequired) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -840,80 +691,10 @@ export class KeyService {
|
||||
return { success: false, error: '获取密钥超时', logs }
|
||||
}
|
||||
|
||||
private cleanWxid(wxid: string): string {
|
||||
const first = wxid.indexOf('_')
|
||||
if (first === -1) return wxid
|
||||
const second = wxid.indexOf('_', first + 1)
|
||||
if (second === -1) return wxid
|
||||
return wxid.substring(0, second)
|
||||
}
|
||||
|
||||
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
const xorKey = code & 0xFF
|
||||
const dataToHash = code.toString() + cleanedWxid
|
||||
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
|
||||
const aesKey = md5Full.substring(0, 16)
|
||||
return { xorKey, aesKey }
|
||||
}
|
||||
|
||||
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
|
||||
try {
|
||||
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
|
||||
decipher.setAutoPadding(false)
|
||||
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise<string[]> {
|
||||
const candidates: string[] = []
|
||||
const pushUnique = (value: string) => {
|
||||
const v = String(value || '').trim()
|
||||
if (!v || candidates.includes(v)) return
|
||||
candidates.push(v)
|
||||
}
|
||||
|
||||
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
|
||||
|
||||
if (manualDir) {
|
||||
const normalized = manualDir.replace(/[\\/]+$/, '')
|
||||
const dirName = normalized.split(/[\\/]/).pop() ?? ''
|
||||
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
||||
|
||||
const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i)
|
||||
if (marker) {
|
||||
const root = normalized.slice(0, marker.index! + marker[0].length)
|
||||
try {
|
||||
const { readdirSync, statSync } = await import('fs')
|
||||
const { join } = await import('path')
|
||||
for (const entry of readdirSync(root)) {
|
||||
if (!entry.startsWith('wxid_')) continue
|
||||
const full = join(root, entry)
|
||||
try {
|
||||
if (statSync(full).isDirectory()) pushUnique(entry)
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
pushUnique('unknown')
|
||||
return candidates
|
||||
}
|
||||
|
||||
async autoGetImageKey(
|
||||
manualDir?: string,
|
||||
onProgress?: (message: string) => void,
|
||||
wxidParam?: string
|
||||
manualDir?: string,
|
||||
onProgress?: (message: string) => void,
|
||||
wxidParam?: string
|
||||
): Promise<ImageKeyResult> {
|
||||
void wxidParam
|
||||
return this.autoGetImageKeyByMemoryScan(manualDir || '', onProgress)
|
||||
@@ -934,16 +715,69 @@ export class KeyService {
|
||||
onProgress?.('正在查找模板文件...')
|
||||
let result = await this._findTemplateData(userDir, 32)
|
||||
let { ciphertext, xorKey } = result
|
||||
|
||||
const firstDiag = (
|
||||
this as {
|
||||
_imageTemplateDiag?: {
|
||||
userDir: string
|
||||
totalTFiles: number
|
||||
v2Count: number
|
||||
nonV2Count: number
|
||||
}
|
||||
}
|
||||
)._imageTemplateDiag
|
||||
|
||||
// 如果找不到密钥,尝试扫描更多文件
|
||||
if (ciphertext && xorKey === null) {
|
||||
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
|
||||
result = await this._findTemplateData(userDir, 100)
|
||||
xorKey = result.xorKey
|
||||
}
|
||||
|
||||
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
||||
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
|
||||
|
||||
if (!ciphertext) {
|
||||
// 用诊断信息给具体提示
|
||||
const diag =
|
||||
(
|
||||
this as {
|
||||
_imageTemplateDiag?: {
|
||||
userDir: string
|
||||
totalTFiles: number
|
||||
v2Count: number
|
||||
nonV2Count: number
|
||||
}
|
||||
}
|
||||
)._imageTemplateDiag || firstDiag
|
||||
if (!diag || diag.totalTFiles === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'在账号目录下未找到任何 _t.dat 图片文件。\n' +
|
||||
`扫描路径:${diag?.userDir || userDir || '(空)'}\n` +
|
||||
'原因:微信没在本地生成缩略图。\n' +
|
||||
'请让用户在微信里打开任意聊天的图片大图(等"原图"按钮可点击),然后再试。'
|
||||
}
|
||||
}
|
||||
if (diag.v2Count === 0 && diag.nonV2Count > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
`找到 ${diag.totalTFiles} 个 _t.dat,但都不是 V2 头(可能图片尚未解密到本地,或微信版本不同)。\n` +
|
||||
`扫描路径:${diag.userDir}\n` +
|
||||
'请让用户在微信里打开 2-3 张不同的图片大图,等"原图"按钮可点击后再试。'
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
`找到 ${diag.totalTFiles} 个 _t.dat,其中 ${diag.v2Count} 个是 V2 头,但没有长度 ≥ 0x1F 的有效模板。\n` +
|
||||
`扫描路径:${diag.userDir}\n` +
|
||||
'请在微信中查看更多图片后再试。'
|
||||
}
|
||||
}
|
||||
if (xorKey === null)
|
||||
return {
|
||||
success: false,
|
||||
error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片'
|
||||
}
|
||||
|
||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||
|
||||
@@ -965,7 +799,7 @@ export class KeyService {
|
||||
return { success: true, xorKey, aesKey }
|
||||
}
|
||||
// 等 5 秒再试
|
||||
await new Promise(r => setTimeout(r, 5000))
|
||||
await new Promise((r) => setTimeout(r, 5000))
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -977,7 +811,10 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||
private async _findTemplateData(
|
||||
userDir: string,
|
||||
limit: number = 32
|
||||
): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||
const { readdirSync, readFileSync, statSync } = await import('fs')
|
||||
const { join } = await import('path')
|
||||
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
@@ -992,7 +829,9 @@ export class KeyService {
|
||||
if (entry.isDirectory()) collect(full, results, maxFiles)
|
||||
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
||||
}
|
||||
} catch { /* 忽略无权限目录 */ }
|
||||
} catch {
|
||||
/* 忽略无权限目录 */
|
||||
}
|
||||
}
|
||||
|
||||
const files: string[] = []
|
||||
@@ -1000,11 +839,17 @@ export class KeyService {
|
||||
|
||||
// 按修改时间降序
|
||||
files.sort((a, b) => {
|
||||
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
|
||||
try {
|
||||
return statSync(b).mtimeMs - statSync(a).mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
let ciphertext: Buffer | null = null
|
||||
const tailCounts: Record<string, number> = {}
|
||||
let v2Count = 0
|
||||
let nonV2Count = 0
|
||||
|
||||
for (const f of files.slice(0, 32)) {
|
||||
try {
|
||||
@@ -1013,24 +858,43 @@ export class KeyService {
|
||||
|
||||
// 统计末尾两字节用于 XOR 密钥
|
||||
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
|
||||
v2Count++
|
||||
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
|
||||
tailCounts[key] = (tailCounts[key] ?? 0) + 1
|
||||
} else {
|
||||
nonV2Count++
|
||||
}
|
||||
|
||||
// 提取密文(取第一个有效的)
|
||||
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
|
||||
ciphertext = data.subarray(0xF, 0x1F)
|
||||
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1f) {
|
||||
ciphertext = data.subarray(0xf, 0x1f)
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 XOR 密钥
|
||||
let xorKey: number | null = null
|
||||
let maxCount = 0
|
||||
for (const [key, count] of Object.entries(tailCounts)) {
|
||||
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k }
|
||||
if (count > maxCount) {
|
||||
maxCount = count
|
||||
const [x, y] = key.split('_').map(Number)
|
||||
const k = x ^ 0xff
|
||||
if (k === (y ^ 0xd9)) xorKey = k
|
||||
}
|
||||
}
|
||||
|
||||
// 诊断信息:远程排查时让 UI 直接告诉用户搜到了什么
|
||||
const diag = {
|
||||
userDir,
|
||||
totalTFiles: files.length,
|
||||
v2Count,
|
||||
nonV2Count
|
||||
}
|
||||
;(this as { _imageTemplateDiag?: unknown })._imageTemplateDiag = diag
|
||||
|
||||
return { ciphertext, xorKey }
|
||||
}
|
||||
|
||||
@@ -1042,8 +906,19 @@ export class KeyService {
|
||||
if (!this.ensureKernel32()) return null
|
||||
|
||||
// 直接用已加载的 kernel32 实例,用 uintptr 传地址
|
||||
const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t'])
|
||||
const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('size_t*')])
|
||||
const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', [
|
||||
'void*',
|
||||
'uintptr',
|
||||
'void*',
|
||||
'size_t'
|
||||
])
|
||||
const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', [
|
||||
'void*',
|
||||
'uintptr',
|
||||
'void*',
|
||||
'size_t',
|
||||
this.koffi.out('size_t*')
|
||||
])
|
||||
|
||||
// RW 保护标志(只扫可写区域,速度更快)
|
||||
const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
|
||||
@@ -1052,7 +927,7 @@ export class KeyService {
|
||||
const PAGE_GUARD = 0x100
|
||||
const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64
|
||||
|
||||
const hProcess = this.OpenProcess(0x1F0FFF, false, pid)
|
||||
const hProcess = this.OpenProcess(0x1f0fff, false, pid)
|
||||
if (!hProcess) return null
|
||||
|
||||
try {
|
||||
@@ -1061,7 +936,7 @@ export class KeyService {
|
||||
let addr = 0
|
||||
const mbi = Buffer.alloc(MBI_SIZE)
|
||||
|
||||
while (addr < 0x7FFFFFFFFFFF) {
|
||||
while (addr < 0x7fffffffffff) {
|
||||
const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE)
|
||||
if (ret === 0) break
|
||||
// MEMORY_BASIC_INFORMATION x64 布局:
|
||||
@@ -1077,11 +952,13 @@ export class KeyService {
|
||||
const state = mbi.readUInt32LE(32)
|
||||
const protect = mbi.readUInt32LE(36)
|
||||
|
||||
if (state === MEM_COMMIT &&
|
||||
protect !== PAGE_NOACCESS &&
|
||||
(protect & PAGE_GUARD) === 0 &&
|
||||
(protect & RW_FLAGS) !== 0 &&
|
||||
size <= 50 * 1024 * 1024) {
|
||||
if (
|
||||
state === MEM_COMMIT &&
|
||||
protect !== PAGE_NOACCESS &&
|
||||
(protect & PAGE_GUARD) === 0 &&
|
||||
(protect & RW_FLAGS) !== 0 &&
|
||||
size <= 50 * 1024 * 1024
|
||||
) {
|
||||
regions.push([base, size])
|
||||
}
|
||||
const next = base + size
|
||||
@@ -1099,7 +976,7 @@ export class KeyService {
|
||||
const [base, size] = regions[i]
|
||||
if (i % 20 === 0) {
|
||||
onProgress?.(`扫描进度 ${i}/${regions.length}...`)
|
||||
await new Promise(r => setTimeout(r, 1)) // 让出事件循环
|
||||
await new Promise((r) => setTimeout(r, 1)) // 让出事件循环
|
||||
}
|
||||
|
||||
let offset = 0
|
||||
@@ -1110,17 +987,29 @@ export class KeyService {
|
||||
const buf = Buffer.alloc(chunkSize)
|
||||
const bytesReadOut = [0]
|
||||
const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut)
|
||||
if (!ok || bytesReadOut[0] === 0) { offset += chunkSize; trailing = null; continue }
|
||||
if (!ok || bytesReadOut[0] === 0) {
|
||||
offset += chunkSize
|
||||
trailing = null
|
||||
continue
|
||||
}
|
||||
|
||||
const data: Buffer = trailing ? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])]) : buf.subarray(0, bytesReadOut[0])
|
||||
const data: Buffer = trailing
|
||||
? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])])
|
||||
: buf.subarray(0, bytesReadOut[0])
|
||||
|
||||
// 搜索 ASCII 32字节密钥
|
||||
const key = this._searchAsciiKey(data, ciphertext)
|
||||
if (key) { this.CloseHandle(hProcess); return key }
|
||||
if (key) {
|
||||
this.CloseHandle(hProcess)
|
||||
return key
|
||||
}
|
||||
|
||||
// 搜索 UTF-16LE 32字节密钥
|
||||
const key16 = this._searchUtf16Key(data, ciphertext)
|
||||
if (key16) { this.CloseHandle(hProcess); return key16 }
|
||||
if (key16) {
|
||||
this.CloseHandle(hProcess)
|
||||
return key16
|
||||
}
|
||||
|
||||
trailing = data.subarray(Math.max(0, data.length - OVERLAP))
|
||||
offset += chunkSize
|
||||
@@ -1138,12 +1027,16 @@ export class KeyService {
|
||||
if (this._isAlphaNum(data[i])) continue
|
||||
let valid = true
|
||||
for (let j = 1; j <= 32; j++) {
|
||||
if (!this._isAlphaNum(data[i + j])) { valid = false; break }
|
||||
if (!this._isAlphaNum(data[i + j])) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!valid) continue
|
||||
if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue
|
||||
const keyBytes = data.subarray(i + 1, i + 33)
|
||||
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
|
||||
if (this._verifyAesKey(keyBytes, ciphertext))
|
||||
return keyBytes.toString('ascii').substring(0, 16)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1152,18 +1045,22 @@ export class KeyService {
|
||||
for (let i = 0; i < data.length - 65; i++) {
|
||||
let valid = true
|
||||
for (let j = 0; j < 32; j++) {
|
||||
if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break }
|
||||
if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!valid) continue
|
||||
const keyBytes = Buffer.alloc(32)
|
||||
for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2]
|
||||
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
|
||||
if (this._verifyAesKey(keyBytes, ciphertext))
|
||||
return keyBytes.toString('ascii').substring(0, 16)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private _isAlphaNum(b: number): boolean {
|
||||
return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39)
|
||||
return (b >= 0x61 && b <= 0x7a) || (b >= 0x41 && b <= 0x5a) || (b >= 0x30 && b <= 0x39)
|
||||
}
|
||||
|
||||
private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean {
|
||||
@@ -1172,12 +1069,14 @@ export class KeyService {
|
||||
decipher.setAutoPadding(false)
|
||||
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
// 支持 JPEG / PNG / WEBP / WXGF / GIF
|
||||
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||
if (dec[0] === 0xff && dec[1] === 0xd8 && dec[2] === 0xff) return true
|
||||
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4e && dec[3] === 0x47) return true
|
||||
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||
return false
|
||||
} catch { return false }
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createHash } from 'crypto'
|
||||
import type {
|
||||
KnowledgeChunk,
|
||||
KnowledgeChunkerConfig,
|
||||
KnowledgeNormalizedMessage
|
||||
} from '../../shared/knowledge'
|
||||
import { isIndexableKnowledgeMessage } from './normalizer'
|
||||
|
||||
function digest(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
function formatChunkText(messages: KnowledgeNormalizedMessage[]): string {
|
||||
return messages
|
||||
.map((message) => {
|
||||
const sender = message.senderName || message.senderId || '未知成员'
|
||||
return `[${new Date(message.createTime).toISOString()}] ${sender}: ${message.searchableText}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function buildChunk(
|
||||
messages: KnowledgeNormalizedMessage[],
|
||||
config: KnowledgeChunkerConfig
|
||||
): KnowledgeChunk {
|
||||
const first = messages[0]
|
||||
const last = messages[messages.length - 1]
|
||||
const text = formatChunkText(messages)
|
||||
const messageIds = messages.map((message) => message.messageId)
|
||||
const participantIds = Array.from(
|
||||
new Set(messages.map((message) => message.senderId).filter((value): value is string => Boolean(value)))
|
||||
)
|
||||
const messageKinds = Array.from(new Set(messages.map((message) => message.kind)))
|
||||
const identity = `${first.accountId}|${first.conversationId}|${config.version}|${messageIds.join('|')}`
|
||||
return {
|
||||
chunkId: digest(identity),
|
||||
accountId: first.accountId,
|
||||
conversationId: first.conversationId,
|
||||
startTime: first.createTime,
|
||||
endTime: last.createTime,
|
||||
text,
|
||||
messageIds,
|
||||
participantIds,
|
||||
messageKinds,
|
||||
contentHash: digest(`${identity}|${text}`),
|
||||
chunkerVersion: config.version
|
||||
}
|
||||
}
|
||||
|
||||
/** Chunks one conversation only; cross-conversation chunks are never allowed. */
|
||||
export function chunkConversation(
|
||||
messages: KnowledgeNormalizedMessage[],
|
||||
config: KnowledgeChunkerConfig
|
||||
): KnowledgeChunk[] {
|
||||
const sorted = messages
|
||||
.filter(isIndexableKnowledgeMessage)
|
||||
.slice()
|
||||
.sort((left, right) => left.createTime - right.createTime || left.messageId.localeCompare(right.messageId))
|
||||
if (!sorted.length) return []
|
||||
|
||||
const conversationId = sorted[0].conversationId
|
||||
const accountId = sorted[0].accountId
|
||||
if (sorted.some((message) => message.conversationId !== conversationId || message.accountId !== accountId)) {
|
||||
throw new Error('Conversation chunker received messages from multiple accounts or conversations')
|
||||
}
|
||||
|
||||
const chunks: KnowledgeChunk[] = []
|
||||
let current: KnowledgeNormalizedMessage[] = []
|
||||
let currentCharacters = 0
|
||||
for (const message of sorted) {
|
||||
const previous = current[current.length - 1]
|
||||
const nextCharacters = currentCharacters + message.searchableText.length
|
||||
const shouldSplit =
|
||||
current.length > 0 &&
|
||||
(message.createTime - previous.createTime > config.maxGapMs ||
|
||||
current.length >= config.maxMessages ||
|
||||
nextCharacters > config.maxCharacters)
|
||||
if (shouldSplit) {
|
||||
chunks.push(buildChunk(current, config))
|
||||
current = []
|
||||
currentCharacters = 0
|
||||
}
|
||||
current.push(message)
|
||||
currentCharacters += message.searchableText.length
|
||||
}
|
||||
if (current.length) chunks.push(buildChunk(current, config))
|
||||
return chunks
|
||||
}
|
||||
@@ -0,0 +1,931 @@
|
||||
import * as chat from '../services/chat-service'
|
||||
import type {
|
||||
KnowledgeAttachmentMetadata,
|
||||
KnowledgeEvidence,
|
||||
KnowledgeMessageKind,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchIpcRequest,
|
||||
KnowledgeSearchIpcResult,
|
||||
KnowledgeSearchResult,
|
||||
KnowledgeSourceMessage
|
||||
} from '../../shared/knowledge'
|
||||
import type {
|
||||
VoiceMessageReference,
|
||||
VoiceTranscriptSnapshot,
|
||||
VoiceTranscriptUpdate
|
||||
} from '../../shared/voice-recognition'
|
||||
import {
|
||||
DEFAULT_KNOWLEDGE_CHUNKER,
|
||||
DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
||||
emptyKnowledgeSearchTimings
|
||||
} from '../../shared/knowledge'
|
||||
import { KnowledgeService } from './knowledge-service'
|
||||
import {
|
||||
voiceAccountIdentity,
|
||||
voiceMessageIdentity
|
||||
} from '../voice-pipeline/voice-message-identity'
|
||||
|
||||
const FALLBACK_LIMIT = 240
|
||||
const MAX_SENDER_NAME_CONVERSATIONS = 8
|
||||
const MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH = 700
|
||||
const MAX_SENDER_ENRICHMENT_SESSIONS = 32
|
||||
const SENDER_ENRICHMENT_SESSION_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
type PendingVoiceTranscriptIndex = {
|
||||
update: VoiceTranscriptUpdate
|
||||
waiters: Array<{
|
||||
resolve: () => void
|
||||
reject: (error: unknown) => void
|
||||
}>
|
||||
}
|
||||
|
||||
type SenderEnrichmentSession = {
|
||||
lastUsedAt: number
|
||||
contacts?: Awaited<ReturnType<typeof chat.listContactsAsync>>
|
||||
groupSnapshots: Map<string, Awaited<ReturnType<typeof chat.getGroupSnapshotAsync>> | undefined>
|
||||
}
|
||||
|
||||
function looksLikeOpaqueSenderId(value: string | undefined): boolean {
|
||||
const normalized = value?.trim() || ''
|
||||
return (
|
||||
normalized.startsWith('wxid_') ||
|
||||
normalized.endsWith('@chatroom') ||
|
||||
/^\d{6,}$/.test(normalized)
|
||||
)
|
||||
}
|
||||
|
||||
function groupMemberDisplayName(member: chat.GroupSnapshot['members'][number]): string {
|
||||
return (
|
||||
[member.groupNickname, member.wechatNickname, member.nickname, member.remark]
|
||||
.map((value) => value.trim())
|
||||
.find((value) => value && !looksLikeOpaqueSenderId(value)) || ''
|
||||
)
|
||||
}
|
||||
|
||||
function sourceMessageId(message: chat.FormattedMessage): string {
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
if (message.id) return String(message.id)
|
||||
return `${message.createTime || 0}:${message.serverId || message.content}`
|
||||
}
|
||||
|
||||
function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
|
||||
if (message.voiceTranscript || message.type === '语音') return 'voice'
|
||||
if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
|
||||
return message.contentData.type === 'share' && message.contentData.typeVal === '6'
|
||||
? 'file'
|
||||
: 'link'
|
||||
}
|
||||
if (message.contentData?.type === 'system') return 'system'
|
||||
return message.content?.trim() ? 'text' : 'other'
|
||||
}
|
||||
|
||||
function sourceTextAndAttachment(message: chat.FormattedMessage): {
|
||||
text?: string
|
||||
attachment?: KnowledgeAttachmentMetadata
|
||||
} {
|
||||
const text = message.content?.trim() || ''
|
||||
const content = message.contentData
|
||||
if (!content) {
|
||||
return {
|
||||
text: text || undefined,
|
||||
attachment: message.exportMediaName
|
||||
? {
|
||||
name: message.exportMediaName,
|
||||
kind: message.exportMediaType === 'file' ? 'file' : 'other'
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
if (content.type === 'share') {
|
||||
const title = content.title?.trim() || ''
|
||||
const description = content.des?.trim() || ''
|
||||
const articles = (content.articles || []).flatMap((article) =>
|
||||
[article.title, article.description].map((value) => value?.trim()).filter(Boolean)
|
||||
)
|
||||
return {
|
||||
text: [text, title, description, ...articles].filter(Boolean).join('\n') || undefined,
|
||||
attachment:
|
||||
title || content.url
|
||||
? {
|
||||
name: title || content.url,
|
||||
kind: content.typeVal === '6' ? 'file' : 'link',
|
||||
url: content.url
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
if (content.type === 'miniProgram') {
|
||||
return {
|
||||
text: [text, content.title, content.description].filter(Boolean).join('\n') || undefined,
|
||||
attachment: content.title ? { name: content.title, kind: 'link' } : undefined
|
||||
}
|
||||
}
|
||||
if (content.type === 'quote') {
|
||||
return {
|
||||
text:
|
||||
[text, content.title, content.content, content.quotedContent].filter(Boolean).join('\n') ||
|
||||
undefined
|
||||
}
|
||||
}
|
||||
if (content.type === 'forwardBundle') {
|
||||
return {
|
||||
text: [text, content.title, content.description, ...content.items.map((item) => item.text)]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
}
|
||||
return { text: text || undefined }
|
||||
}
|
||||
|
||||
function toSourceMessage(
|
||||
accountId: string,
|
||||
conversationId: string,
|
||||
message: chat.FormattedMessage,
|
||||
transcriptOverride?: string
|
||||
): KnowledgeSourceMessage | null {
|
||||
if (!message.createTime) return null
|
||||
const extracted = sourceTextAndAttachment(message)
|
||||
const voiceTranscript = transcriptOverride?.trim() || message.voiceTranscript?.trim() || undefined
|
||||
if (!extracted.text && !extracted.attachment && !voiceTranscript) return null
|
||||
return {
|
||||
accountId,
|
||||
conversationId,
|
||||
messageId: sourceMessageId(message),
|
||||
// Existing chat messages use Unix seconds; the knowledge contract uses milliseconds.
|
||||
createTime: message.createTime * 1000,
|
||||
senderId: message.senderId || message.from || undefined,
|
||||
senderName: message.isSender ? '我' : message.name || undefined,
|
||||
kind: sourceKind(message),
|
||||
text: extracted.text,
|
||||
attachment: extracted.attachment,
|
||||
voiceTranscript
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeComparable(value: string): string {
|
||||
return value.toLocaleLowerCase().replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function fallbackTermScore(message: chat.FormattedMessage, terms: string[]): number {
|
||||
const source = toSourceMessage('fallback', 'fallback', message)
|
||||
const text = `${source?.text || ''}\n${source?.voiceTranscript || ''}\n${source?.attachment?.name || ''}`
|
||||
const normalized = normalizeComparable(text)
|
||||
return terms.reduce((score, term) => {
|
||||
const normalizedTerm = normalizeComparable(term)
|
||||
return normalizedTerm && normalized.includes(normalizedTerm)
|
||||
? score + normalizedTerm.length
|
||||
: score
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-process adapter for the read-only chat archive. It never passes source
|
||||
* database handles or keys to the worker; only normalized serializable values.
|
||||
*/
|
||||
export class KnowledgeSearchService {
|
||||
private readonly service: KnowledgeService
|
||||
private readonly indexing = new Map<string, Promise<void>>()
|
||||
private readonly statusByAccount = new Map<string, KnowledgeRuntimeStatus>()
|
||||
private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>()
|
||||
private readonly senderEnrichmentSessions = new Map<string, SenderEnrichmentSession>()
|
||||
private wcdbReadTail: Promise<void> = Promise.resolve()
|
||||
private wcdbQueueMsTotal = 0
|
||||
private wcdbExecutionMsTotal = 0
|
||||
private voiceTranscriptResolver:
|
||||
| ((reference: VoiceMessageReference) => VoiceTranscriptSnapshot)
|
||||
| undefined
|
||||
private voiceIndexTail: Promise<void> = Promise.resolve()
|
||||
private voiceIndexFlushScheduled = false
|
||||
private readonly pendingVoiceIndexes = new Map<string, PendingVoiceTranscriptIndex>()
|
||||
|
||||
constructor(userDataPath: string, workerPath: string) {
|
||||
this.service = new KnowledgeService(userDataPath, workerPath)
|
||||
}
|
||||
|
||||
startCurrentAccountIndex(): KnowledgeRuntimeStatus {
|
||||
const accountId = this.currentAccountId()
|
||||
if (!accountId) return this.emptyStatus('')
|
||||
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
||||
if (this.indexing.has(accountId)) return current
|
||||
const started: KnowledgeRuntimeStatus = {
|
||||
...current,
|
||||
state: current.indexedMessageCount ? 'syncing' : 'building',
|
||||
processedMessages: 0,
|
||||
totalMessages: current.sourceMessageCount,
|
||||
estimatedRemainingMs: null,
|
||||
lastError: undefined
|
||||
}
|
||||
this.publishStatus(started)
|
||||
const task = this.indexAccount(accountId)
|
||||
.catch((error) => {
|
||||
const previous = this.statusByAccount.get(accountId)
|
||||
this.publishStatus({
|
||||
...(previous || this.emptyStatus(accountId)),
|
||||
state: 'error',
|
||||
lastError: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
this.indexing.delete(accountId)
|
||||
void this.refreshStatus(accountId).catch(() => undefined)
|
||||
})
|
||||
this.indexing.set(accountId, task)
|
||||
void task.catch((error) => {
|
||||
console.warn('[Knowledge] background index failed:', error)
|
||||
})
|
||||
return started
|
||||
}
|
||||
|
||||
/**
|
||||
* The voice cache remains owned by the voice pipeline. Knowledge only reads
|
||||
* a current-account snapshot while constructing a derived local index.
|
||||
*/
|
||||
setVoiceTranscriptResolver(
|
||||
resolver: (reference: VoiceMessageReference) => VoiceTranscriptSnapshot
|
||||
): void {
|
||||
this.voiceTranscriptResolver = resolver
|
||||
}
|
||||
|
||||
/**
|
||||
* A successful recognition updates its source conversation. Consecutive
|
||||
* updates for the same conversation are coalesced because a complete
|
||||
* snapshot already includes every finished transcript for that conversation.
|
||||
*/
|
||||
indexVoiceTranscript(update: VoiceTranscriptUpdate): Promise<void> {
|
||||
const key = this.voiceIndexKey(update)
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const existing = this.pendingVoiceIndexes.get(key)
|
||||
if (existing) {
|
||||
existing.update = update
|
||||
existing.waiters.push({ resolve, reject })
|
||||
} else {
|
||||
this.pendingVoiceIndexes.set(key, {
|
||||
update,
|
||||
waiters: [{ resolve, reject }]
|
||||
})
|
||||
}
|
||||
this.scheduleVoiceIndexFlush()
|
||||
})
|
||||
}
|
||||
|
||||
private voiceIndexKey(update: VoiceTranscriptUpdate): string {
|
||||
return `${update.accountIdentity}:${update.reference.sessionId}`
|
||||
}
|
||||
|
||||
private scheduleVoiceIndexFlush(): void {
|
||||
if (this.voiceIndexFlushScheduled) return
|
||||
this.voiceIndexFlushScheduled = true
|
||||
const task = this.voiceIndexTail.then(() => this.flushPendingVoiceIndexes())
|
||||
this.voiceIndexTail = task.catch(() => undefined)
|
||||
void task.then(
|
||||
() => this.finishVoiceIndexFlush(),
|
||||
() => this.finishVoiceIndexFlush()
|
||||
)
|
||||
}
|
||||
|
||||
private async flushPendingVoiceIndexes(): Promise<void> {
|
||||
while (this.pendingVoiceIndexes.size) {
|
||||
const pending = Array.from(this.pendingVoiceIndexes.values())
|
||||
this.pendingVoiceIndexes.clear()
|
||||
for (const entry of pending) {
|
||||
try {
|
||||
await this.indexVoiceTranscriptNow(entry.update)
|
||||
entry.waiters.forEach((waiter) => waiter.resolve())
|
||||
} catch (error) {
|
||||
entry.waiters.forEach((waiter) => waiter.reject(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private finishVoiceIndexFlush(): void {
|
||||
this.voiceIndexFlushScheduled = false
|
||||
if (this.pendingVoiceIndexes.size) this.scheduleVoiceIndexFlush()
|
||||
}
|
||||
|
||||
async search(request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> {
|
||||
const accountId = this.currentAccountId()
|
||||
if (!accountId) return this.searchFallback(request, 'unavailable')
|
||||
try {
|
||||
const searchRequest: Omit<KnowledgeSearchRequest, 'databaseRoot'> = {
|
||||
accountId,
|
||||
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
||||
text: request.text,
|
||||
terms: request.terms,
|
||||
limit: Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)),
|
||||
conversationIds: request.conversationIds,
|
||||
senderIds: request.senderIds,
|
||||
startTime: request.startTime === undefined ? undefined : request.startTime * 1000,
|
||||
endTime: request.endTime === undefined ? undefined : request.endTime * 1000
|
||||
}
|
||||
const result = await this.searchKnowledge(searchRequest)
|
||||
// An existing derived database can answer while its next incremental pass is running.
|
||||
// Never turn an interactive global search into another full WCDB scan during that pass.
|
||||
if (result.state === 'ready' || result.evidence.length) {
|
||||
return this.toKnowledgeResult(result, request.retrievalSessionId)
|
||||
}
|
||||
if (this.indexing.has(accountId)) {
|
||||
return {
|
||||
...result,
|
||||
source: 'knowledge',
|
||||
totalMessages: result.indexedMessageCount
|
||||
}
|
||||
}
|
||||
return this.searchFallback(request, 'unavailable')
|
||||
} catch (error) {
|
||||
console.warn('[Knowledge] search failed, using legacy fallback:', error)
|
||||
return this.searchFallback(request, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.service.dispose()
|
||||
}
|
||||
|
||||
/** Safely release derived SQLite handles before the cache screen removes them. */
|
||||
async prepareForCacheClear(): Promise<void> {
|
||||
if (this.indexing.size) {
|
||||
throw new Error('本地知识库正在同步,请等待同步完成后再清理')
|
||||
}
|
||||
await this.service.dispose()
|
||||
const accountIds = Array.from(this.statusByAccount.keys())
|
||||
this.statusByAccount.clear()
|
||||
accountIds.forEach((accountId) => this.publishStatus(this.emptyStatus(accountId)))
|
||||
}
|
||||
|
||||
onStatusChange(listener: (status: KnowledgeRuntimeStatus) => void): () => void {
|
||||
this.statusListeners.add(listener)
|
||||
return () => this.statusListeners.delete(listener)
|
||||
}
|
||||
|
||||
async getStatus(): Promise<KnowledgeRuntimeStatus> {
|
||||
const accountId = this.currentAccountId()
|
||||
if (!accountId) return this.emptyStatus('')
|
||||
return this.refreshStatus(accountId)
|
||||
}
|
||||
|
||||
private currentAccountId(): string {
|
||||
if (!chat.isReady()) return ''
|
||||
return chat.getSelfAccountInfo()?.wxid || chat.getCurrentAccountRoot()
|
||||
}
|
||||
|
||||
private async indexAccount(accountId: string): Promise<void> {
|
||||
const contacts = await this.listContacts()
|
||||
let processedMessages = 0
|
||||
const startedAt = Date.now()
|
||||
this.publishStatus({
|
||||
...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)),
|
||||
state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building',
|
||||
processedMessages: 0,
|
||||
totalMessages: null,
|
||||
estimatedRemainingMs: null
|
||||
})
|
||||
for (const [index, contact] of contacts.entries()) {
|
||||
// WCDB rejects overlapping async pagination. Queue every archive read so
|
||||
// background indexing and an interactive fallback search can interleave safely.
|
||||
const messages = await this.listMessages(contact.md5)
|
||||
const sourceMessages = messages
|
||||
.map((message) => this.toSourceMessage(accountId, contact.md5, message))
|
||||
.filter((message): message is KnowledgeSourceMessage => Boolean(message))
|
||||
await this.service.index(
|
||||
{
|
||||
accountId,
|
||||
conversations: [
|
||||
{
|
||||
conversationId: contact.md5,
|
||||
completeSnapshot: true,
|
||||
messages: sourceMessages
|
||||
}
|
||||
],
|
||||
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
|
||||
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
||||
sourceMessageCount:
|
||||
index === contacts.length - 1 ? processedMessages + sourceMessages.length : undefined
|
||||
},
|
||||
(progress) => {
|
||||
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
||||
this.publishStatus({
|
||||
...current,
|
||||
state: current.indexedMessageCount ? 'syncing' : 'building',
|
||||
processedMessages: processedMessages + progress.processedMessages,
|
||||
totalMessages: null,
|
||||
currentConversationId: progress.conversationId,
|
||||
estimatedRemainingMs: null
|
||||
})
|
||||
}
|
||||
)
|
||||
processedMessages += sourceMessages.length
|
||||
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
||||
this.publishStatus({
|
||||
...current,
|
||||
state: current.indexedMessageCount ? 'syncing' : 'building',
|
||||
processedMessages,
|
||||
totalMessages: null,
|
||||
currentConversationId: contact.md5,
|
||||
estimatedRemainingMs: null
|
||||
})
|
||||
}
|
||||
await this.refreshStatus(accountId, {
|
||||
processedMessages,
|
||||
totalMessages: processedMessages,
|
||||
startedAt
|
||||
})
|
||||
}
|
||||
|
||||
private async searchFallback(
|
||||
request: KnowledgeSearchIpcRequest,
|
||||
fallbackReason: 'unavailable' | 'indexing' | 'error'
|
||||
): Promise<KnowledgeSearchIpcResult> {
|
||||
const startedAt = Date.now()
|
||||
const contacts = await this.listContacts()
|
||||
const allowedConversations = new Set(request.conversationIds || [])
|
||||
const sourceContacts = allowedConversations.size
|
||||
? contacts.filter((contact) => allowedConversations.has(contact.md5))
|
||||
: contacts
|
||||
const senderIds = new Set(request.senderIds || [])
|
||||
const terms = request.terms.filter((term) => term.trim().length >= 2)
|
||||
const matches: Array<{
|
||||
contact: (typeof sourceContacts)[number]
|
||||
message: chat.FormattedMessage
|
||||
score: number
|
||||
}> = []
|
||||
let totalMessages = 0
|
||||
|
||||
for (const contact of sourceContacts) {
|
||||
const messages = await this.listMessages(contact.md5, request.startTime, request.endTime)
|
||||
totalMessages += messages.length
|
||||
for (const message of messages) {
|
||||
const hydrated = this.withVoiceTranscript(message)
|
||||
matches.push({
|
||||
contact,
|
||||
message: hydrated,
|
||||
score: fallbackTermScore(hydrated, terms)
|
||||
})
|
||||
}
|
||||
}
|
||||
const filtered = matches
|
||||
.filter(({ message, score }) => {
|
||||
const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from)
|
||||
const termMatches = !terms.length || score > 0
|
||||
return senderMatches && termMatches
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score ||
|
||||
(right.message.createTime || 0) - (left.message.createTime || 0)
|
||||
)
|
||||
.slice(0, Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)))
|
||||
const result: KnowledgeSearchIpcResult = {
|
||||
source: 'fallback',
|
||||
fallbackReason,
|
||||
state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable',
|
||||
indexedMessageCount: 0,
|
||||
indexedChunkCount: 0,
|
||||
totalMessages,
|
||||
timings: {
|
||||
...emptyKnowledgeSearchTimings(),
|
||||
messageLoadMs: Date.now() - startedAt,
|
||||
totalMs: Date.now() - startedAt
|
||||
},
|
||||
evidence: filtered.map(({ contact, message, score }) => ({
|
||||
chunkId: `fallback:${contact.md5}:${sourceMessageId(message)}`,
|
||||
conversationId: contact.md5,
|
||||
startTime: (message.createTime || 0) * 1000,
|
||||
endTime: (message.createTime || 0) * 1000,
|
||||
messageId: sourceMessageId(message),
|
||||
senderId: message.senderId || message.from || undefined,
|
||||
sender: message.isSender ? '我' : message.name || '未知成员',
|
||||
timestamp: (message.createTime || 0) * 1000,
|
||||
messageIds: [sourceMessageId(message)],
|
||||
sourceKind: sourceKind(message),
|
||||
text:
|
||||
this.toSourceMessage('fallback', contact.md5, message)?.voiceTranscript ||
|
||||
sourceTextAndAttachment(message).text ||
|
||||
message.content ||
|
||||
`[${message.type}]`,
|
||||
score: -score
|
||||
}))
|
||||
}
|
||||
const beforeQueueMs = this.wcdbQueueMsTotal
|
||||
const beforeExecutionMs = this.wcdbExecutionMsTotal
|
||||
const enrichmentStartedAt = Date.now()
|
||||
const evidence = await this.enrichEvidenceSenders(result.evidence, request.retrievalSessionId)
|
||||
return {
|
||||
...result,
|
||||
evidence,
|
||||
timings: {
|
||||
...result.timings,
|
||||
senderEnrichmentMs: Date.now() - enrichmentStartedAt,
|
||||
wcdbQueueMs: this.wcdbQueueMsTotal - beforeQueueMs,
|
||||
wcdbExecutionMs: this.wcdbExecutionMsTotal - beforeExecutionMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite has a finite bind-parameter limit. Group/one-to-one scope filters
|
||||
* can contain over one thousand conversations, so split only the Worker
|
||||
* query and merge real Evidence instead of dropping the selected scope.
|
||||
*/
|
||||
private async searchKnowledge(
|
||||
request: Omit<KnowledgeSearchRequest, 'databaseRoot'>
|
||||
): Promise<KnowledgeSearchResult> {
|
||||
const conversationIds = Array.from(new Set(request.conversationIds || []))
|
||||
if (conversationIds.length <= MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH) {
|
||||
return this.searchWorker(request)
|
||||
}
|
||||
const partialResults: KnowledgeSearchResult[] = []
|
||||
for (
|
||||
let start = 0;
|
||||
start < conversationIds.length;
|
||||
start += MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH
|
||||
) {
|
||||
partialResults.push(
|
||||
await this.searchWorker({
|
||||
...request,
|
||||
conversationIds: conversationIds.slice(
|
||||
start,
|
||||
start + MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
const evidenceByIdentity = new Map<string, KnowledgeEvidence>()
|
||||
partialResults
|
||||
.flatMap((result) => result.evidence)
|
||||
.forEach((item) => {
|
||||
const identity = `${item.conversationId}:${item.messageId}`
|
||||
const existing = evidenceByIdentity.get(identity)
|
||||
if (!existing || (item.score || 0) < (existing.score || 0)) {
|
||||
evidenceByIdentity.set(identity, item)
|
||||
}
|
||||
})
|
||||
const mergeStartedAt = Date.now()
|
||||
const mergedEvidence = Array.from(evidenceByIdentity.values())
|
||||
.sort(
|
||||
(left, right) => (left.score || 0) - (right.score || 0) || right.timestamp - left.timestamp
|
||||
)
|
||||
.slice(0, request.limit)
|
||||
const timings = partialResults.reduce(
|
||||
(total, result) => ({
|
||||
workerIpcMs: total.workerIpcMs + (result.timings?.workerIpcMs || 0),
|
||||
workerBootMs: total.workerBootMs + (result.timings?.workerBootMs || 0),
|
||||
dispatchMs: total.dispatchMs + (result.timings?.dispatchMs || 0),
|
||||
workerSqlMs: total.workerSqlMs + (result.timings?.workerSqlMs || 0),
|
||||
responseTransferMs: total.responseTransferMs + (result.timings?.responseTransferMs || 0),
|
||||
responseSerializeMs: total.responseSerializeMs + (result.timings?.responseSerializeMs || 0),
|
||||
ftsMs: total.ftsMs + (result.timings?.ftsMs || 0),
|
||||
messageLoadMs: total.messageLoadMs + (result.timings?.messageLoadMs || 0),
|
||||
chunkExpandMs: total.chunkExpandMs + (result.timings?.chunkExpandMs || 0),
|
||||
rankingMs: total.rankingMs + (result.timings?.rankingMs || 0),
|
||||
totalMs: total.totalMs + (result.timings?.totalMs || 0),
|
||||
globalCountMs: (total.globalCountMs || 0) + (result.timings?.globalCountMs || 0),
|
||||
voiceCoverageMs: (total.voiceCoverageMs || 0) + (result.timings?.voiceCoverageMs || 0),
|
||||
workerExecutionMs:
|
||||
(total.workerExecutionMs || 0) +
|
||||
(result.timings?.workerExecutionMs || result.timings?.totalMs || 0),
|
||||
workerQueueMs: (total.workerQueueMs || 0) + (result.timings?.workerQueueMs || 0),
|
||||
ipcMs: (total.ipcMs || 0) + (result.timings?.ipcMs || result.timings?.workerIpcMs || 0),
|
||||
serializationMs:
|
||||
(total.serializationMs || 0) +
|
||||
(result.timings?.serializationMs || result.timings?.responseSerializeMs || 0)
|
||||
}),
|
||||
emptyKnowledgeSearchTimings()
|
||||
)
|
||||
const mergeRankingMs = Date.now() - mergeStartedAt
|
||||
timings.rankingMs += mergeRankingMs
|
||||
timings.totalMs += mergeRankingMs
|
||||
const voiceCoverageParts = partialResults
|
||||
.map((result) => result.voiceCoverage)
|
||||
.filter((coverage): coverage is NonNullable<typeof coverage> => Boolean(coverage))
|
||||
const voiceCoverage = voiceCoverageParts.length
|
||||
? voiceCoverageParts.reduce(
|
||||
(total, coverage) => ({
|
||||
voiceMessageCount: total.voiceMessageCount + coverage.voiceMessageCount,
|
||||
transcribedVoiceCount: total.transcribedVoiceCount + coverage.transcribedVoiceCount,
|
||||
failedVoiceCount: total.failedVoiceCount + coverage.failedVoiceCount,
|
||||
voiceCoverageComplete: false
|
||||
}),
|
||||
{
|
||||
voiceMessageCount: 0,
|
||||
transcribedVoiceCount: 0,
|
||||
failedVoiceCount: 0,
|
||||
voiceCoverageComplete: false
|
||||
}
|
||||
)
|
||||
: undefined
|
||||
if (voiceCoverage) {
|
||||
voiceCoverage.voiceCoverageComplete =
|
||||
voiceCoverage.voiceMessageCount === voiceCoverage.transcribedVoiceCount
|
||||
}
|
||||
return {
|
||||
state: partialResults.some((result) => result.state === 'ready')
|
||||
? 'ready'
|
||||
: partialResults.some((result) => result.state === 'indexing')
|
||||
? 'indexing'
|
||||
: 'unavailable',
|
||||
indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)),
|
||||
indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)),
|
||||
evidence: mergedEvidence,
|
||||
timings,
|
||||
voiceCoverage
|
||||
}
|
||||
}
|
||||
|
||||
private async searchWorker(
|
||||
request: Omit<KnowledgeSearchRequest, 'databaseRoot'>
|
||||
): Promise<KnowledgeSearchResult> {
|
||||
const startedAt = Date.now()
|
||||
const result = await this.service.search(request)
|
||||
const timings = result.timings || emptyKnowledgeSearchTimings()
|
||||
const workerExecutionMs = timings.workerExecutionMs ?? timings.totalMs
|
||||
const ipcMs = timings.ipcMs ?? timings.workerIpcMs
|
||||
const serializationMs = timings.serializationMs ?? timings.responseSerializeMs
|
||||
return {
|
||||
...result,
|
||||
timings: {
|
||||
...timings,
|
||||
// Do not infer IPC by subtracting the Worker timer from wall clock:
|
||||
// that previously hid unmeasured Worker execution inside “通信”.
|
||||
workerIpcMs: timings.workerIpcMs,
|
||||
ipcMs,
|
||||
workerSqlMs: timings.workerSqlMs || timings.totalMs,
|
||||
workerExecutionMs,
|
||||
serializationMs,
|
||||
otherMs:
|
||||
timings.otherMs ??
|
||||
Math.max(0, Date.now() - startedAt - workerExecutionMs - ipcMs - serializationMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private listContacts(): ReturnType<typeof chat.listContactsAsync> {
|
||||
return this.enqueueWcdbRead(() => chat.listContactsAsync())
|
||||
}
|
||||
|
||||
private listMessages(
|
||||
conversationId: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): ReturnType<typeof chat.listMessagesAsync> {
|
||||
return this.enqueueWcdbRead(() => chat.listMessagesAsync(conversationId, startTime, endTime))
|
||||
}
|
||||
|
||||
private withVoiceTranscript(message: chat.FormattedMessage): chat.FormattedMessage {
|
||||
const reference = this.voiceReferenceFromMessage(message)
|
||||
if (!reference || !this.voiceTranscriptResolver) return message
|
||||
const snapshot = this.voiceTranscriptResolver(reference)
|
||||
if (snapshot.state !== 'transcribed' || !snapshot.transcript?.trim()) return message
|
||||
return { ...message, voiceTranscript: snapshot.transcript.trim() }
|
||||
}
|
||||
|
||||
private toSourceMessage(
|
||||
accountId: string,
|
||||
conversationId: string,
|
||||
message: chat.FormattedMessage,
|
||||
transcriptOverride?: string,
|
||||
stateOverride?: 'pending' | 'transcribed' | 'failed'
|
||||
): KnowledgeSourceMessage | null {
|
||||
const reference = this.voiceReferenceFromMessage(message)
|
||||
const snapshot = reference ? this.voiceTranscriptResolver?.(reference) : undefined
|
||||
const hydrated = this.withVoiceTranscript(message)
|
||||
const source = toSourceMessage(accountId, conversationId, hydrated, transcriptOverride)
|
||||
if (!source || source.kind !== 'voice') return source
|
||||
return {
|
||||
...source,
|
||||
voiceTranscriptState:
|
||||
stateOverride ||
|
||||
(transcriptOverride?.trim() ? 'transcribed' : undefined) ||
|
||||
snapshot?.state ||
|
||||
(source.voiceTranscript ? 'transcribed' : 'pending')
|
||||
}
|
||||
}
|
||||
|
||||
private voiceReferenceFromMessage(
|
||||
message: chat.FormattedMessage
|
||||
): VoiceMessageReference | undefined {
|
||||
if (
|
||||
message.type !== '语音' ||
|
||||
!message.sessionId ||
|
||||
message.localId === undefined ||
|
||||
!message.createTime
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
sessionId: message.sessionId,
|
||||
localId: message.localId,
|
||||
createTime: message.createTime,
|
||||
svrId: message.serverId
|
||||
}
|
||||
}
|
||||
|
||||
private async indexVoiceTranscriptNow(update: VoiceTranscriptUpdate): Promise<void> {
|
||||
if (!chat.isReady()) return
|
||||
if (update.state === 'transcribed' && !update.transcript?.trim()) return
|
||||
if (voiceAccountIdentity(chat.getCurrentAccountRoot()) !== update.accountIdentity) {
|
||||
return
|
||||
}
|
||||
const accountId = this.currentAccountId()
|
||||
if (!accountId) return
|
||||
const activeIndex = this.indexing.get(accountId)
|
||||
if (activeIndex) await activeIndex
|
||||
if (voiceAccountIdentity(chat.getCurrentAccountRoot()) !== update.accountIdentity) {
|
||||
return
|
||||
}
|
||||
const contacts = await this.listContacts()
|
||||
const contact = contacts.find((item) => item.m_nsUsrName === update.reference.sessionId)
|
||||
if (!contact) return
|
||||
const messages = await this.listMessages(contact.md5)
|
||||
const sourceMessages = messages
|
||||
.map((message) => {
|
||||
const reference = this.voiceReferenceFromMessage(message)
|
||||
const transcriptOverride =
|
||||
reference && voiceMessageIdentity(reference) === update.messageIdentity
|
||||
? update.transcript
|
||||
: undefined
|
||||
const stateOverride =
|
||||
reference && voiceMessageIdentity(reference) === update.messageIdentity
|
||||
? update.state
|
||||
: undefined
|
||||
return this.toSourceMessage(
|
||||
accountId,
|
||||
contact.md5,
|
||||
message,
|
||||
transcriptOverride,
|
||||
stateOverride
|
||||
)
|
||||
})
|
||||
.filter((message): message is KnowledgeSourceMessage => Boolean(message))
|
||||
await this.service.index({
|
||||
accountId,
|
||||
conversations: [
|
||||
{
|
||||
conversationId: contact.md5,
|
||||
completeSnapshot: true,
|
||||
messages: sourceMessages
|
||||
}
|
||||
],
|
||||
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
|
||||
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG
|
||||
})
|
||||
await this.refreshStatus(accountId)
|
||||
}
|
||||
|
||||
private async toKnowledgeResult(
|
||||
result: KnowledgeSearchResult,
|
||||
retrievalSessionId?: string
|
||||
): Promise<KnowledgeSearchIpcResult> {
|
||||
const beforeQueueMs = this.wcdbQueueMsTotal
|
||||
const beforeExecutionMs = this.wcdbExecutionMsTotal
|
||||
const enrichmentStartedAt = Date.now()
|
||||
const evidence = await this.enrichEvidenceSenders(result.evidence, retrievalSessionId)
|
||||
return {
|
||||
...result,
|
||||
evidence,
|
||||
timings: {
|
||||
...result.timings,
|
||||
senderEnrichmentMs: Date.now() - enrichmentStartedAt,
|
||||
wcdbQueueMs: this.wcdbQueueMsTotal - beforeQueueMs,
|
||||
wcdbExecutionMs: this.wcdbExecutionMsTotal - beforeExecutionMs
|
||||
},
|
||||
source: 'knowledge',
|
||||
totalMessages: result.indexedMessageCount
|
||||
}
|
||||
}
|
||||
|
||||
private async enrichEvidenceSenders(
|
||||
evidence: KnowledgeEvidence[],
|
||||
retrievalSessionId?: string
|
||||
): Promise<KnowledgeEvidence[]> {
|
||||
const candidateConversationIds = Array.from(
|
||||
new Set(
|
||||
evidence
|
||||
.filter((item) => item.senderId && looksLikeOpaqueSenderId(item.sender))
|
||||
.map((item) => item.conversationId)
|
||||
)
|
||||
).slice(0, MAX_SENDER_NAME_CONVERSATIONS)
|
||||
if (!candidateConversationIds.length) return evidence
|
||||
|
||||
const session = retrievalSessionId
|
||||
? this.senderEnrichmentSession(retrievalSessionId)
|
||||
: undefined
|
||||
const contacts = session?.contacts || (await this.listContacts())
|
||||
if (session && !session.contacts) session.contacts = contacts
|
||||
const groupConversationIds = new Set(
|
||||
contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5)
|
||||
)
|
||||
const memberNamesByConversation = new Map<string, Map<string, string>>()
|
||||
for (const conversationId of candidateConversationIds) {
|
||||
if (!groupConversationIds.has(conversationId)) continue
|
||||
let snapshot = session?.groupSnapshots.get(conversationId)
|
||||
if (!snapshot) {
|
||||
snapshot = await this.enqueueWcdbRead(() => chat.getGroupSnapshotAsync(conversationId))
|
||||
session?.groupSnapshots.set(conversationId, snapshot)
|
||||
}
|
||||
const memberNames = new Map(
|
||||
(snapshot?.members || [])
|
||||
.map((member) => [member.wxid, groupMemberDisplayName(member)] as const)
|
||||
.filter(([, name]) => Boolean(name))
|
||||
)
|
||||
if (memberNames.size) memberNamesByConversation.set(conversationId, memberNames)
|
||||
}
|
||||
|
||||
return evidence.map((item) => {
|
||||
const sender = memberNamesByConversation.get(item.conversationId)?.get(item.senderId || '')
|
||||
return sender ? { ...item, sender } : item
|
||||
})
|
||||
}
|
||||
|
||||
private senderEnrichmentSession(retrievalSessionId: string): SenderEnrichmentSession {
|
||||
const now = Date.now()
|
||||
for (const [key, value] of this.senderEnrichmentSessions) {
|
||||
if (now - value.lastUsedAt > SENDER_ENRICHMENT_SESSION_TTL_MS) {
|
||||
this.senderEnrichmentSessions.delete(key)
|
||||
}
|
||||
}
|
||||
let session = this.senderEnrichmentSessions.get(retrievalSessionId)
|
||||
if (!session) {
|
||||
session = { lastUsedAt: now, groupSnapshots: new Map() }
|
||||
this.senderEnrichmentSessions.set(retrievalSessionId, session)
|
||||
}
|
||||
session.lastUsedAt = now
|
||||
while (this.senderEnrichmentSessions.size > MAX_SENDER_ENRICHMENT_SESSIONS) {
|
||||
const oldest = this.senderEnrichmentSessions.keys().next().value as string | undefined
|
||||
if (!oldest) break
|
||||
this.senderEnrichmentSessions.delete(oldest)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private enqueueWcdbRead<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const enqueuedAt = Date.now()
|
||||
const run = async (): Promise<T> => {
|
||||
const startedAt = Date.now()
|
||||
this.wcdbQueueMsTotal += Math.max(0, startedAt - enqueuedAt)
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
this.wcdbExecutionMsTotal += Date.now() - startedAt
|
||||
}
|
||||
}
|
||||
const result = this.wcdbReadTail.then(run, run)
|
||||
// Keep the queue usable after a read failure while returning that failure to its caller.
|
||||
this.wcdbReadTail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
private emptyStatus(accountId: string): KnowledgeRuntimeStatus {
|
||||
return {
|
||||
accountId,
|
||||
state: 'unavailable',
|
||||
indexedMessageCount: 0,
|
||||
indexedChunkCount: 0,
|
||||
sourceMessageCount: null,
|
||||
processedMessages: 0,
|
||||
totalMessages: null,
|
||||
estimatedRemainingMs: null,
|
||||
databaseBytes: 0,
|
||||
walBytes: 0,
|
||||
shmBytes: 0
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshStatus(
|
||||
accountId: string,
|
||||
progress?: Pick<KnowledgeRuntimeStatus, 'processedMessages' | 'totalMessages'> & {
|
||||
startedAt?: number
|
||||
}
|
||||
): Promise<KnowledgeRuntimeStatus> {
|
||||
const remote = await this.service.status({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
|
||||
const current = this.statusByAccount.get(accountId)
|
||||
const indexing = this.indexing.has(accountId)
|
||||
const processedMessages =
|
||||
progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages
|
||||
const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount
|
||||
const state = indexing
|
||||
? remote.indexedMessageCount > 0
|
||||
? 'syncing'
|
||||
: 'building'
|
||||
: remote.state
|
||||
const status: KnowledgeRuntimeStatus = {
|
||||
...remote,
|
||||
state,
|
||||
processedMessages,
|
||||
totalMessages,
|
||||
estimatedRemainingMs: null
|
||||
}
|
||||
this.publishStatus(status)
|
||||
return status
|
||||
}
|
||||
|
||||
private publishStatus(status: KnowledgeRuntimeStatus): void {
|
||||
this.statusByAccount.set(status.accountId, status)
|
||||
for (const listener of this.statusListeners) listener(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { join } from 'path'
|
||||
import type {
|
||||
KnowledgeCapacityPreflight,
|
||||
KnowledgeCapacityPreflightRequest,
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchResult,
|
||||
KnowledgeStatusRequest
|
||||
} from '../../shared/knowledge'
|
||||
import { KnowledgeWorkerHost } from './knowledge-worker-host'
|
||||
|
||||
/** Minimal main-process service; no renderer API is exposed in Task 0~Task 2. */
|
||||
export class KnowledgeService {
|
||||
private readonly worker: KnowledgeWorkerHost
|
||||
|
||||
constructor(userDataPath: string, workerPath: string) {
|
||||
this.worker = new KnowledgeWorkerHost(workerPath)
|
||||
this.databaseRoot = join(userDataPath, 'knowledge')
|
||||
}
|
||||
|
||||
private readonly databaseRoot: string
|
||||
|
||||
index(
|
||||
request: Omit<KnowledgeIndexRequest, 'databaseRoot'>,
|
||||
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||
): Promise<KnowledgeIndexResult> {
|
||||
return this.worker.index({ ...request, databaseRoot: this.databaseRoot }, onProgress)
|
||||
}
|
||||
|
||||
preflight(
|
||||
request: Omit<KnowledgeCapacityPreflightRequest, 'databaseRoot'>
|
||||
): Promise<KnowledgeCapacityPreflight> {
|
||||
return this.worker.preflight({ ...request, databaseRoot: this.databaseRoot })
|
||||
}
|
||||
|
||||
remove(accountId: string): Promise<{ removed: true }> {
|
||||
return this.worker.remove(accountId, this.databaseRoot)
|
||||
}
|
||||
|
||||
search(request: Omit<KnowledgeSearchRequest, 'databaseRoot'>): Promise<KnowledgeSearchResult> {
|
||||
return this.worker.search({ ...request, databaseRoot: this.databaseRoot })
|
||||
}
|
||||
|
||||
status(request: Omit<KnowledgeStatusRequest, 'databaseRoot'>): Promise<KnowledgeRuntimeStatus> {
|
||||
return this.worker.status({ ...request, databaseRoot: this.databaseRoot })
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
return this.worker.dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { fork, type ChildProcess } from 'child_process'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type {
|
||||
KnowledgeCapacityPreflight,
|
||||
KnowledgeCapacityPreflightRequest,
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchResult,
|
||||
KnowledgeStatusRequest,
|
||||
KnowledgeWorkerRequest,
|
||||
KnowledgeWorkerResponse
|
||||
} from '../../shared/knowledge'
|
||||
|
||||
type WorkerResult =
|
||||
| KnowledgeIndexResult
|
||||
| KnowledgeCapacityPreflight
|
||||
| KnowledgeSearchResult
|
||||
| KnowledgeRuntimeStatus
|
||||
| { removed: true }
|
||||
type PendingRequest = {
|
||||
resolve: (result: WorkerResult) => void
|
||||
reject: (error: Error) => void
|
||||
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||
sentAt: number
|
||||
workerBootStartedAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-process boundary for the derived knowledge database. The child runs
|
||||
* with ELECTRON_RUN_AS_NODE so synchronous node:sqlite calls never block UI.
|
||||
*/
|
||||
export class KnowledgeWorkerHost {
|
||||
private child: ChildProcess | null = null
|
||||
private childStartedAt = 0
|
||||
private readonly pending = new Map<string, PendingRequest>()
|
||||
|
||||
constructor(private readonly workerPath: string) {}
|
||||
|
||||
index(
|
||||
payload: KnowledgeIndexRequest,
|
||||
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||
): Promise<KnowledgeIndexResult> {
|
||||
return this.request('index', payload, onProgress) as Promise<KnowledgeIndexResult>
|
||||
}
|
||||
|
||||
preflight(payload: KnowledgeCapacityPreflightRequest): Promise<KnowledgeCapacityPreflight> {
|
||||
return this.request('preflight', payload) as Promise<KnowledgeCapacityPreflight>
|
||||
}
|
||||
|
||||
search(payload: KnowledgeSearchRequest): Promise<KnowledgeSearchResult> {
|
||||
return this.request('search', payload) as Promise<KnowledgeSearchResult>
|
||||
}
|
||||
|
||||
status(payload: KnowledgeStatusRequest): Promise<KnowledgeRuntimeStatus> {
|
||||
return this.request('status', payload) as Promise<KnowledgeRuntimeStatus>
|
||||
}
|
||||
|
||||
remove(accountId: string, databaseRoot: string): Promise<{ removed: true }> {
|
||||
return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }>
|
||||
}
|
||||
|
||||
cancel(targetRequestId: string): Promise<{ removed: true }> {
|
||||
return this.request('cancel', { targetRequestId }) as Promise<{ removed: true }>
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
const child = this.child
|
||||
if (!child) return
|
||||
try {
|
||||
await this.request('close', {})
|
||||
} catch {
|
||||
// The child is about to be stopped; its only job is a derived local index.
|
||||
}
|
||||
if (this.child === child) this.child = null
|
||||
if (!child.killed) child.kill()
|
||||
}
|
||||
|
||||
private request(
|
||||
type: KnowledgeWorkerRequest['type'],
|
||||
payload: KnowledgeWorkerRequest['payload'],
|
||||
onProgress?: (progress: KnowledgeIndexProgress) => 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 }
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
onProgress,
|
||||
sentAt,
|
||||
workerBootStartedAt: hadWorker ? undefined : this.childStartedAt
|
||||
})
|
||||
child.send(request, (error) => {
|
||||
if (error) this.finish(requestId, undefined, error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private ensureChild(): ChildProcess {
|
||||
if (this.child?.connected) return this.child
|
||||
const child = fork(this.workerPath, [], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||
serialization: 'advanced',
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
child.on('message', (message: KnowledgeWorkerResponse) => {
|
||||
if (message?.version !== 1) return
|
||||
if (message.type === 'progress') {
|
||||
const pending = this.pending.get(message.requestId)
|
||||
if (pending && message.payload)
|
||||
pending.onProgress?.(message.payload as KnowledgeIndexProgress)
|
||||
return
|
||||
}
|
||||
this.finish(
|
||||
message.requestId,
|
||||
message.payload as WorkerResult | undefined,
|
||||
message.type === 'error'
|
||||
? new Error(message.error || 'Knowledge worker failed')
|
||||
: undefined,
|
||||
message.transport
|
||||
)
|
||||
})
|
||||
child.once('error', (error) => this.failAll(error))
|
||||
child.once('exit', (code) => {
|
||||
if (this.child === child) this.child = null
|
||||
this.failAll(new Error(`Knowledge worker exited (${code ?? 'unknown'})`))
|
||||
})
|
||||
this.child = child
|
||||
this.childStartedAt = Date.now()
|
||||
return child
|
||||
}
|
||||
|
||||
private finish(
|
||||
requestId: string,
|
||||
result?: WorkerResult,
|
||||
error?: Error,
|
||||
transport?: KnowledgeWorkerResponse['transport']
|
||||
): void {
|
||||
const pending = this.pending.get(requestId)
|
||||
if (!pending) return
|
||||
this.pending.delete(requestId)
|
||||
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'))
|
||||
}
|
||||
|
||||
private applyTransportTimings(
|
||||
result: WorkerResult,
|
||||
pending: PendingRequest,
|
||||
transport?: KnowledgeWorkerResponse['transport']
|
||||
): WorkerResult {
|
||||
if (!('timings' in result) || !transport) return result
|
||||
const receivedAt = Date.now()
|
||||
const workerBootMs = pending.workerBootStartedAt
|
||||
? Math.max(0, transport.workerReceivedAt - pending.workerBootStartedAt)
|
||||
: 0
|
||||
const dispatchMs = Math.max(0, transport.workerReceivedAt - pending.sentAt)
|
||||
const responseTransferMs = Math.max(0, receivedAt - transport.workerCompletedAt)
|
||||
return {
|
||||
...result,
|
||||
timings: {
|
||||
...result.timings,
|
||||
workerBootMs,
|
||||
dispatchMs,
|
||||
workerSqlMs: result.timings.totalMs,
|
||||
workerExecutionMs: result.timings.workerExecutionMs ?? result.timings.totalMs,
|
||||
workerQueueMs: transport.workerQueueMs ?? 0,
|
||||
responseSerializeMs: transport.responseSerializeMs,
|
||||
responseTransferMs,
|
||||
serializationMs: transport.responseSerializeMs,
|
||||
ipcMs: workerBootMs + dispatchMs + responseTransferMs,
|
||||
workerIpcMs: workerBootMs + dispatchMs + responseTransferMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private failAll(error: Error): void {
|
||||
for (const requestId of this.pending.keys()) this.finish(requestId, undefined, error)
|
||||
}
|
||||
}
|
||||