Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
597667e005 | ||
|
|
515348b6d8 | ||
|
|
e150605c91 | ||
|
|
556d70eab3 | ||
|
|
9bd2c4bb94 | ||
|
|
19128fccca | ||
|
|
eaf8e9b07d | ||
|
|
aebf56b3f7 | ||
|
|
ce4b00bcd9 | ||
|
|
31df97237d | ||
|
|
7b54b611d3 | ||
|
|
f1ceef0e5e | ||
|
|
ab24185670 | ||
|
|
842144eba0 | ||
|
|
d37341ab52 | ||
|
|
5d041aa137 | ||
|
|
519c10223d | ||
|
|
d28b579cb4 | ||
|
|
c291c94bd8 | ||
|
|
16a9aed6bb | ||
|
|
7880e874b2 | ||
|
|
7e217f7034 | ||
|
|
4f0b4884b6 | ||
|
|
d4f6b755e5 | ||
|
|
995a95e8dc | ||
|
|
8bb9576f10 | ||
|
|
a776830144 | ||
|
|
ad50939005 | ||
|
|
71e970c55c | ||
|
|
8d82fabf57 | ||
|
|
e8c12696b2 | ||
|
|
267730d59e | ||
|
|
3dbc11c19d | ||
|
|
d079d45279 | ||
|
|
b39cb362f7 | ||
|
|
a0b163823d |
@@ -1,7 +1,14 @@
|
||||
# WeChat Database Key (Optional, can be entered in UI)
|
||||
VITE_DB_KEY=
|
||||
|
||||
# Auto login on startup with VITE_DB_KEY or saved key.
|
||||
# Set to true/1/yes/on for local development. Default is disabled.
|
||||
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-chat
|
||||
@@ -10,7 +17,7 @@ VITE_AI_MODEL=deepseek-chat
|
||||
VITE_FILTER_MSG_TYPES=
|
||||
|
||||
# Image Decryption Keys (Optional, for WeChat 4.0+ image decryption)
|
||||
# These are used to decrypt image .dat files in WeChat 4.0+
|
||||
# These are dev fallbacks. End users can fill or auto-fetch them in Settings.
|
||||
# XOR Key: hex format like 0x40, 0x53 etc.
|
||||
# AES Key: 16-character string, derived from wxid and code
|
||||
VITE_IMAGE_XOR_KEY=
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
node_modules
|
||||
*.tsbuildinfo
|
||||
dist
|
||||
out
|
||||
.env
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
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,6 +1,6 @@
|
||||
# WechatExplorer
|
||||
|
||||
MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
||||
macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
||||
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
|
||||
|
||||
## 项目说明
|
||||
@@ -9,17 +9,23 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
||||
|
||||
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
||||
|
||||
> macOS 支持相对稳定;Windows 已初步支持 但因聊天记录大/机械硬盘等问题 会有所卡顿,仍在持续兼容不同微信版本与本地目录结构。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
|
||||
- **全局搜索**: 快速搜索聊天内容。
|
||||
- **消息防撤回**: 高亮查看对方已撤回消息
|
||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||
- **一句话速览**: 首屏突出今日核心结论与待跟进事项。
|
||||
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
||||
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
||||
- **有趣对话或金句**: 收录群内的精彩对话。
|
||||
- **问题与解答**: 整理群内的问答内容。
|
||||
- **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
|
||||
- **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
|
||||
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||
- **词云/关键词**: 可视化展示群聊关键词。
|
||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||
@@ -28,38 +34,41 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
||||
|
||||
## 📸 预览
|
||||
|
||||
### 群聊总结长图
|
||||
### 日报模板
|
||||
|
||||
<img src="./public/example1.png" alt="总结图片" />
|
||||
<details>
|
||||
<summary>点击查看完整日报模板</summary>
|
||||
<br />
|
||||
<img src="./public/report-template-1.png" alt="完整日报模板" />
|
||||
</details>
|
||||
|
||||
### 软件主页面
|
||||
### AI 群聊日报界面
|
||||
|
||||
<img src="./public/example2.png" alt="软件主页面" />
|
||||
<img src="./public/software-1.png" alt="AI 群聊日报页面" />
|
||||
|
||||
### 本地 API 与 Reader Skill
|
||||
|
||||
<img src="./public/software-2.png" alt="本地 API 与 Reader Skill 页面" />
|
||||
|
||||
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||
|
||||
## 📦 安装说明
|
||||
## 📖 使用方法
|
||||
|
||||
1. 下载下方的 `xxx.dmg` 文件。
|
||||
2. 打开 DMG 并将应用拖动到 **Applications** (应用程序) 文件夹。
|
||||
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
|
||||
`系统设置 -> 隐私与安全性 -> 仍要打开`。
|
||||
安装、获取数据库密钥、连接微信数据及常见问题,请查看:
|
||||
|
||||
## 🚀 快速开始
|
||||
### [👉 WechatExplorer 完整使用教程](./docs/user-guide/getting-started.md)
|
||||
|
||||
### 使用前置要求
|
||||
教程包含 macOS 与 Windows 的分步截图,以及数据目录、SIP、图片解密密钥和自动获取失败的排查方法。
|
||||
|
||||
- **微信版本**:
|
||||
- 微信 4.0+: 已支持部分能力,仍在持续迭代与兼容性验证中;如需更成熟的完整方案,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog)
|
||||
- 如无法获取本地数据库密码,则无法使用当前项目
|
||||
- Node.js (推荐 v16+)
|
||||
- pnpm@7
|
||||
- 解密后的微信数据库文件 (`.db`) 和对应的密钥
|
||||
- AI API Key(支持 OpenAI 兼容 API,可选 DeepSeek/GPT/Claude/Moonshot 等)
|
||||
> 微信 4.0+ 在 macOS / Windows 上已支持部分能力,目前仍在持续适配。如需其他成熟方案,也可参考 [WeFlow](https://github.com/hicccc77/WeFlow) 和 [Chatlog](https://github.com/sjzar/chatlog)。
|
||||
|
||||
### 环境变量配置 (.env)
|
||||
## 🛠️ 开发配置(可选)
|
||||
|
||||
可选配置项,可在 `.env` 文件中设置:
|
||||
本地开发需要 Node.js(推荐 v16+)和 pnpm 7。
|
||||
|
||||
### 环境变量
|
||||
|
||||
可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。
|
||||
|
||||
| 变量名 | 说明 | 示例 |
|
||||
| ----------------------- | --------------------------- | --------------------------- |
|
||||
@@ -71,18 +80,6 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
||||
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
||||
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
||||
|
||||
#### 图片解密密钥说明
|
||||
|
||||
微信 4.0+ 的图片以 `.dat` 文件存储,需要密钥解密:
|
||||
|
||||
- **XOR Key**: 单字节 hex 值(如 `0x40`),用于简单的字节异或解密
|
||||
- **AES Key**: 16字符字符串,用于 AES-128-ECB 解密
|
||||
|
||||
这两个密钥可以通过以下方式获取:
|
||||
|
||||
1. 从 WeFlow/Chatlog 设置中导出
|
||||
2. 使用内存扫描工具从微信进程中自动提取(待实现)
|
||||
|
||||
## 🤖 AI 集成(本地 HTTP API)
|
||||
|
||||
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
|
||||
@@ -90,6 +87,7 @@ WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6
|
||||
### 启用本地 API
|
||||
|
||||
API 服务在 WechatExplorer 启动时自动启用,**不需要任何配置**。只需要:
|
||||
|
||||
1. 安装并启动 WechatExplorer
|
||||
2. 完成首次密钥配置(主窗口第一步),解锁 WCDB 数据库
|
||||
3. API 即在 `http://127.0.0.1:6131` 可用
|
||||
@@ -105,37 +103,42 @@ WXE_TRAY=1 open /Applications/WechatExplorer.app
|
||||
```
|
||||
|
||||
启用后:
|
||||
|
||||
- macOS dock 图标自动隐藏
|
||||
- 菜单栏出现 WechatExplorer 图标(可点击重新打开主窗口、查看 API 状态)
|
||||
- 主窗口关闭后 API 服务继续运行
|
||||
|
||||
### 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 |
|
||||
| 端点 | 说明 |
|
||||
| ------------------------------------------------ | --------------------------------------- |
|
||||
| `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 |
|
||||
|
||||
详细参数、返回结构、时间格式见 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
|
||||
### 让 Claude 自动总结你的群聊
|
||||
### 安装 Reader Skill,让 Agent 读取和总结群聊
|
||||
|
||||
复制 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md) 到 `~/.claude/skills/`,然后在 Claude Desktop 里说:
|
||||
WechatExplorer 已内置 **Reader Skill**,无需手动复制仓库中的 `SKILL.md`:
|
||||
|
||||
> "今天 技术交流群 聊了啥?"
|
||||
1. 启动 WechatExplorer,并确认数据库已连接、本地 API 已运行。
|
||||
2. 打开应用内的 **API** 页面。
|
||||
3. 在“快速接入”中选择 **Codex** 或 **Claude Code**。
|
||||
4. 点击复制安装指令,将指令粘贴给对应的 Agent 执行。
|
||||
5. 安装完成后,可以直接向 Agent 提问:
|
||||
|
||||
Claude 会自动:
|
||||
1. 调 `current_time` 拿到今天日期
|
||||
2. 调 `chatroom` 找到目标群
|
||||
3. 调 `chatlog` 拿 JSON 聊天记录
|
||||
4. 自己用 LLM 生成总结报告
|
||||
> “今天技术交流群聊了什么?”
|
||||
|
||||
### curl 示例
|
||||
Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。详细接口说明仍可查看 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
|
||||
### curl 调试示例(可选)
|
||||
|
||||
不使用 Agent 时,也可以通过 `curl` 直接调试本地 HTTP API:
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
@@ -155,8 +158,24 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
||||
|
||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||
|
||||
## 🔗 参考
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Wxw-Gu%2FWechatExplorer&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&theme=dark&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 🔗 参考致谢
|
||||
|
||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||
- [WeFlow](https://github.com/hicccc77/WeFlow)
|
||||
- [chatlog](https://github.com/sjzar/chatlog)
|
||||
|
||||
## 📱 交流与反馈
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/二维码.jpg" alt="WechatExplorer 交流二维码" width="280" />
|
||||
</p>
|
||||
|
||||
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,47 @@
|
||||
# macOS 关闭 SIP 教程
|
||||
|
||||
SIP(System Integrity Protection,系统完整性保护)是 macOS 的系统安全机制。关闭 SIP 会降低系统安全性,只建议在确实需要读取或调试本地微信数据时临时关闭;操作完成后,建议重新开启。
|
||||
|
||||
## 准备
|
||||
|
||||
- 一台 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
|
||||
```
|
||||
|
||||
然后重启电脑。
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: wechatexplorer-reader
|
||||
description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊天数据(本地服务由 WechatExplorer.app 提供)。当用户提到微信聊天记录、群消息、看看群里说了什么、查一下微信、分析微信对话、总结群聊等场景时,使用此技能。注意:此技能的数据源是用户本机 WechatExplorer app,而非 chatlog/WeFlow。
|
||||
description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊天数据(本地服务由 WechatExplorer.app 提供)。当用户提到微信聊天记录、群消息、看看群里说了什么、查一下微信、分析微信对话、总结群聊等场景时,使用此技能。注意:此技能的数据源是用户本机 WechatExplorer app。
|
||||
---
|
||||
|
||||
# WechatExplorer Reader
|
||||
@@ -141,12 +141,31 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图
|
||||
"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 }]
|
||||
"topSpeakers": [{ "name": "张三", "count": 58 }],
|
||||
"voiceLeaderboard": [{ "sender": "张三", "count": 3, "durationSec": 97 }]
|
||||
},
|
||||
"keywords": ["高频词1", "高频词2"]
|
||||
"keywords": ["高频词1", "高频词2"],
|
||||
"hero": {
|
||||
"headline": "一句抓重点的日报标题",
|
||||
"summary": "一句概览",
|
||||
"keyTakeaway": "最重要结论",
|
||||
"pendingNote": "待跟进事项"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"groupName": "技术交流",
|
||||
@@ -333,4 +352,4 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图
|
||||
}
|
||||
```
|
||||
|
||||
(视 MCP over HTTP 支持情况调整)
|
||||
(视 MCP over HTTP 支持情况调整)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# WechatExplorer 使用教程
|
||||
|
||||
本文介绍如何安装 WechatExplorer、自动获取微信数据库密钥,并完成首次连接。
|
||||
|
||||
## 1. 使用前准备
|
||||
|
||||
### 支持的版本
|
||||
|
||||
| 系统 | 已测试的微信版本 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| macOS | `4.1.8.100` | 支持相对稳定;自动获取密钥前需要关闭 SIP |
|
||||
| Windows | `4.1.9.57` | 已初步支持;不同安装路径和数据目录可能仍需手动调整 |
|
||||
|
||||
- macOS 微信下载:[wechat-versions v4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100)
|
||||
- Windows 微信下载:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
|
||||
- WechatExplorer 下载:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> WechatExplorer 必须取得本机微信数据库密钥才能读取聊天记录。请仅处理你有权访问的数据。
|
||||
|
||||
### macOS:关闭 SIP
|
||||
|
||||
macOS 自动获取密钥前需要关闭 SIP,具体操作见 [macOS 关闭 SIP 教程](../mac-disable-sip.md)。
|
||||
|
||||
关闭 SIP 会降低系统安全性。建议了解风险后再操作,并在不再需要自动获取密钥时重新开启。
|
||||
|
||||
## 2. 安装 WechatExplorer
|
||||
|
||||
### macOS
|
||||
|
||||
1. 从 Releases 下载 `.dmg` 文件。
|
||||
2. 打开 DMG,将 WechatExplorer 拖入“应用程序”文件夹。
|
||||
3. 如果系统提示“无法打开,因为开发者无法验证”,请前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||
4. 如果系统提示应用已损坏,在终端执行:
|
||||
|
||||
```bash
|
||||
xattr -cr "/Applications/WechatExplorer.app"
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
1. 从 Releases 下载 `-setup.exe` 安装包。
|
||||
2. 双击安装,并按安装向导完成操作。
|
||||
|
||||
## 3. 自动获取密钥
|
||||
|
||||
### 第一步:确认微信数据目录
|
||||
|
||||
启动 WechatExplorer 后,先检查页面中的“存储路径”是否正确。
|
||||
|
||||

|
||||
|
||||
Windows 当前不会扫描二级目录。如果没有正确识别微信数据,请进入“设置”,手动选择微信数据所在目录。
|
||||
|
||||

|
||||
|
||||
### 第二步:让微信停留在登录页面
|
||||
|
||||
如果微信已经登录,请先退出登录;然后重新打开微信,让它停留在未登录页面,暂时不要点击登录。
|
||||
|
||||

|
||||
|
||||
### 第三步:开始获取密钥
|
||||
|
||||
返回 WechatExplorer,点击“自动获取密钥”。
|
||||
|
||||
- **Windows**:看到“Hook 注入成功”后,返回微信完成登录。
|
||||
- **macOS**:系统会弹出授权提示,请输入当前 macOS 用户密码并完成授权,然后返回微信完成登录。
|
||||
|
||||

|
||||
|
||||
> 点击“自动获取密钥”前,微信必须停留在登录页面。WechatExplorer 提示可以登录后,再回到微信完成登录。
|
||||
|
||||
### 第四步:完成连接
|
||||
|
||||
如果系统环境和微信版本符合要求,WechatExplorer 会自动填写数据库密钥并连接数据库。连接成功后即可查看、搜索和导出聊天记录,也可以配置 AI 服务生成群聊总结。
|
||||
|
||||

|
||||
|
||||
## 4. 图片解密密钥
|
||||
|
||||
微信 4.0 及以上版本的图片通常以 `.dat` 文件存储,显示图片还需要:
|
||||
|
||||
- **XOR Key**:单字节十六进制值,例如 `0x40`。
|
||||
- **AES Key**:用于 AES-128-ECB 解密的 16 字符字符串。
|
||||
|
||||
可以通过以下方式配置:
|
||||
|
||||
1. 使用首次连接页面的“自动获取密钥”。
|
||||
2. 在“设置 → 图片解密密钥”中自动获取或手动填写。
|
||||
3. 从 WeFlow 或 Chatlog 的设置中导出后手动填写。
|
||||
|
||||
数据库连接成功但图片无法显示时,请优先检查这两项密钥。
|
||||
|
||||
## 5. 常见问题
|
||||
|
||||
### 自动获取密钥失败
|
||||
|
||||
请依次确认:
|
||||
|
||||
1. 微信版本是否与上方已测试版本一致。
|
||||
2. 点击“自动获取密钥”时,微信是否停留在未登录页面。
|
||||
3. 微信数据目录是否正确;Windows 用户尤其需要检查是否多选或少选了一层目录。
|
||||
4. macOS 是否已按教程关闭 SIP,并完成系统授权。
|
||||
5. 微信和 WechatExplorer 是否都保持运行。
|
||||
|
||||
仍然失败时,可以切换到“手动输入”,粘贴从其他兼容工具中取得的数据库密钥。
|
||||
|
||||
### Windows 使用时卡顿
|
||||
|
||||
Windows 支持仍处于初步阶段,不同微信版本、安装路径、数据目录和权限环境可能存在差异。建议优先使用上方已测试的微信版本。
|
||||
|
||||
### 数据会上传吗?
|
||||
|
||||
聊天数据库在本机读取和处理。只有使用 AI 总结功能时,相关聊天内容才会按你配置的模型服务发送;是否启用以及使用哪个服务由你决定。
|
||||
|
||||
## 6. 下一步
|
||||
|
||||
- 在应用“设置”中填写兼容 OpenAI API 的模型服务和 API Key,使用 AI 总结功能。
|
||||
- 在应用的 **API** 页面安装 Reader Skill,让 Codex 或 Claude Code 读取和总结本地群聊。
|
||||
- 本地 API 的端点和调试方法见项目 [README](../../README.md#ai-集成本地-http-api)。
|
||||
|
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 |
@@ -16,18 +16,30 @@ extraMetadata:
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
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
|
||||
filter:
|
||||
- '**/*'
|
||||
win:
|
||||
executableName: wechatexplorer
|
||||
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.
|
||||
executableName: electron
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
icon: icon.icns
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
# The bundled WCDB bridge accepts Electron as its internal host name. The
|
||||
@@ -42,6 +54,7 @@ mac:
|
||||
dmg:
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
linux:
|
||||
icon: icon.png
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
@@ -53,4 +66,6 @@ appImage:
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
repo: WechatExplorer
|
||||
releaseType: draft
|
||||
|
||||
@@ -3,7 +3,19 @@ import { defineConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
main: {},
|
||||
main: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts')
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].js'
|
||||
},
|
||||
external: ['koffi']
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {},
|
||||
renderer: {
|
||||
resolve: {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "wechatexplorer",
|
||||
"version": "2.0.1",
|
||||
"description": "mac 版本获取微信聊天记录, AI群聊总结助手",
|
||||
"version": "2.1.5",
|
||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"chat",
|
||||
"mac微信",
|
||||
"windows微信",
|
||||
"微信聊天记录",
|
||||
"AI群聊总结助手"
|
||||
],
|
||||
"author": "Qingmao",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Wxw-Gu/WechatExplorer.git"
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
@@ -17,25 +22,39 @@
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
|
||||
"cp:env": "node scripts/ensure-env.cjs",
|
||||
"prepare:env": "node scripts/ensure-env.cjs",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"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 ./...",
|
||||
"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 x64,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 && electron-vite build && electron-builder --config electron-builder.yml --win --x64",
|
||||
"build:mac:x64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --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 --x64 --arm64 --publish always",
|
||||
"release:win": "npm run typecheck && npm run build:wechat-connector:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64 --publish always",
|
||||
"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",
|
||||
"@tanstack/react-virtual": "^3.14.6",
|
||||
"fs-extra": "^11.3.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"koffi": "^2.9.0",
|
||||
"jsonrepair": "^3.15.0",
|
||||
"koffi": "^3.1.0",
|
||||
"openai": "^6.10.0",
|
||||
"silk-wasm": "^3.7.1"
|
||||
"silk-wasm": "^3.7.1",
|
||||
"wechat-emojis": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
@@ -47,7 +66,7 @@
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"electron": "^39.2.6",
|
||||
"electron": "^43.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
@@ -61,6 +80,16 @@
|
||||
"vite": "^7.2.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"supportedArchitectures": {
|
||||
"os": [
|
||||
"current",
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"current",
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"esbuild"
|
||||
|
||||
@@ -10,13 +10,15 @@ specifiers:
|
||||
'@electron-toolkit/preload': ^3.0.2
|
||||
'@electron-toolkit/tsconfig': ^2.0.0
|
||||
'@electron-toolkit/utils': ^4.0.0
|
||||
'@koromix/koffi-win32-x64': 3.1.0
|
||||
'@rollup/rollup-darwin-arm64': ^4.62.2
|
||||
'@tanstack/react-virtual': ^3.14.6
|
||||
'@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
|
||||
electron: ^39.2.6
|
||||
electron: ^43.0.0
|
||||
electron-builder: ^26.0.12
|
||||
electron-vite: ^5.0.0
|
||||
eslint: ^9.39.1
|
||||
@@ -25,7 +27,8 @@ specifiers:
|
||||
eslint-plugin-react-refresh: ^0.4.24
|
||||
fs-extra: ^11.3.2
|
||||
fzstd: ^0.1.1
|
||||
koffi: ^2.9.0
|
||||
jsonrepair: ^3.15.0
|
||||
koffi: ^3.1.0
|
||||
openai: ^6.10.0
|
||||
prettier: ^3.7.4
|
||||
react: ^19.2.1
|
||||
@@ -33,15 +36,20 @@ specifiers:
|
||||
silk-wasm: ^3.7.1
|
||||
typescript: ^5.9.3
|
||||
vite: ^7.2.6
|
||||
wechat-emojis: ^1.0.2
|
||||
|
||||
dependencies:
|
||||
'@electron-toolkit/preload': 3.0.2_electron@39.2.6
|
||||
'@electron-toolkit/utils': 4.0.0_electron@39.2.6
|
||||
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
||||
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
|
||||
'@koromix/koffi-win32-x64': 3.1.0
|
||||
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
|
||||
fs-extra: 11.3.2
|
||||
fzstd: 0.1.1
|
||||
koffi: 2.16.2
|
||||
jsonrepair: 3.15.0
|
||||
koffi: 3.1.0
|
||||
openai: 6.10.0
|
||||
silk-wasm: 3.7.1
|
||||
wechat-emojis: 1.0.2
|
||||
|
||||
devDependencies:
|
||||
'@electron-toolkit/eslint-config-prettier': 3.0.0_fiitszekoa4sqtbwyewxi6kyy4
|
||||
@@ -53,7 +61,7 @@ devDependencies:
|
||||
'@types/react': 19.2.7
|
||||
'@types/react-dom': 19.2.3_@types+react@19.2.7
|
||||
'@vitejs/plugin-react': 5.1.2_vite@7.2.7
|
||||
electron: 39.2.6
|
||||
electron: 43.1.0
|
||||
electron-builder: 26.0.12
|
||||
electron-vite: 5.0.0_vite@7.2.7
|
||||
eslint: 9.39.1
|
||||
@@ -266,6 +274,10 @@ packages:
|
||||
ajv-keywords: 3.5.2_ajv@6.12.6
|
||||
dev: true
|
||||
|
||||
/@electron-internal/extract-zip/1.0.4:
|
||||
resolution: {integrity: sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
/@electron-toolkit/eslint-config-prettier/3.0.0_fiitszekoa4sqtbwyewxi6kyy4:
|
||||
resolution: {integrity: sha512-YapmIOVkbYdHLuTa+ad1SAVtcqYL9A/SJsc7cxQokmhcwAwonGevNom37jBf9slXegcZ/Slh01I/JARG1yhNFw==}
|
||||
peerDependencies:
|
||||
@@ -298,12 +310,12 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/@electron-toolkit/preload/3.0.2_electron@39.2.6:
|
||||
/@electron-toolkit/preload/3.0.2_electron@43.1.0:
|
||||
resolution: {integrity: sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==}
|
||||
peerDependencies:
|
||||
electron: '>=13.0.0'
|
||||
dependencies:
|
||||
electron: 39.2.6
|
||||
electron: 43.1.0
|
||||
dev: false
|
||||
|
||||
/@electron-toolkit/tsconfig/2.0.0_@types+node@22.19.2:
|
||||
@@ -314,12 +326,12 @@ packages:
|
||||
'@types/node': 22.19.2
|
||||
dev: true
|
||||
|
||||
/@electron-toolkit/utils/4.0.0_electron@39.2.6:
|
||||
/@electron-toolkit/utils/4.0.0_electron@43.1.0:
|
||||
resolution: {integrity: sha512-qXSntwEzluSzKl4z5yFNBknmPGjPa3zFhE4mp9+h0cgokY5ornAeP+CJQDBhKsL1S58aOQfcwkD3NwLZCl+64g==}
|
||||
peerDependencies:
|
||||
electron: '>=13.0.0'
|
||||
dependencies:
|
||||
electron: 39.2.6
|
||||
electron: 43.1.0
|
||||
dev: false
|
||||
|
||||
/@electron/asar/3.2.18:
|
||||
@@ -341,19 +353,18 @@ packages:
|
||||
minimist: 1.2.8
|
||||
dev: true
|
||||
|
||||
/@electron/get/2.0.3:
|
||||
resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==}
|
||||
engines: {node: '>=12'}
|
||||
/@electron/get/5.0.0:
|
||||
resolution: {integrity: sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
env-paths: 2.2.1
|
||||
fs-extra: 8.1.0
|
||||
got: 11.8.6
|
||||
env-paths: 3.0.0
|
||||
graceful-fs: 4.2.11
|
||||
progress: 2.0.3
|
||||
semver: 6.3.1
|
||||
semver: 7.7.3
|
||||
sumchecker: 3.0.1
|
||||
optionalDependencies:
|
||||
global-agent: 3.0.0
|
||||
undici: 7.28.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -786,6 +797,103 @@ packages:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
dev: true
|
||||
|
||||
/@koromix/koffi-darwin-arm64/3.1.0:
|
||||
resolution: {integrity: sha512-VEt5r3fXTfbejr83PnuOP0H7s9Zmazcs+lofu96DOcRkistlMsn59wYyWiKpyAjs9PCgm0Ykh62ChZ3CGMmIOg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-darwin-x64/3.1.0:
|
||||
resolution: {integrity: sha512-n/tVRB9xIzdXT5H3zZt8ueThgWTSDL+yU7PWnU8wbZPBSawP/otx3swQyd6nMOqj1bmHgSHopiKSBXRS9pllmg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-freebsd-arm64/3.1.0:
|
||||
resolution: {integrity: sha512-vazoPYIhOAlXZksVIqDRMIID4VeUZKx8F3dR90hOobT2ATyOkqNS5dv5UCV7Q7DSq22lQTrdbvENBAhROzCp0w==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-freebsd-ia32/3.1.0:
|
||||
resolution: {integrity: sha512-Vm7Uc97ru6RTSVmae2zCZZQeaizqVZ8WoU4+gG4H03Qe+WOj7kbKt/MxT7VBzdbPYIU5ZJeG/ZED1YlZyab6eQ==}
|
||||
cpu: [ia32]
|
||||
os: [freebsd]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-freebsd-x64/3.1.0:
|
||||
resolution: {integrity: sha512-N+VuVWjoiYPy1Go5mRadZ3B6RM5Qz+eCLhj2LXrMlefbUJ+O4gg7teCUGvPGfBEHDgmSN4yYUrfQmdJC10vOYw==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-linux-arm64/3.1.0:
|
||||
resolution: {integrity: sha512-Wx5iOkeALe2ympLdiYwRpIg5qUkyQIv8N2foZ9rRker0uE7ZtXew2RRkbEgMir4b0yDYR1zyXd6B62GUzLtZ/g==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-linux-ia32/3.1.0:
|
||||
resolution: {integrity: sha512-1DjYm1QehXU0dgn0uE+FGYOb3Of7GiTMqLS+ZI2gbl1b+h76sz4LRBvDVrQyAmSMVVU8/7696S21YgE/iBhBVg==}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-linux-loong64/3.1.0:
|
||||
resolution: {integrity: sha512-NOa0LdyltdESz3oeTqUH6MErHVoJOHoeXIsEp6xIMTUh4eKXEtlDQeoK6EYqo0DnBt83Xud95qLvi4Aw12pG4Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-linux-riscv64/3.1.0:
|
||||
resolution: {integrity: sha512-Ye6kiXZCGxGtAIXSly6XuOP5tJZNYOZ2eVg33k1MilKrzimAy9Mpw4d6e9+Sfsc1jesgeNYs1sb5iaI8HS3ncA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-linux-x64/3.1.0:
|
||||
resolution: {integrity: sha512-3yQTOkQrMna4VX+yeyfYImBjLlGrItMpsWyfaW1uSiz/A6GRydqdwYH7DWnp4Z+RSGYZpsewkf7byMc8pOOQKA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-openbsd-ia32/3.1.0:
|
||||
resolution: {integrity: sha512-/cDoFHb9yx4+yoT3GUpnKnfi3W2drG+/Ewo0TTZaQHb4PsxnYYyT6V8+t4cL5XXbQcTTcOsZxpmBRrn0NBa3dA==}
|
||||
cpu: [ia32]
|
||||
os: [openbsd]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-openbsd-x64/3.1.0:
|
||||
resolution: {integrity: sha512-CoQdqgnKvWgTXXZlUst8cBRQEov7QsxlTN2WAsu9wez01Xe6gEcH/zYePANualzzCbnaELfe5P0rA80QkoDuPA==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-win32-ia32/3.1.0:
|
||||
resolution: {integrity: sha512-WjrA+DEkpy0xEHu48+NSOboHhTnzkIfsFuq3d/WrSs+T9WflWRng3jC7mdJxmR4eHb6i6BqjW3k/U0mNUTjFPA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@koromix/koffi-win32-x64/3.1.0:
|
||||
resolution: {integrity: sha512-tnK5+IkzQBauQAQSzuyjso8OOIQRlaTZS39xIWpfqVYDLVDIuLDQk/WwHcOrR5yxlDrZq9ygiebBTOfcJFia7w==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
dev: false
|
||||
|
||||
/@malept/cross-spawn-promise/2.0.0:
|
||||
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
@@ -1011,12 +1119,29 @@ packages:
|
||||
/@sindresorhus/is/4.6.0:
|
||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/@szmarczak/http-timer/4.0.6:
|
||||
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
defer-to-connect: 2.0.1
|
||||
dev: true
|
||||
|
||||
/@tanstack/react-virtual/3.14.6_bokjwhiew3ov3ffvbmafuwoalq:
|
||||
resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.17.4
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1_react@19.2.1
|
||||
dev: false
|
||||
|
||||
/@tanstack/virtual-core/3.17.4:
|
||||
resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==}
|
||||
dev: false
|
||||
|
||||
/@tootallnate/once/2.0.0:
|
||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||
@@ -1059,6 +1184,7 @@ packages:
|
||||
'@types/keyv': 3.1.4
|
||||
'@types/node': 22.19.2
|
||||
'@types/responselike': 1.0.3
|
||||
dev: true
|
||||
|
||||
/@types/debug/4.1.12:
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
@@ -1085,6 +1211,7 @@ packages:
|
||||
|
||||
/@types/http-cache-semantics/4.0.4:
|
||||
resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==}
|
||||
dev: true
|
||||
|
||||
/@types/json-schema/7.0.15:
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
@@ -1100,6 +1227,7 @@ packages:
|
||||
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
|
||||
dependencies:
|
||||
'@types/node': 22.19.2
|
||||
dev: true
|
||||
|
||||
/@types/ms/2.1.0:
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
@@ -1109,6 +1237,12 @@ packages:
|
||||
resolution: {integrity: sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==}
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
dev: true
|
||||
|
||||
/@types/node/24.13.3:
|
||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
/@types/plist/3.0.5:
|
||||
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
|
||||
@@ -1136,18 +1270,13 @@ packages:
|
||||
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
|
||||
dependencies:
|
||||
'@types/node': 22.19.2
|
||||
dev: true
|
||||
|
||||
/@types/verror/1.10.11:
|
||||
resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==}
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@types/yauzl/2.10.3:
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
dependencies:
|
||||
'@types/node': 22.19.2
|
||||
optional: true
|
||||
|
||||
/@typescript-eslint/eslint-plugin/8.49.0_nj5tjtfh637kziiwuhl3v5a4iq:
|
||||
resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1591,11 +1720,6 @@ packages:
|
||||
readable-stream: 3.6.2
|
||||
dev: true
|
||||
|
||||
/boolean/3.2.0:
|
||||
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
optional: true
|
||||
|
||||
/brace-expansion/1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
dependencies:
|
||||
@@ -1621,9 +1745,6 @@ packages:
|
||||
update-browserslist-db: 1.2.2_browserslist@4.28.1
|
||||
dev: true
|
||||
|
||||
/buffer-crc32/0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
/buffer-from/1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
dev: true
|
||||
@@ -1703,6 +1824,7 @@ packages:
|
||||
/cacheable-lookup/5.0.4:
|
||||
resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==}
|
||||
engines: {node: '>=10.6.0'}
|
||||
dev: true
|
||||
|
||||
/cacheable-request/7.0.4:
|
||||
resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==}
|
||||
@@ -1715,6 +1837,7 @@ packages:
|
||||
lowercase-keys: 2.0.0
|
||||
normalize-url: 6.1.0
|
||||
responselike: 2.0.1
|
||||
dev: true
|
||||
|
||||
/call-bind-apply-helpers/1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
@@ -1812,6 +1935,7 @@ packages:
|
||||
resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==}
|
||||
dependencies:
|
||||
mimic-response: 1.0.1
|
||||
dev: true
|
||||
|
||||
/clone/1.0.4:
|
||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||
@@ -1929,6 +2053,7 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
dev: true
|
||||
|
||||
/deep-is/0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
@@ -1943,6 +2068,7 @@ packages:
|
||||
/defer-to-connect/2.0.1:
|
||||
resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/define-data-property/1.1.4:
|
||||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||
@@ -1951,6 +2077,7 @@ packages:
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
dev: true
|
||||
|
||||
/define-properties/1.2.1:
|
||||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||
@@ -1959,6 +2086,7 @@ packages:
|
||||
define-data-property: 1.1.4
|
||||
has-property-descriptors: 1.0.2
|
||||
object-keys: 1.1.1
|
||||
dev: true
|
||||
|
||||
/delayed-stream/1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
@@ -1970,10 +2098,6 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/detect-node/2.1.0:
|
||||
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
|
||||
optional: true
|
||||
|
||||
/dir-compare/4.2.0:
|
||||
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
|
||||
dependencies:
|
||||
@@ -2117,15 +2241,14 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/electron/39.2.6:
|
||||
resolution: {integrity: sha512-dHBgTodWBZd+tL1Dt0PSh/CFLHeDkFCTKCTXu1dgPhlE9Z3k2zzlBQ9B2oW55CFsKanBDHiUomHJNw0XaSdQpA==}
|
||||
engines: {node: '>= 12.20.55'}
|
||||
/electron/43.1.0:
|
||||
resolution: {integrity: sha512-DPfxpQLd4NL3BJ8DBxYAfmLUKKesF5Rx9dQx5FyczAP8bhOPScjHE48GArVeXu68LlAainuwkmQTQvdZwpIIAQ==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
'@electron/get': 2.0.3
|
||||
'@types/node': 22.19.2
|
||||
extract-zip: 2.0.1
|
||||
'@electron-internal/extract-zip': 1.0.4
|
||||
'@electron/get': 5.0.0
|
||||
'@types/node': 24.13.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -2148,10 +2271,16 @@ packages:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
dev: true
|
||||
|
||||
/env-paths/2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/env-paths/3.0.0:
|
||||
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
/err-code/2.0.3:
|
||||
resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
|
||||
@@ -2220,10 +2349,12 @@ packages:
|
||||
/es-define-property/1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dev: true
|
||||
|
||||
/es-errors/1.3.0:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dev: true
|
||||
|
||||
/es-iterator-helpers/1.2.1:
|
||||
resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==}
|
||||
@@ -2280,10 +2411,6 @@ packages:
|
||||
is-symbol: 1.1.1
|
||||
dev: true
|
||||
|
||||
/es6-error/4.1.1:
|
||||
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
|
||||
optional: true
|
||||
|
||||
/esbuild/0.25.12:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2326,6 +2453,7 @@ packages:
|
||||
/escape-string-regexp/4.0.0:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/eslint-config-prettier/10.1.8_eslint@9.39.1:
|
||||
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
|
||||
@@ -2511,19 +2639,6 @@ packages:
|
||||
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
||||
dev: true
|
||||
|
||||
/extract-zip/2.0.1:
|
||||
resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
|
||||
engines: {node: '>= 10.17.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
get-stream: 5.2.0
|
||||
yauzl: 2.10.0
|
||||
optionalDependencies:
|
||||
'@types/yauzl': 2.10.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
/extsprintf/1.4.1:
|
||||
resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==}
|
||||
engines: {'0': node >=0.6.0}
|
||||
@@ -2546,11 +2661,6 @@ packages:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
dev: true
|
||||
|
||||
/fd-slicer/1.1.0:
|
||||
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
|
||||
dependencies:
|
||||
pend: 1.2.0
|
||||
|
||||
/fdir/6.5.0_picomatch@4.0.3:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -2639,14 +2749,6 @@ packages:
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
|
||||
/fs-extra/8.1.0:
|
||||
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 4.0.0
|
||||
universalify: 0.1.2
|
||||
|
||||
/fs-extra/9.1.0:
|
||||
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2743,6 +2845,7 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
pump: 3.0.3
|
||||
dev: true
|
||||
|
||||
/get-symbol-description/1.1.0:
|
||||
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
|
||||
@@ -2796,18 +2899,6 @@ packages:
|
||||
once: 1.4.0
|
||||
dev: true
|
||||
|
||||
/global-agent/3.0.0:
|
||||
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
|
||||
engines: {node: '>=10.0'}
|
||||
dependencies:
|
||||
boolean: 3.2.0
|
||||
es6-error: 4.1.1
|
||||
matcher: 3.0.0
|
||||
roarr: 2.15.4
|
||||
semver: 7.7.3
|
||||
serialize-error: 7.0.1
|
||||
optional: true
|
||||
|
||||
/globals/14.0.0:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2824,10 +2915,12 @@ packages:
|
||||
dependencies:
|
||||
define-properties: 1.2.1
|
||||
gopd: 1.2.0
|
||||
dev: true
|
||||
|
||||
/gopd/1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dev: true
|
||||
|
||||
/got/11.8.6:
|
||||
resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==}
|
||||
@@ -2844,6 +2937,7 @@ packages:
|
||||
lowercase-keys: 2.0.0
|
||||
p-cancelable: 2.1.1
|
||||
responselike: 2.0.1
|
||||
dev: true
|
||||
|
||||
/graceful-fs/4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
@@ -2862,6 +2956,7 @@ packages:
|
||||
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
dev: true
|
||||
|
||||
/has-proto/1.2.0:
|
||||
resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
|
||||
@@ -2908,6 +3003,7 @@ packages:
|
||||
|
||||
/http-cache-semantics/4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
dev: true
|
||||
|
||||
/http-proxy-agent/5.0.0:
|
||||
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
|
||||
@@ -2936,6 +3032,7 @@ packages:
|
||||
dependencies:
|
||||
quick-lru: 5.1.1
|
||||
resolve-alpn: 1.2.1
|
||||
dev: true
|
||||
|
||||
/https-proxy-agent/5.0.1:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
@@ -3313,6 +3410,7 @@ packages:
|
||||
|
||||
/json-buffer/3.0.1:
|
||||
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
||||
dev: true
|
||||
|
||||
/json-schema-traverse/0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
@@ -3322,21 +3420,12 @@ packages:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
dev: true
|
||||
|
||||
/json-stringify-safe/5.0.1:
|
||||
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
|
||||
optional: true
|
||||
|
||||
/json5/2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/jsonfile/4.0.0:
|
||||
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
/jsonfile/6.2.0:
|
||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||
dependencies:
|
||||
@@ -3344,6 +3433,11 @@ packages:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
/jsonrepair/3.15.0:
|
||||
resolution: {integrity: sha512-wy8OTjwsJwQRnQJkKnMJJ9vcytRdBPAgIF/Hy6+s1dAj42BHMKiyL8JzEieIl3JY7idt8eyHwBWTO8mh/+mtwA==}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/jsx-ast-utils/3.3.5:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
@@ -3358,9 +3452,25 @@ packages:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
dev: true
|
||||
|
||||
/koffi/2.16.2:
|
||||
resolution: {integrity: sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==}
|
||||
/koffi/3.1.0:
|
||||
resolution: {integrity: sha512-0mCvdjTJBXioiaKNz0vajAEdWtfM5qyhVXSq+wQrrU3odzNvl/J7Cqna79QpNo9mfoKpQgGsyFFDRtDACCwGrQ==}
|
||||
optionalDependencies:
|
||||
'@koromix/koffi-darwin-arm64': 3.1.0
|
||||
'@koromix/koffi-darwin-x64': 3.1.0
|
||||
'@koromix/koffi-freebsd-arm64': 3.1.0
|
||||
'@koromix/koffi-freebsd-ia32': 3.1.0
|
||||
'@koromix/koffi-freebsd-x64': 3.1.0
|
||||
'@koromix/koffi-linux-arm64': 3.1.0
|
||||
'@koromix/koffi-linux-ia32': 3.1.0
|
||||
'@koromix/koffi-linux-loong64': 3.1.0
|
||||
'@koromix/koffi-linux-riscv64': 3.1.0
|
||||
'@koromix/koffi-linux-x64': 3.1.0
|
||||
'@koromix/koffi-openbsd-ia32': 3.1.0
|
||||
'@koromix/koffi-openbsd-x64': 3.1.0
|
||||
'@koromix/koffi-win32-ia32': 3.1.0
|
||||
'@koromix/koffi-win32-x64': 3.1.0
|
||||
dev: false
|
||||
|
||||
/lazy-val/1.0.5:
|
||||
@@ -3408,6 +3518,7 @@ packages:
|
||||
/lowercase-keys/2.0.0:
|
||||
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
|
||||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/lru-cache/10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
@@ -3462,13 +3573,6 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/matcher/3.0.0:
|
||||
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
escape-string-regexp: 4.0.0
|
||||
optional: true
|
||||
|
||||
/math-intrinsics/1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3500,10 +3604,12 @@ packages:
|
||||
/mimic-response/1.0.1:
|
||||
resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
|
||||
engines: {node: '>=4'}
|
||||
dev: true
|
||||
|
||||
/mimic-response/3.1.0:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/minimatch/10.1.1:
|
||||
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
|
||||
@@ -3657,6 +3763,7 @@ packages:
|
||||
/normalize-url/6.1.0:
|
||||
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/object-assign/4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
@@ -3671,6 +3778,7 @@ packages:
|
||||
/object-keys/1.1.1:
|
||||
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dev: true
|
||||
|
||||
/object.assign/4.1.7:
|
||||
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
||||
@@ -3718,6 +3826,7 @@ packages:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
dev: true
|
||||
|
||||
/onetime/5.1.2:
|
||||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||
@@ -3778,6 +3887,7 @@ packages:
|
||||
/p-cancelable/2.1.1:
|
||||
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
|
||||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/p-limit/3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
@@ -3843,9 +3953,6 @@ packages:
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
dev: true
|
||||
|
||||
/pend/1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
/picocolors/1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
dev: true
|
||||
@@ -3935,6 +4042,7 @@ packages:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
dev: true
|
||||
|
||||
/punycode/2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
@@ -3944,6 +4052,7 @@ packages:
|
||||
/quick-lru/5.1.1:
|
||||
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/react-dom/19.2.1_react@19.2.1:
|
||||
resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==}
|
||||
@@ -3952,7 +4061,6 @@ packages:
|
||||
dependencies:
|
||||
react: 19.2.1
|
||||
scheduler: 0.27.0
|
||||
dev: true
|
||||
|
||||
/react-is/16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
@@ -3966,7 +4074,6 @@ packages:
|
||||
/react/19.2.1:
|
||||
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/read-binary-file-arch/1.0.6:
|
||||
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
|
||||
@@ -4026,6 +4133,7 @@ packages:
|
||||
|
||||
/resolve-alpn/1.2.1:
|
||||
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
||||
dev: true
|
||||
|
||||
/resolve-from/4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
@@ -4045,6 +4153,7 @@ packages:
|
||||
resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==}
|
||||
dependencies:
|
||||
lowercase-keys: 2.0.0
|
||||
dev: true
|
||||
|
||||
/restore-cursor/3.1.0:
|
||||
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
|
||||
@@ -4067,18 +4176,6 @@ packages:
|
||||
glob: 7.2.3
|
||||
dev: true
|
||||
|
||||
/roarr/2.15.4:
|
||||
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
|
||||
engines: {node: '>=8.0'}
|
||||
dependencies:
|
||||
boolean: 3.2.0
|
||||
detect-node: 2.1.0
|
||||
globalthis: 1.0.4
|
||||
json-stringify-safe: 5.0.1
|
||||
semver-compare: 1.0.0
|
||||
sprintf-js: 1.1.3
|
||||
optional: true
|
||||
|
||||
/rollup/4.53.3:
|
||||
resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@@ -4159,11 +4256,6 @@ packages:
|
||||
|
||||
/scheduler/0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
dev: true
|
||||
|
||||
/semver-compare/1.0.0:
|
||||
resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
|
||||
optional: true
|
||||
|
||||
/semver/5.7.2:
|
||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||
@@ -4173,19 +4265,13 @@ packages:
|
||||
/semver/6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/semver/7.7.3:
|
||||
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
/serialize-error/7.0.1:
|
||||
resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
type-fest: 0.13.1
|
||||
optional: true
|
||||
|
||||
/set-function-length/1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4341,10 +4427,6 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/sprintf-js/1.1.3:
|
||||
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
|
||||
optional: true
|
||||
|
||||
/ssri/9.0.1:
|
||||
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
|
||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||
@@ -4559,11 +4641,6 @@ packages:
|
||||
prelude-ls: 1.2.1
|
||||
dev: true
|
||||
|
||||
/type-fest/0.13.1:
|
||||
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
|
||||
engines: {node: '>=10'}
|
||||
optional: true
|
||||
|
||||
/typed-array-buffer/1.0.3:
|
||||
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4644,6 +4721,15 @@ packages:
|
||||
|
||||
/undici-types/6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
dev: true
|
||||
|
||||
/undici-types/7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
/undici/7.28.0:
|
||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
optional: true
|
||||
|
||||
/unique-filename/2.0.1:
|
||||
resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==}
|
||||
@@ -4659,10 +4745,6 @@ packages:
|
||||
imurmurhash: 0.1.4
|
||||
dev: true
|
||||
|
||||
/universalify/0.1.2:
|
||||
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
|
||||
/universalify/2.0.1:
|
||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -4759,6 +4841,11 @@ packages:
|
||||
defaults: 1.0.4
|
||||
dev: true
|
||||
|
||||
/wechat-emojis/1.0.2:
|
||||
resolution: {integrity: sha512-T1drHGy92rKm/Vo7LRkU4D4wdREpVTjAMEa4gR1NB9IAyck3qmmewFSrnEEIyZfsv3SXTA7X1kt6Smt0UKVCyw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
dev: false
|
||||
|
||||
/which-boxed-primitive/1.1.1:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4845,6 +4932,7 @@ packages:
|
||||
|
||||
/wrappy/1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
dev: true
|
||||
|
||||
/xmlbuilder/15.1.1:
|
||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
||||
@@ -4882,12 +4970,6 @@ packages:
|
||||
yargs-parser: 21.1.1
|
||||
dev: true
|
||||
|
||||
/yauzl/2.10.0:
|
||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||
dependencies:
|
||||
buffer-crc32: 0.2.13
|
||||
fd-slicer: 1.1.0
|
||||
|
||||
/yocto-queue/0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
|
Before Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 767 KiB |
|
After Width: | Height: | Size: 680 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 27 KiB |
@@ -27,13 +27,13 @@
|
||||
.report {
|
||||
width: 430px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 14px 34px;
|
||||
padding: 20px 14px 34px;
|
||||
}
|
||||
.hero,
|
||||
.card,
|
||||
.section {
|
||||
.section,
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.hero {
|
||||
@@ -41,25 +41,35 @@
|
||||
}
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.hero-top > div:first-child {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 23px;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 8px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 8px;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.mode-tag {
|
||||
display: inline-flex;
|
||||
margin-top: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #eef8f2;
|
||||
color: #07a352;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.avatar-grid {
|
||||
width: 58px;
|
||||
@@ -76,6 +86,49 @@
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.hero-headline {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||
}
|
||||
.hero-headline b {
|
||||
display: block;
|
||||
font-size: 17px;
|
||||
color: #076c39;
|
||||
}
|
||||
.hero-headline p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: #1f2933;
|
||||
}
|
||||
.hero-inline-notes {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.hero-note,
|
||||
.hero-status {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hero-status {
|
||||
margin-top: 10px;
|
||||
background: #f7faf9;
|
||||
color: #076c39;
|
||||
font-weight: 700;
|
||||
}
|
||||
.hero-note.takeaway {
|
||||
background: #eef8f2;
|
||||
color: #076c39;
|
||||
}
|
||||
.hero-note.pending {
|
||||
background: #fff8e8;
|
||||
color: #8a5a00;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -87,14 +140,11 @@
|
||||
border-radius: 12px;
|
||||
padding: 10px 6px;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat b {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
color: #07a352;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.stat span {
|
||||
@@ -102,16 +152,16 @@
|
||||
color: #667085;
|
||||
}
|
||||
.section {
|
||||
margin-top: 14px;
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title::before {
|
||||
content: '';
|
||||
@@ -120,11 +170,23 @@
|
||||
border-radius: 99px;
|
||||
background: #07c160;
|
||||
}
|
||||
.section-subtitle {
|
||||
margin: 10px 0 6px;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.section-more {
|
||||
margin-top: 10px;
|
||||
color: #98a2b3;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
.card {
|
||||
padding: 14px;
|
||||
margin-top: 10px;
|
||||
box-shadow: none;
|
||||
border: 1px solid #edf0f2;
|
||||
box-shadow: none;
|
||||
}
|
||||
.topic-title-row {
|
||||
display: flex;
|
||||
@@ -133,17 +195,17 @@
|
||||
gap: 8px;
|
||||
}
|
||||
.topic-title-row h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
font-weight: 850;
|
||||
}
|
||||
.heat,
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef8f2;
|
||||
color: #07a352;
|
||||
font-size: 11px;
|
||||
@@ -158,19 +220,44 @@
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
.red {
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
.topic-meta {
|
||||
margin-top: 6px;
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.card p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.topic-conclusions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.topic-conclusion {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: #edf9f1;
|
||||
color: #076c39;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-weight: 700;
|
||||
}
|
||||
.topic-inline-image {
|
||||
display: grid;
|
||||
grid-template-columns: 76px 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
background: #f7faf9;
|
||||
}
|
||||
.topic-inline-image img {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.participants {
|
||||
display: flex;
|
||||
@@ -182,7 +269,6 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
background: #f6f8fa;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px 3px 3px;
|
||||
@@ -191,7 +277,6 @@
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.person-chip b {
|
||||
max-width: 58px;
|
||||
@@ -213,17 +298,6 @@
|
||||
background: #f2f4f7;
|
||||
color: #667085;
|
||||
}
|
||||
.resource {
|
||||
padding: 11px 12px;
|
||||
background: #f7f8fa;
|
||||
border-radius: 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.resource b {
|
||||
color: #1677ff;
|
||||
}
|
||||
.important-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -248,9 +322,9 @@
|
||||
color: #1f2933;
|
||||
}
|
||||
.important-text {
|
||||
margin-top: 5px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.important-note {
|
||||
margin-top: 8px;
|
||||
@@ -262,6 +336,34 @@
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.action-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.action-card {
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
.todo-card {
|
||||
background: #eef5ff;
|
||||
}
|
||||
.unresolved-card {
|
||||
background: #fff8e8;
|
||||
}
|
||||
.action-card b {
|
||||
display: block;
|
||||
color: #1f2933;
|
||||
font-size: 14px;
|
||||
}
|
||||
.action-card div {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: #485465;
|
||||
}
|
||||
.action-note {
|
||||
color: #667085;
|
||||
}
|
||||
.chat-block {
|
||||
background: #f0f2f5;
|
||||
border-radius: 14px;
|
||||
@@ -292,49 +394,180 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
.quote-note {
|
||||
background: #fff8e1;
|
||||
border-radius: 10px;
|
||||
padding: 9px 10px;
|
||||
margin-top: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
background: #fff8e1;
|
||||
color: #8a5a00;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.qa-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
.qa-card,
|
||||
.resource {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.qa-card b {
|
||||
.qa-card b,
|
||||
.resource b {
|
||||
display: block;
|
||||
color: #1f2933;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.qa-card div {
|
||||
.qa-card div,
|
||||
.resource {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #485465;
|
||||
}
|
||||
.bar-row {
|
||||
.storyline-card,
|
||||
.chain-card {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.storyline-steps {
|
||||
display: grid;
|
||||
grid-template-columns: 82px 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 9px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.storyline-step {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.storyline-step span {
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bar {
|
||||
height: 10px;
|
||||
background: #edf1f5;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
.storyline-step b {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #07c160;
|
||||
.chain-flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.chain-flow span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 9px;
|
||||
border-radius: 999px;
|
||||
background: #eef8f2;
|
||||
color: #076c39;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chain-flow i {
|
||||
font-style: normal;
|
||||
color: #98a2b3;
|
||||
}
|
||||
.gallery-card {
|
||||
display: grid;
|
||||
grid-template-columns: 112px 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #f7faf9;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.gallery-image {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.gallery-stats {
|
||||
display: inline-flex;
|
||||
margin-top: 7px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
/* AI 图片识别板块 */
|
||||
.vision-card {
|
||||
display: grid;
|
||||
grid-template-columns: 132px 1fr;
|
||||
gap: 14px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||
border: 1px solid #d6efde;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.vision-image {
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.vision-description {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #1f2933;
|
||||
}
|
||||
.vision-ocr {
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
font-size: 11px;
|
||||
border-radius: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.vision-tags {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.vision-tag {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.vision-label {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
color: #07a352;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-card {
|
||||
background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%);
|
||||
border: 1px solid #edf0f2;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
.badge-card b {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.badge-card p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.data-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.rank {
|
||||
display: flex;
|
||||
@@ -343,6 +576,9 @@
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid #eef0f2;
|
||||
}
|
||||
.rank:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.rank img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -395,7 +631,58 @@
|
||||
color: #8a94a6;
|
||||
}
|
||||
.empty-section {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
.compact .report {
|
||||
padding-top: 18px;
|
||||
}
|
||||
.compact .section {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
.compact .card {
|
||||
padding: 11px;
|
||||
}
|
||||
.compact .important-card,
|
||||
.compact .gallery-card,
|
||||
.compact .chat-block {
|
||||
padding: 10px;
|
||||
}
|
||||
.compact .section-title {
|
||||
margin-bottom: 9px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.compact .hero-headline p,
|
||||
.compact .card p,
|
||||
.compact .important-text,
|
||||
.compact .chat-bubble {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.compact .participants,
|
||||
.compact .keywords,
|
||||
.compact .hero-inline-notes {
|
||||
gap: 6px;
|
||||
}
|
||||
.compact .topic-conclusions {
|
||||
gap: 6px;
|
||||
}
|
||||
.compact .topic-inline-image {
|
||||
grid-template-columns: 64px 1fr;
|
||||
padding: 8px;
|
||||
}
|
||||
.compact .topic-inline-image img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.compact .stats {
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.compact .stat {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
.compact .stat b {
|
||||
font-size: 17px;
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
html,
|
||||
@@ -407,75 +694,140 @@
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.stats {
|
||||
gap: 6px;
|
||||
}
|
||||
.stat b {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body class="{{REPORT_MODE_CLASS}}">
|
||||
<main class="report">
|
||||
<header class="hero">
|
||||
<div class="hero-top">
|
||||
<div>
|
||||
<h1>{{GROUP_NAME}}日报</h1>
|
||||
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||
<div class="mode-tag">{{REPORT_MODE_LABEL}}</div>
|
||||
</div>
|
||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||
</div>
|
||||
<div class="hero-headline">
|
||||
<b>{{HERO_HEADLINE}}</b>
|
||||
<p>{{HERO_SUMMARY}}</p>
|
||||
</div>
|
||||
<div class="hero-status {{HERO_STATUS_EMPTY_CLASS}}">{{HERO_STATUS_LINE}}</div>
|
||||
<div class="hero-inline-notes">
|
||||
<div class="hero-note takeaway {{HERO_TAKEAWAY_EMPTY_CLASS}}">{{HERO_TAKEAWAY}}</div>
|
||||
<div class="hero-note pending {{HERO_PENDING_EMPTY_CLASS}}">{{HERO_PENDING}}</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
||||
<div class="stat"><b>{{TIME_SPAN}}</b><span>持续时长</span></div>
|
||||
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
|
||||
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>话题数</span></div>
|
||||
<div class="stat"><b>{{MEDIA_COUNT}}</b><span>媒体消息</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="section topics">
|
||||
<section class="section {{TOPICS_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日讨论热点</div>
|
||||
{{TOPIC_CARDS}}
|
||||
{{TOPICS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
|
||||
<section class="section {{MESSAGES_EMPTY_CLASS}}">
|
||||
<div class="section-title">重要消息</div>
|
||||
{{IMPORTANT_MESSAGES}}
|
||||
{{MESSAGES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{ACTIONS_EMPTY_CLASS}}">
|
||||
<div class="section-title">待办事项和未解决问题</div>
|
||||
<div class="section-subtitle {{TODO_EMPTY_CLASS}}">待办事项</div>
|
||||
<div class="action-grid {{TODO_EMPTY_CLASS}}">{{TODO_CARDS}}</div>
|
||||
<div class="section-subtitle {{UNRESOLVED_EMPTY_CLASS}}">尚未解决</div>
|
||||
<div class="action-grid {{UNRESOLVED_EMPTY_CLASS}}">{{UNRESOLVED_CARDS}}</div>
|
||||
{{ACTIONS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{QUOTES_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日名场面</div>
|
||||
{{QUOTE_BLOCKS}}
|
||||
{{QUOTES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{ANALYTICS_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日群数据</div>
|
||||
<div class="data-grid">
|
||||
<div class="card">
|
||||
<div class="muted" style="font-size: 12px; margin-bottom: 6px">话唠榜 TOP5</div>
|
||||
{{RANK_ITEMS}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<p><b>最活跃时段:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section {{KEYWORDS_EMPTY_CLASS}}">
|
||||
<div class="section-title">关键词</div>
|
||||
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||
{{KEYWORDS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{RESOURCES_EMPTY_CLASS}}">
|
||||
<div class="section-title">实用信息与资源</div>
|
||||
{{RESOURCE_ITEMS}}
|
||||
{{RESOURCES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
|
||||
<div class="section-title">重要消息汇总</div>
|
||||
{{IMPORTANT_MESSAGES}}
|
||||
</section>
|
||||
|
||||
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
|
||||
<div class="section-title">有趣对话或金句</div>
|
||||
{{QUOTE_BLOCKS}}
|
||||
</section>
|
||||
|
||||
<section class="section qa {{QA_EMPTY_CLASS}}">
|
||||
<section class="section {{QA_EMPTY_CLASS}}">
|
||||
<div class="section-title">问题与解答</div>
|
||||
{{QA_CARDS}}
|
||||
{{QA_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section analytics">
|
||||
<div class="section-title">群内数据可视化</div>
|
||||
{{HEAT_BARS}}
|
||||
<div class="card">
|
||||
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
|
||||
话唠榜 TOP5(基于已读取记录估算)
|
||||
</div>
|
||||
{{RANK_ITEMS}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||
</div>
|
||||
<section class="section {{STORYLINES_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日剧情时间线</div>
|
||||
{{STORYLINE_CARDS}}
|
||||
{{STORYLINES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section cloud">
|
||||
<div class="section-title">词云/关键词</div>
|
||||
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||
<section class="section {{REVERSALS_EMPTY_CLASS}}">
|
||||
<div class="section-title">群聊反转现场</div>
|
||||
{{REVERSAL_CARDS}}
|
||||
{{REVERSALS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{VISION_EMPTY_CLASS}}">
|
||||
<div class="section-title">{{VISION_TITLE}}</div>
|
||||
{{VISION_CARDS}}
|
||||
</section>
|
||||
|
||||
<section class="section {{GALLERY_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日群相册</div>
|
||||
{{GALLERY_CARDS}}
|
||||
{{GALLERY_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{VOICE_EMPTY_CLASS}}">
|
||||
<div class="section-title">语音之最</div>
|
||||
{{VOICE_CARDS}}
|
||||
{{VOICE_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{VOICE_RANK_EMPTY_CLASS}}">
|
||||
<div class="section-title">语音时长榜</div>
|
||||
<div class="card">{{VOICE_RANK_CARDS}}</div>
|
||||
</section>
|
||||
|
||||
<section class="section {{BADGES_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日临时人设</div>
|
||||
<div class="badge-grid">{{BADGE_CARDS}}</div>
|
||||
{{BADGES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{CHAINS_EMPTY_CLASS}}">
|
||||
<div class="section-title">话题参与链路</div>
|
||||
{{CHAIN_CARDS}}
|
||||
{{CHAINS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>{{REPORT_TITLE}}</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
html {
|
||||
width: 430px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
width: 430px;
|
||||
background: #f3f5f7;
|
||||
color: #1f2933;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
.report {
|
||||
width: 430px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 14px 34px;
|
||||
}
|
||||
.hero,
|
||||
.card,
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.hero {
|
||||
padding: 20px;
|
||||
}
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.hero-top > div:first-child {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 23px;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 8px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.sub {
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.record-note {
|
||||
color: #485465;
|
||||
font-weight: 650;
|
||||
}
|
||||
.overview {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.avatar-grid {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.avatar-grid img,
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.stat {
|
||||
background: #f7faf9;
|
||||
border-radius: 12px;
|
||||
padding: 10px 6px;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat b {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
color: #07a352;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.stat span {
|
||||
font-size: 11px;
|
||||
color: #667085;
|
||||
}
|
||||
.section {
|
||||
margin-top: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 20px;
|
||||
border-radius: 99px;
|
||||
background: #07c160;
|
||||
}
|
||||
.card {
|
||||
padding: 14px;
|
||||
margin-top: 10px;
|
||||
box-shadow: none;
|
||||
border: 1px solid #edf0f2;
|
||||
}
|
||||
.topic-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.topic-title-row h3 {
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
font-weight: 850;
|
||||
}
|
||||
.heat,
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
background: #eef8f2;
|
||||
color: #07a352;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hot {
|
||||
background: #fff4e5;
|
||||
color: #d46b08;
|
||||
}
|
||||
.blue {
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
.red {
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
.topic-meta {
|
||||
margin-top: 6px;
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.card p {
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.participants {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.person-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
background: #f6f8fa;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px 3px 3px;
|
||||
}
|
||||
.person-chip img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.person-chip b {
|
||||
max-width: 58px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.keywords {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.keywords span {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f2f4f7;
|
||||
color: #667085;
|
||||
}
|
||||
.resource {
|
||||
padding: 11px 12px;
|
||||
background: #f7f8fa;
|
||||
border-radius: 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.resource b {
|
||||
color: #1677ff;
|
||||
}
|
||||
.important-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
background: #f7faf9;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.important-card > .avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.important-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
}
|
||||
.important-meta b {
|
||||
color: #1f2933;
|
||||
}
|
||||
.important-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.important-note {
|
||||
margin-top: 8px;
|
||||
padding: 7px 9px;
|
||||
border-left: 3px solid #07c160;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
color: #07a352;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.chat-block {
|
||||
background: #f0f2f5;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.chat-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.chat-name {
|
||||
font-size: 11px;
|
||||
color: #667085;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.chat-bubble {
|
||||
background: #fff;
|
||||
border-radius: 4px 12px 12px 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.quote-note {
|
||||
background: #fff8e1;
|
||||
border-radius: 10px;
|
||||
padding: 9px 10px;
|
||||
margin-top: 10px;
|
||||
color: #8a5a00;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.qa-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.qa-card b {
|
||||
display: block;
|
||||
color: #1f2933;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.qa-card div {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #485465;
|
||||
}
|
||||
.bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 82px 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bar {
|
||||
height: 10px;
|
||||
background: #edf1f5;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #07c160;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid #eef0f2;
|
||||
}
|
||||
.rank img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.rank b {
|
||||
font-size: 13px;
|
||||
}
|
||||
.rank span {
|
||||
margin-left: auto;
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cloud-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.cloud-tags span {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f2f4f7;
|
||||
color: #485465;
|
||||
font-weight: 800;
|
||||
}
|
||||
.cloud-tags .xl {
|
||||
font-size: 20px;
|
||||
color: #07a352;
|
||||
background: #e9f8ef;
|
||||
}
|
||||
.cloud-tags .lg {
|
||||
font-size: 17px;
|
||||
color: #1677ff;
|
||||
background: #eef5ff;
|
||||
}
|
||||
.cloud-tags .md {
|
||||
font-size: 15px;
|
||||
color: #d46b08;
|
||||
background: #fff4e5;
|
||||
}
|
||||
.footer {
|
||||
padding: 16px 4px 0;
|
||||
color: #98a2b3;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.muted {
|
||||
color: #8a94a6;
|
||||
}
|
||||
.empty-section {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
}
|
||||
.report {
|
||||
width: 100%;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.stats {
|
||||
gap: 6px;
|
||||
}
|
||||
.stat b {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
/* AI 图片识别板块(v1 模板) */
|
||||
.vision-card {
|
||||
display: grid;
|
||||
grid-template-columns: 132px 1fr;
|
||||
gap: 14px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||
border: 1px solid #d6efde;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.vision-image {
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.vision-description {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #1f2933;
|
||||
}
|
||||
.vision-ocr {
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
font-size: 11px;
|
||||
border-radius: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.vision-tags {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.vision-tag {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.vision-label {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
color: #07a352;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 热度条形图(v1 模板) */
|
||||
.heat-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr 40px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.heat-name {
|
||||
color: #1f2933;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.heat-bar {
|
||||
background: #f3f5f7;
|
||||
border-radius: 999px;
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.heat-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #07c160 0%, #34d399 100%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.heat-val {
|
||||
color: #485465;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="report">
|
||||
<header class="hero">
|
||||
<div class="hero-top">
|
||||
<div>
|
||||
<h1>{{GROUP_NAME}}日报</h1>
|
||||
<div class="sub">
|
||||
<div>{{DATE_RANGE}}</div>
|
||||
<div class="record-note">{{RECORD_NOTE}}</div>
|
||||
<div class="overview">{{OVERVIEW}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
||||
<div class="stat"><b>{{TIME_SPAN}}</b><span>持续时长</span></div>
|
||||
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="section topics">
|
||||
<div class="section-title">今日讨论热点</div>
|
||||
{{TOPIC_CARDS}}
|
||||
</section>
|
||||
|
||||
<section class="section vision {{VISION_EMPTY_CLASS}}">
|
||||
<div class="section-title">{{VISION_TITLE}}</div>
|
||||
{{VISION_CARDS}}
|
||||
</section>
|
||||
|
||||
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
|
||||
<div class="section-title">实用信息与资源</div>
|
||||
{{RESOURCE_ITEMS}}
|
||||
</section>
|
||||
|
||||
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
|
||||
<div class="section-title">重要消息汇总</div>
|
||||
{{IMPORTANT_MESSAGES}}
|
||||
</section>
|
||||
|
||||
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
|
||||
<div class="section-title">有趣对话或金句</div>
|
||||
{{QUOTE_BLOCKS}}
|
||||
</section>
|
||||
|
||||
<section class="section qa {{QA_EMPTY_CLASS}}">
|
||||
<div class="section-title">问题与解答</div>
|
||||
{{QA_CARDS}}
|
||||
</section>
|
||||
|
||||
<section class="section analytics">
|
||||
<div class="section-title">群内数据可视化</div>
|
||||
{{HEAT_BARS}}
|
||||
<div class="card">
|
||||
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
|
||||
话唠榜 TOP5(基于已读取记录估算)
|
||||
</div>
|
||||
{{RANK_ITEMS}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section cloud">
|
||||
<div class="section-title">词云/关键词</div>
|
||||
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
数据来源:WechatExplorer · 微信群聊记录<br />
|
||||
生成时间:{{GENERATED_AT}}<br />
|
||||
{{FOOTER_NOTE}}
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,829 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>{{REPORT_TITLE}}</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
html {
|
||||
width: 430px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
width: 430px;
|
||||
background: #f3f5f7;
|
||||
color: #1f2933;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
.report {
|
||||
width: 430px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 14px 34px;
|
||||
}
|
||||
.hero,
|
||||
.section,
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.hero {
|
||||
padding: 20px;
|
||||
}
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
.hero-top > div:first-child {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 23px;
|
||||
line-height: 1.2;
|
||||
font-weight: 900;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 8px;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.avatar-grid {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.avatar-grid img,
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.hero-headline {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||
}
|
||||
.hero-headline b {
|
||||
display: block;
|
||||
font-size: 17px;
|
||||
color: #076c39;
|
||||
}
|
||||
.hero-headline p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: #1f2933;
|
||||
}
|
||||
.hero-inline-notes {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.hero-note,
|
||||
.hero-status {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hero-status {
|
||||
margin-top: 10px;
|
||||
background: #f7faf9;
|
||||
color: #076c39;
|
||||
font-weight: 700;
|
||||
}
|
||||
.hero-note.takeaway {
|
||||
background: #eef8f2;
|
||||
color: #076c39;
|
||||
}
|
||||
.hero-note.pending {
|
||||
background: #fff8e8;
|
||||
color: #8a5a00;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.stat {
|
||||
background: #f7faf9;
|
||||
border-radius: 12px;
|
||||
padding: 10px 6px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat b {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
color: #07a352;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.stat span {
|
||||
font-size: 11px;
|
||||
color: #667085;
|
||||
}
|
||||
.section {
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.section-title::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 20px;
|
||||
border-radius: 99px;
|
||||
background: #07c160;
|
||||
}
|
||||
.section-subtitle {
|
||||
margin: 10px 0 6px;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.section-more {
|
||||
margin-top: 10px;
|
||||
color: #98a2b3;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
.card {
|
||||
padding: 14px;
|
||||
margin-top: 10px;
|
||||
border: 1px solid #edf0f2;
|
||||
box-shadow: none;
|
||||
}
|
||||
.topic-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.topic-title-row h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
font-weight: 850;
|
||||
}
|
||||
.heat,
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef8f2;
|
||||
color: #07a352;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hot {
|
||||
background: #fff4e5;
|
||||
color: #d46b08;
|
||||
}
|
||||
.blue {
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
.topic-meta {
|
||||
margin-top: 6px;
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.card p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.topic-conclusions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.topic-conclusion {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: #edf9f1;
|
||||
color: #076c39;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-weight: 700;
|
||||
}
|
||||
.topic-inline-image {
|
||||
display: grid;
|
||||
grid-template-columns: 76px 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
background: #f7faf9;
|
||||
}
|
||||
.topic-inline-image img {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.participants {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.person-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: #f6f8fa;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px 3px 3px;
|
||||
}
|
||||
.person-chip img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.person-chip b {
|
||||
max-width: 58px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.keywords {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.keywords span {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f2f4f7;
|
||||
color: #667085;
|
||||
}
|
||||
.important-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
background: #f7faf9;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.important-card > .avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.important-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
}
|
||||
.important-meta b {
|
||||
color: #1f2933;
|
||||
}
|
||||
.important-text {
|
||||
margin-top: 5px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.important-note {
|
||||
margin-top: 8px;
|
||||
padding: 7px 9px;
|
||||
border-left: 3px solid #07c160;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
color: #07a352;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.action-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.action-card {
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
.todo-card {
|
||||
background: #eef5ff;
|
||||
}
|
||||
.unresolved-card {
|
||||
background: #fff8e8;
|
||||
}
|
||||
.action-card b {
|
||||
display: block;
|
||||
color: #1f2933;
|
||||
font-size: 14px;
|
||||
}
|
||||
.action-card div {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: #485465;
|
||||
}
|
||||
.action-note {
|
||||
color: #667085;
|
||||
}
|
||||
.chat-block {
|
||||
background: #f0f2f5;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.chat-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.chat-name {
|
||||
font-size: 11px;
|
||||
color: #667085;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.chat-bubble {
|
||||
background: #fff;
|
||||
border-radius: 4px 12px 12px 12px;
|
||||
padding: 9px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.quote-note {
|
||||
margin-top: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
background: #fff8e1;
|
||||
color: #8a5a00;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.qa-card,
|
||||
.resource {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.qa-card b,
|
||||
.resource b {
|
||||
display: block;
|
||||
color: #1f2933;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.qa-card div,
|
||||
.resource {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #485465;
|
||||
}
|
||||
.storyline-card,
|
||||
.chain-card {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.storyline-steps {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.storyline-step {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.storyline-step span {
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.storyline-step b {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.chain-flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.chain-flow span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 9px;
|
||||
border-radius: 999px;
|
||||
background: #eef8f2;
|
||||
color: #076c39;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chain-flow i {
|
||||
font-style: normal;
|
||||
color: #98a2b3;
|
||||
}
|
||||
.gallery-card {
|
||||
display: grid;
|
||||
grid-template-columns: 112px 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #f7faf9;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.gallery-image {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.gallery-stats {
|
||||
display: inline-flex;
|
||||
margin-top: 7px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
/* AI 图片识别板块 */
|
||||
.vision-card {
|
||||
display: grid;
|
||||
grid-template-columns: 132px 1fr;
|
||||
gap: 14px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||
border: 1px solid #d6efde;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.vision-image {
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.vision-description {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #1f2933;
|
||||
}
|
||||
.vision-ocr {
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
font-size: 11px;
|
||||
border-radius: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.vision-tags {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.vision-tag {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.vision-label {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
color: #07a352;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-card {
|
||||
background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%);
|
||||
border: 1px solid #edf0f2;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
.badge-card b {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.badge-card p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.data-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid #eef0f2;
|
||||
}
|
||||
.rank:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.rank img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.rank b {
|
||||
font-size: 13px;
|
||||
}
|
||||
.rank span {
|
||||
margin-left: auto;
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cloud-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.cloud-tags span {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f2f4f7;
|
||||
color: #485465;
|
||||
font-weight: 800;
|
||||
}
|
||||
.cloud-tags .xl {
|
||||
font-size: 20px;
|
||||
color: #07a352;
|
||||
background: #e9f8ef;
|
||||
}
|
||||
.cloud-tags .lg {
|
||||
font-size: 17px;
|
||||
color: #1677ff;
|
||||
background: #eef5ff;
|
||||
}
|
||||
.cloud-tags .md {
|
||||
font-size: 15px;
|
||||
color: #d46b08;
|
||||
background: #fff4e5;
|
||||
}
|
||||
.footer {
|
||||
padding: 16px 4px 0;
|
||||
color: #98a2b3;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.muted {
|
||||
color: #8a94a6;
|
||||
}
|
||||
.empty-section {
|
||||
display: none !important;
|
||||
}
|
||||
.compact .report {
|
||||
padding-top: 18px;
|
||||
}
|
||||
.compact .section {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
.compact .card {
|
||||
padding: 11px;
|
||||
}
|
||||
.compact .important-card,
|
||||
.compact .gallery-card,
|
||||
.compact .chat-block {
|
||||
padding: 10px;
|
||||
}
|
||||
.compact .section-title {
|
||||
margin-bottom: 9px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.compact .hero-headline p,
|
||||
.compact .card p,
|
||||
.compact .important-text,
|
||||
.compact .chat-bubble {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.compact .participants,
|
||||
.compact .keywords,
|
||||
.compact .hero-inline-notes {
|
||||
gap: 6px;
|
||||
}
|
||||
.compact .topic-conclusions {
|
||||
gap: 6px;
|
||||
}
|
||||
.compact .topic-inline-image {
|
||||
grid-template-columns: 64px 1fr;
|
||||
padding: 8px;
|
||||
}
|
||||
.compact .topic-inline-image img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.compact .stats {
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.compact .stat {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
.compact .stat b {
|
||||
font-size: 17px;
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
}
|
||||
.report {
|
||||
width: 100%;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="{{REPORT_MODE_CLASS}}">
|
||||
<main class="report">
|
||||
<header class="hero">
|
||||
<div class="hero-top">
|
||||
<div>
|
||||
<h1>{{GROUP_NAME}}日报</h1>
|
||||
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||
</div>
|
||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||
</div>
|
||||
<div class="hero-headline">
|
||||
<b>{{HERO_HEADLINE}}</b>
|
||||
<p>{{HERO_SUMMARY}}</p>
|
||||
</div>
|
||||
<div class="hero-status {{HERO_STATUS_EMPTY_CLASS}}">{{HERO_STATUS_LINE}}</div>
|
||||
<div class="hero-inline-notes">
|
||||
<div class="hero-note takeaway {{HERO_TAKEAWAY_EMPTY_CLASS}}">{{HERO_TAKEAWAY}}</div>
|
||||
<div class="hero-note pending {{HERO_PENDING_EMPTY_CLASS}}">{{HERO_PENDING}}</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
||||
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>话题数</span></div>
|
||||
<div class="stat"><b>{{MEDIA_COUNT}}</b><span>媒体消息</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="section {{TOPICS_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日讨论热点</div>
|
||||
{{TOPIC_CARDS}}
|
||||
{{TOPICS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{MESSAGES_EMPTY_CLASS}}">
|
||||
<div class="section-title">重要消息</div>
|
||||
{{IMPORTANT_MESSAGES}}
|
||||
{{MESSAGES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{ACTIONS_EMPTY_CLASS}}">
|
||||
<div class="section-title">待办事项和未解决问题</div>
|
||||
<div class="section-subtitle {{TODO_EMPTY_CLASS}}">待办事项</div>
|
||||
<div class="action-grid {{TODO_EMPTY_CLASS}}">{{TODO_CARDS}}</div>
|
||||
<div class="section-subtitle {{UNRESOLVED_EMPTY_CLASS}}">尚未解决</div>
|
||||
<div class="action-grid {{UNRESOLVED_EMPTY_CLASS}}">{{UNRESOLVED_CARDS}}</div>
|
||||
{{ACTIONS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{QUOTES_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日名场面</div>
|
||||
{{QUOTE_BLOCKS}}
|
||||
{{QUOTES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{ANALYTICS_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日群数据</div>
|
||||
<div class="data-grid">
|
||||
<div class="card">
|
||||
<div class="muted" style="font-size: 12px; margin-bottom: 6px">话唠榜 TOP5</div>
|
||||
{{RANK_ITEMS}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<p><b>最活跃时段:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section {{KEYWORDS_EMPTY_CLASS}}">
|
||||
<div class="section-title">关键词</div>
|
||||
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||
{{KEYWORDS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{RESOURCES_EMPTY_CLASS}}">
|
||||
<div class="section-title">实用信息与资源</div>
|
||||
{{RESOURCE_ITEMS}}
|
||||
{{RESOURCES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{QA_EMPTY_CLASS}}">
|
||||
<div class="section-title">问题与解答</div>
|
||||
{{QA_CARDS}}
|
||||
{{QA_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{STORYLINES_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日剧情时间线</div>
|
||||
{{STORYLINE_CARDS}}
|
||||
{{STORYLINES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{REVERSALS_EMPTY_CLASS}}">
|
||||
<div class="section-title">群聊反转现场</div>
|
||||
{{REVERSAL_CARDS}}
|
||||
{{REVERSALS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{VISION_EMPTY_CLASS}}">
|
||||
<div class="section-title">{{VISION_TITLE}}</div>
|
||||
{{VISION_CARDS}}
|
||||
</section>
|
||||
|
||||
<section class="section {{GALLERY_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日群相册</div>
|
||||
{{GALLERY_CARDS}}
|
||||
{{GALLERY_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{VOICE_EMPTY_CLASS}}">
|
||||
<div class="section-title">语音之最</div>
|
||||
{{VOICE_CARDS}}
|
||||
{{VOICE_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{VOICE_RANK_EMPTY_CLASS}}">
|
||||
<div class="section-title">语音时长榜</div>
|
||||
<div class="card">{{VOICE_RANK_CARDS}}</div>
|
||||
</section>
|
||||
|
||||
<section class="section {{BADGES_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日临时人设</div>
|
||||
<div class="badge-grid">{{BADGE_CARDS}}</div>
|
||||
{{BADGES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{CHAINS_EMPTY_CLASS}}">
|
||||
<div class="section-title">话题参与链路</div>
|
||||
{{CHAIN_CARDS}}
|
||||
{{CHAINS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
数据来源:WechatExplorer · 微信群聊记录<br />
|
||||
生成时间:{{GENERATED_AT}}<br />
|
||||
{{FOOTER_NOTE}}
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,6 +10,23 @@ function setPlistValue(plistPath, key, value) {
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
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
|
||||
|
||||
@@ -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,13 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const source = path.join(root, '.env.example')
|
||||
const target = path.join(root, '.env')
|
||||
|
||||
if (!fs.existsSync(source) || fs.existsSync(target)) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
fs.copyFileSync(source, target)
|
||||
console.log('[ensure-env] created .env from .env.example')
|
||||
@@ -0,0 +1,48 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const runtimeNames = [
|
||||
'msvcp140.dll',
|
||||
'msvcp140_1.dll',
|
||||
'vcruntime140.dll',
|
||||
'vcruntime140_1.dll'
|
||||
]
|
||||
|
||||
function copyIfDifferent(sourcePath, targetPath) {
|
||||
const source = fs.statSync(sourcePath)
|
||||
const targetExists = fs.existsSync(targetPath)
|
||||
|
||||
if (targetExists) {
|
||||
const target = fs.statSync(targetPath)
|
||||
if (target.size === source.size && target.mtimeMs >= source.mtimeMs) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, targetPath)
|
||||
return true
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.platform !== 'win32') return
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32')
|
||||
const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist')
|
||||
|
||||
if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) return
|
||||
|
||||
let copiedCount = 0
|
||||
for (const name of runtimeNames) {
|
||||
const sourcePath = path.join(sourceDir, name)
|
||||
const targetPath = path.join(targetDir, name)
|
||||
if (!fs.existsSync(sourcePath)) continue
|
||||
if (copyIfDifferent(sourcePath, targetPath)) copiedCount += 1
|
||||
}
|
||||
|
||||
if (copiedCount > 0) {
|
||||
console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,409 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
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 escapeHtml = (value) =>
|
||||
String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const replacePlaceholder = (html, key, value) => html.replaceAll(`{{${key}}}`, value)
|
||||
|
||||
const avatarSvg = (label, color) =>
|
||||
`data:image/svg+xml;base64,${Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="${color}"/><text x="48" y="58" text-anchor="middle" font-family="PingFang SC, sans-serif" font-size="36" fill="#0f172a">${label}</text></svg>`
|
||||
).toString('base64')}`
|
||||
|
||||
const localImagePath = '/Users/Wxw_/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/a969409112_d784/temp/RWTemp/2026-07/94ce24699a5a1d539c00a37ec8ace755.png'
|
||||
const sampleImage = fs.existsSync(localImagePath)
|
||||
? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
|
||||
: avatarSvg('图', '#dbeafe')
|
||||
|
||||
const avatars = {
|
||||
阿宇: avatarSvg('宇', '#dcfce7'),
|
||||
老周: avatarSvg('周', '#e0f2fe'),
|
||||
小李: avatarSvg('李', '#fef3c7'),
|
||||
'we water': avatarSvg('W', '#ede9fe'),
|
||||
佩佩: avatarSvg('佩', '#fee2e2')
|
||||
}
|
||||
|
||||
const heroNames = ['阿宇', '老周', '小李', 'we water']
|
||||
const heroAvatars = heroNames
|
||||
.map((name) => `<img src="${avatars[name]}" alt="${name}">`)
|
||||
.join('')
|
||||
|
||||
const compactRequest = {
|
||||
metadata: {
|
||||
groupName: '技术交流',
|
||||
reportDate: '2026-07-10',
|
||||
dateRange: '2026-07-10 09:12-19:48',
|
||||
messageCount: 382,
|
||||
activeUsers: 47,
|
||||
imageCount: 9,
|
||||
voiceCount: 5,
|
||||
stickerCount: 14,
|
||||
mediaMessageCount: 28,
|
||||
timeSpan: '11 h',
|
||||
generatedAt: '2026-07-10 22:18',
|
||||
recordNote: '基于示例数据生成的精简版日报',
|
||||
footerNote: '精简版默认面向长图转发;无内容模块自动隐藏。',
|
||||
heroParticipants: heroNames,
|
||||
avatars,
|
||||
reportMode: 'compact'
|
||||
},
|
||||
report: {
|
||||
hero: {
|
||||
headline: '接口排查和版本升级是今天主线',
|
||||
summary: '白天主要围绕接口异常、升级节奏和上线安排展开,结论比争论更多,待跟进事项也比较集中。',
|
||||
keyTakeaway: '大家确认本次异常更像缓存与配置问题,而不是服务端挂掉。',
|
||||
pendingNote: '测试环境接口文档和回滚方案仍需补齐。',
|
||||
statusLine: '今日形成 3 个结论 · 2 个待办 · 1 个问题尚未解决'
|
||||
},
|
||||
summaryStats: {
|
||||
messageCount: 382,
|
||||
activeUsers: 47,
|
||||
topicCount: 4,
|
||||
mediaCount: 28,
|
||||
imageCount: 9,
|
||||
voiceCount: 5,
|
||||
stickerCount: 14,
|
||||
conclusionCount: 3,
|
||||
todoCount: 2,
|
||||
unresolvedCount: 1
|
||||
},
|
||||
sectionMeta: {
|
||||
hero: { enabled: true, importance: 1, confidence: 0.95, totalCount: 1, displayedCount: 1 },
|
||||
topics: { enabled: true, importance: 0.98, confidence: 0.86, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
|
||||
importantMessages: { enabled: true, importance: 0.95, confidence: 0.84, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
|
||||
actions: { enabled: true, importance: 0.97, confidence: 0.81, totalCount: 5, displayedCount: 3, hiddenCount: 2 },
|
||||
moments: { enabled: true, importance: 0.75, confidence: 0.75, totalCount: 3, displayedCount: 1, hiddenCount: 2 },
|
||||
analytics: { enabled: true, importance: 0.8, confidence: 0.95, totalCount: 1, displayedCount: 1 },
|
||||
keywords: { enabled: true, importance: 0.68, confidence: 0.92, totalCount: 16, displayedCount: 12, hiddenCount: 4 }
|
||||
},
|
||||
topics: [
|
||||
{
|
||||
title: 'GPT 接口异常排查',
|
||||
timeRange: '09:20-11:05',
|
||||
heat: '高',
|
||||
summary: '上午先从接口超时和返回结构异常入手,几轮排查后,大家逐步把问题收敛到缓存与环境配置,而不是后端服务不可用。',
|
||||
conclusions: [
|
||||
{ text: '接口本身可用,异常更像本地缓存与环境变量冲突。' },
|
||||
{ text: '先清缓存再复测,避免把旧响应误判成线上事故。' }
|
||||
],
|
||||
participants: ['阿宇', '老周', '小李'],
|
||||
keywords: ['接口', '缓存', '环境变量'],
|
||||
image: {
|
||||
imageUrl: sampleImage,
|
||||
note: '该图片引发 12 条回复。根据图片前后对话推断,这是一张帮助定位问题的截图。'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '版本升级节奏',
|
||||
timeRange: '11:40-12:20',
|
||||
heat: '中',
|
||||
summary: '关于 React 版本是否立刻升级,讨论从“能不能升”转成“这周值不值得升”,最终倾向先补兼容性清单再动。',
|
||||
conclusions: [{ text: '先列旧组件兼容清单,再决定升级窗口。' }],
|
||||
participants: ['阿宇', 'we water', '佩佩'],
|
||||
keywords: ['React', '升级', '兼容性']
|
||||
},
|
||||
{
|
||||
title: '上线节奏与回滚准备',
|
||||
timeRange: '15:10-16:05',
|
||||
heat: '中',
|
||||
summary: '下午讨论上线方案时,大家更关注回滚准备是否充分,最后把重点放在文档、监控和回滚路径补齐上。',
|
||||
conclusions: [{ text: '上线前需要补一版简短回滚说明。' }],
|
||||
participants: ['老周', '小李'],
|
||||
keywords: ['上线', '回滚', '监控']
|
||||
},
|
||||
],
|
||||
importantMessages: [
|
||||
{ sender: '阿宇', time: '10:41', content: '先别回滚,接口能通。', note: '稳定了排查方向。' },
|
||||
{ sender: '老周', time: '11:02', content: '像是缓存没清掉。', note: '把问题从服务端收敛到本地环境。' },
|
||||
{ sender: '小李', time: '15:36', content: '上线前把回滚文档补一下。', note: '明确形成待办。' }
|
||||
],
|
||||
todos: [
|
||||
{ task: '补测试环境接口文档', owner: '小李', topic: 'GPT 接口异常排查', note: '方便明天复测。' },
|
||||
{ task: '整理回滚说明', owner: '老周', deadline: '今晚', topic: '上线节奏与回滚准备' }
|
||||
],
|
||||
unresolved: [
|
||||
{ question: '缓存问题的根因是不是插件残留?', owner: '阿宇', status: '待跟进', lastDiscussedAt: '18:26', note: '目前只有推断,还没有最终证据。' }
|
||||
],
|
||||
quotes: [
|
||||
{
|
||||
messages: [
|
||||
{ sender: '阿宇', content: '我以为接口炸了。' },
|
||||
{ sender: '老周', content: '先别慌,先清缓存。' },
|
||||
{ sender: '小李', content: '清完它居然真好了。' }
|
||||
],
|
||||
note: '从“要不要回滚”迅速切到“先做最小验证”,很像今天的群聊节奏。'
|
||||
}
|
||||
],
|
||||
analytics: {
|
||||
topSpeakers: [
|
||||
{ name: '阿宇', count: 112 },
|
||||
{ name: '老周', count: 78 },
|
||||
{ name: '小李', count: 61 },
|
||||
{ name: 'we water', count: 49 },
|
||||
{ name: '佩佩', count: 33 }
|
||||
],
|
||||
activeTimeline: '09:00-09:59、10:00-10:59、15:00-15:59',
|
||||
voiceLeaderboard: []
|
||||
},
|
||||
keywords: ['GPT', '接口', '缓存', '升级', '上线', '回滚', '监控', '兼容性', '截图', '文档', '测试环境', '复测'],
|
||||
media: {
|
||||
gallery: [],
|
||||
voiceHighlights: [],
|
||||
funBadges: []
|
||||
},
|
||||
resources: [],
|
||||
qa: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: []
|
||||
}
|
||||
}
|
||||
|
||||
const fullRequest = JSON.parse(JSON.stringify(compactRequest))
|
||||
fullRequest.metadata.recordNote = '基于示例数据生成的完整版日报'
|
||||
fullRequest.metadata.reportMode = 'full'
|
||||
fullRequest.report.sectionMeta = {
|
||||
...fullRequest.report.sectionMeta,
|
||||
resources: { enabled: true, importance: 0.58, confidence: 0.76, totalCount: 2, displayedCount: 2 },
|
||||
qa: { enabled: true, importance: 0.62, confidence: 0.8, totalCount: 2, displayedCount: 2 },
|
||||
storylines: { enabled: true, importance: 0.68, confidence: 0.74, totalCount: 2, displayedCount: 2 },
|
||||
reversals: { enabled: true, importance: 0.55, confidence: 0.72, totalCount: 1, displayedCount: 1 },
|
||||
gallery: { enabled: true, importance: 0.64, confidence: 0.82, totalCount: 2, displayedCount: 2 },
|
||||
voices: { enabled: true, importance: 0.6, confidence: 0.83, totalCount: 2, displayedCount: 2 },
|
||||
badges: { enabled: true, importance: 0.45, confidence: 0.68, totalCount: 2, displayedCount: 2 },
|
||||
chains: { enabled: true, importance: 0.58, confidence: 0.72, totalCount: 1, displayedCount: 1 }
|
||||
}
|
||||
fullRequest.report.resources = [
|
||||
{ title: '测试环境接口文档', description: '明天复测会直接用到的说明。', sender: '小李' },
|
||||
{ title: '回滚说明草稿', description: '上线前确认回滚路径与负责人。', sender: '老周' }
|
||||
]
|
||||
fullRequest.report.qa = [
|
||||
{ question: '今晚要不要升级 React?', answer: '先不升级,先补兼容清单。', answerer: 'we water' },
|
||||
{ question: '接口是不是服务端挂了?', answer: '不是,当前更像缓存与环境问题。', answerer: '老周' }
|
||||
]
|
||||
fullRequest.report.storylines = [
|
||||
{
|
||||
title: '接口异常排查线',
|
||||
stages: [
|
||||
{ time: '09:20', event: '阿宇提出接口异常。' },
|
||||
{ time: '09:46', event: '老周建议先清缓存。' },
|
||||
{ time: '10:41', event: '确认接口本身可通。' }
|
||||
],
|
||||
result: '初步定位为缓存与配置冲突。'
|
||||
},
|
||||
{
|
||||
title: '版本升级讨论线',
|
||||
stages: [
|
||||
{ time: '11:40', event: '开始讨论是否本周升级。' },
|
||||
{ time: '12:05', event: '补充兼容性与工期顾虑。' }
|
||||
],
|
||||
result: '今晚不升,先补兼容清单。'
|
||||
}
|
||||
]
|
||||
fullRequest.report.reversals = [
|
||||
{ topic: '接口异常', initialView: '最初以为后端服务不稳定。', finalView: '最终判断更像缓存与配置问题。', note: '多轮验证后,排查方向明显收敛。' }
|
||||
]
|
||||
fullRequest.report.media = {
|
||||
gallery: [
|
||||
{
|
||||
sender: '阿宇',
|
||||
time: '09:52',
|
||||
imageUrl: sampleImage,
|
||||
note: '图片发出后,群里立刻围绕异常现象、返回结构和复现环境展开讨论。',
|
||||
stats: '12 条后续消息 · 6 人接话',
|
||||
inferenceLabel: '基于图片后的聊天上下文推断'
|
||||
},
|
||||
{
|
||||
sender: '佩佩',
|
||||
time: '17:14',
|
||||
imageUrl: sampleImage,
|
||||
note: '第二张图带起一轮轻松但有效的快速确认。',
|
||||
stats: '5 条后续消息 · 3 人接话',
|
||||
inferenceLabel: '基于图片后的聊天上下文推断'
|
||||
}
|
||||
],
|
||||
voiceHighlights: [
|
||||
{ title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' },
|
||||
{ title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' }
|
||||
],
|
||||
funBadges: [
|
||||
{ title: '高能输出王', owner: '阿宇', note: '今天一共发了 112 条消息。' },
|
||||
{ title: '语音麦霸', owner: '老周', note: '语音总时长位列第一。' }
|
||||
]
|
||||
}
|
||||
fullRequest.report.participantChains = [
|
||||
{ topic: '接口异常排查', chain: ['阿宇 提出', '老周 收敛方向', '小李 验证', 'we water 定结论'], note: '比较典型的一条技术讨论链路。' }
|
||||
]
|
||||
fullRequest.report.analytics.voiceLeaderboard = [
|
||||
{ sender: '老周', count: 3, durationSec: 97 },
|
||||
{ sender: '阿宇', count: 2, durationSec: 54 }
|
||||
]
|
||||
|
||||
async function renderRequest(request, targetBase) {
|
||||
let html = await fs.readFile(templatePath, 'utf8')
|
||||
const report = request.report
|
||||
const metadata = request.metadata
|
||||
const topicCards = report.topics
|
||||
.map((topic) => {
|
||||
const conclusions = (topic.conclusions || [])
|
||||
.slice(0, 2)
|
||||
.map((entry) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
|
||||
.join('')
|
||||
const image = topic.image?.imageUrl
|
||||
? `<div class="topic-inline-image"><img src="${topic.image.imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||
: ''
|
||||
return `<div class="card topic-card"><div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${topic.heat === '高' ? 'hot' : topic.heat === '低' ? 'blue' : ''}">${escapeHtml(topic.heat)}热</span></div><div class="topic-meta">${escapeHtml(topic.timeRange)}</div><p>${escapeHtml(topic.summary)}</p>${conclusions ? `<div class="topic-conclusions">${conclusions}</div>` : ''}${image}<div class="participants">${topic.participants.map((name) => `<span class="person-chip"><img src="${avatars[name] || avatarSvg(name[0], '#e5e7eb')}" alt=""><b>${escapeHtml(name)}</b></span>`).join('')}</div><div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div></div>`
|
||||
})
|
||||
.join('')
|
||||
const importantMessages = report.importantMessages
|
||||
.map((message) => `<div class="important-card"><img class="avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div><div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div></div>`)
|
||||
.join('')
|
||||
const todoCards = (report.todos || []).map((item) => `<div class="action-card todo-card"><b>${escapeHtml(item.task)}</b><div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
|
||||
const unresolvedCards = (report.unresolved || []).map((item) => `<div class="action-card unresolved-card"><b>${escapeHtml(item.question)}</b><div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div><div class="action-note">${escapeHtml(item.note)}</div></div>`).join('')
|
||||
const quoteBlocks = report.quotes.map((quote) => `<div class="chat-block">${quote.messages.map((message) => `<div class="chat-msg"><img class="chat-avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div><div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div></div></div>`).join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`).join('')
|
||||
const rankItems = report.analytics.topSpeakers.map((speaker, index) => `<div class="rank"><img src="${avatars[speaker.name] || avatarSvg(speaker.name[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${speaker.count} 条</span></div>`).join('')
|
||||
const cloudTags = report.keywords.map((word, index) => `<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`).join('')
|
||||
const resourceItems = (report.resources || []).map((resource) => `<div class="resource"><b>${escapeHtml(resource.title)}</b>${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}<br>${escapeHtml(resource.description)}</div>`).join('')
|
||||
const qaCards = (report.qa || []).map((item) => `<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`).join('')
|
||||
const storylineCards = (report.storylines || []).map((item) => `<div class="card storyline-card"><div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div><div class="storyline-steps">${item.stages.map((stage) => `<div class="storyline-step"><span>${escapeHtml(stage.time)}</span><b>${escapeHtml(stage.event)}</b></div>`).join('')}</div>${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}</div>`).join('')
|
||||
const reversalCards = (report.reversals || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.topic)}</b><div>最初:${escapeHtml(item.initialView)}</div><div>后来:${escapeHtml(item.finalView)}</div>${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
|
||||
const galleryCards = (report.media.gallery || []).map((item) => `<div class="gallery-card"><img class="gallery-image" src="${item.imageUrl}" alt=""><div class="gallery-body"><div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}<div class="important-text">${escapeHtml(item.note)}</div>${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}</div></div>`).join('')
|
||||
const voiceCards = (report.media.voiceHighlights || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b><div>${escapeHtml(item.note)}</div></div>`).join('')
|
||||
const voiceRankCards = (report.analytics.voiceLeaderboard || []).map((item, index) => `<div class="rank"><img src="${avatars[item.sender] || avatarSvg(item.sender[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(item.sender)}</b><span>${item.count} 条 · ${item.durationSec} 秒</span></div>`).join('')
|
||||
const badgeCards = (report.media.funBadges || []).map((item) => `<div class="badge-card"><span class="tag">${escapeHtml(item.title)}</span><b>${escapeHtml(item.owner)}</b><p>${escapeHtml(item.note)}</p></div>`).join('')
|
||||
const chainCards = (report.participantChains || []).map((item) => `<div class="card chain-card"><div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div><div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}</div>`).join('')
|
||||
|
||||
const replaceMap = {
|
||||
REPORT_TITLE: `${metadata.groupName}日报`,
|
||||
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
|
||||
GROUP_NAME: metadata.groupName,
|
||||
DATE_RANGE: metadata.dateRange,
|
||||
RECORD_NOTE: metadata.recordNote,
|
||||
REPORT_MODE_LABEL: metadata.reportMode === 'full' ? '完整版' : '精简版',
|
||||
HERO_HEADLINE: report.hero.headline,
|
||||
HERO_SUMMARY: report.hero.summary,
|
||||
HERO_STATUS_LINE: report.hero.statusLine || '',
|
||||
HERO_STATUS_EMPTY_CLASS: report.hero.statusLine ? '' : 'empty-section',
|
||||
HERO_TAKEAWAY: report.hero.keyTakeaway || '',
|
||||
HERO_TAKEAWAY_EMPTY_CLASS: report.hero.keyTakeaway ? '' : 'empty-section',
|
||||
HERO_PENDING: report.hero.pendingNote || '',
|
||||
HERO_PENDING_EMPTY_CLASS: report.hero.pendingNote ? '' : 'empty-section',
|
||||
HERO_AVATARS: heroAvatars,
|
||||
MESSAGE_COUNT: String(report.summaryStats.messageCount),
|
||||
ACTIVE_USERS: String(report.summaryStats.activeUsers),
|
||||
TOPIC_COUNT: String(report.summaryStats.topicCount),
|
||||
MEDIA_COUNT: String(report.summaryStats.mediaCount),
|
||||
TOPICS_EMPTY_CLASS: report.sectionMeta.topics?.enabled ? '' : 'empty-section',
|
||||
TOPIC_CARDS: topicCards,
|
||||
TOPICS_MORE_NOTE: report.sectionMeta.topics?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.topics.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||
MESSAGES_EMPTY_CLASS: report.sectionMeta.importantMessages?.enabled ? '' : 'empty-section',
|
||||
IMPORTANT_MESSAGES: importantMessages,
|
||||
MESSAGES_MORE_NOTE: report.sectionMeta.importantMessages?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.importantMessages.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||
ACTIONS_EMPTY_CLASS: report.sectionMeta.actions?.enabled ? '' : 'empty-section',
|
||||
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
|
||||
TODO_CARDS: todoCards,
|
||||
UNRESOLVED_EMPTY_CLASS: report.unresolved.length ? '' : 'empty-section',
|
||||
UNRESOLVED_CARDS: unresolvedCards,
|
||||
ACTIONS_MORE_NOTE: report.sectionMeta.actions?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.actions.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||
QUOTES_EMPTY_CLASS: report.sectionMeta.moments?.enabled ? '' : 'empty-section',
|
||||
QUOTE_BLOCKS: quoteBlocks,
|
||||
QUOTES_MORE_NOTE: report.sectionMeta.moments?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.moments.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||
ANALYTICS_EMPTY_CLASS: '',
|
||||
RANK_ITEMS: rankItems,
|
||||
ACTIVITY_TIMELINE: report.analytics.activeTimeline,
|
||||
CONCLUSION_COUNT: String(report.summaryStats.conclusionCount),
|
||||
TODO_COUNT: String(report.summaryStats.todoCount),
|
||||
UNRESOLVED_COUNT: String(report.summaryStats.unresolvedCount),
|
||||
KEYWORDS_EMPTY_CLASS: report.sectionMeta.keywords?.enabled ? '' : 'empty-section',
|
||||
CLOUD_TAGS: cloudTags,
|
||||
KEYWORDS_MORE_NOTE: report.sectionMeta.keywords?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.keywords.hiddenCount} 个关键词,请在完整版中查看</div>` : '',
|
||||
RESOURCES_EMPTY_CLASS: report.sectionMeta.resources?.enabled ? '' : 'empty-section',
|
||||
RESOURCE_ITEMS: resourceItems,
|
||||
RESOURCES_MORE_NOTE: '',
|
||||
QA_EMPTY_CLASS: report.sectionMeta.qa?.enabled ? '' : 'empty-section',
|
||||
QA_CARDS: qaCards,
|
||||
QA_MORE_NOTE: '',
|
||||
STORYLINES_EMPTY_CLASS: report.sectionMeta.storylines?.enabled ? '' : 'empty-section',
|
||||
STORYLINE_CARDS: storylineCards,
|
||||
STORYLINES_MORE_NOTE: '',
|
||||
REVERSALS_EMPTY_CLASS: report.sectionMeta.reversals?.enabled ? '' : 'empty-section',
|
||||
REVERSAL_CARDS: reversalCards,
|
||||
REVERSALS_MORE_NOTE: '',
|
||||
GALLERY_EMPTY_CLASS: report.sectionMeta.gallery?.enabled ? '' : 'empty-section',
|
||||
GALLERY_CARDS: galleryCards,
|
||||
GALLERY_MORE_NOTE: '',
|
||||
VOICE_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
|
||||
VOICE_CARDS: voiceCards,
|
||||
VOICE_MORE_NOTE: '',
|
||||
VOICE_RANK_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
|
||||
VOICE_RANK_CARDS: voiceRankCards,
|
||||
BADGES_EMPTY_CLASS: report.sectionMeta.badges?.enabled ? '' : 'empty-section',
|
||||
BADGE_CARDS: badgeCards,
|
||||
BADGES_MORE_NOTE: '',
|
||||
CHAINS_EMPTY_CLASS: report.sectionMeta.chains?.enabled ? '' : 'empty-section',
|
||||
CHAIN_CARDS: chainCards,
|
||||
CHAINS_MORE_NOTE: '',
|
||||
GENERATED_AT: metadata.generatedAt,
|
||||
FOOTER_NOTE: metadata.footerNote
|
||||
}
|
||||
for (const [key, value] of Object.entries(replaceMap)) html = replacePlaceholder(html, key, value)
|
||||
const htmlPath = path.join(outputDir, `${targetBase}.html`)
|
||||
const pngPath = path.join(outputDir, `${targetBase}.png`)
|
||||
await fs.ensureDir(outputDir)
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
width: 430,
|
||||
height: 800,
|
||||
frame: false,
|
||||
backgroundColor: '#f3f5f7',
|
||||
webPreferences: { sandbox: true }
|
||||
})
|
||||
await win.loadFile(htmlPath)
|
||||
await win.webContents.executeJavaScript(`Promise.all([
|
||||
document.fonts.ready,
|
||||
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
|
||||
img.addEventListener('load', resolve, { once: true });
|
||||
img.addEventListener('error', resolve, { once: true });
|
||||
}))
|
||||
])`)
|
||||
win.webContents.debugger.attach('1.3')
|
||||
const metrics = await win.webContents.debugger.sendCommand('Page.getLayoutMetrics')
|
||||
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
|
||||
const height = Math.ceil(metrics.cssContentSize.height)
|
||||
const screenshot = await win.webContents.debugger.sendCommand('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
captureBeyondViewport: true,
|
||||
fromSurface: true,
|
||||
clip: { x: 0, y: 0, width, height, scale: 1 }
|
||||
})
|
||||
await fs.writeFile(pngPath, Buffer.from(screenshot.data, 'base64'))
|
||||
if (win.webContents.debugger.isAttached()) win.webContents.debugger.detach()
|
||||
win.destroy()
|
||||
return { htmlPath, pngPath, height }
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const mode = process.argv.includes('--full') ? 'full' : 'compact'
|
||||
const result =
|
||||
mode === 'full'
|
||||
? await renderRequest(fullRequest, 'full-fixture')
|
||||
: await renderRequest(compactRequest, 'compact-fixture')
|
||||
console.log(JSON.stringify({ mode, result, outputDir }, null, 2))
|
||||
app.quit()
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const ts = require('typescript')
|
||||
|
||||
const filePath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'src',
|
||||
'renderer',
|
||||
'src',
|
||||
'features',
|
||||
'api-center',
|
||||
'utils',
|
||||
'buildSkillInstallInstruction.ts'
|
||||
)
|
||||
const source = fs.readFileSync(filePath, 'utf8')
|
||||
const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const 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' }
|
||||
|
||||
for (const [target, expected] of [
|
||||
['codex', 'Codex 项目或用户 Skill 目录'],
|
||||
['claude-code', '按照 SKILL\.md 调用本地 HTTP API'],
|
||||
['openclaw', '作为 WechatExplorer Reader Skill 安装'],
|
||||
['generic', '读取并安装']
|
||||
]) {
|
||||
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(
|
||||
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 } }),
|
||||
/https:\/\/example\.com\/skill/
|
||||
)
|
||||
|
||||
console.log('skill install instruction tests passed')
|
||||
@@ -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 @@
|
||||
# WechatExplorer WeChat Connector
|
||||
|
||||
This repository-local service provides the minimal WeChat bridge required by WechatExplorer:
|
||||
|
||||
- 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`. 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). WechatExplorer-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding WechatExplorer 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,197 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AccountsDir returns the directory where account credentials are stored.
|
||||
func AccountsDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts"), 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
|
||||
}
|
||||
|
||||
// LoadAllCredentials loads all saved account credentials.
|
||||
func LoadAllCredentials() ([]*Credentials, error) {
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// CredentialsPath returns the path for display purposes.
|
||||
func CredentialsPath() (string, error) {
|
||||
return AccountsDir()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountID := NormalizeAccountID(client.BotID())
|
||||
bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", 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 WechatExplorer 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 WechatExplorer.")
|
||||
}
|
||||
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,79 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
|
||||
interface StoredKeys {
|
||||
version: 1
|
||||
keys: Record<string, string>
|
||||
}
|
||||
|
||||
export class AIProviderKeyStore {
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'ai-provider-keys.bin')
|
||||
}
|
||||
|
||||
get(providerId: string): { success: boolean; key?: string; error?: string; available: boolean } {
|
||||
const result = this.read()
|
||||
return { ...result, key: result.data?.keys[providerId] }
|
||||
}
|
||||
|
||||
save(providerId: string, key: string): { success: boolean; error?: string } {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
const current = this.read()
|
||||
if (!current.success) return { success: false, error: current.error }
|
||||
const data = current.data || { version: 1 as const, keys: {} }
|
||||
data.keys[providerId] = key
|
||||
return this.write(data)
|
||||
}
|
||||
|
||||
clear(providerId: string): { success: boolean; error?: string } {
|
||||
const current = this.read()
|
||||
if (!current.success) return { success: false, error: current.error }
|
||||
if (!current.data?.keys[providerId]) return { success: true }
|
||||
delete current.data.keys[providerId]
|
||||
try {
|
||||
if (Object.keys(current.data.keys).length === 0) fs.removeSync(this.filePath)
|
||||
else return this.write(current.data)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '无法清除 AI Provider 密钥' }
|
||||
}
|
||||
}
|
||||
|
||||
private read(): {
|
||||
success: boolean
|
||||
data?: StoredKeys
|
||||
error?: string
|
||||
available: boolean
|
||||
} {
|
||||
const available = safeStorage.isEncryptionAvailable()
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
return { success: true, data: { version: 1, keys: {} }, available }
|
||||
}
|
||||
if (!available) return { success: false, error: '系统安全存储不可用', available }
|
||||
try {
|
||||
const data = JSON.parse(
|
||||
safeStorage.decryptString(fs.readFileSync(this.filePath))
|
||||
) as StoredKeys
|
||||
if (data.version !== 1 || !data.keys) throw new Error('invalid AI key store')
|
||||
return { success: true, data, available }
|
||||
} catch {
|
||||
return { success: false, error: 'AI Provider 安全存储不可读取', available }
|
||||
}
|
||||
}
|
||||
|
||||
private write(data: StoredKeys): { success: boolean; error?: string } {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.filePath))
|
||||
fs.writeFileSync(this.filePath, safeStorage.encryptString(JSON.stringify(data)), {
|
||||
mode: 0o600
|
||||
})
|
||||
fs.chmodSync(this.filePath, 0o600)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: 'AI Provider 密钥保存失败' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { app, shell } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
|
||||
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 ***')
|
||||
.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, 'wechatexplorer.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: app.isPackaged ? 'packaged' : 'development',
|
||||
level: entry.level,
|
||||
scope: String(entry.scope || 'app').slice(0, 80),
|
||||
message: String(entry.message || '').slice(0, 500),
|
||||
details: sanitize(entry.details || {})
|
||||
}
|
||||
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
|
||||
if (!app.isPackaged) {
|
||||
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,12 +1,7 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
|
||||
export interface StoredKeyResult {
|
||||
success: boolean
|
||||
key?: string
|
||||
error?: string
|
||||
}
|
||||
import type { DatabaseKeyStorageResult } from '../shared/database-key'
|
||||
|
||||
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '')
|
||||
|
||||
@@ -18,37 +13,67 @@ export class DatabaseKeyStore {
|
||||
return path.join(app.getPath('userData'), 'wechat-db-key.bin')
|
||||
}
|
||||
|
||||
async load(): Promise<StoredKeyResult> {
|
||||
try {
|
||||
if (!(await fs.pathExists(this.filePath))) return { success: true }
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
const encrypted = await fs.readFile(this.filePath)
|
||||
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
|
||||
if (!isValidDatabaseKey(key)) return { success: false, error: '已保存的密钥格式无效' }
|
||||
return { success: true, key }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
async getStatus(): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
|
||||
return {
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
async save(rawKey: string): Promise<StoredKeyResult> {
|
||||
async load(): Promise<DatabaseKeyStorageResult> {
|
||||
try {
|
||||
const status = await this.getStatus()
|
||||
if (!status.saved) return { success: true, ...status }
|
||||
if (!status.encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', ...status }
|
||||
}
|
||||
const encrypted = await fs.readFile(this.filePath)
|
||||
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()
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
...status
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async save(rawKey: string): Promise<DatabaseKeyStorageResult> {
|
||||
const key = normalizeDatabaseKey(rawKey)
|
||||
if (!isValidDatabaseKey(key)) {
|
||||
return { success: false, error: '密钥必须是 64 位十六进制字符' }
|
||||
return {
|
||||
success: false,
|
||||
error: '密钥必须是 64 位十六进制字符',
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
return {
|
||||
success: false,
|
||||
error: '系统安全存储不可用',
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
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)
|
||||
return { success: true, key }
|
||||
return { success: true, key, saved: true, encryptionAvailable: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
encryptionAvailable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// src/main/db/image-insights-store.ts
|
||||
// 持久化 ImageInsight 到 JSON 文件(userData/image-insights.json)
|
||||
// 跟项目现有风格一致(ai-provider-service 用 ai-providers.json)
|
||||
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { ImageInsight } from '../../shared/image-insight'
|
||||
|
||||
interface ImageInsightsFile {
|
||||
version: 1
|
||||
/** imageHash -> ImageInsight 索引(缓存查询 O(1)) */
|
||||
byHash: Record<string, ImageInsight>
|
||||
/** messageId -> imageHash 反向索引(防止同一 message 重复入库) */
|
||||
byMessageId: Record<string, string>
|
||||
}
|
||||
|
||||
const EMPTY_FILE: ImageInsightsFile = {
|
||||
version: 1,
|
||||
byHash: {},
|
||||
byMessageId: {}
|
||||
}
|
||||
|
||||
class ImageInsightsStore {
|
||||
private cache: ImageInsightsFile | null = null
|
||||
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'image-insights.json')
|
||||
}
|
||||
|
||||
private ensureLoaded(): ImageInsightsFile {
|
||||
if (this.cache) return this.cache
|
||||
try {
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
const raw = fs.readJsonSync(this.filePath) as Partial<ImageInsightsFile>
|
||||
this.cache = {
|
||||
version: 1,
|
||||
byHash: raw.byHash || {},
|
||||
byMessageId: raw.byMessageId || {}
|
||||
}
|
||||
return this.cache
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ImageInsightsStore] failed to load, fallback to empty:', error)
|
||||
}
|
||||
this.cache = { ...EMPTY_FILE }
|
||||
return this.cache
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
if (!this.cache) return
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.filePath))
|
||||
fs.writeJsonSync(this.filePath, this.cache, { spaces: 2 })
|
||||
} catch (error) {
|
||||
console.error('[ImageInsightsStore] failed to persist:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 通过 imageHash 查询缓存 */
|
||||
getByHash(imageHash: string): ImageInsight | null {
|
||||
return this.ensureLoaded().byHash[imageHash] || null
|
||||
}
|
||||
|
||||
/** 列出某会话的所有 insights(按时间倒序) */
|
||||
listBySession(sessionId: string, limit?: number): ImageInsight[] {
|
||||
const data = this.ensureLoaded()
|
||||
const items = Object.values(data.byHash)
|
||||
.filter((it) => it.sessionId === sessionId)
|
||||
.sort((a, b) => b.sentAt - a.sentAt)
|
||||
return typeof limit === 'number' ? items.slice(0, limit) : items
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入或更新 Insight。
|
||||
* - 同 imageHash 已存在:更新 description/ocrText/tags/category/importance/provider/model/updatedAt(保留 createdAt)
|
||||
* - 新 hash:插入
|
||||
* 同步维护 byMessageId 反向索引。
|
||||
*/
|
||||
upsert(insight: ImageInsight): void {
|
||||
const data = this.ensureLoaded()
|
||||
const existing = data.byHash[insight.imageHash]
|
||||
const now = Date.now()
|
||||
if (existing) {
|
||||
data.byHash[insight.imageHash] = {
|
||||
...existing,
|
||||
...insight,
|
||||
id: existing.id, // 保留 id
|
||||
createdAt: existing.createdAt, // 保留首次分析时间
|
||||
updatedAt: now
|
||||
}
|
||||
} else {
|
||||
data.byHash[insight.imageHash] = { ...insight, createdAt: now, updatedAt: now }
|
||||
data.byMessageId[insight.messageId] = insight.imageHash
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const imageInsightsStore = new ImageInsightsStore()
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Message } from '../shared/types'
|
||||
|
||||
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}`
|
||||
const safe = (value: unknown): string =>
|
||||
String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c
|
||||
)
|
||||
|
||||
export function renderExportPage(name: string, messages: Message[]): string {
|
||||
const body = messages
|
||||
.map((m) => {
|
||||
const avatar = m.img
|
||||
? `<img src="${safe(m.img)}" alt="">`
|
||||
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
|
||||
const audio = m.voiceDataUrl
|
||||
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${m.voiceDataUrl}"></audio></div>`
|
||||
: ''
|
||||
const quote =
|
||||
m.contentData?.type === 'quote'
|
||||
? `<div class="quote-reference"><strong>${safe(m.contentData.quotedSender || '引用消息')}</strong><span>${safe(m.contentData.quotedContent || '[引用消息]')}</span></div>`
|
||||
: ''
|
||||
const media =
|
||||
m.exportMediaUrl && m.exportMediaType === 'image'
|
||||
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="图片">`
|
||||
: m.exportMediaUrl && m.exportMediaType === 'video'
|
||||
? `<video class="media-image" controls src="${safe(m.exportMediaUrl)}"></video>`
|
||||
: m.exportMediaUrl && m.exportMediaType === 'sticker'
|
||||
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="表情包">`
|
||||
: ''
|
||||
const avatarMarkup =
|
||||
m.exportShowAvatar === false
|
||||
? ''
|
||||
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
|
||||
const isPat = m.contentData?.type === 'system' && m.contentData.pat
|
||||
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
|
||||
return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div></div></div></article>`
|
||||
})
|
||||
.join('')
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)box.classList.remove('open')});update()})()</script></body></html>`
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { promises as fs } from 'fs'
|
||||
import { extname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import * as chat from './services/chat-service'
|
||||
import type {
|
||||
ExportJobProgress,
|
||||
ExportMessageKind,
|
||||
ExportRequest,
|
||||
ExportResult
|
||||
} from '../shared/export'
|
||||
import type { Message } from '../shared/types'
|
||||
import { VoiceService } from './voice-service'
|
||||
import { renderExportPage } from './export-html-template'
|
||||
import { ImageDecryptService } from './image-decrypt-service'
|
||||
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||
import { VideoAssetService } from './video-asset-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
||||
const exportStamp = (): string => {
|
||||
const date = new Date()
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||
}
|
||||
const imageKeys = new ImageKeyConfigService()
|
||||
function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null {
|
||||
const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
|
||||
if (!match) return null
|
||||
return {
|
||||
extension: match[1].split('/')[1] === 'jpeg' ? 'jpg' : match[1].split('/')[1],
|
||||
buffer: Buffer.from(match[2], 'base64')
|
||||
}
|
||||
}
|
||||
const normalizeAssetExtension = (value: string): string => {
|
||||
const extension = value.toLowerCase().replace(/^\./, '')
|
||||
return /^(png|jpg|jpeg|webp|gif)$/.test(extension)
|
||||
? extension === 'jpeg'
|
||||
? 'jpg'
|
||||
: extension
|
||||
: 'jpg'
|
||||
}
|
||||
const detectAssetExtension = (buffer: Buffer): string | null => {
|
||||
if (buffer.subarray(0, 3).toString('ascii') === 'GIF') return 'gif'
|
||||
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
||||
return 'png'
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'jpg'
|
||||
if (
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
)
|
||||
return 'webp'
|
||||
return null
|
||||
}
|
||||
async function readAvatarAsset(
|
||||
source: string
|
||||
): Promise<{ extension: string; buffer: Buffer } | null> {
|
||||
const decoded = decodeDataUrl(source)
|
||||
if (decoded) return { ...decoded, extension: normalizeAssetExtension(decoded.extension) }
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const response = await fetch(source)
|
||||
if (!response.ok) return null
|
||||
const contentType = response.headers.get('content-type')?.split(';')[0].split('/')[1]
|
||||
const extension = normalizeAssetExtension(contentType || extname(new URL(source).pathname))
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
return { extension: detectAssetExtension(buffer) || extension, buffer }
|
||||
}
|
||||
const path = source.startsWith('file://') ? fileURLToPath(source) : source
|
||||
const buffer = await fs.readFile(path)
|
||||
return {
|
||||
extension: detectAssetExtension(buffer) || normalizeAssetExtension(extname(path)),
|
||||
buffer
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const kindOf = (message: Message): ExportMessageKind => {
|
||||
const type = message.contentData?.type
|
||||
if (type === 'system' && message.contentData?.pat) return 'text'
|
||||
if (
|
||||
type === 'image' ||
|
||||
type === 'video' ||
|
||||
type === 'voice' ||
|
||||
type === 'sticker' ||
|
||||
type === 'share' ||
|
||||
type === 'location' ||
|
||||
type === 'system'
|
||||
)
|
||||
return type
|
||||
if (message.type === '图片') return 'image'
|
||||
if (message.type === '视频') return 'video'
|
||||
if (message.type === '语音') return 'voice'
|
||||
if (message.type === '表情包') return 'sticker'
|
||||
return 'text'
|
||||
}
|
||||
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
|
||||
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
||||
if (format === 'html') return renderExportPage(name, messages)
|
||||
if (format === 'json')
|
||||
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||||
if (format === 'markdown')
|
||||
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}\n`).join('\n')}`
|
||||
return [
|
||||
'时间,发送者,类型,内容',
|
||||
...messages.map((m) =>
|
||||
[m.datetime, m.name || (m.isSender ? '我' : '联系人'), m.type, m.content].map(csv).join(',')
|
||||
)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function runExport(request: ExportRequest, win: BrowserWindow): Promise<ExportResult> {
|
||||
jobs.add(request.jobId)
|
||||
const send = (p: ExportJobProgress): void => {
|
||||
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
||||
}
|
||||
try {
|
||||
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
const messages = chat
|
||||
.listMessages(request.userMd5, request.startTime, request.endTime)
|
||||
.filter((m) => request.kinds.includes(kindOf(m)))
|
||||
for (const message of messages) {
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
||||
if (mappedName) message.name = mappedName
|
||||
}
|
||||
send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 })
|
||||
if (!jobs.has(request.jobId)) {
|
||||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 10 })
|
||||
return { success: false, error: '已取消' }
|
||||
}
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: 0,
|
||||
total: messages.length,
|
||||
percent: 15
|
||||
})
|
||||
const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
const ext = request.format === 'markdown' ? 'md' : request.format
|
||||
const outputFolder = `${safeFilePart(request.outputName)}_${exportStamp()}`
|
||||
const outputDir = join(root, outputFolder)
|
||||
const outputPath =
|
||||
request.format === 'html'
|
||||
? join(outputDir, 'index.html')
|
||||
: join(root, `${outputFolder}.${ext}`)
|
||||
if (request.format === 'html') {
|
||||
await fs.mkdir(join(outputDir, 'voices'), { recursive: true })
|
||||
await fs.mkdir(join(outputDir, 'media'), { recursive: true })
|
||||
await fs.mkdir(join(outputDir, 'avatars'), { recursive: true })
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
const avatarUsernames = Array.from(
|
||||
new Set(
|
||||
messages
|
||||
.map((message) => message.senderId)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
const avatarMap =
|
||||
request.includeAvatars === false
|
||||
? {}
|
||||
: { ...chat.getContactAvatars(avatarUsernames), ...(request.avatarUrls || {}) }
|
||||
const imageConfig = imageKeys.getConfig()
|
||||
const imageService =
|
||||
client && imageConfig.aesKey
|
||||
? new ImageDecryptService(imageConfig.xorKey || '0x40', imageConfig.aesKey, client)
|
||||
: null
|
||||
const videoService = client ? new VideoAssetService(client) : null
|
||||
const stickerService = client ? new StickerService(client) : null
|
||||
const exportedAvatars = new Map<string, string>()
|
||||
const voiceService =
|
||||
request.includeMedia && chat.getChatDb()
|
||||
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
||||
: null
|
||||
if (voiceService) {
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (
|
||||
kindOf(message) !== 'voice' ||
|
||||
!message.sessionId ||
|
||||
!message.localId ||
|
||||
!message.createTime
|
||||
)
|
||||
continue
|
||||
const voice = await voiceService.resolveVoice(
|
||||
message.sessionId,
|
||||
message.localId,
|
||||
message.createTime,
|
||||
message.serverId
|
||||
)
|
||||
if (!voice.success || !voice.data) continue
|
||||
const voiceName = `voice_${index + 1}_${message.localId}.wav`
|
||||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||
message.voiceDataUrl = `voices/${voiceName}`
|
||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||
}
|
||||
}
|
||||
for (const [index, message] of messages.entries()) {
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
|
||||
const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null
|
||||
const avatarBuffer = resolvedAvatar?.buffer || null
|
||||
const avatarExtension = resolvedAvatar?.extension || 'jpg'
|
||||
if (avatarBuffer) {
|
||||
const avatarKey = message.senderId || `message_${index + 1}`
|
||||
let avatarName = exportedAvatars.get(avatarKey)
|
||||
if (!avatarName) {
|
||||
avatarName = `avatar_${exportedAvatars.size + 1}.${avatarExtension}`
|
||||
await fs.writeFile(join(outputDir, 'avatars', avatarName), avatarBuffer)
|
||||
exportedAvatars.set(avatarKey, avatarName)
|
||||
}
|
||||
message.exportAvatarUrl = `avatars/${avatarName}`
|
||||
}
|
||||
if (!request.includeMedia || !message.contentData) {
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: index + 1,
|
||||
total: messages.length,
|
||||
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (message.contentData.type === 'image' && imageService) {
|
||||
const file = imageService.findImageFile(
|
||||
message.contentData.md5,
|
||||
message.contentData.datName,
|
||||
{ allowThumbnail: true }
|
||||
)
|
||||
const decrypted = file ? imageService.decryptImageToBase64WithFallback(file, true) : null
|
||||
const decoded = decrypted ? decodeDataUrl(decrypted.data) : null
|
||||
if (decoded) {
|
||||
const name = `image_${index + 1}.${decoded.extension}`
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'image'
|
||||
}
|
||||
} else if (message.contentData.type === 'video' && videoService) {
|
||||
const hashes = [
|
||||
message.contentData.md5,
|
||||
message.contentData.newMd5,
|
||||
message.contentData.rawMd5
|
||||
].filter((value): value is string => Boolean(value))
|
||||
const resolved = videoService.resolve(hashes)
|
||||
const token = resolved.url?.split('/').pop()
|
||||
const source = token ? videoService.pathForToken(token) : undefined
|
||||
if (source) {
|
||||
const name = `video_${index + 1}.mp4`
|
||||
await fs.copyFile(source, join(outputDir, 'media', name))
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'video'
|
||||
}
|
||||
} else if (message.contentData.type === 'sticker' && stickerService) {
|
||||
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
||||
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
|
||||
const decoded = result.data
|
||||
? decodeDataUrl(result.data)
|
||||
: stickerSource
|
||||
? await readAvatarAsset(stickerSource)
|
||||
: null
|
||||
if (decoded) {
|
||||
const name = `sticker_${index + 1}.${decoded.extension}`
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'sticker'
|
||||
}
|
||||
}
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: index + 1,
|
||||
total: messages.length,
|
||||
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: messages.length,
|
||||
total: messages.length,
|
||||
percent: 90
|
||||
})
|
||||
}
|
||||
await fs.writeFile(outputPath, render(request.format, messages, request.name), 'utf8')
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'completed',
|
||||
processed: messages.length,
|
||||
total: messages.length,
|
||||
percent: 100,
|
||||
outputPath
|
||||
})
|
||||
return { success: true, outputPath, messageCount: messages.length }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
jobs.delete(request.jobId)
|
||||
}
|
||||
}
|
||||
export function cancelExport(jobId: string): void {
|
||||
jobs.delete(jobId)
|
||||
}
|
||||
export async function revealExport(path: string): Promise<void> {
|
||||
shell.showItemInFolder(path)
|
||||
}
|
||||
@@ -6,11 +6,29 @@ import {
|
||||
GroupReportExportRequest,
|
||||
GroupReportExportResult,
|
||||
GroupReportMetadata,
|
||||
ReportHeat
|
||||
ReportHeat,
|
||||
ReportSectionMeta
|
||||
} from '../shared/group-report'
|
||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||
import { imageInsightService } from './services/image-insight-service'
|
||||
|
||||
const TEMPLATE_NAME = 'mobile_daily_report.html'
|
||||
const TEMPLATE_FILES: Record<string, string> = {
|
||||
v1: 'mobile_daily_report_v1.html',
|
||||
v2: 'mobile_daily_report_v2.html'
|
||||
}
|
||||
const DEFAULT_TEMPLATE = TEMPLATE_FILES.v1
|
||||
|
||||
const templatePath = (templateId?: string): string => {
|
||||
const name = TEMPLATE_FILES[templateId || ''] || DEFAULT_TEMPLATE
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, 'resources', name),
|
||||
path.join(app.getAppPath(), 'resources', name),
|
||||
path.join(process.cwd(), 'resources', name)
|
||||
]
|
||||
const found = candidates.find((candidate) => fs.existsSync(candidate))
|
||||
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
|
||||
return found
|
||||
}
|
||||
|
||||
const escapeHtml = (value: unknown): string =>
|
||||
String(value ?? '')
|
||||
@@ -75,17 +93,6 @@ const embedAvatar = async (source: string | undefined, name: string): Promise<st
|
||||
}
|
||||
}
|
||||
|
||||
const templatePath = (): string => {
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, 'resources', TEMPLATE_NAME),
|
||||
path.join(app.getAppPath(), 'resources', TEMPLATE_NAME),
|
||||
path.join(process.cwd(), 'resources', TEMPLATE_NAME)
|
||||
]
|
||||
const found = candidates.find((candidate) => fs.existsSync(candidate))
|
||||
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* 从群成员快照反推真头像,填进 metadata.avatars。
|
||||
* - 没传 talker → 跳过(向后兼容)
|
||||
@@ -106,9 +113,7 @@ const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<vo
|
||||
const snapshot = getGroupSnapshot(resolved.md5)
|
||||
if (!snapshot) {
|
||||
metadata.warnings = metadata.warnings ?? []
|
||||
metadata.warnings.push(
|
||||
`enrich skipped: group snapshot not available for "${metadata.talker}"`
|
||||
)
|
||||
metadata.warnings.push(`enrich skipped: group snapshot not available for "${metadata.talker}"`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -140,6 +145,26 @@ const heatClass = (heat: ReportHeat): string => {
|
||||
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||
html.replaceAll(`{{${key}}}`, value)
|
||||
|
||||
const sectionMeta = (
|
||||
request: GroupReportExportRequest,
|
||||
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||
): ReportSectionMeta | undefined => request.report.sectionMeta?.[key]
|
||||
|
||||
const sectionClass = (
|
||||
request: GroupReportExportRequest,
|
||||
key: keyof NonNullable<typeof request.report.sectionMeta>,
|
||||
hasContent: boolean
|
||||
): string => (sectionMeta(request, key)?.enabled && hasContent ? '' : 'empty-section')
|
||||
|
||||
const overflowNote = (
|
||||
request: GroupReportExportRequest,
|
||||
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||
): string => {
|
||||
const meta = sectionMeta(request, key)
|
||||
if (request.metadata.reportMode !== 'compact' || !meta?.hiddenCount) return ''
|
||||
return `<div class="section-more">另有 ${meta.hiddenCount} 条内容,请在完整版中查看</div>`
|
||||
}
|
||||
|
||||
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||
const { report, metadata } = request
|
||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||
@@ -149,6 +174,9 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
||||
)
|
||||
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
|
||||
report.media?.gallery?.forEach((item) => avatarNames.add(item.sender))
|
||||
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
|
||||
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
|
||||
|
||||
const avatars = new Map<string, string>()
|
||||
await Promise.all(
|
||||
@@ -170,7 +198,38 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
||||
<p>${escapeHtml(topic.summary)}</p>
|
||||
${topic.conclusion ? `<p class="muted">${escapeHtml(topic.conclusion)}</p>` : ''}
|
||||
${
|
||||
topic.conclusions?.length
|
||||
? `<div class="topic-conclusions">${topic.conclusions
|
||||
.slice(0, 2)
|
||||
.map((entry) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
|
||||
.join('')}</div>`
|
||||
: topic.conclusion
|
||||
? `<div class="topic-conclusions"><div class="topic-conclusion">${escapeHtml(topic.conclusion)}</div></div>`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
topic.image
|
||||
? (() => {
|
||||
// 优先用已有 imageUrl;若有 imageHash(来自 visionGallery),按 hash 取原图
|
||||
let imageUrl = topic.image.imageUrl
|
||||
if (!imageUrl && topic.image.imageHash) {
|
||||
const insight = imageInsightService.getInsight(topic.image.imageHash)
|
||||
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
|
||||
if (decryptService) {
|
||||
const filePath = decryptService.findImageFile(insight.md5, insight.datName)
|
||||
if (filePath) imageUrl = decryptService.decryptImageToBase64(filePath) || undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!imageUrl) return ''
|
||||
return `<div class="topic-inline-image"><img src="${imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||
})()
|
||||
: ''
|
||||
}
|
||||
<div class="participants">${topic.participants
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
@@ -215,18 +274,122 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
)
|
||||
.join('')
|
||||
|
||||
const qaCards = report.qa
|
||||
const todoCards = (report.todos || [])
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||
(item) => `<div class="action-card todo-card">
|
||||
<b>${escapeHtml(item.task)}</b>
|
||||
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
|
||||
const heatBars = report.analytics.topicHeat
|
||||
const unresolvedCards = (report.unresolved || [])
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="bar-row"><span>${escapeHtml(item.topic)}</span><div class="bar"><i style="width:${Math.max(8, Math.round((item.score / maxHeat) * 100))}%"></i></div></div>`
|
||||
(item) => `<div class="action-card unresolved-card">
|
||||
<b>${escapeHtml(item.question)}</b>
|
||||
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||
<div class="action-note">${escapeHtml(item.note)}</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const storylineCards = (report.storylines || [])
|
||||
.map(
|
||||
(item) => `<div class="card storyline-card">
|
||||
<div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div>
|
||||
<div class="storyline-steps">${item.stages
|
||||
.map(
|
||||
(stage) => `<div class="storyline-step">
|
||||
<span>${escapeHtml(stage.time || '--:--')}</span>
|
||||
<b>${escapeHtml(stage.event)}</b>
|
||||
</div>`
|
||||
)
|
||||
.join('')}</div>
|
||||
${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const reversalCards = (report.reversals || [])
|
||||
.map(
|
||||
(item) => `<div class="qa-card">
|
||||
<b>${escapeHtml(item.topic)}</b>
|
||||
<div>最初:${escapeHtml(item.initialView)}</div>
|
||||
<div>后来:${escapeHtml(item.finalView)}</div>
|
||||
${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const chainCards = (report.participantChains || [])
|
||||
.map(
|
||||
(item) => `<div class="card chain-card">
|
||||
<div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div>
|
||||
<div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>
|
||||
${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const galleryCards = (report.media?.gallery || [])
|
||||
.map(
|
||||
(item) => `<div class="gallery-card">
|
||||
<img class="gallery-image" src="${item.imageUrl}" alt="群聊图片">
|
||||
<div class="gallery-body">
|
||||
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||
${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}
|
||||
<div class="important-text">${escapeHtml(item.note)}</div>
|
||||
${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
// AI 图片理解结果板块(ImageInsight)
|
||||
// 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文
|
||||
const visionCards = (report.media?.visionGallery || [])
|
||||
.filter((item) => item.imageUrl) // 只显示加载成功的图
|
||||
.map(
|
||||
(item) => `<div class="vision-card">
|
||||
<img class="vision-image" src="${item.imageUrl}" alt="AI 识别的图片">
|
||||
<div class="vision-body">
|
||||
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||
<div class="vision-description">${escapeHtml(item.description)}</div>
|
||||
${item.ocrText ? `<div class="vision-ocr">📝 ${escapeHtml(item.ocrText)}</div>` : ''}
|
||||
${item.tags.length ? `<div class="vision-tags">${item.tags.map((t) => `<span class="vision-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
|
||||
<div class="vision-label">AI 图片识别</div>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const voiceCards = (report.media?.voiceHighlights || [])
|
||||
.map(
|
||||
(item) => `<div class="qa-card">
|
||||
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
|
||||
<div>${escapeHtml(item.note)}</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const voiceRankCards = (report.analytics.voiceLeaderboard || [])
|
||||
.map(
|
||||
(item, index) => `<div class="rank">
|
||||
<img src="${avatar(item.sender)}" alt="">
|
||||
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
|
||||
<span>${item.count} 条 · ${item.durationSec} 秒</span>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const badgeCards = (report.media?.funBadges || [])
|
||||
.map(
|
||||
(item) => `<div class="badge-card">
|
||||
<span class="tag">${escapeHtml(item.title)}</span>
|
||||
<b>${escapeHtml(item.owner)}</b>
|
||||
<p>${escapeHtml(item.note)}</p>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
@@ -238,6 +401,20 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
)
|
||||
.join('')
|
||||
|
||||
// v1 模板使用的水平条形热度图,渲染 top speakers 排行
|
||||
const heatBarsHtml = report.analytics.topSpeakers
|
||||
.slice(0, 8)
|
||||
.map((speaker) => {
|
||||
const count = Math.max(0, speaker.count)
|
||||
const width = Math.min(100, count * 12)
|
||||
return `<div class="heat-row">
|
||||
<span class="heat-name">${escapeHtml(speaker.name)}</span>
|
||||
<span class="heat-bar"><i style="width:${width}%"></i></span>
|
||||
<span class="heat-val">${count}</span>
|
||||
</div>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const cloudTags = report.keywords
|
||||
.slice(0, 15)
|
||||
.map(
|
||||
@@ -246,38 +423,121 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
)
|
||||
.join('')
|
||||
|
||||
let html = await fs.readFile(templatePath(), 'utf8')
|
||||
const qaCards = report.qa
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const summaryStats = report.summaryStats || {
|
||||
messageCount: metadata.messageCount,
|
||||
activeUsers: metadata.activeUsers,
|
||||
topicCount: report.topics.length,
|
||||
mediaCount: metadata.mediaMessageCount || 0,
|
||||
imageCount: metadata.imageCount || 0,
|
||||
voiceCount: metadata.voiceCount || 0,
|
||||
stickerCount: metadata.stickerCount || 0,
|
||||
conclusionCount: 0,
|
||||
todoCount: report.todos.length,
|
||||
unresolvedCount: report.unresolved.length
|
||||
}
|
||||
|
||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
||||
const values: Record<string, string> = {
|
||||
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
||||
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
|
||||
GROUP_NAME: escapeHtml(metadata.groupName),
|
||||
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||
RECORD_NOTE: escapeHtml(`${metadata.recordNote} ${report.overview}`.trim()),
|
||||
RECORD_NOTE: escapeHtml(metadata.recordNote),
|
||||
// v1 模板使用的 OVERVIEW(经典版以概览段落呈现)
|
||||
OVERVIEW: escapeHtml(report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'),
|
||||
// v2 模板使用的 hero-*
|
||||
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
|
||||
HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
|
||||
HERO_TAKEAWAY: escapeHtml(report.hero?.keyTakeaway || ''),
|
||||
HERO_PENDING: escapeHtml(report.hero?.pendingNote || ''),
|
||||
HERO_STATUS_LINE: escapeHtml(report.hero?.statusLine || ''),
|
||||
HERO_STATUS_EMPTY_CLASS: report.hero?.statusLine ? '' : 'empty-section',
|
||||
HERO_TAKEAWAY_EMPTY_CLASS: report.hero?.keyTakeaway ? '' : 'empty-section',
|
||||
HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section',
|
||||
HERO_AVATARS: heroAvatars,
|
||||
MESSAGE_COUNT: String(metadata.messageCount),
|
||||
ACTIVE_USERS: String(metadata.activeUsers),
|
||||
MESSAGE_COUNT: String(summaryStats.messageCount),
|
||||
ACTIVE_USERS: String(summaryStats.activeUsers),
|
||||
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
|
||||
TOPIC_COUNT: String(report.topics.length),
|
||||
TOPIC_COUNT: String(summaryStats.topicCount),
|
||||
MEDIA_COUNT: String(summaryStats.mediaCount),
|
||||
TOPIC_CARDS: topicCards,
|
||||
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
|
||||
TOPICS_EMPTY_CLASS: sectionClass(request, 'topics', report.topics.length > 0),
|
||||
TOPICS_MORE_NOTE: overflowNote(request, 'topics'),
|
||||
RESOURCES_EMPTY_CLASS: sectionClass(request, 'resources', report.resources.length > 0),
|
||||
RESOURCE_ITEMS: resourceItems,
|
||||
MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section',
|
||||
RESOURCES_MORE_NOTE: overflowNote(request, 'resources'),
|
||||
MESSAGES_EMPTY_CLASS: sectionClass(request, 'importantMessages', report.importantMessages.length > 0),
|
||||
IMPORTANT_MESSAGES: importantMessages,
|
||||
QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section',
|
||||
MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
|
||||
QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
|
||||
QUOTE_BLOCKS: quoteBlocks,
|
||||
QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section',
|
||||
QUOTES_MORE_NOTE: overflowNote(request, 'moments'),
|
||||
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',
|
||||
UNRESOLVED_CARDS: unresolvedCards,
|
||||
ACTIONS_MORE_NOTE: overflowNote(request, 'actions'),
|
||||
QA_EMPTY_CLASS: sectionClass(request, 'qa', report.qa.length > 0),
|
||||
QA_CARDS: qaCards,
|
||||
HEAT_BARS: heatBars,
|
||||
QA_MORE_NOTE: overflowNote(request, 'qa'),
|
||||
STORYLINES_EMPTY_CLASS: sectionClass(request, 'storylines', report.storylines?.length > 0),
|
||||
STORYLINE_CARDS: storylineCards,
|
||||
STORYLINES_MORE_NOTE: overflowNote(request, 'storylines'),
|
||||
REVERSALS_EMPTY_CLASS: sectionClass(request, 'reversals', report.reversals?.length > 0),
|
||||
REVERSAL_CARDS: reversalCards,
|
||||
REVERSALS_MORE_NOTE: overflowNote(request, 'reversals'),
|
||||
CHAINS_EMPTY_CLASS: sectionClass(request, 'chains', report.participantChains?.length > 0),
|
||||
CHAIN_CARDS: chainCards,
|
||||
CHAINS_MORE_NOTE: overflowNote(request, 'chains'),
|
||||
// AI 图片识别板块
|
||||
VISION_EMPTY_CLASS: sectionClass(
|
||||
request,
|
||||
'vision',
|
||||
(report.media?.visionGallery?.length ?? 0) > 0
|
||||
),
|
||||
VISION_CARDS: visionCards,
|
||||
VISION_TITLE: '📸 AI 识别的图片精选',
|
||||
GALLERY_EMPTY_CLASS: sectionClass(request, 'gallery', report.media?.gallery?.length > 0),
|
||||
GALLERY_CARDS: galleryCards,
|
||||
GALLERY_MORE_NOTE: overflowNote(request, 'gallery'),
|
||||
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_CARDS: voiceRankCards,
|
||||
BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0),
|
||||
BADGE_CARDS: badgeCards,
|
||||
BADGES_MORE_NOTE: overflowNote(request, 'badges'),
|
||||
RANK_ITEMS: rankItems,
|
||||
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
|
||||
CONCLUSION_COUNT: String(summaryStats.conclusionCount),
|
||||
TODO_COUNT: String(summaryStats.todoCount),
|
||||
UNRESOLVED_COUNT: String(summaryStats.unresolvedCount),
|
||||
CLOUD_TAGS: cloudTags,
|
||||
KEYWORDS_EMPTY_CLASS: sectionClass(request, 'keywords', report.keywords.length > 0),
|
||||
KEYWORDS_MORE_NOTE: overflowNote(request, 'keywords'),
|
||||
ANALYTICS_EMPTY_CLASS: sectionClass(request, 'analytics', true),
|
||||
GENERATED_AT: escapeHtml(metadata.generatedAt),
|
||||
FOOTER_NOTE: escapeHtml(metadata.footerNote)
|
||||
FOOTER_NOTE: escapeHtml(metadata.footerNote),
|
||||
// v1 模板独有:从 analytics.topSpeakers 渲染水平条形热度图
|
||||
HEAT_BARS: heatBarsHtml
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
||||
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
||||
return html
|
||||
}
|
||||
|
||||
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
|
||||
console.log(`[GroupReport] capture begin html=${htmlPath}`)
|
||||
const reportWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 430,
|
||||
@@ -289,6 +549,7 @@ const captureFullPage = async (htmlPath: string, pngPath: string): Promise<strin
|
||||
|
||||
try {
|
||||
await reportWindow.loadFile(htmlPath)
|
||||
console.log('[GroupReport] capture loaded html')
|
||||
await reportWindow.webContents.executeJavaScript(`Promise.all([
|
||||
document.fonts.ready,
|
||||
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
|
||||
@@ -296,29 +557,23 @@ const captureFullPage = async (htmlPath: string, pngPath: string): Promise<strin
|
||||
img.addEventListener('error', resolve, { once: true });
|
||||
}))
|
||||
])`)
|
||||
reportWindow.webContents.debugger.attach('1.3')
|
||||
const metrics = (await reportWindow.webContents.debugger.sendCommand(
|
||||
'Page.getLayoutMetrics'
|
||||
)) as { cssContentSize: { width: number; height: number } }
|
||||
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
|
||||
const height = Math.ceil(metrics.cssContentSize.height)
|
||||
const screenshot = (await reportWindow.webContents.debugger.sendCommand(
|
||||
'Page.captureScreenshot',
|
||||
{
|
||||
format: 'png',
|
||||
captureBeyondViewport: true,
|
||||
fromSurface: true,
|
||||
clip: { x: 0, y: 0, width, height, scale: 1 }
|
||||
}
|
||||
)) as { data: string }
|
||||
const png = Buffer.from(screenshot.data, 'base64')
|
||||
console.log('[GroupReport] capture assets ready')
|
||||
const metrics = (await reportWindow.webContents.executeJavaScript(`({
|
||||
width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 430)),
|
||||
height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800))
|
||||
})`)) as { width: number; height: number }
|
||||
const width = Math.max(430, Math.min(1200, Math.ceil(metrics.width)))
|
||||
const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height)))
|
||||
reportWindow.setContentSize(width, height)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
console.log(`[GroupReport] capture native page width=${width} height=${height}`)
|
||||
const image = await reportWindow.webContents.capturePage({ x: 0, y: 0, width, height })
|
||||
const png = image.toPNG()
|
||||
if (png.length < 1000) throw new Error('生成的日报图片为空')
|
||||
await fs.writeFile(pngPath, png)
|
||||
return `data:image/png;base64,${screenshot.data}`
|
||||
console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`)
|
||||
return `data:image/png;base64,${png.toString('base64')}`
|
||||
} finally {
|
||||
if (reportWindow.webContents.debugger.isAttached()) {
|
||||
reportWindow.webContents.debugger.detach()
|
||||
}
|
||||
reportWindow.destroy()
|
||||
}
|
||||
}
|
||||
@@ -332,17 +587,34 @@ export const exportGroupReport = async (
|
||||
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
|
||||
const templateLabel = request.templateId === 'v1' ? '经典版' : '模板2'
|
||||
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${templateLabel}`
|
||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||
const htmlStartedAt = new Date()
|
||||
const html = await renderReportHtml(request)
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
htmlPath,
|
||||
pngPath,
|
||||
imageDataUrl,
|
||||
exportTimings: {
|
||||
html: {
|
||||
startedAt: htmlStartedAt.toISOString(),
|
||||
endedAt: htmlEndedAt.toISOString(),
|
||||
duration: htmlEndedAt.getTime() - htmlStartedAt.getTime()
|
||||
},
|
||||
png: {
|
||||
startedAt: pngStartedAt.toISOString(),
|
||||
endedAt: pngEndedAt.toISOString(),
|
||||
duration: pngEndedAt.getTime() - pngStartedAt.getTime()
|
||||
}
|
||||
},
|
||||
warnings: request.metadata.warnings?.length ? request.metadata.warnings : undefined
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,6 +9,8 @@ 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'
|
||||
|
||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||
@@ -157,7 +159,9 @@ const routes: Record<string, RouteHandler> = {
|
||||
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 })
|
||||
@@ -236,13 +240,62 @@ 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, 'WechatExplorer 数据库未初始化')
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +364,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,7 +383,10 @@ 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()
|
||||
}
|
||||
|
||||
@@ -4,12 +4,28 @@ import crypto from 'crypto'
|
||||
import os from 'os'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
export class ImageDecryptService {
|
||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
|
||||
const imageDecryptLog = (...args: unknown[]): void => {
|
||||
if (imageDecryptDebugEnabled) console.log(...args)
|
||||
}
|
||||
|
||||
type DecodedImage = {
|
||||
data: string
|
||||
filePath: string
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||
|
||||
export class ImageDecryptService {
|
||||
private xorKey: number = 0
|
||||
private aesKey: string = ''
|
||||
private wcdb4Client: Wcdb4Client | null = null
|
||||
private accountDirResolved = false
|
||||
private cachedAccountDir: string | null = null
|
||||
private imagePathCache = new Map<string, string>()
|
||||
private decodedImageCache = new Map<string, DecodedImage>()
|
||||
private decodedImageCacheBytes = 0
|
||||
|
||||
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
|
||||
// 解析 XOR Key (支持 0x40 或 64 格式)
|
||||
@@ -29,9 +45,13 @@ export class ImageDecryptService {
|
||||
* 获取账号目录
|
||||
*/
|
||||
private getAccountDir(): string | null {
|
||||
if (this.accountDirResolved) return this.cachedAccountDir
|
||||
this.accountDirResolved = true
|
||||
|
||||
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
|
||||
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
|
||||
return wcdbAccountRoot
|
||||
this.cachedAccountDir = wcdbAccountRoot
|
||||
return this.cachedAccountDir
|
||||
}
|
||||
|
||||
const homeDir = os.homedir()
|
||||
@@ -41,7 +61,7 @@ export class ImageDecryptService {
|
||||
)
|
||||
|
||||
if (!existsSync(accountRoot)) {
|
||||
console.log('[ImageDecrypt] account root not found:', accountRoot)
|
||||
imageDecryptLog('[ImageDecrypt] account root not found:', accountRoot)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -61,61 +81,144 @@ export class ImageDecryptService {
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
if (accounts.length === 0) {
|
||||
console.log('[ImageDecrypt] no accounts found')
|
||||
imageDecryptLog('[ImageDecrypt] no accounts found')
|
||||
return null
|
||||
}
|
||||
|
||||
// 返回最新的账号目录
|
||||
return join(accountRoot, accounts[0].name)
|
||||
this.cachedAccountDir = join(accountRoot, accounts[0].name)
|
||||
return this.cachedAccountDir
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 md5 查找图片文件 (WechatExplorer 风格)
|
||||
*/
|
||||
findImageFile(md5?: string, imageDatName?: string): string | null {
|
||||
const accountDir = this.getAccountDir()
|
||||
if (!accountDir) return null
|
||||
|
||||
findImageFile(
|
||||
md5?: string,
|
||||
imageDatName?: string,
|
||||
options?: { allowThumbnail?: boolean; accountDir?: string; preferThumbnail?: boolean }
|
||||
): string | null {
|
||||
const allowThumbnail = options?.allowThumbnail !== false
|
||||
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
||||
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
||||
console.log('[ImageDecrypt] findImageFile:', {
|
||||
const pathCacheKey = [
|
||||
normalizedMd5,
|
||||
normalizedDatName,
|
||||
allowThumbnail ? 'thumb' : 'original',
|
||||
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
|
||||
options?.accountDir || ''
|
||||
].join('|')
|
||||
const cachedPath = this.imagePathCache.get(pathCacheKey)
|
||||
if (cachedPath && existsSync(cachedPath)) return cachedPath
|
||||
|
||||
const rememberPath = (path: string | null): string | null => {
|
||||
if (path) this.imagePathCache.set(pathCacheKey, path)
|
||||
return path
|
||||
}
|
||||
|
||||
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
|
||||
const accountDir =
|
||||
options?.accountDir && existsSync(options.accountDir)
|
||||
? options.accountDir
|
||||
: this.getAccountDir()
|
||||
if (!accountDir) return null
|
||||
imageDecryptLog('[ImageDecrypt] findImageFile:', {
|
||||
md5: normalizedMd5,
|
||||
imageDatName: normalizedDatName,
|
||||
accountDir
|
||||
accountDir,
|
||||
allowThumbnail
|
||||
})
|
||||
|
||||
for (const key of this.uniq([normalizedMd5, normalizedDatName])) {
|
||||
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
||||
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
||||
if (fullPath && existsSync(fullPath)) {
|
||||
console.log('[ImageDecrypt] hardlink hit:', fullPath)
|
||||
return this.getPreferredDatVariantPath(fullPath, true)
|
||||
const selected = this.getPreferredDatVariantPath(
|
||||
fullPath,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
||||
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
|
||||
return rememberPath(selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
|
||||
const attachDir = join(accountDir, 'msg', 'attach')
|
||||
if (!existsSync(attachDir)) {
|
||||
console.log('[ImageDecrypt] attach dir not found:', attachDir)
|
||||
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
|
||||
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
||||
return rememberPath(
|
||||
this.findImageFileInLegacyDirs(
|
||||
accountDir,
|
||||
normalizedMd5 || normalizedDatName,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
|
||||
if (searchKeys.length === 0) return null
|
||||
|
||||
for (const key of searchKeys) {
|
||||
const directHit = this.fastProbabilisticSearch(attachDir, key)
|
||||
if (directHit) return directHit
|
||||
const directHit = this.fastProbabilisticSearch(
|
||||
attachDir,
|
||||
key,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
if (directHit) return rememberPath(directHit)
|
||||
}
|
||||
|
||||
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0])
|
||||
if (legacyHit) return legacyHit
|
||||
const legacyHit = this.findImageFileInLegacyDirs(
|
||||
accountDir,
|
||||
searchKeys[0],
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
if (legacyHit) return rememberPath(legacyHit)
|
||||
|
||||
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||
return null
|
||||
}
|
||||
|
||||
private fastProbabilisticSearch(attachDir: string, datName: string): string | null {
|
||||
getCachedDecodedImage(key: string): DecodedImage | null {
|
||||
const cached = this.decodedImageCache.get(key)
|
||||
if (!cached) return null
|
||||
this.decodedImageCache.delete(key)
|
||||
this.decodedImageCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
cacheDecodedImage(key: string, image: DecodedImage): void {
|
||||
const size = image.data.length * 2
|
||||
const previous = this.decodedImageCache.get(key)
|
||||
if (previous) {
|
||||
this.decodedImageCacheBytes -= previous.data.length * 2
|
||||
this.decodedImageCache.delete(key)
|
||||
}
|
||||
this.decodedImageCache.set(key, image)
|
||||
this.decodedImageCacheBytes += size
|
||||
while (
|
||||
this.decodedImageCacheBytes > MAX_DECODED_IMAGE_CACHE_BYTES &&
|
||||
this.decodedImageCache.size > 1
|
||||
) {
|
||||
const oldestKey = this.decodedImageCache.keys().next().value
|
||||
if (!oldestKey) break
|
||||
const oldest = this.decodedImageCache.get(oldestKey)
|
||||
this.decodedImageCache.delete(oldestKey)
|
||||
this.decodedImageCacheBytes -= oldest?.data.length ? oldest.data.length * 2 : 0
|
||||
}
|
||||
}
|
||||
|
||||
private fastProbabilisticSearch(
|
||||
attachDir: string,
|
||||
datName: string,
|
||||
allowThumbnail = true,
|
||||
preferThumbnail = false
|
||||
): string | null {
|
||||
const normalized = this.normalizeDatBase(datName)
|
||||
if (!normalized) return null
|
||||
|
||||
@@ -131,9 +234,9 @@ export class ImageDecryptService {
|
||||
join(attachDir, dir1, dir2, 'Image', variant),
|
||||
join(attachDir, dir1, dir2, 'image', variant)
|
||||
]
|
||||
const found = candidates.find((candidate) => existsSync(candidate))
|
||||
const found = this.getLargestExistingPath(candidates, allowThumbnail, preferThumbnail)
|
||||
if (found) {
|
||||
console.log('[ImageDecrypt] prefix path hit:', found)
|
||||
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
@@ -157,24 +260,31 @@ export class ImageDecryptService {
|
||||
const imgDir = join(attachDir, sessDir, month, sub)
|
||||
if (!existsSync(imgDir)) continue
|
||||
|
||||
const found = variants
|
||||
.map((variant) => join(imgDir, variant))
|
||||
.find((candidate) => existsSync(candidate))
|
||||
const found = this.getLargestExistingPath(
|
||||
variants.map((variant) => join(imgDir, variant)),
|
||||
allowThumbnail,
|
||||
preferThumbnail
|
||||
)
|
||||
if (found) {
|
||||
console.log('[ImageDecrypt] found at:', found)
|
||||
imageDecryptLog('[ImageDecrypt] found at:', found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[ImageDecrypt]遍历目录失败:', e)
|
||||
imageDecryptLog('[ImageDecrypt]遍历目录失败:', e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private findImageFileInLegacyDirs(accountDir: string, datName: string): string | null {
|
||||
private findImageFileInLegacyDirs(
|
||||
accountDir: string,
|
||||
datName: string,
|
||||
allowThumbnail = true,
|
||||
preferThumbnail = false
|
||||
): string | null {
|
||||
const normalized = this.normalizeDatBase(datName)
|
||||
if (!normalized) return null
|
||||
|
||||
@@ -185,32 +295,63 @@ export class ImageDecryptService {
|
||||
].filter((root) => existsSync(root))
|
||||
|
||||
for (const root of roots) {
|
||||
const found = this.recursiveFindDat(root, normalized, 5)
|
||||
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail, preferThumbnail)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private recursiveFindDat(dir: string, datName: string, depth: number): string | null {
|
||||
private recursiveFindDat(
|
||||
dir: string,
|
||||
datName: string,
|
||||
depth: number,
|
||||
allowThumbnail = true,
|
||||
preferThumbnail = false
|
||||
): string | null {
|
||||
if (depth < 0) return null
|
||||
|
||||
try {
|
||||
const variants = new Set(this.buildPreferredDatNames(datName))
|
||||
const variantNames = this.buildPreferredDatNames(datName).filter(
|
||||
(name) => allowThumbnail || !this.isThumbnailName(name)
|
||||
)
|
||||
const variants = new Set(
|
||||
preferThumbnail
|
||||
? [
|
||||
...variantNames.filter((name) => this.isThumbnailName(name)),
|
||||
...variantNames.filter((name) => !this.isThumbnailName(name))
|
||||
]
|
||||
: variantNames
|
||||
)
|
||||
const entries = readdirSync(dir)
|
||||
const matchingFiles: string[] = []
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry)
|
||||
const stat = statSync(fullPath)
|
||||
if (stat.isFile() && variants.has(entry.toLowerCase())) {
|
||||
console.log('[ImageDecrypt] legacy path hit:', fullPath)
|
||||
return fullPath
|
||||
matchingFiles.push(fullPath)
|
||||
}
|
||||
}
|
||||
const preferredFile = this.getLargestExistingPath(
|
||||
matchingFiles,
|
||||
allowThumbnail,
|
||||
preferThumbnail
|
||||
)
|
||||
if (preferredFile) {
|
||||
imageDecryptLog('[ImageDecrypt] legacy path hit:', preferredFile)
|
||||
return preferredFile
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry)
|
||||
if (!statSync(fullPath).isDirectory()) continue
|
||||
const found = this.recursiveFindDat(fullPath, datName, depth - 1)
|
||||
const found = this.recursiveFindDat(
|
||||
fullPath,
|
||||
datName,
|
||||
depth - 1,
|
||||
allowThumbnail,
|
||||
preferThumbnail
|
||||
)
|
||||
if (found) return found
|
||||
}
|
||||
} catch {
|
||||
@@ -225,13 +366,13 @@ export class ImageDecryptService {
|
||||
*/
|
||||
decryptImage(datPath: string): Buffer | null {
|
||||
if (!existsSync(datPath)) {
|
||||
console.log('[ImageDecrypt] file not found:', datPath)
|
||||
imageDecryptLog('[ImageDecrypt] file not found:', datPath)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const version = this.getDatVersion(datPath)
|
||||
console.log(
|
||||
imageDecryptLog(
|
||||
'[ImageDecrypt] dat version:',
|
||||
version,
|
||||
'file:',
|
||||
@@ -241,26 +382,24 @@ export class ImageDecryptService {
|
||||
)
|
||||
|
||||
let decrypted: Buffer
|
||||
if (version === 1) {
|
||||
console.log('[ImageDecrypt] using V1 (default AES key)')
|
||||
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
|
||||
decrypted = this.decryptDatV4(datPath, key)
|
||||
} else if (version === 2) {
|
||||
console.log('[ImageDecrypt] using V2 (user AES key)')
|
||||
if (version === 2) {
|
||||
// WeChat 4.0 标准 dat 头: 07 08 56 32 08 07
|
||||
imageDecryptLog('[ImageDecrypt] using WeChat 4.0 (user AES key)')
|
||||
if (!this.aesKey) {
|
||||
console.log('[ImageDecrypt] no AES key configured')
|
||||
imageDecryptLog('[ImageDecrypt] no AES key configured')
|
||||
return null
|
||||
}
|
||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||
decrypted = this.decryptDatV4(datPath, key)
|
||||
} else {
|
||||
console.log('[ImageDecrypt] unsupported dat version:', version)
|
||||
// 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。
|
||||
imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version)
|
||||
return null
|
||||
}
|
||||
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
console.error('[ImageDecrypt] decrypt error:', error)
|
||||
imageDecryptLog('[ImageDecrypt] decrypt error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -282,7 +421,7 @@ export class ImageDecryptService {
|
||||
const unwrapped = this.unwrapWxgf(decrypted)
|
||||
const ext = this.detectImageExtension(unwrapped)
|
||||
if (!ext) {
|
||||
console.log('[ImageDecrypt] unknown image format')
|
||||
imageDecryptLog('[ImageDecrypt] unknown image format')
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -291,7 +430,41 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 DAT 文件版本
|
||||
* 首选 DAT 无法解密时,继续尝试同目录下属于同一图片的其他清晰度变体。
|
||||
* 微信可能只保留 base/_h/_hd/_t 中的一部分,不能把首个文件失败等同于整张图失败。
|
||||
*/
|
||||
decryptImageToBase64WithFallback(
|
||||
datPath: string,
|
||||
allowThumbnail = true
|
||||
): { data: string; filePath: string } | null {
|
||||
const candidates = [datPath]
|
||||
if (extname(datPath).toLowerCase().includes('dat')) {
|
||||
const dir = dirname(datPath)
|
||||
const base = this.normalizeDatBase(basename(datPath))
|
||||
const siblings = this.buildPreferredDatNames(base)
|
||||
.filter((name) => allowThumbnail || !this.isThumbnailName(name))
|
||||
.map((name) => join(dir, name))
|
||||
.filter((candidate) => existsSync(candidate))
|
||||
.sort((left, right) => {
|
||||
const leftThumb = this.isThumbnailName(basename(left)) ? 1 : 0
|
||||
const rightThumb = this.isThumbnailName(basename(right)) ? 1 : 0
|
||||
if (leftThumb !== rightThumb) return leftThumb - rightThumb
|
||||
return statSync(right).size - statSync(left).size
|
||||
})
|
||||
candidates.push(...siblings)
|
||||
}
|
||||
|
||||
for (const candidate of this.uniq(candidates)) {
|
||||
const data = this.decryptImageToBase64(candidate)
|
||||
if (data) return { data, filePath: candidate }
|
||||
}
|
||||
imageDecryptLog('[ImageDecrypt] all variants failed:', this.uniq(candidates))
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 DAT 文件版本(仅识别 WeChat 4.0 头 V2)。
|
||||
* 老 V1 头(V3 及以下)直接返回 0,由调用方走"不支持"分支。
|
||||
*/
|
||||
private getDatVersion(inputPath: string): number {
|
||||
const bytes = readFileSync(inputPath)
|
||||
@@ -300,9 +473,6 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
const signature = bytes.subarray(0, 6)
|
||||
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) {
|
||||
return 1
|
||||
}
|
||||
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
|
||||
return 2
|
||||
}
|
||||
@@ -418,9 +588,11 @@ export class ImageDecryptService {
|
||||
const base = this.normalizeDatBase(baseName)
|
||||
if (!base) return []
|
||||
return [
|
||||
`${base}_h.dat`,
|
||||
`${base}.dat`,
|
||||
`${base}_hd.dat`,
|
||||
`${base}_h.dat`,
|
||||
`${base}_b.dat`,
|
||||
`${base}_w.dat`,
|
||||
`${base}_c.dat`,
|
||||
`${base}_t.dat`,
|
||||
`${base}.thumb.dat`,
|
||||
@@ -428,25 +600,66 @@ export class ImageDecryptService {
|
||||
]
|
||||
}
|
||||
|
||||
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
|
||||
private getPreferredDatVariantPath(
|
||||
inputPath: string,
|
||||
allowThumbnail: boolean,
|
||||
preferThumbnail = false
|
||||
): string {
|
||||
const actualDir = dirname(inputPath)
|
||||
const base = this.normalizeDatBase(basename(inputPath))
|
||||
const variants = this.buildPreferredDatNames(base)
|
||||
const ordered = allowThumbnail
|
||||
? variants
|
||||
: variants.filter((name) => !this.isThumbnailName(name))
|
||||
for (const variant of ordered) {
|
||||
const candidate = join(actualDir, variant)
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
const largest = this.getLargestExistingPath(
|
||||
ordered.map((variant) => join(actualDir, variant)),
|
||||
allowThumbnail,
|
||||
preferThumbnail
|
||||
)
|
||||
if (largest) return largest
|
||||
return inputPath
|
||||
}
|
||||
|
||||
private getLargestExistingPath(
|
||||
paths: string[],
|
||||
allowThumbnail: boolean,
|
||||
preferThumbnail = false
|
||||
): string | null {
|
||||
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
|
||||
candidates
|
||||
.filter((candidate) => existsSync(candidate))
|
||||
.map((candidate) => {
|
||||
try {
|
||||
return { candidate, size: statSync(candidate).size }
|
||||
} catch {
|
||||
return { candidate, size: 0 }
|
||||
}
|
||||
})
|
||||
.sort((left, right) => right.size - left.size)
|
||||
|
||||
const thumbnail = toSized(
|
||||
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
|
||||
)
|
||||
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
|
||||
const nonThumb = toSized(
|
||||
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
|
||||
)
|
||||
if (nonThumb[0]) return nonThumb[0].candidate
|
||||
if (!allowThumbnail) return null
|
||||
|
||||
const existing = toSized(paths)
|
||||
return existing[0]?.candidate || null
|
||||
}
|
||||
|
||||
private isThumbnailName(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase()
|
||||
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
|
||||
}
|
||||
|
||||
isThumbnailFile(filePath: string): boolean {
|
||||
return this.isThumbnailName(basename(filePath))
|
||||
}
|
||||
|
||||
private unwrapWxgf(buffer: Buffer): Buffer {
|
||||
if (
|
||||
buffer.length < 20 ||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
|
||||
export interface StoredImageKeyEntry {
|
||||
xorKey: string
|
||||
aesKey: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface StoredImageKeyFile {
|
||||
version: 1
|
||||
accounts: Record<string, StoredImageKeyEntry>
|
||||
}
|
||||
|
||||
interface StoreReadResult {
|
||||
success: boolean
|
||||
data?: StoredImageKeyFile
|
||||
error?: string
|
||||
encryptionAvailable: boolean
|
||||
}
|
||||
|
||||
export class ImageKeyStore {
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'wechat-image-keys.bin')
|
||||
}
|
||||
|
||||
get(accountId: string): StoreReadResult & { entry?: StoredImageKeyEntry } {
|
||||
const result = this.read()
|
||||
return { ...result, entry: result.data?.accounts[accountId] }
|
||||
}
|
||||
|
||||
save(
|
||||
accountId: string,
|
||||
entry: Omit<StoredImageKeyEntry, 'updatedAt'>
|
||||
): { success: boolean; entry?: StoredImageKeyEntry; error?: string } {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
const current = this.read()
|
||||
if (!current.success && fs.existsSync(this.filePath)) {
|
||||
return { success: false, error: '无法读取现有图片密钥配置' }
|
||||
}
|
||||
const nextEntry = { ...entry, updatedAt: Date.now() }
|
||||
const data: StoredImageKeyFile = current.data || { version: 1, accounts: {} }
|
||||
data.accounts[accountId] = nextEntry
|
||||
const saved = this.write(data)
|
||||
return saved.success ? { success: true, entry: nextEntry } : saved
|
||||
}
|
||||
|
||||
clear(accountId: string): { success: boolean; error?: string } {
|
||||
const current = this.read()
|
||||
if (!current.success) return { success: false, error: current.error }
|
||||
if (!current.data?.accounts[accountId]) return { success: true }
|
||||
delete current.data.accounts[accountId]
|
||||
try {
|
||||
if (Object.keys(current.data.accounts).length === 0) fs.removeSync(this.filePath)
|
||||
else return this.write(current.data)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '无法清除图片密钥配置' }
|
||||
}
|
||||
}
|
||||
|
||||
private read(): StoreReadResult {
|
||||
const encryptionAvailable = safeStorage.isEncryptionAvailable()
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
return {
|
||||
success: true,
|
||||
data: { version: 1, accounts: {} },
|
||||
encryptionAvailable
|
||||
}
|
||||
}
|
||||
if (!encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', encryptionAvailable }
|
||||
}
|
||||
try {
|
||||
const decrypted = safeStorage.decryptString(fs.readFileSync(this.filePath))
|
||||
const data = JSON.parse(decrypted) as StoredImageKeyFile
|
||||
if (data.version !== 1 || !data.accounts) throw new Error('invalid image key store')
|
||||
return { success: true, data, encryptionAvailable }
|
||||
} catch {
|
||||
return { success: false, error: '图片密钥安全存储不可读取', encryptionAvailable }
|
||||
}
|
||||
}
|
||||
|
||||
private write(data: StoredImageKeyFile): { success: boolean; error?: string } {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.filePath))
|
||||
fs.writeFileSync(this.filePath, safeStorage.encryptString(JSON.stringify(data)), {
|
||||
mode: 0o600
|
||||
})
|
||||
fs.chmodSync(this.filePath, 0o600)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '图片密钥保存失败' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { isValidDatabaseKey } from './database-key-store'
|
||||
import crypto from 'crypto'
|
||||
import { findResource, getResourceCandidates } from './resource-paths'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -14,19 +16,22 @@ export interface DatabaseKeyResult {
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface ImageKeyResult {
|
||||
success: boolean
|
||||
xorKey?: number
|
||||
aesKey?: string
|
||||
verified?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class KeyServiceMac {
|
||||
private getHelperPath(): string {
|
||||
const candidates = app.isPackaged
|
||||
? [
|
||||
path.join(process.resourcesPath, 'resources', 'xkey_helper'),
|
||||
path.join(process.resourcesPath, 'xkey_helper')
|
||||
]
|
||||
: [
|
||||
path.join(process.cwd(), 'resources', 'xkey_helper'),
|
||||
path.join(app.getAppPath(), 'resources', 'xkey_helper')
|
||||
]
|
||||
const helperPath = candidates.find((candidate) => fs.existsSync(candidate))
|
||||
if (!helperPath) throw new Error('找不到 xkey_helper')
|
||||
const helperPath = findResource('xkey_helper')
|
||||
if (!helperPath) {
|
||||
throw new Error(
|
||||
`找不到 xkey_helper(已检查:${getResourceCandidates('xkey_helper').join(';')})`
|
||||
)
|
||||
}
|
||||
return helperPath
|
||||
}
|
||||
|
||||
@@ -136,7 +141,7 @@ export class KeyServiceMac {
|
||||
'return "ERR::" & errNum & "::" & errMsg',
|
||||
'end try'
|
||||
]
|
||||
onStatus?.('授权后请保持微信已登录并活动...')
|
||||
onStatus?.('授权后请保持微信登录界面 并点击登录微信...')
|
||||
const { stdout } = await execFileAsync(
|
||||
'/usr/bin/osascript',
|
||||
scriptLines.flatMap((line) => ['-e', line]),
|
||||
@@ -157,4 +162,283 @@ export class KeyServiceMac {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async autoGetImageKey(
|
||||
accountPath?: string,
|
||||
onStatus?: (message: string) => void,
|
||||
wxid?: string
|
||||
): Promise<ImageKeyResult> {
|
||||
try {
|
||||
onStatus?.('正在从缓存目录扫描图片密钥...')
|
||||
const codes = this.collectKvcommCodes(accountPath)
|
||||
if (codes.length === 0) {
|
||||
return { success: false, error: '未找到有效的密钥码(kvcomm 缓存为空)' }
|
||||
}
|
||||
|
||||
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
|
||||
const accountPathCandidates = this.collectAccountPathCandidates(accountPath)
|
||||
|
||||
for (const candidateAccountPath of accountPathCandidates) {
|
||||
if (!fs.existsSync(candidateAccountPath)) continue
|
||||
const template = this.findTemplateData(candidateAccountPath, 32)
|
||||
if (!template.ciphertext) continue
|
||||
|
||||
const orderedWxids: string[] = []
|
||||
this.pushAccountIdCandidates(orderedWxids, path.basename(candidateAccountPath))
|
||||
for (const candidate of wxidCandidates)
|
||||
this.pushAccountIdCandidates(orderedWxids, candidate)
|
||||
|
||||
onStatus?.(`正在校验候选 wxid(${orderedWxids.length} 个)...`)
|
||||
for (const candidateWxid of orderedWxids) {
|
||||
for (const code of codes) {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
|
||||
onStatus?.(`图片密钥获取成功 (wxid: ${candidateWxid})`)
|
||||
return { success: true, xorKey, aesKey, verified: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackWxid = wxidCandidates[0]
|
||||
const fallbackCode = codes[0]
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||
onStatus?.(`图片密钥已计算 (wxid: ${fallbackWxid})`)
|
||||
return { success: true, xorKey, aesKey, verified: false }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private collectKvcommCodes(accountPath?: string): number[] {
|
||||
const codeSet = new Set<number>()
|
||||
const pattern = /^key_(\d+)_.+\.statistic$/i
|
||||
for (const kvcommDir of this.getKvcommCandidates(accountPath)) {
|
||||
if (!fs.existsSync(kvcommDir)) continue
|
||||
try {
|
||||
for (const file of fs.readdirSync(kvcommDir)) {
|
||||
const match = file.match(pattern)
|
||||
if (!match) continue
|
||||
const code = Number(match[1])
|
||||
if (Number.isFinite(code) && code > 0 && code <= 0xffffffff) codeSet.add(code)
|
||||
}
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
return Array.from(codeSet)
|
||||
}
|
||||
|
||||
private getKvcommCandidates(accountPath?: string): string[] {
|
||||
const home = app.getPath('home')
|
||||
const candidates = new Set<string>([
|
||||
path.join(
|
||||
home,
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm'
|
||||
),
|
||||
path.join(
|
||||
home,
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/xwechat/net/kvcomm'
|
||||
),
|
||||
path.join(
|
||||
home,
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/net/kvcomm'
|
||||
),
|
||||
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat/net/kvcomm')
|
||||
])
|
||||
|
||||
const normalized = String(accountPath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+$/, '')
|
||||
const marker = '/xwechat_files'
|
||||
const markerIndex = normalized.indexOf(marker)
|
||||
if (markerIndex >= 0) {
|
||||
candidates.add(`${normalized.slice(0, markerIndex)}/app_data/net/kvcomm`)
|
||||
}
|
||||
|
||||
const newPathMatch = normalized.match(
|
||||
/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/
|
||||
)
|
||||
if (newPathMatch) {
|
||||
candidates.add(`${newPathMatch[1]}/net/kvcomm`)
|
||||
candidates.add(`${newPathMatch[1]}/xwechat/net/kvcomm`)
|
||||
}
|
||||
|
||||
return Array.from(candidates)
|
||||
}
|
||||
|
||||
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
|
||||
const candidates: string[] = []
|
||||
this.pushAccountIdCandidates(candidates, wxidParam)
|
||||
|
||||
const normalized = String(accountPath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+$/, '')
|
||||
if (normalized) {
|
||||
this.pushAccountIdCandidates(candidates, path.basename(normalized))
|
||||
const root = this.resolveXwechatRootFromPath(normalized)
|
||||
if (root && fs.existsSync(root)) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const entryPath = path.join(root, entry.name)
|
||||
if (this.isAccountDirPath(entryPath))
|
||||
this.pushAccountIdCandidates(candidates, entry.name)
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable directories.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) candidates.push('unknown')
|
||||
return candidates
|
||||
}
|
||||
|
||||
private collectAccountPathCandidates(accountPath?: string): string[] {
|
||||
const candidates: string[] = []
|
||||
const push = (value?: string): void => {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized && !candidates.includes(normalized)) candidates.push(normalized)
|
||||
}
|
||||
|
||||
push(accountPath)
|
||||
const root = this.resolveXwechatRootFromPath(accountPath)
|
||||
if (root && fs.existsSync(root)) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const entryPath = path.join(root, entry.name)
|
||||
if (this.isAccountDirPath(entryPath) && this.isReasonableAccountId(entry.name))
|
||||
push(entryPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable directories.
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private resolveXwechatRootFromPath(accountPath?: string): string | null {
|
||||
const normalized = String(accountPath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+$/, '')
|
||||
if (!normalized) return null
|
||||
const marker = '/xwechat_files'
|
||||
const markerIndex = normalized.indexOf(marker)
|
||||
if (markerIndex >= 0) return normalized.slice(0, markerIndex + marker.length)
|
||||
const newPathMatch = normalized.match(
|
||||
/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/
|
||||
)
|
||||
return newPathMatch ? newPathMatch[1] : null
|
||||
}
|
||||
|
||||
private isAccountDirPath(entryPath: string): boolean {
|
||||
return (
|
||||
fs.existsSync(path.join(entryPath, 'db_storage')) ||
|
||||
fs.existsSync(path.join(entryPath, 'msg')) ||
|
||||
fs.existsSync(path.join(entryPath, 'FileStorage', 'Image')) ||
|
||||
fs.existsSync(path.join(entryPath, 'FileStorage', 'Image2'))
|
||||
)
|
||||
}
|
||||
|
||||
private pushAccountIdCandidates(candidates: string[], value?: string): void {
|
||||
const raw = String(value || '').trim()
|
||||
if (!this.isReasonableAccountId(raw)) return
|
||||
for (const candidate of [raw, this.normalizeAccountId(raw)]) {
|
||||
if (candidate && !candidates.includes(candidate) && this.isReasonableAccountId(candidate)) {
|
||||
candidates.push(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeAccountId(value: string): string {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match?.[1] || trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
private isReasonableAccountId(value: string): boolean {
|
||||
const lowered = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!lowered || lowered.includes('/') || lowered.includes('\\')) return false
|
||||
return !['xwechat_files', 'all_users', 'backup', 'wmpf', 'app_data'].includes(lowered)
|
||||
}
|
||||
|
||||
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
||||
const xorKey = code & 0xff
|
||||
const aesKey = crypto
|
||||
.createHash('md5')
|
||||
.update(`${code}${this.normalizeAccountId(wxid)}`)
|
||||
.digest('hex')
|
||||
.substring(0, 16)
|
||||
return { xorKey, aesKey }
|
||||
}
|
||||
|
||||
private findTemplateData(userDir: string, limit = 32): { ciphertext: Buffer | null } {
|
||||
const magic = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
const files: string[] = []
|
||||
const collect = (dir: string): void => {
|
||||
if (files.length >= limit) return
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (files.length >= limit) break
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) collect(full)
|
||||
else if (entry.isFile() && entry.name.endsWith('_t.dat')) files.push(full)
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable directories.
|
||||
}
|
||||
}
|
||||
collect(userDir)
|
||||
files.sort((a, b) => {
|
||||
try {
|
||||
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const data = fs.readFileSync(file)
|
||||
if (data.length >= 0x1f && data.subarray(0, 6).equals(magic)) {
|
||||
return { ciphertext: data.subarray(0x0f, 0x1f) }
|
||||
}
|
||||
} catch {
|
||||
// Try the next file.
|
||||
}
|
||||
}
|
||||
return { ciphertext: null }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,22 @@ type ShareContent = {
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
}
|
||||
type MiniProgramContent = {
|
||||
type: 'miniProgram'
|
||||
title: string
|
||||
description?: string
|
||||
appName?: string
|
||||
iconUrl?: string
|
||||
thumbMd5?: string
|
||||
thumbDatName?: string
|
||||
thumbDataUrl?: string
|
||||
}
|
||||
type RedPacketContent = {
|
||||
type: 'redPacket'
|
||||
title: string
|
||||
description?: string
|
||||
url?: string
|
||||
}
|
||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||
type ImageContent = {
|
||||
type: 'image'
|
||||
@@ -24,6 +40,15 @@ type ImageContent = {
|
||||
aeskey?: string
|
||||
encrypVer?: number
|
||||
}
|
||||
type VideoContent = {
|
||||
type: 'video'
|
||||
md5?: string
|
||||
newMd5?: string
|
||||
rawMd5?: string
|
||||
duration?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
type StickerContent = {
|
||||
type: 'sticker'
|
||||
md5?: string
|
||||
@@ -40,8 +65,23 @@ type QuoteContent = {
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedType?: string
|
||||
quotedImageMd5?: string
|
||||
quotedImageDatName?: string
|
||||
}
|
||||
type SystemContent = {
|
||||
type: 'system'
|
||||
content: string
|
||||
raw?: string
|
||||
pat?: boolean
|
||||
recall?: {
|
||||
targetId?: string
|
||||
targetIds?: string[]
|
||||
replacement: string
|
||||
actor?: string
|
||||
sessionId?: string
|
||||
recallTime?: number
|
||||
}
|
||||
}
|
||||
type SystemContent = { type: 'system'; content: string; raw?: string }
|
||||
type UnknownContent = { type: 'unknown'; raw: string }
|
||||
|
||||
export type ParsedContent =
|
||||
@@ -50,8 +90,11 @@ export type ParsedContent =
|
||||
| LocationContent
|
||||
| CardContent
|
||||
| ShareContent
|
||||
| MiniProgramContent
|
||||
| RedPacketContent
|
||||
| VoipContent
|
||||
| ImageContent
|
||||
| VideoContent
|
||||
| StickerContent
|
||||
| QuoteContent
|
||||
| SystemContent
|
||||
@@ -69,6 +112,8 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
return parseImageMessage(normalized)
|
||||
case 42:
|
||||
return parseCardMessage(normalized)
|
||||
case 43:
|
||||
return parseVideoMessage(normalized)
|
||||
case 47:
|
||||
return parseStickerMessage(normalized)
|
||||
case 48:
|
||||
@@ -85,9 +130,31 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
}
|
||||
}
|
||||
|
||||
function parseVideoMessage(content: string): ParsedContent {
|
||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
|
||||
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
|
||||
const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5'))
|
||||
if (!md5 && !newMd5 && !rawMd5) return { type: 'unknown', raw: content }
|
||||
|
||||
const duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined
|
||||
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
|
||||
const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || undefined
|
||||
return { type: 'video', md5, newMd5, rawMd5, duration, width, height }
|
||||
}
|
||||
|
||||
function parseSystemMessage(content: string): ParsedContent {
|
||||
const stripped = stripChatroomPrefix(content)
|
||||
const decoded = decodeXmlEntities(stripped)
|
||||
const recall = extractRecallMessage(decoded)
|
||||
if (recall) {
|
||||
return {
|
||||
type: 'system',
|
||||
content: recall.replacement,
|
||||
raw: content,
|
||||
recall
|
||||
}
|
||||
}
|
||||
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
||||
if (delChatroomMemberText) {
|
||||
return {
|
||||
@@ -113,6 +180,50 @@ function parseSystemMessage(content: string): ParsedContent {
|
||||
}
|
||||
}
|
||||
|
||||
function extractRecallMessage(xml: string):
|
||||
| {
|
||||
targetId?: string
|
||||
targetIds?: string[]
|
||||
replacement: string
|
||||
actor?: string
|
||||
sessionId?: string
|
||||
recallTime?: number
|
||||
}
|
||||
| undefined {
|
||||
if (!/<revokemsg\b/i.test(xml)) return undefined
|
||||
|
||||
const replacement = normalizeSystemText(
|
||||
extractXmlValue(xml, 'replacemsg') ||
|
||||
extractXmlNodeText(xml, 'replacemsg') ||
|
||||
extractXmlValue(xml, 'content') ||
|
||||
extractXmlNodeText(xml, 'content')
|
||||
)
|
||||
if (!replacement) return undefined
|
||||
|
||||
const actorMatch =
|
||||
/^["“](.+?)["”]\s*撤回了一条消息/.exec(replacement) ||
|
||||
/^(.+?)\s*撤回了一条消息/.exec(replacement)
|
||||
|
||||
const targetIds = Array.from(
|
||||
new Set(
|
||||
[
|
||||
extractXmlValue(xml, 'newmsgid'),
|
||||
extractXmlValue(xml, 'msgid'),
|
||||
extractXmlValue(xml, 'clientmsgid')
|
||||
].filter(Boolean)
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
targetId: targetIds[0] || undefined,
|
||||
targetIds,
|
||||
replacement,
|
||||
actor: actorMatch?.[1]?.trim() || undefined,
|
||||
sessionId: extractXmlValue(xml, 'session') || undefined,
|
||||
recallTime: Number(extractXmlValue(xml, 'revoketime')) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parseImageMessage(content: string): ParsedContent {
|
||||
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
||||
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
||||
@@ -301,6 +412,10 @@ function parseLocationMessage(content: string): ParsedContent {
|
||||
|
||||
function parseShareMessage(content: string): ParsedContent {
|
||||
const appMsgType = extractAppMsgType(content)
|
||||
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
|
||||
const sticker = parseStickerMessage(content)
|
||||
if (sticker.type === 'sticker') return sticker
|
||||
}
|
||||
if (appMsgType === '57' || content.includes('<refermsg>')) {
|
||||
const quote = parseQuoteMessage(content)
|
||||
const title = extractXmlValue(content, 'title') || undefined
|
||||
@@ -310,7 +425,34 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
content: title,
|
||||
quotedContent: quote.content || '[引用消息]',
|
||||
quotedSender: quote.sender,
|
||||
quotedType: quote.type
|
||||
quotedType: quote.type,
|
||||
quotedImageMd5: quote.imageMd5,
|
||||
quotedImageDatName: quote.imageDatName
|
||||
}
|
||||
}
|
||||
|
||||
if (appMsgType === '33' || appMsgType === '36') {
|
||||
return {
|
||||
type: 'miniProgram',
|
||||
title: extractXmlValue(content, 'title') || '小程序',
|
||||
description: extractXmlValue(content, 'des') || undefined,
|
||||
appName:
|
||||
extractXmlValue(content, 'sourcedisplayname') ||
|
||||
extractXmlValue(content, 'appname') ||
|
||||
'小程序',
|
||||
iconUrl: decodeXmlUrl(extractXmlValue(content, 'weappiconurl')) || undefined,
|
||||
thumbMd5: normalizeMd5(
|
||||
extractXmlValue(content, 'cdnthumbmd5') || extractXmlValue(content, 'md5')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (appMsgType === '2001') {
|
||||
return {
|
||||
type: 'redPacket',
|
||||
title: extractXmlValue(content, 'title') || '微信红包',
|
||||
description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利',
|
||||
url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +469,13 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
return { type: 'share', title, des, url, appname, typeVal }
|
||||
}
|
||||
|
||||
function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
|
||||
function parseQuoteMessage(content: string): {
|
||||
content?: string
|
||||
sender?: string
|
||||
type?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
} {
|
||||
const referMsgStart = content.indexOf('<refermsg>')
|
||||
const referMsgEnd = content.indexOf('</refermsg>')
|
||||
if (referMsgStart === -1 || referMsgEnd === -1) return {}
|
||||
@@ -343,8 +491,16 @@ function parseQuoteMessage(content: string): { content?: string; sender?: string
|
||||
switch (referType) {
|
||||
case '1':
|
||||
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
|
||||
case '3':
|
||||
return { sender, content: '[图片]', type: referType }
|
||||
case '3': {
|
||||
const image = parseImageMessage(referContent)
|
||||
return {
|
||||
sender,
|
||||
content: '[图片]',
|
||||
type: referType,
|
||||
imageMd5: image.type === 'image' ? image.md5 : undefined,
|
||||
imageDatName: image.type === 'image' ? image.datName : undefined
|
||||
}
|
||||
}
|
||||
case '34':
|
||||
return { sender, content: '[语音]', type: referType }
|
||||
case '43':
|
||||
@@ -372,6 +528,10 @@ function extractAppMsgType(content: string): string {
|
||||
const inner = appmsgMatch[1]
|
||||
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||
.replace(/<weappinfo[\s\S]*?<\/weappinfo>/gi, '')
|
||||
.replace(/<appattach[\s\S]*?<\/appattach>/gi, '')
|
||||
.replace(/<wcpayinfo[\s\S]*?<\/wcpayinfo>/gi, '')
|
||||
.replace(/<findernamecard[\s\S]*?<\/findernamecard>/gi, '')
|
||||
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
||||
return typeMatch?.[1]?.trim() || ''
|
||||
}
|
||||
@@ -433,7 +593,10 @@ function extractXmlValue(xml: string, tagName: string): string {
|
||||
}
|
||||
|
||||
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
||||
const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i')
|
||||
const pattern = new RegExp(
|
||||
`<${tagName}\\b[^>]*?(?:\\s|^)${attrName}\\s*=\\s*["']([^"']*)["']`,
|
||||
'i'
|
||||
)
|
||||
const match = xml.match(pattern)
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
@@ -550,6 +713,71 @@ export function parseImageDatNameFromRow(row: Record<string, unknown>): string |
|
||||
return hexMatch?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
export function parseImageBufferDataUrlFromRow(
|
||||
row: Record<string, unknown>
|
||||
): string | undefined {
|
||||
const raw = pickRowString(row, [
|
||||
'ImgBuf',
|
||||
'imgBuf',
|
||||
'img_buf',
|
||||
'imageBuffer',
|
||||
'image_buffer',
|
||||
'thumbBuffer',
|
||||
'thumb_buffer',
|
||||
'WCDB_CT_img_buf',
|
||||
'WCDB_CT_ImgBuf'
|
||||
])
|
||||
const buffer = decodeInlineImageBuffer(raw)
|
||||
if (!buffer || buffer.length === 0) return undefined
|
||||
const mime = detectImageMime(buffer)
|
||||
return mime ? `data:${mime};base64,${buffer.toString('base64')}` : undefined
|
||||
}
|
||||
|
||||
function decodeInlineImageBuffer(raw: unknown): Buffer | null {
|
||||
if (!raw) return null
|
||||
if (Buffer.isBuffer(raw)) return raw
|
||||
if (raw instanceof Uint8Array) return Buffer.from(raw)
|
||||
if (Array.isArray(raw)) return Buffer.from(raw)
|
||||
if (typeof raw === 'object') {
|
||||
const record = raw as { buffer?: unknown; data?: unknown }
|
||||
return decodeInlineImageBuffer(record.buffer ?? record.data)
|
||||
}
|
||||
if (typeof raw !== 'string') return null
|
||||
const value = raw.trim()
|
||||
const dataUrl = /^data:image\/[a-z0-9.+-]+;base64,(.+)$/i.exec(value)
|
||||
const encoded = dataUrl?.[1] || value
|
||||
if (!/^[a-z0-9+/]+={0,2}$/i.test(encoded)) return null
|
||||
try {
|
||||
return Buffer.from(encoded, 'base64')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function detectImageMime(buffer: Buffer): string | undefined {
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
if (
|
||||
buffer.length >= 8 &&
|
||||
buffer[0] === 0x89 &&
|
||||
buffer.subarray(1, 4).toString('ascii') === 'PNG'
|
||||
) {
|
||||
return 'image/png'
|
||||
}
|
||||
if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString('ascii'))) {
|
||||
return 'image/gif'
|
||||
}
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'image/webp'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
function prependPath(values: string[]): void {
|
||||
if (process.platform !== 'win32') return
|
||||
|
||||
const existing = process.env.PATH || ''
|
||||
const next = Array.from(new Set(values.filter(Boolean))).join(path.delimiter)
|
||||
process.env.PATH = next ? `${next}${path.delimiter}${existing}` : existing
|
||||
process.env.Path = process.env.PATH
|
||||
}
|
||||
|
||||
try {
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const resourceRoots = [
|
||||
path.join(process.cwd(), 'resources'),
|
||||
path.join(process.cwd(), 'resources', 'resources'),
|
||||
path.join(process.resourcesPath || '', 'resources'),
|
||||
process.resourcesPath || ''
|
||||
].filter((value, index, list) => value && list.indexOf(value) === index && fs.existsSync(value))
|
||||
|
||||
const resourcesRoot = resourceRoots[0] || path.join(process.cwd(), 'resources')
|
||||
const dllDirs = resourceRoots.flatMap((root) => [
|
||||
root,
|
||||
path.join(root, 'wcdb', 'win32', archDir),
|
||||
path.join(root, 'wcdb', 'win32', 'x64'),
|
||||
path.join(root, 'key', 'win32', archDir),
|
||||
path.join(root, 'key', 'win32', 'x64'),
|
||||
path.join(root, 'runtime', 'win32')
|
||||
])
|
||||
|
||||
process.env.WCDB_RESOURCES_PATH = process.env.WCDB_RESOURCES_PATH || resourcesRoot
|
||||
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
|
||||
prependPath(dllDirs.filter((dir) => fs.existsSync(dir)))
|
||||
} catch (error) {
|
||||
console.error('[WechatExplorer] failed to enforce local DLL priority:', error)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { app, nativeImage } from 'electron'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import type {
|
||||
GeneratedReportRecord,
|
||||
DeleteGeneratedReportResult,
|
||||
ReportAssetStatus,
|
||||
ReportHistoryResult,
|
||||
SaveGeneratedReportRequest,
|
||||
SaveGeneratedReportResult
|
||||
} from '../shared/report-history'
|
||||
|
||||
const REPORTS_DIR = 'reports'
|
||||
|
||||
const getReportsRoot = (): string => path.join(app.getPath('userData'), REPORTS_DIR)
|
||||
|
||||
const pad2 = (value: number): string => String(value).padStart(2, '0')
|
||||
|
||||
const safeSegment = (value: string): string =>
|
||||
value
|
||||
.trim()
|
||||
// Strip Windows-reserved filename characters and ASCII control chars.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||
.replace(/\s+/g, '_')
|
||||
.slice(0, 48) || 'report'
|
||||
|
||||
const parseDataUrl = (dataUrl: string): Buffer | null => {
|
||||
const match = /^data:image\/png;base64,(.+)$/i.exec(dataUrl)
|
||||
if (!match) return null
|
||||
return Buffer.from(match[1], 'base64')
|
||||
}
|
||||
|
||||
const exists = async (filePath?: string): Promise<boolean> => {
|
||||
if (!filePath) return false
|
||||
try {
|
||||
const stat = await fs.stat(filePath)
|
||||
return stat.isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const fileStatus = async (filePath?: string): Promise<ReportAssetStatus> =>
|
||||
(await exists(filePath)) ? 'ready' : 'missing'
|
||||
|
||||
const readPngAsDataUrl = async (filePath?: string): Promise<string | undefined> => {
|
||||
if (!filePath || !(await exists(filePath))) return undefined
|
||||
const content = await fs.readFile(filePath)
|
||||
return `data:image/png;base64,${content.toString('base64')}`
|
||||
}
|
||||
|
||||
const readPngSize = async (
|
||||
filePath?: string
|
||||
): Promise<{ width: number; height: number } | undefined> => {
|
||||
if (!filePath || !(await exists(filePath))) return undefined
|
||||
const image = nativeImage.createFromPath(filePath)
|
||||
if (image.isEmpty()) return undefined
|
||||
const size = image.getSize()
|
||||
return size.width && size.height ? size : undefined
|
||||
}
|
||||
|
||||
const readFileSize = async (filePath?: string): Promise<number | undefined> => {
|
||||
if (!filePath) return undefined
|
||||
try {
|
||||
const stat = await fs.stat(filePath)
|
||||
return stat.isFile() ? stat.size : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeRecord = async (
|
||||
record: GeneratedReportRecord,
|
||||
jsonPath: string
|
||||
): Promise<GeneratedReportRecord> => {
|
||||
const htmlStatus = await fileStatus(record.htmlPath)
|
||||
const pngStatus = await fileStatus(record.pngPath)
|
||||
return {
|
||||
...record,
|
||||
jsonPath,
|
||||
htmlStatus,
|
||||
pngStatus,
|
||||
generatedImage: await readPngAsDataUrl(record.pngPath),
|
||||
imageSize: await readPngSize(record.pngPath),
|
||||
fileSize: {
|
||||
html: await readFileSize(record.htmlPath),
|
||||
png: await readFileSize(record.pngPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const walkJsonFiles = async (directory: string): Promise<string[]> => {
|
||||
try {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true })
|
||||
const children = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) return walkJsonFiles(entryPath)
|
||||
return entry.isFile() && entry.name.endsWith('.json') ? [entryPath] : []
|
||||
})
|
||||
)
|
||||
return children.flat()
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGeneratedReports(): Promise<ReportHistoryResult> {
|
||||
try {
|
||||
const jsonFiles = await walkJsonFiles(getReportsRoot())
|
||||
const records = await Promise.all(
|
||||
jsonFiles.map(async (jsonPath) => {
|
||||
try {
|
||||
const content = await fs.readFile(jsonPath, 'utf8')
|
||||
return normalizeRecord(JSON.parse(content) as GeneratedReportRecord, jsonPath)
|
||||
} catch (error) {
|
||||
console.warn(`[ReportHistory] skip invalid report record: ${jsonPath}`, error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
const reports = records.filter((record): record is GeneratedReportRecord => Boolean(record))
|
||||
reports.sort((left, right) => Date.parse(right.generatedAt) - Date.parse(left.generatedAt))
|
||||
return { success: true, reports }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveGeneratedReport(
|
||||
request: SaveGeneratedReportRequest
|
||||
): Promise<SaveGeneratedReportResult> {
|
||||
try {
|
||||
const generatedAtDate = new Date(request.generatedAt)
|
||||
const timestamp = Number.isFinite(generatedAtDate.getTime()) ? generatedAtDate : new Date()
|
||||
const year = String(timestamp.getFullYear())
|
||||
const month = pad2(timestamp.getMonth() + 1)
|
||||
const directory = path.join(getReportsRoot(), year, month)
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
|
||||
const id = `report_${timestamp.getTime()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
const baseName = `${id}_${safeSegment(request.contactName)}`
|
||||
const htmlPath = path.join(directory, `${baseName}.html`)
|
||||
const pngPath = path.join(directory, `${baseName}.png`)
|
||||
const jsonPath = path.join(directory, `${baseName}.json`)
|
||||
|
||||
let savedHtmlPath: string | undefined
|
||||
if (request.htmlPath && (await exists(request.htmlPath))) {
|
||||
await fs.copyFile(request.htmlPath, htmlPath)
|
||||
savedHtmlPath = htmlPath
|
||||
}
|
||||
|
||||
let savedPngPath: string | undefined
|
||||
const imageBuffer = request.generatedImage ? parseDataUrl(request.generatedImage) : null
|
||||
if (imageBuffer) {
|
||||
await fs.writeFile(pngPath, imageBuffer)
|
||||
savedPngPath = pngPath
|
||||
} else if (request.pngPath && (await exists(request.pngPath))) {
|
||||
await fs.copyFile(request.pngPath, pngPath)
|
||||
savedPngPath = pngPath
|
||||
}
|
||||
|
||||
const record: GeneratedReportRecord = {
|
||||
id,
|
||||
contactId: request.contactId,
|
||||
contactName: request.contactName,
|
||||
contactAvatar: request.contactAvatar,
|
||||
dateRange: request.dateRange,
|
||||
messageCount: request.messageCount,
|
||||
generatedAt: timestamp.toISOString(),
|
||||
reportDate: `${year}-${month}-${pad2(timestamp.getDate())}`,
|
||||
htmlPath: savedHtmlPath,
|
||||
pngPath: savedPngPath,
|
||||
jsonPath,
|
||||
htmlStatus: savedHtmlPath ? 'ready' : 'missing',
|
||||
pngStatus: savedPngPath ? 'ready' : 'missing',
|
||||
imageSize: await readPngSize(savedPngPath),
|
||||
duration: request.duration,
|
||||
modelName: request.modelName,
|
||||
tokenUsage: request.tokenUsage,
|
||||
fileSize: {
|
||||
html: await readFileSize(savedHtmlPath),
|
||||
png: await readFileSize(savedPngPath)
|
||||
},
|
||||
generationLogs: request.generationLogs
|
||||
}
|
||||
|
||||
await fs.writeFile(jsonPath, JSON.stringify(record, null, 2), 'utf8')
|
||||
return {
|
||||
success: true,
|
||||
record: {
|
||||
...record,
|
||||
generatedImage: savedPngPath ? await readPngAsDataUrl(savedPngPath) : undefined
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGeneratedReport(
|
||||
reportId: string
|
||||
): Promise<DeleteGeneratedReportResult> {
|
||||
try {
|
||||
const jsonFiles = await walkJsonFiles(getReportsRoot())
|
||||
for (const jsonPath of jsonFiles) {
|
||||
try {
|
||||
const content = await fs.readFile(jsonPath, 'utf8')
|
||||
const record = JSON.parse(content) as GeneratedReportRecord
|
||||
if (record.id !== reportId) continue
|
||||
|
||||
const paths = [record.htmlPath, record.pngPath, jsonPath].filter(
|
||||
(filePath): filePath is string => Boolean(filePath)
|
||||
)
|
||||
await Promise.all(
|
||||
paths.map(async (filePath) => {
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
})
|
||||
)
|
||||
return { success: true, deletedId: reportId }
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ReportHistory] skip invalid report record while deleting: ${jsonPath}`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
return { success: false, error: '未找到要删除的日报记录' }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { app } from 'electron'
|
||||
import { existsSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values.filter(Boolean)))
|
||||
}
|
||||
|
||||
export function getResourceRoots(): string[] {
|
||||
const appPath = app.getAppPath()
|
||||
const appPathDir = dirname(appPath)
|
||||
const execDir = dirname(process.execPath)
|
||||
|
||||
return unique([
|
||||
process.env.WECHATEXPLORER_RESOURCES_PATH || '',
|
||||
join(process.cwd(), 'resources'),
|
||||
join(process.cwd(), 'resources', 'resources'),
|
||||
join(process.resourcesPath || '', 'resources'),
|
||||
process.resourcesPath || '',
|
||||
join(appPath, 'resources'),
|
||||
join(appPathDir, 'resources'),
|
||||
appPathDir,
|
||||
join(execDir, 'resources'),
|
||||
join(dirname(execDir), 'Resources', 'resources'),
|
||||
join(dirname(execDir), 'Resources')
|
||||
]).filter((root) => existsSync(root))
|
||||
}
|
||||
|
||||
export function getResourceCandidates(relativePath: string): string[] {
|
||||
return unique(getResourceRoots().map((root) => join(root, relativePath)))
|
||||
}
|
||||
|
||||
export function findResource(relativePath: string): string | null {
|
||||
return getResourceCandidates(relativePath).find((candidate) => existsSync(candidate)) || null
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Contact, Message } from '../../shared/types'
|
||||
import { exportGroupReport } from '../group-report-service'
|
||||
import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service'
|
||||
import { AIProviderService } from './ai-provider-service'
|
||||
import {
|
||||
buildGroupReportInput,
|
||||
getSummaryDateRange,
|
||||
GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT,
|
||||
GROUP_REPORT_SYSTEM_PROMPT,
|
||||
isInternalName,
|
||||
parseGroupDailyReport,
|
||||
type SummaryDateRange
|
||||
} from '../../renderer/src/utils/group-report'
|
||||
|
||||
const aiProvider = new AIProviderService()
|
||||
|
||||
export interface AgentGroupReportRequest {
|
||||
group: string
|
||||
range?: SummaryDateRange
|
||||
}
|
||||
|
||||
export interface AgentGroupReportResult {
|
||||
success: boolean
|
||||
groupName?: string
|
||||
pngPath?: string
|
||||
messageCount?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function generateAgentGroupReport(
|
||||
request: AgentGroupReportRequest
|
||||
): Promise<AgentGroupReportResult> {
|
||||
const query = String(request.group || '')
|
||||
.trim()
|
||||
.replace(/群聊?$/, '')
|
||||
.trim()
|
||||
if (!query) return { success: false, error: '缺少群聊名称' }
|
||||
const contact = resolveMd5(query)
|
||||
if (!contact) return { success: false, error: `没有找到群聊“${query}”` }
|
||||
if (contact.type !== 'group' && !contact.m_nsUsrName.endsWith('@chatroom')) {
|
||||
return { success: false, error: `“${query}”不是群聊` }
|
||||
}
|
||||
|
||||
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
|
||||
const { startTime, endTime } = getSummaryDateRange(range)
|
||||
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
|
||||
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
|
||||
|
||||
const snapshot = getGroupSnapshot(contact.md5)
|
||||
if (snapshot) {
|
||||
const members = new Map(
|
||||
snapshot.members.map((member) => [
|
||||
member.wxid,
|
||||
{ name: member.nickname, avatar: member.avatar }
|
||||
])
|
||||
)
|
||||
messages = messages.map((message) => {
|
||||
if (!isInternalName(message.name)) return message
|
||||
const member = members.get(String(message.senderId || message.name || ''))
|
||||
return member?.name
|
||||
? { ...message, name: member.name, img: message.img || member.avatar }
|
||||
: message
|
||||
})
|
||||
}
|
||||
|
||||
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
|
||||
const ai = await aiProvider.chat([
|
||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: input.prompt }
|
||||
])
|
||||
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
|
||||
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
|
||||
parseGroupDailyReport(
|
||||
raw,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard,
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
let report: ReturnType<typeof parseGroupDailyReport>
|
||||
try {
|
||||
report = parseReport(ai.data)
|
||||
} catch (parseError) {
|
||||
const repaired = await aiProvider.chat([
|
||||
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: ai.data }
|
||||
])
|
||||
if (!repaired.success || !repaired.data) {
|
||||
const cause = parseError instanceof Error ? parseError.message : String(parseError)
|
||||
return {
|
||||
success: false,
|
||||
error: `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause})`
|
||||
}
|
||||
}
|
||||
try {
|
||||
report = parseReport(repaired.data)
|
||||
} catch (repairError) {
|
||||
return {
|
||||
success: false,
|
||||
error: repairError instanceof Error ? repairError.message : String(repairError)
|
||||
}
|
||||
}
|
||||
}
|
||||
const exported = await exportGroupReport({ report, metadata: input.metadata })
|
||||
if (!exported.success || !exported.pngPath) {
|
||||
return { success: false, error: exported.error || '总结图片生成失败' }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
groupName: input.metadata.groupName,
|
||||
pngPath: exported.pngPath,
|
||||
messageCount: messages.length
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type {
|
||||
AIChatRequestOptions,
|
||||
AIConnectionTestResult,
|
||||
AIProviderConfig,
|
||||
AIProviderListResult,
|
||||
AIProviderSummary,
|
||||
AIRuntimeModelConfig,
|
||||
AIVisionTestRequest,
|
||||
AIVisionTestResult,
|
||||
LegacyAIConfig
|
||||
} from '../../shared/ai-provider'
|
||||
import { AIProviderKeyStore } from '../ai-provider-key-store'
|
||||
|
||||
interface AIProviderMetadataFile {
|
||||
version: 1
|
||||
defaultProviderId?: string
|
||||
providers: Array<Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'>>
|
||||
}
|
||||
|
||||
type AIMessagePart = { type: 'text'; text: string } | { type: 'image'; dataUrl: string }
|
||||
type AIMessage = { role: string; content: string | AIMessagePart[] }
|
||||
type AIRequestResult = {
|
||||
data: string
|
||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||
}
|
||||
interface OpenAIResponsePayload {
|
||||
error?: { message?: string }
|
||||
choices?: Array<{ message?: { content?: string } }>
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
||||
}
|
||||
interface AnthropicResponsePayload {
|
||||
error?: { message?: string }
|
||||
content?: Array<{ type?: string; text?: string }>
|
||||
usage?: { input_tokens?: number; output_tokens?: number }
|
||||
}
|
||||
|
||||
export class AIProviderService {
|
||||
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
|
||||
|
||||
list(): AIProviderListResult {
|
||||
try {
|
||||
const data = this.readMetadata()
|
||||
return {
|
||||
success: true,
|
||||
defaultProviderId: data.defaultProviderId,
|
||||
providers: data.providers.map((provider) =>
|
||||
this.toSummary(provider, data.defaultProviderId)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return { success: false, providers: [], error: 'AI Provider 配置无法读取' }
|
||||
}
|
||||
}
|
||||
|
||||
getRuntimeConfig(): AIRuntimeModelConfig {
|
||||
const result = this.list()
|
||||
const provider =
|
||||
result.providers.find((item) => item.id === result.defaultProviderId) || result.providers[0]
|
||||
const model = provider?.models.find((item) => item.id === provider.defaultModel)
|
||||
return {
|
||||
providerId: provider?.id,
|
||||
providerName: provider?.name || '尚未配置',
|
||||
model: provider?.defaultModel || '',
|
||||
modelName: model?.name || provider?.defaultModel || '尚未选择模型',
|
||||
configured: Boolean(
|
||||
provider && provider.models.length && (provider.hasApiKey || !needsApiKey(provider))
|
||||
),
|
||||
status: provider?.status || 'untested',
|
||||
timeoutMs: provider?.advanced.timeoutMs
|
||||
}
|
||||
}
|
||||
|
||||
save(input: AIProviderConfig): AIProviderListResult {
|
||||
const validationError = validateProvider(input)
|
||||
if (validationError) return { success: false, providers: [], error: validationError }
|
||||
const data = this.readMetadata()
|
||||
const existing = data.providers.find((provider) => provider.id === input.id)
|
||||
if (input.apiKey?.trim()) {
|
||||
const saved = this.keyStore.save(input.id, input.apiKey.trim())
|
||||
if (!saved.success) return { success: false, providers: [], error: saved.error }
|
||||
} else if (needsApiKey(input) && !this.keyStore.get(input.id).key) {
|
||||
return { success: false, providers: [], error: '请填写 API Key' }
|
||||
}
|
||||
|
||||
const metadata: Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'> = {
|
||||
id: input.id,
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
baseUrl: input.baseUrl.trim().replace(/\/+$/, ''),
|
||||
auth: input.auth,
|
||||
models: input.models,
|
||||
defaultModel: input.defaultModel,
|
||||
advanced: input.advanced,
|
||||
status: existing?.status || 'untested',
|
||||
lastTestedAt: existing?.lastTestedAt,
|
||||
lastError: existing?.lastError
|
||||
}
|
||||
const index = data.providers.findIndex((provider) => provider.id === input.id)
|
||||
if (index >= 0) data.providers[index] = metadata
|
||||
else data.providers.push(metadata)
|
||||
if (!data.defaultProviderId) data.defaultProviderId = input.id
|
||||
this.writeMetadata(data)
|
||||
return this.list()
|
||||
}
|
||||
|
||||
delete(providerId: string): AIProviderListResult {
|
||||
const data = this.readMetadata()
|
||||
data.providers = data.providers.filter((provider) => provider.id !== providerId)
|
||||
if (data.defaultProviderId === providerId) data.defaultProviderId = data.providers[0]?.id
|
||||
const cleared = this.keyStore.clear(providerId)
|
||||
if (!cleared.success) return { success: false, providers: [], error: cleared.error }
|
||||
this.writeMetadata(data)
|
||||
return this.list()
|
||||
}
|
||||
|
||||
setDefault(providerId: string): AIProviderListResult {
|
||||
const data = this.readMetadata()
|
||||
if (!data.providers.some((provider) => provider.id === providerId)) {
|
||||
return { success: false, providers: [], error: '供应商不存在' }
|
||||
}
|
||||
data.defaultProviderId = providerId
|
||||
this.writeMetadata(data)
|
||||
return this.list()
|
||||
}
|
||||
|
||||
migrateLegacy(config: LegacyAIConfig): AIProviderListResult {
|
||||
const data = this.readMetadata()
|
||||
if (data.providers.length) return this.list()
|
||||
const provider = deepSeekProvider(config.baseUrl, config.model)
|
||||
if (config.apiKey?.trim()) {
|
||||
const saved = this.keyStore.save(provider.id, config.apiKey.trim())
|
||||
if (!saved.success) return { success: false, providers: [], error: saved.error }
|
||||
}
|
||||
data.providers = [stripRuntimeFields(provider)]
|
||||
data.defaultProviderId = provider.id
|
||||
this.writeMetadata(data)
|
||||
return this.list()
|
||||
}
|
||||
|
||||
async test(providerId: string): Promise<AIConnectionTestResult> {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
await this.request([{ role: 'user', content: 'Reply with OK.' }], { providerId }, true)
|
||||
this.updateTestStatus(providerId, 'connected')
|
||||
return { success: true, latencyMs: Date.now() - startedAt }
|
||||
} catch (error) {
|
||||
const message = safeAIError(error)
|
||||
this.updateTestStatus(providerId, 'error', message)
|
||||
return { success: false, error: message, latencyMs: Date.now() - startedAt }
|
||||
}
|
||||
}
|
||||
|
||||
async chat(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options?: AIChatRequestOptions
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
return { success: true, ...(await this.request(messages, options)) }
|
||||
} catch (error) {
|
||||
return { success: false, error: safeAIError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 多模态图片理解。
|
||||
* 输入:text + image parts 的 messages,返回 AI 文本响应。
|
||||
* 与 testVision 区别:不校验 prompt,不写入 capability marker(供 ImageInsightService 复用)。
|
||||
*/
|
||||
async analyzeImage(
|
||||
messages: Array<{
|
||||
role: string
|
||||
content: string | Array<{ type: 'text'; text: string } | { type: 'image'; dataUrl: string }>
|
||||
}>,
|
||||
options?: AIChatRequestOptions
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const imagePart = messages
|
||||
.flatMap((message) => (typeof message.content === 'string' ? [] : message.content))
|
||||
.find((part) => part.type === 'image')
|
||||
if (!imagePart || imagePart.type !== 'image') throw new Error('图片识别请求缺少图片数据')
|
||||
const imageError = validateVisionImage(imagePart.dataUrl)
|
||||
if (imageError) throw new Error(imageError)
|
||||
const result = await this.request(messages as AIMessage[], options)
|
||||
if (options?.providerId && options.modelId) {
|
||||
this.markCapabilities(options.providerId, options.modelId, { vision: true, ocr: true })
|
||||
}
|
||||
return { success: true, ...result }
|
||||
} catch (error) {
|
||||
return { success: false, error: safeAIError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async testVision(request: AIVisionTestRequest): Promise<AIVisionTestResult> {
|
||||
const startedAt = Date.now()
|
||||
const imageError = validateVisionImage(request.imageDataUrl)
|
||||
if (imageError) return { success: false, code: 'INVALID_IMAGE', error: imageError }
|
||||
if (!request.prompt.trim()) {
|
||||
return { success: false, code: 'INVALID_IMAGE', error: '请填写图片识别提示词' }
|
||||
}
|
||||
try {
|
||||
const resolved = this.resolveProvider(request)
|
||||
const result = await requestProvider(resolved.provider, resolved.key, resolved.model, [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: request.prompt.trim() },
|
||||
{ type: 'image', dataUrl: request.imageDataUrl }
|
||||
]
|
||||
}
|
||||
])
|
||||
if (!result.data.trim()) throw new Error('API 未返回识别内容')
|
||||
this.markVisionCapability(resolved.provider.id, resolved.model)
|
||||
const model = resolved.provider.models.find((item) => item.id === resolved.model)
|
||||
return {
|
||||
success: true,
|
||||
providerName: resolved.provider.name,
|
||||
modelId: resolved.model,
|
||||
modelName: model?.name || resolved.model,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
usage: result.usage,
|
||||
answer: result.data
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = visionFailure(error)
|
||||
return { success: false, ...failure, latencyMs: Date.now() - startedAt }
|
||||
}
|
||||
}
|
||||
|
||||
private async request(
|
||||
messages: AIMessage[],
|
||||
options?: AIChatRequestOptions,
|
||||
testing = false
|
||||
): Promise<{
|
||||
data: string
|
||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||
}> {
|
||||
if (options?.apiKey) return this.requestLegacy(messages, options)
|
||||
const resolved = this.resolveProvider(options)
|
||||
const provider = options?.timeoutMs
|
||||
? {
|
||||
...resolved.provider,
|
||||
advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs }
|
||||
}
|
||||
: resolved.provider
|
||||
return requestProvider(provider, resolved.key, resolved.model, messages, testing)
|
||||
}
|
||||
|
||||
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
|
||||
provider: AIProviderSummary
|
||||
model: string
|
||||
key: string
|
||||
} {
|
||||
const list = this.list()
|
||||
const provider =
|
||||
list.providers.find((item) => item.id === options?.providerId) ||
|
||||
list.providers.find((item) => item.id === list.defaultProviderId)
|
||||
if (!provider) throw new Error('尚未配置 AI Provider')
|
||||
const model = options?.modelId || provider.defaultModel
|
||||
if (!provider.models.some((item) => item.id === model)) throw new Error('当前模型不存在')
|
||||
const key = this.keyStore.get(provider.id).key || ''
|
||||
if (needsApiKey(provider) && !key) throw new Error('当前供应商尚未配置 API Key')
|
||||
return { provider, model, key }
|
||||
}
|
||||
|
||||
private async requestLegacy(
|
||||
messages: AIMessage[],
|
||||
options: AIChatRequestOptions
|
||||
): Promise<AIRequestResult> {
|
||||
const provider = deepSeekProvider(options.baseURL, options.model)
|
||||
return requestOpenAICompatible(
|
||||
provider,
|
||||
options.apiKey || '',
|
||||
options.model || provider.defaultModel,
|
||||
messages
|
||||
)
|
||||
}
|
||||
|
||||
private updateTestStatus(
|
||||
providerId: string,
|
||||
status: 'connected' | 'error',
|
||||
lastError?: string
|
||||
): void {
|
||||
const data = this.readMetadata()
|
||||
const provider = data.providers.find((item) => item.id === providerId)
|
||||
if (!provider) return
|
||||
provider.status = status
|
||||
provider.lastTestedAt = Date.now()
|
||||
provider.lastError = lastError
|
||||
this.writeMetadata(data)
|
||||
}
|
||||
|
||||
private markVisionCapability(providerId: string, modelId: string): void {
|
||||
this.markCapabilities(providerId, modelId, { vision: true, ocr: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记模型已验证的 capabilities(已存在则跳过)。
|
||||
* OCR 跟随 vision:几乎所有 vision 模型都能 OCR,标记 vision 时同步标记 ocr。
|
||||
*/
|
||||
private markCapabilities(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
caps: { vision?: boolean; ocr?: boolean }
|
||||
): void {
|
||||
const data = this.readMetadata()
|
||||
const provider = data.providers.find((item) => item.id === providerId)
|
||||
const model = provider?.models.find((item) => item.id === modelId)
|
||||
if (!provider || !model) return
|
||||
// 老配置可能没有 ocr 字段,补默认 false
|
||||
if (typeof model.capabilities.ocr !== 'boolean') model.capabilities.ocr = false
|
||||
let changed = false
|
||||
if (caps.vision === true && !model.capabilities.vision) {
|
||||
model.capabilities.vision = true
|
||||
// vision 开启默认带 ocr(派生能力)
|
||||
if (!model.capabilities.ocr) {
|
||||
model.capabilities.ocr = true
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if (caps.ocr === true && !model.capabilities.ocr) {
|
||||
model.capabilities.ocr = true
|
||||
changed = true
|
||||
}
|
||||
if (changed) this.writeMetadata(data)
|
||||
}
|
||||
|
||||
private toSummary(
|
||||
provider: Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'>,
|
||||
defaultProviderId?: string
|
||||
): AIProviderSummary {
|
||||
return {
|
||||
...provider,
|
||||
hasApiKey: Boolean(this.keyStore.get(provider.id).key),
|
||||
isDefault: provider.id === defaultProviderId
|
||||
}
|
||||
}
|
||||
|
||||
private readMetadata(): AIProviderMetadataFile {
|
||||
const filePath = this.metadataPath
|
||||
if (!fs.existsSync(filePath)) return { version: 1, providers: [] }
|
||||
const data = fs.readJsonSync(filePath) as AIProviderMetadataFile
|
||||
if (data.version !== 1 || !Array.isArray(data.providers))
|
||||
throw new Error('invalid provider metadata')
|
||||
// 老配置兼容:补 capabilities.ocr 默认值(vision 派生 OCR)
|
||||
for (const provider of data.providers) {
|
||||
for (const model of provider.models) {
|
||||
if (typeof model.capabilities.ocr !== 'boolean') {
|
||||
model.capabilities.ocr = model.capabilities.vision === true
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private writeMetadata(data: AIProviderMetadataFile): void {
|
||||
fs.ensureDirSync(path.dirname(this.metadataPath))
|
||||
fs.writeJsonSync(this.metadataPath, data, { spaces: 2 })
|
||||
}
|
||||
|
||||
private get metadataPath(): string {
|
||||
return path.join(app.getPath('userData'), 'ai-providers.json')
|
||||
}
|
||||
}
|
||||
|
||||
function deepSeekProvider(baseUrl?: string, model?: string): AIProviderSummary {
|
||||
const modelId = model?.trim() || 'deepseek-chat'
|
||||
return {
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
type: 'openai-compatible',
|
||||
baseUrl: baseUrl?.trim() || 'https://api.deepseek.com',
|
||||
auth: { type: 'bearer' },
|
||||
models: [
|
||||
{
|
||||
name: modelId === 'deepseek-chat' ? 'DeepSeek Chat' : modelId,
|
||||
id: modelId,
|
||||
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
|
||||
}
|
||||
],
|
||||
defaultModel: modelId,
|
||||
advanced: { timeoutMs: 120_000, temperature: 0.7, maxTokens: 4096, extraHeaders: {} },
|
||||
hasApiKey: false,
|
||||
isDefault: true,
|
||||
status: 'untested'
|
||||
}
|
||||
}
|
||||
|
||||
function stripRuntimeFields(
|
||||
provider: AIProviderSummary
|
||||
): Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'> {
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
type: provider.type,
|
||||
baseUrl: provider.baseUrl,
|
||||
auth: provider.auth,
|
||||
models: provider.models,
|
||||
defaultModel: provider.defaultModel,
|
||||
advanced: provider.advanced,
|
||||
status: provider.status,
|
||||
lastTestedAt: provider.lastTestedAt,
|
||||
lastError: provider.lastError
|
||||
}
|
||||
}
|
||||
|
||||
function needsApiKey(provider: Pick<AIProviderConfig, 'type' | 'auth'>): boolean {
|
||||
return provider.type !== 'ollama' && provider.auth.type !== 'none'
|
||||
}
|
||||
|
||||
function validateProvider(provider: AIProviderConfig): string | undefined {
|
||||
if (!provider.id.trim() || !/^[a-z0-9][a-z0-9-_]*$/i.test(provider.id))
|
||||
return '供应商 ID 格式不正确'
|
||||
if (!provider.name.trim()) return '供应商名称不能为空'
|
||||
if (!provider.baseUrl.trim()) return 'Base URL 不能为空'
|
||||
if (!provider.models.length) return '请至少添加一个模型'
|
||||
if (provider.models.some((model) => !model.name.trim() || !model.id.trim()))
|
||||
return '模型名称和 ID 不能为空'
|
||||
if (!provider.models.some((model) => model.id === provider.defaultModel))
|
||||
return '默认模型不在模型列表中'
|
||||
if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim())
|
||||
return '请填写自定义认证字段'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildHeaders(provider: AIProviderSummary, apiKey: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
...provider.advanced.extraHeaders
|
||||
}
|
||||
if (!apiKey || provider.auth.type === 'none') return headers
|
||||
if (provider.auth.type === 'bearer') headers.authorization = `Bearer ${apiKey}`
|
||||
else if (provider.auth.type === 'x-api-key') headers['x-api-key'] = apiKey
|
||||
else headers[provider.auth.headerName || 'authorization'] = apiKey
|
||||
return headers
|
||||
}
|
||||
|
||||
function requestProvider(
|
||||
provider: AIProviderSummary,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: AIMessage[],
|
||||
testing = false
|
||||
): Promise<AIRequestResult> {
|
||||
return provider.type === 'anthropic-messages'
|
||||
? requestAnthropic(provider, apiKey, model, messages, testing)
|
||||
: requestOpenAICompatible(provider, apiKey, model, messages, testing)
|
||||
}
|
||||
|
||||
function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
|
||||
return messages.map((message) => ({
|
||||
role: message.role,
|
||||
content:
|
||||
typeof message.content === 'string'
|
||||
? message.content
|
||||
: message.content.map((part) =>
|
||||
part.type === 'text'
|
||||
? { type: 'text', text: part.text }
|
||||
: { type: 'image_url', image_url: { url: part.dataUrl } }
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
|
||||
return messages
|
||||
.filter((message) => message.role !== 'system')
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content:
|
||||
typeof message.content === 'string'
|
||||
? message.content
|
||||
: message.content.map((part) => {
|
||||
if (part.type === 'text') return { type: 'text', text: part.text }
|
||||
const image = parseVisionImage(part.dataUrl)
|
||||
return {
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: image.mimeType, data: image.base64 }
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
async function requestOpenAICompatible(
|
||||
provider: AIProviderSummary,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: AIMessage[],
|
||||
testing = false
|
||||
): Promise<AIRequestResult> {
|
||||
const endpoint = provider.baseUrl.endsWith('/chat/completions')
|
||||
? provider.baseUrl
|
||||
: `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions`
|
||||
const response = await fetchWithTimeout(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildHeaders(provider, apiKey),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: toOpenAIMessages(messages),
|
||||
temperature: provider.advanced.temperature,
|
||||
max_tokens: testing ? 8 : provider.advanced.maxTokens
|
||||
})
|
||||
},
|
||||
provider.advanced.timeoutMs
|
||||
)
|
||||
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
||||
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
||||
return {
|
||||
data: String(payload.choices?.[0]?.message?.content || ''),
|
||||
usage: payload.usage
|
||||
? {
|
||||
input: payload.usage.prompt_tokens,
|
||||
output: payload.usage.completion_tokens,
|
||||
total: payload.usage.total_tokens,
|
||||
estimated: false
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAnthropic(
|
||||
provider: AIProviderSummary,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: AIMessage[],
|
||||
testing = false
|
||||
): Promise<AIRequestResult> {
|
||||
const system = messages
|
||||
.filter((message) => message.role === 'system')
|
||||
.map((message) =>
|
||||
typeof message.content === 'string'
|
||||
? message.content
|
||||
: message.content
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => (part.type === 'text' ? part.text : ''))
|
||||
.join('\n')
|
||||
)
|
||||
.join('\n\n')
|
||||
const anthropicMessages = toAnthropicMessages(messages)
|
||||
const headers = buildHeaders(provider, apiKey)
|
||||
if (!headers['anthropic-version']) headers['anthropic-version'] = '2023-06-01'
|
||||
const endpoint = provider.baseUrl.endsWith('/messages')
|
||||
? provider.baseUrl
|
||||
: `${provider.baseUrl.replace(/\/+$/, '')}/messages`
|
||||
const response = await fetchWithTimeout(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
system: system || undefined,
|
||||
messages: anthropicMessages,
|
||||
temperature: provider.advanced.temperature,
|
||||
max_tokens: testing ? 8 : provider.advanced.maxTokens || 4096
|
||||
})
|
||||
},
|
||||
provider.advanced.timeoutMs
|
||||
)
|
||||
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
|
||||
if (!response.ok)
|
||||
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
|
||||
return {
|
||||
data: Array.isArray(payload.content)
|
||||
? payload.content
|
||||
.filter((item) => item.type === 'text')
|
||||
.map((item) => item.text || '')
|
||||
.join('\n')
|
||||
: '',
|
||||
usage: payload.usage
|
||||
? {
|
||||
input: payload.usage.input_tokens,
|
||||
output: payload.usage.output_tokens,
|
||||
total: Number(payload.usage.input_tokens || 0) + Number(payload.usage.output_tokens || 0),
|
||||
estimated: false
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs || 120_000))
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal })
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
||||
const body = await response.text()
|
||||
try {
|
||||
return JSON.parse(body) as T
|
||||
} catch {
|
||||
const looksLikeHtml = /^\s*(?:<!doctype\s+html|<html\b)/i.test(body)
|
||||
const status = `${response.status}${response.statusText ? ` ${response.statusText}` : ''}`
|
||||
if (looksLikeHtml) {
|
||||
throw new Error(`模型服务返回了网页而不是 JSON(HTTP ${status}),请稍后重试或检查中转服务`)
|
||||
}
|
||||
throw new Error(`模型服务返回格式异常(HTTP ${status})`)
|
||||
}
|
||||
}
|
||||
|
||||
function safeAIError(error: unknown): string {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return 'AI 请求超时'
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return message.replace(/sk-[a-z0-9_-]+/gi, '***').slice(0, 300)
|
||||
}
|
||||
|
||||
function parseVisionImage(dataUrl: string): { mimeType: string; base64: string; bytes: number } {
|
||||
const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/=]+)$/i.exec(dataUrl)
|
||||
if (!match) throw new Error('图片格式不受支持,请选择 PNG、JPG、JPEG 或 WebP')
|
||||
const bytes = Buffer.byteLength(match[2], 'base64')
|
||||
return { mimeType: match[1].toLowerCase(), base64: match[2], bytes }
|
||||
}
|
||||
|
||||
function validateVisionImage(dataUrl: string): string | undefined {
|
||||
try {
|
||||
const image = parseVisionImage(dataUrl)
|
||||
if (!image.bytes) return '图片内容为空'
|
||||
if (image.bytes > 10 * 1024 * 1024) return '图片不能超过 10 MB'
|
||||
return undefined
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : '图片无法读取'
|
||||
}
|
||||
}
|
||||
|
||||
function visionFailure(error: unknown): {
|
||||
code: 'VISION_UNSUPPORTED' | 'API_ERROR'
|
||||
error: string
|
||||
} {
|
||||
const message = safeAIError(error)
|
||||
const unsupported =
|
||||
/vision|multimodal|image[_ ]url|image input|image.*support|support.*image|图片.*不支持|不支持.*图片/i.test(
|
||||
message
|
||||
)
|
||||
return unsupported
|
||||
? { code: 'VISION_UNSUPPORTED', error: '当前模型不支持图片理解' }
|
||||
: { code: 'API_ERROR', error: message || 'API 返回错误' }
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { app } from 'electron'
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { Contact, Message } from '../../shared/types'
|
||||
|
||||
export interface CachedSelfInfo {
|
||||
wxid: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
export interface CachedGroupSnapshot {
|
||||
roomId: string
|
||||
memberCount: number
|
||||
members: {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}[]
|
||||
}
|
||||
|
||||
interface CachedMessageBucket {
|
||||
updatedAt: number
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
items: Message[]
|
||||
}
|
||||
|
||||
interface BootstrapCacheFile {
|
||||
version: 1
|
||||
platform: NodeJS.Platform
|
||||
accountRoot: string
|
||||
updatedAt: number
|
||||
self?: CachedSelfInfo
|
||||
contacts?: Contact[]
|
||||
messages?: Record<string, CachedMessageBucket>
|
||||
groupSnapshots?: Record<string, { updatedAt: number; snapshot: CachedGroupSnapshot }>
|
||||
}
|
||||
|
||||
const CACHE_VERSION = 1
|
||||
const MAX_MESSAGE_BUCKETS = 768
|
||||
const MAX_MESSAGES_PER_BUCKET = 120
|
||||
const WRITE_DEBOUNCE_MS = 300
|
||||
const memoryCache = new Map<string, BootstrapCacheFile>()
|
||||
const writeTimers = new Map<string, NodeJS.Timeout>()
|
||||
const writeQueues = new Map<string, Promise<void>>()
|
||||
|
||||
function normalizeRoot(accountRoot?: string): string {
|
||||
return String(accountRoot || '').trim()
|
||||
}
|
||||
|
||||
function getCacheFile(accountRoot?: string): string {
|
||||
const normalizedRoot = normalizeRoot(accountRoot) || 'default'
|
||||
const hash = crypto
|
||||
.createHash('sha1')
|
||||
.update(`${process.platform}:${normalizedRoot}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
return path.join(
|
||||
app.getPath('userData'),
|
||||
'cache',
|
||||
'bootstrap',
|
||||
`${process.platform}-${hash}.json`
|
||||
)
|
||||
}
|
||||
|
||||
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getCacheFile(normalizedRoot)
|
||||
const cached = memoryCache.get(file)
|
||||
if (cached) return cached
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
|
||||
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
|
||||
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
|
||||
const result: BootstrapCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
self: raw.self,
|
||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {},
|
||||
groupSnapshots:
|
||||
raw.groupSnapshots && typeof raw.groupSnapshots === 'object' ? raw.groupSnapshots : {}
|
||||
}
|
||||
memoryCache.set(file, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] read failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeCacheFile(cache: BootstrapCacheFile): void {
|
||||
const file = getCacheFile(cache.accountRoot)
|
||||
memoryCache.set(file, cache)
|
||||
const existingTimer = writeTimers.get(file)
|
||||
if (existingTimer) clearTimeout(existingTimer)
|
||||
writeTimers.set(
|
||||
file,
|
||||
setTimeout(() => {
|
||||
writeTimers.delete(file)
|
||||
const serialized = JSON.stringify(memoryCache.get(file) || cache)
|
||||
const previous = writeQueues.get(file) || Promise.resolve()
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await fs.ensureDir(path.dirname(file))
|
||||
await fs.writeFile(file, serialized, 'utf8')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[BootstrapCache] write failed:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (writeQueues.get(file) === next) writeQueues.delete(file)
|
||||
})
|
||||
writeQueues.set(file, next)
|
||||
}, WRITE_DEBOUNCE_MS)
|
||||
)
|
||||
}
|
||||
|
||||
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const existing = readCacheFile(normalizedRoot)
|
||||
if (existing) return existing
|
||||
const created: BootstrapCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Date.now(),
|
||||
contacts: [],
|
||||
messages: {},
|
||||
groupSnapshots: {}
|
||||
}
|
||||
memoryCache.set(getCacheFile(normalizedRoot), created)
|
||||
return created
|
||||
}
|
||||
|
||||
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
|
||||
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
|
||||
}
|
||||
|
||||
function cachedMessageIdentity(message: Message): string {
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
if (message.serverId) return `server:${message.serverId}`
|
||||
return `id:${message.id}`
|
||||
}
|
||||
|
||||
function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
|
||||
return items.some((message) => {
|
||||
const content = message.contentData
|
||||
if (content?.type === 'system' && content.raw) {
|
||||
return (
|
||||
/<weappinfo\b/i.test(content.raw) &&
|
||||
/<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
|
||||
)
|
||||
}
|
||||
if (
|
||||
content?.type === 'share' &&
|
||||
((content.typeVal === '3' && !content.url) || content.typeVal === '2001')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (content?.type !== 'sticker') return false
|
||||
const url = String(content.url || content.thumbUrl || '')
|
||||
return /wxapp\.tenpay\.com\/mmpayhb|mp\.weixin\.qq\.com\/mp\/waerrpage/i.test(url)
|
||||
})
|
||||
}
|
||||
|
||||
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
||||
const entries = Object.entries(messages)
|
||||
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
||||
entries
|
||||
.sort((left, right) => (right[1].updatedAt || 0) - (left[1].updatedAt || 0))
|
||||
.slice(MAX_MESSAGE_BUCKETS)
|
||||
.forEach(([key]) => {
|
||||
delete messages[key]
|
||||
})
|
||||
}
|
||||
|
||||
export function getBootstrapCache(accountRoot?: string): {
|
||||
self?: CachedSelfInfo
|
||||
contacts: Contact[]
|
||||
updatedAt: number
|
||||
} | null {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
if (!cache) return null
|
||||
return {
|
||||
self: cache.self,
|
||||
contacts: cache.contacts || [],
|
||||
updatedAt: cache.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
function isRawContactName(contact: Contact): boolean {
|
||||
const name = String(contact.m_nsNickName || '').trim()
|
||||
const username = String(contact.m_nsUsrName || '').trim()
|
||||
if (!name) return true
|
||||
if (name === username) return true
|
||||
if (name.endsWith('@chatroom')) return true
|
||||
if (name.startsWith('Group_') || name.startsWith('Unknown_')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
if (!cache?.contacts?.length) return contacts
|
||||
const avatarByUsername = new Map(
|
||||
cache.contacts
|
||||
.filter((contact) => contact.m_nsUsrName && contact.avatar)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.avatar as string])
|
||||
)
|
||||
const nameByUsername = new Map(
|
||||
cache.contacts
|
||||
.filter(
|
||||
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
|
||||
)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
||||
)
|
||||
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
|
||||
return contacts.map((contact) => ({
|
||||
...contact,
|
||||
avatar:
|
||||
contact.avatar || !avatarByUsername.has(contact.m_nsUsrName)
|
||||
? contact.avatar
|
||||
: avatarByUsername.get(contact.m_nsUsrName),
|
||||
m_nsNickName:
|
||||
!isRawContactName(contact) || !nameByUsername.has(contact.m_nsUsrName)
|
||||
? contact.m_nsNickName
|
||||
: nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName
|
||||
}))
|
||||
}
|
||||
|
||||
export function saveBootstrapSelf(accountRoot: string, self: CachedSelfInfo): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
cache.self = self
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
|
||||
export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
const avatarByUsername = new Map(
|
||||
(cache.contacts || [])
|
||||
.filter((contact) => contact.m_nsUsrName && contact.avatar)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.avatar as string])
|
||||
)
|
||||
const nameByUsername = new Map(
|
||||
(cache.contacts || [])
|
||||
.filter(
|
||||
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
|
||||
)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
||||
)
|
||||
cache.contacts = contacts.map((contact) => ({
|
||||
...contact,
|
||||
avatar:
|
||||
contact.avatar || !avatarByUsername.has(contact.m_nsUsrName)
|
||||
? contact.avatar
|
||||
: avatarByUsername.get(contact.m_nsUsrName),
|
||||
m_nsNickName:
|
||||
!isRawContactName(contact) || !nameByUsername.has(contact.m_nsUsrName)
|
||||
? contact.m_nsNickName
|
||||
: nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName
|
||||
}))
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
|
||||
export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<string, string>): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache || !cache.contacts?.length) return
|
||||
let changed = false
|
||||
cache.contacts = cache.contacts.map((contact) => {
|
||||
const avatar = avatars[contact.m_nsUsrName]
|
||||
if (!avatar || contact.avatar === avatar) return contact
|
||||
changed = true
|
||||
return { ...contact, avatar }
|
||||
})
|
||||
if (!changed) return
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
|
||||
export function getCachedMessages(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Message[] {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
const bucket = cache?.messages?.[messageBucketKey(userMd5, startTime, endTime)]
|
||||
return bucket?.items || []
|
||||
}
|
||||
|
||||
export function getCachedMessagePage(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
const key = messageBucketKey(userMd5, startTime, endTime)
|
||||
let bucket = cache?.messages?.[key]
|
||||
if (!bucket && cache?.messages && startTime === undefined && endTime === undefined) {
|
||||
const merged = new Map<string, Message>()
|
||||
for (const [cachedKey, candidate] of Object.entries(cache.messages)) {
|
||||
if (!cachedKey.startsWith(`${userMd5}:`)) continue
|
||||
for (const message of candidate.items || []) {
|
||||
merged.set(cachedMessageIdentity(message), message)
|
||||
}
|
||||
}
|
||||
const migratedMessages = Array.from(merged.values())
|
||||
.sort((left, right) => (left.createTime || 0) - (right.createTime || 0))
|
||||
.slice(-MAX_MESSAGES_PER_BUCKET)
|
||||
if (migratedMessages.length > 0) {
|
||||
bucket = {
|
||||
updatedAt: Date.now(),
|
||||
items: migratedMessages
|
||||
}
|
||||
cache.messages[key] = bucket
|
||||
cache.updatedAt = Date.now()
|
||||
pruneMessageBuckets(cache.messages)
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
}
|
||||
return {
|
||||
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(bucket?.items || []),
|
||||
messages: bucket?.items || [],
|
||||
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCachedGroupSnapshot(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
snapshot: CachedGroupSnapshot
|
||||
): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
cache.groupSnapshots ||= {}
|
||||
cache.groupSnapshots[userMd5] = { updatedAt: Date.now(), snapshot }
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
|
||||
export function flushBootstrapCacheWritesSync(): void {
|
||||
for (const [file, cache] of memoryCache) {
|
||||
const timer = writeTimers.get(file)
|
||||
if (timer) clearTimeout(timer)
|
||||
writeTimers.delete(file)
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(file))
|
||||
fs.writeFileSync(file, JSON.stringify(cache), 'utf8')
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] flush failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCachedMessages(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
startTime: number | undefined,
|
||||
endTime: number | undefined,
|
||||
messages: Message[]
|
||||
): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
const nextMessages = cache.messages || {}
|
||||
nextMessages[messageBucketKey(userMd5, startTime, endTime)] = {
|
||||
updatedAt: Date.now(),
|
||||
startTime,
|
||||
endTime,
|
||||
items: messages.slice(-MAX_MESSAGES_PER_BUCKET)
|
||||
}
|
||||
pruneMessageBuckets(nextMessages)
|
||||
cache.messages = nextMessages
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
}
|
||||