Compare commits

...
71 Commits
Author SHA1 Message Date
Wxw-Gu d0ae9e6019 perf: 优化图片消息后台加载与解密缓存
- 缩略图优先展示并在后台准备原图
- 增加图片请求去重与受控并发队列
- 缓存图片路径、账号目录和解密结果
2026-07-28 16:11:59 +08:00
Wxw-Gu 49684f3365 feat: 完善 AI 智能检索与定位
新增 AI 查询规划和主题变体多轮检索
修复无结果时回退全量消息导致的错误结论
优化目标成员优先级和大数据量消息匹配性能
新增检索诊断日志、任务中心和持久化缓存
支持证据按时间定位档案并闪烁提示
2026-07-28 15:52:31 +08:00
Wxw-Gu 33aaf4d558 feat: 修改缓存加载逻辑 2026-07-28 09:56:50 +08:00
电摇小子 5ccabb3898 feat: 完善微信小程序与红包消息解析
- 新增小程序和红包结构化消息及专用卡片
- 修复嵌套类型、拍一拍和表情包误判
- 补充常见 AppMsg 类型分类
2026-07-28 03:20:11 +08:00
电摇小子 7db845ac7e feat: 优化聊天记录缓存、虚拟分页与群聊媒体展示
- 使用持久化缓存加速启动并异步读取 WCDB 消息
- 修复空群缓存、头像和群成员名称丢失问题
- 修复引用图片缩略图、虚拟卸载缓存和图片预览
- 修正引用消息发送者显示为群 ID 的问题
2026-07-28 02:45:20 +08:00
电摇小子 9348c5ce4a feat: 修复导出任务状态并优化聊天记录导出体验 2026-07-27 22:06:08 +08:00
Wxw-Gu f626d89a1a feat: 提交 2026-07-27 20:34:57 +08:00
Wxw-Gu 95e517eca5 feat: 表情包优化 2026-07-27 17:55:11 +08:00
Wxw-Gu e93e4554d7 feat: 导出功能 2026-07-27 17:43:36 +08:00
Wxw-Gu 9ae0c6cc47 chore: 删除无用代码 2026-07-27 10:13:13 +08:00
Wxw-Gu 59e609b08a Merge branch 'develop' 2026-07-27 09:42:25 +08:00
Wxw-Gu 9de7dcde3b chore: 提升版本号 2026-07-27 09:41:49 +08:00
电摇小子 cb3c2855c0 fix(paths): 同步 imageKeyRoot 与 dbRoot,仅识别 xwechat_files
之前的状态面板(chat.getCurrentAccountRoot())和自动获取图片密钥(settings.imageKeyRoot)
走两个完全不同的来源,二者漂移导致状态显示 D 盘但实际扫描到 C 盘。

index.ts:
- key:autoGetImageKey 优先级调整为 chat.getCurrentAccountRoot() → self.accountRoot →
  settings.imageKeyRoot → settings.dbRoot,让运行时识别的目录永远最优先。
- db:init:保存 dbRoot 时同步更新 imageKeyRoot,避免 settings 缓存漂移。
- db:reopenWithRoot:用户手动切换根目录时同步更新 imageKeyRoot。

settings-store.ts:
- loadSettings:dbRoot 修正时同步 imageKeyRoot;若 imageKeyRoot 指向旧路径或不存在,
  回退到 dbRoot。
- 新增 redirectLegacyWeChatFilesToXwechat():当 settings 里的 dbRoot/imageKeyRoot 仍指向
  V3 时代的 "Documents\WeChat Files" 路径时,自动重定向到同目录下的 xwechat_files(如果存在)。
  老用户升级时无需手动迁移。

wcdb4-client / windows-db-root-discovery / key-service-win:
- 候选目录列表只保留 xwechat_files 形态(Documents\xwechat_files、
  AppData\Roaming\Tencent\xwechat_files、macOS 的 Library/Containers/.../xwechat_files),
  移除 V3 时代的 "WeChat Files" 候选。
- DB_ROOT_NAMES 只包含 xwechat_files,自动扫描也只认 V4 路径。

V3 数据(V1 头 dat / WeChat Files 目录结构)如果用户机器上还存在,仅作为残留,
不会被 WechatExplorer 当成有效数据源。
2026-07-26 20:46:51 +08:00
电摇小子 dca88e5db3 fix(image-decrypt): 仅支持 WeChat 4.0,移除 V3 兜底 + 模板诊断
WeChat 4.0 dat 文件头为 07 08 56 32 08 07。V3 / 老版本(V1 头)不在支持范围。

image-decrypt-service:
- 移除 defaultV1AesKey ('cfcd208495d565ef') 字段。
- 移除 if (version === 1) 的 V1 默认 key 分支。
- getDatVersion 只识别 V2 头;其他返回 0 走 unsupported。
- 注释说明 decryptDatV4 方法名里的 V4 是历史命名,跟协议版本无关。

key-service-win._findTemplateData / autoGetImageKeyByMemoryScan:
- 统计扫描诊断信息:搜索到的 _t.dat 数量、V2 头数量、非 V2 数量、扫描根目录。
- 把模糊的「未找到 V2 模板文件」拆成三种具体提示:
  1) 目录下完全没有 _t.dat → 让用户先在微信里点开图片大图(缩略图未生成)。
  2) 有 _t.dat 但都不是 V2 头 → 让用户查看更多图片。
  3) 有 V2 但没有有效长度 → 让用户查看更多图片。
- 每条都附带实际扫描根目录,便于远程排查时一眼看出路径是否正确。
2026-07-26 20:46:34 +08:00
电摇小子 1a9f27488b refactor(image-decrypt): 设置面板 UI 重构 + 测试步骤联动
页面拆分两个目录语义:
- 上方「图片解密状态」的「图片资源目录」始终等于 chat 实时识别的默认目录(chat.getCurrentAccountRoot()),
  不再被用户输入覆盖。
- 下方「图片密钥管理」移除「图片资源目录」输入框,只保留 XOR / AES 两个 Key。
  该字段之前会被保存但实际不影响图片解密(数据库查询走默认根目录),保留只会让用户困惑。

UI 改动:
- ImageKeyConfiguration:移除 resourceRoot 输入框,edit() 签名收窄为 xorKey | aesKey。
- ImageTestSection:测试结果由 3 列横排改为步骤条样式(pending / ok / fail / skipped),
  任意一步 ✗ 后续步骤自动标记「跳过」并灰掉,前端语义上变成「要么都成功要么都失败」。
- AutoDetectImageKeySection:加一行小字提示「仅支持 WeChat 4.0,V3 及以下无法解析」。

后端改动:
- testImageDecryption:解密失败时把 fileFound/decrypted/readable 统一置 false,
  拆出「解密成功但不可读」中间态,让 UI 步骤联动准确反映。
- inspectImageDecryptionStatus:accountRoot 兜底改为 getCurrentAccountRoot() || config.resourceRoot,
  让状态面板始终显示当前识别到的默认目录。
- sanitizeImageError:把 'no_image_message' / '300' / 'unsupported' / 'dat version' 关键字翻译成具体提示。
- useImageDecryptionController.autoDetect:错误信息原文透传,不再走 sanitizeImageError(后者会
  把扫描阶段的「未找到 V2 模板文件」/「60 秒未找到 AES 密钥」等归类成「无法解析媒体文件」,掩盖真因)。
- image-key-config-service.save():不再写回 imageKeyRoot,下方输入框只用于校验,不再落盘。
2026-07-26 20:46:20 +08:00
电摇小子 3b64e18b5e chore(security): 移除 AI 内置 Key fallback + 修正默认模型名
发布版本不再自动从环境变量读取 VITE_DEEPSEEK_API_KEY 创建默认 DeepSeek provider。
ensureEnvironmentMigration() 改为 no-op(保留方法作为占位)。
用户首次启动必须在「设置 → AI 模型」手动配置 API Key。

migrateLegacy() 保留:用于把用户自己之前存在 localStorage 的旧配置迁移到加密存储,
与内置 Key 是两回事。

附带修正 .env.example 默认模型名:deepseek-v4-flash 是已弃用/不存在的标识符,
DeepSeek 官方未发布此模型,统一改为 deepseek-chat。
2026-07-26 20:45:53 +08:00
Wxw-Gu 554dccdb21 Merge branch 'develop' 2026-07-24 16:36:11 +08:00
Wxw-Gu c674dcbc4a docs: 更新文档 2026-07-24 16:19:27 +08:00
Wxw-Gu 0f65b96da1 docs: 修改二维码 2026-07-24 14:46:17 +08:00
Wxw-Gu 8488e81bb5 feat: 支持微信视频消息解析与播放
支持视频 XML 解析、本地文件映射与拖动播放。

修复源码乱码注释并统一 UTF-8 编码。
2026-07-24 14:16:34 +08:00
Wxw-Gu 430a36333b docs: 修改Readme 2026-07-24 11:28:10 +08:00
Wxw-Gu 474250c6c7 feat: 添加微信消息防撤回功能 2026-07-24 11:27:44 +08:00
Wxw-Gu 794880d389 chore: 提升版本 2026-07-23 16:25:08 +08:00
Wxw-Gu d0ceeceb68 fix: 容错修复群聊日报 JSON, 图片样式问题 2026-07-23 16:20:45 +08:00
Wxw-Gu 62f729d281 chore: 添加应用诊断日志 2026-07-23 16:09:20 +08:00
Wxw-Gu 0d544275c0 chore: 删除图片 2026-07-17 14:46:11 +08:00
Wxw-Gu a91b4d7a56 docs: 添加交流二维码并升级版本 2026-07-17 14:44:50 +08:00
Wxw-Gu f9b567fba2 feat: 重构数据库登录与路径发现 2026-07-17 14:35:53 +08:00
Wxw-Gu 1f693aa3d7 perf: 优化聊天渲染与后台任务性能 2026-07-17 11:37:02 +08:00
Wxw-Gu 8e1e166856 merge: 合并日报名称与图片识别修复 2026-07-15 21:18:02 +08:00
Wxw-Gu 1275c512c4 fix: 修复日报成员名称与图片识别兼容性 2026-07-15 21:17:54 +08:00
Wxw-Gu 1abaa57a0c merge: 合并 AI 微信 Agent 能力 2026-07-15 20:50:04 +08:00
Wxw-Gu d08db46479 feat: 完善跨平台发布与生产密钥隔离 2026-07-15 20:49:47 +08:00
Wxw-Gu eaea8d8435 feat: 内置微信连接器并完善 Agent 查询能力 2026-07-15 20:27:05 +08:00
电摇小子andWxw-Gu 1e97953d67 feat: 集成 Agent Hub 微信机器人能力 2026-07-15 17:26:15 +08:00
Wxw-Gu 597667e005 merge: 合并群聊日报与图片理解功能 2026-07-15 16:32:35 +08:00
Wxw-Gu 515348b6d8 feat: 完善群聊日报模板与图片理解 2026-07-15 16:29:25 +08:00
Wxw-Gu e150605c91 merge: feat/report 集成进 feat/newReport (M1)
合并 feat/report 的日报功能 + main 的图片识别基建:
- 保留 main 分支的图片识别能力(ai-provider-service + key-store + settings/ai-model)
- 保留 feat/report 的日报功能(ChatWindow.tsx + group-report* + mobile_daily_report.html)
- index.ts/preload 同时保留两边的 IPC handler(api:chat / ai:testVision / report:export / db:getImage)

冲突解决:
- main/index.ts ai:chat:采用 main 版本(AIProviderService)
- group-report-service.ts values:采用 feat/report 版本(完整 hero/section 占位符)
- 整文件替换:ChatWindow.tsx / group-report.ts / mobile_daily_report.html
  (采用 feat/report 版本,与 group-report-facts.ts 协同)
2026-07-15 10:34:19 +08:00
电摇小子 556d70eab3 feat: 临时加一个windows 目录输入框 2026-07-14 14:33:40 +08:00
电摇小子 9bd2c4bb94 chore: 提升版本号 2026-07-14 11:10:42 +08:00
电摇小子 19128fccca docs: 修改README 2026-07-14 11:07:35 +08:00
电摇小子 eaf8e9b07d fix: 修复登录报错key问题 2026-07-14 11:07:35 +08:00
电摇小子 aebf56b3f7 feat: 统一应用品牌图标并修复 macOS 打包路径 2026-07-14 11:07:35 +08:00
电摇小子 ce4b00bcd9 实现 AI 图片理解能力测试 2026-07-14 11:07:35 +08:00
电摇小子 31df97237d 重构 AI 模型配置中心 2026-07-14 11:07:35 +08:00
电摇小子 7b54b611d3 实现图片解密设置页 2026-07-14 11:07:35 +08:00
电摇小子 f1ceef0e5e 实现数据库密钥设置页 2026-07-14 11:07:35 +08:00
电摇小子 ab24185670 精简账号与数据库设置页 2026-07-14 11:07:35 +08:00
电摇小子 842144eba0 fix: 修改登录报错, error改为warn 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 d37341ab52 feat: 设置页面新UI 未做完 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 5d041aa137 feat: 完成本地 API 中心与 Reader Skill 流程 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 519c10223d fix: 完善 AI 日报详情状态与解析容错 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 d28b579cb4 feat: 优化 AI 日报结果中心信息架构 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 c291c94bd8 feat: 实现 AI 日报历史资产化 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 16a9aed6bb 重构 UI-04 AI 群聊日报工作区 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 7880e874b2 完成 UI-02 和 UI-03 聊天档案界面 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 7e217f7034 增加自动登录环境开关 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 4f0b4884b6 完成 UI-01 应用外壳与设计令牌 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 d4f6b755e5 修复 Windows 消息游标回退查询 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 995a95e8dc 修复 Windows 会话名称和密钥显示图标 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 8bb9576f10 feat: 支持点击图片优先打开原图 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 a776830144 feat: 优化 Windows 缓存加载和图片显示 2026-07-14 11:07:35 +08:00
电摇小子and电摇小子 ad50939005 feat: 支持微信内置表情渲染 2026-07-14 10:20:45 +08:00
电摇小子and电摇小子 71e970c55c feat: 支持图片密钥配置与内存扫描 2026-07-14 10:20:23 +08:00
电摇小子and电摇小子 8d82fabf57 docs: 更新 Windows 支持说明与 SIP 教程 2026-07-14 10:20:00 +08:00
电摇小子and电摇小子 e8c12696b2 fix: 优化群聊展示与群日报生成 2026-07-14 10:19:40 +08:00
电摇小子and电摇小子 267730d59e feat: 支持 Windows WCDB 解密与打包 2026-07-14 10:15:36 +08:00
电摇小子 3dbc11c19d fix: gitignore 2026-07-14 10:12:05 +08:00
电摇小子 d079d45279 feat: 更换日报功能模板
- ChatWindow 模型下拉框新增 deepseek-v4-pro /
  deepseek-v4-flash
  - 主进程 model 兜底值改为 deepseek-v4-flash
  - 同步更新 .env.example 默认模型
  - 修复旧 localStorage 中 gpt-5.5 等不支持的模型导致的
  400 报错
2026-07-10 15:40:35 +08:00
电摇小子 b39cb362f7 Merge branch 'fix/xkey' 2026-07-10 10:38:39 +08:00
电摇小子 a0b163823d fix: 修复登录 2026-07-09 10:37:11 +08:00
256 changed files with 40823 additions and 2108 deletions
+8 -1
View File
@@ -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=
+10
View File
@@ -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
+63 -44
View File
@@ -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,6 +103,7 @@ WXE_TRAY=1 open /Applications/WechatExplorer.app
```
启用后:
- macOS dock 图标自动隐藏
- 菜单栏出现 WechatExplorer 图标(可点击重新打开主窗口、查看 API 状态)
- 主窗口关闭后 API 服务继续运行
@@ -112,7 +111,7 @@ WXE_TRAY=1 open /Applications/WechatExplorer.app
### API 端点一览
| 端点 | 说明 |
|------|------|
| ------------------------------------------------ | --------------------------------------- |
| `GET /api/v1/health` | 健康检查 |
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) |
| `GET /api/v1/contact?filter=xxx` | 联系人 / 群聊列表 |
@@ -123,19 +122,23 @@ WXE_TRAY=1 open /Applications/WechatExplorer.app
详细参数、返回结构、时间格式见 [`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>
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+47
View File
@@ -0,0 +1,47 @@
# macOS 关闭 SIP 教程
SIPSystem Integrity Protection,系统完整性保护)是 macOS 的系统安全机制。关闭 SIP 会降低系统安全性,只建议在确实需要读取或调试本地微信数据时临时关闭;操作完成后,建议重新开启。
## 准备
- 一台 Mac 电脑,Intel 芯片和 Apple Silicon 芯片均可。
- 需要进入 macOS 恢复模式。
- 请先保存正在编辑的文件,并预留一次重启时间。
## 关闭 SIP
### Intel Mac
1. 关机。
2. 按下开机键后,立刻按住 `Command + R`
3. 保持按住,直到进入 macOS 恢复模式。
### Apple Silicon MacM1/M2/M3/M4
1. 关机。
2. 长按开机键不放。
3. 直到出现启动选项界面后松开。
4. 选择“选项”,进入 macOS 恢复模式。
### 在恢复模式中执行命令
1. 进入恢复模式后,点击顶部菜单栏的 **Utilities(实用工具)**
2. 选择 **Terminal(终端)**
3. 在终端中输入:
```bash
csrutil disable
```
4. 按回车执行。
5. 看到关闭成功提示后,重启电脑。
## 重新开启 SIP
如果后续不再需要关闭 SIP,建议重新进入恢复模式,在终端中执行:
```bash
csrutil enable
```
然后重启电脑。
+22 -3
View File
@@ -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": "技术交流",
+121
View File
@@ -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 后,先检查页面中的“存储路径”是否正确。
![确认微信数据目录](./images/initial-setup.png)
Windows 当前不会扫描二级目录。如果没有正确识别微信数据,请进入“设置”,手动选择微信数据所在目录。
![Windows 自定义数据目录](./images/windows-data-path.png)
### 第二步:让微信停留在登录页面
如果微信已经登录,请先退出登录;然后重新打开微信,让它停留在未登录页面,暂时不要点击登录。
![微信未登录页面](./images/wechat-login-window.png)
### 第三步:开始获取密钥
返回 WechatExplorer,点击“自动获取密钥”。
- **Windows**:看到“Hook 注入成功”后,返回微信完成登录。
- **macOS**:系统会弹出授权提示,请输入当前 macOS 用户密码并完成授权,然后返回微信完成登录。
![macOS 授权页面](./images/macos-authorization.png)
> 点击“自动获取密钥”前,微信必须停留在登录页面。WechatExplorer 提示可以登录后,再回到微信完成登录。
### 第四步:完成连接
如果系统环境和微信版本符合要求,WechatExplorer 会自动填写数据库密钥并连接数据库。连接成功后即可查看、搜索和导出聊天记录,也可以配置 AI 服务生成群聊总结。
![密钥获取完成](./images/setup-complete.png)
## 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)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

+16 -1
View File
@@ -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
+13 -1
View File
@@ -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: {
+41 -12
View File
@@ -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"
+233 -151
View File
@@ -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'}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.
+444 -92
View File
@@ -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}}">
<div class="section-title">实用信息与资源</div>
{{RESOURCE_ITEMS}}
</section>
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息汇总</div>
<section class="section {{MESSAGES_EMPTY_CLASS}}">
<div class="section-title">重要消息</div>
{{IMPORTANT_MESSAGES}}
{{MESSAGES_MORE_NOTE}}
</section>
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
<div class="section-title">有趣对话或金句</div>
<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 qa {{QA_EMPTY_CLASS}}">
<div class="section-title">问题与解答</div>
{{QA_CARDS}}
</section>
<section class="section analytics">
<div class="section-title">群内数据可视化</div>
{{HEAT_BARS}}
<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>
<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>{{ACTIVITY_TIMELINE}}</p>
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
</div>
</div>
</section>
<section class="section cloud">
<div class="section-title">词云/关键词</div>
<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">
+592
View File
@@ -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>
+829
View File
@@ -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>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
View File
@@ -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
+65
View File
@@ -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}`)
}
+13
View File
@@ -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')
+48
View File
@@ -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()
+409
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
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')
+21
View File
@@ -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.
+25
View File
@@ -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.
+135
View File
@@ -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")
}
}
+8
View File
@@ -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
)
+4
View File
@@ -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=
+197
View File
@@ -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)
}
}
+218
View File
@@ -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))
}
+181
View File
@@ -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)
}
+219
View File
@@ -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"`
}
+200
View File
@@ -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
}
+232
View File
@@ -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: ![alt](url)
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
// ExtractImageURLs extracts image URLs from markdown text.
func ExtractImageURLs(text string) []string {
matches := reMarkdownImage.FindAllStringSubmatch(text, -1)
var urls []string
for _, m := range matches {
url := strings.TrimSpace(m[1])
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
urls = append(urls, url)
}
}
return urls
}
// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message.
func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error {
// Check if it's a local file
if _, err := os.Stat(mediaURL); err == nil {
return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken)
}
// Must be a valid HTTP URL to download
if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") {
return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL)
}
data, contentType, err := downloadFile(ctx, mediaURL)
if err != nil {
return fmt.Errorf("download %s: %w", mediaURL, err)
}
return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken)
}
// SendMediaFromPath reads a local file and sends it as a media message.
func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken)
}
func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error {
if fileName == "" {
fileName = "file"
}
cdnMediaType, itemType := classifyMedia(contentType, source)
log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID)
uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType)
if err != nil {
return fmt.Errorf("upload to CDN: %w", err)
}
media := &ilink.MediaInfo{
EncryptQueryParam: uploaded.DownloadParam,
AESKey: AESKeyToBase64(uploaded.AESKeyHex),
EncryptType: 1,
}
var item ilink.MessageItem
switch itemType {
case ilink.ItemTypeImage:
item = ilink.MessageItem{
Type: ilink.ItemTypeImage,
ImageItem: &ilink.ImageItem{
Media: media,
MidSize: uploaded.CipherSize,
},
}
case ilink.ItemTypeVideo:
item = ilink.MessageItem{
Type: ilink.ItemTypeVideo,
VideoItem: &ilink.VideoItem{
Media: media,
VideoSize: uploaded.CipherSize,
},
}
default:
item = ilink.MessageItem{
Type: ilink.ItemTypeFile,
FileItem: &ilink.FileItem{
Media: media,
FileName: fileName,
Len: fmt.Sprintf("%d", uploaded.FileSize),
},
}
}
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: NewClientID(),
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{item},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send media message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source)
return nil
}
func downloadFile(ctx context.Context, url string) ([]byte, string, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = inferContentType(url)
}
return data, contentType, nil
}
func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) {
ct := strings.ToLower(contentType)
if strings.HasPrefix(ct, "image/") || isImageExt(url) {
return ilink.CDNMediaTypeImage, ilink.ItemTypeImage
}
if strings.HasPrefix(ct, "video/") || isVideoExt(url) {
return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo
}
return ilink.CDNMediaTypeFile, ilink.ItemTypeFile
}
func isImageExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
return true
}
return false
}
func isVideoExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".mp4", ".mov", ".webm", ".mkv", ".avi":
return true
}
return false
}
func inferContentType(url string) string {
ext := filepath.Ext(stripQuery(url))
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
func filenameFromURL(rawURL string) string {
u := stripQuery(rawURL)
name := filepath.Base(u)
if name == "" || name == "." || name == "/" {
return "file"
}
return name
}
func stripQuery(rawURL string) string {
if i := strings.IndexByte(rawURL, '?'); i >= 0 {
return rawURL[:i]
}
return rawURL
}
@@ -0,0 +1,73 @@
package messaging
import "testing"
func TestExtractImageURLs(t *testing.T) {
text := "check ![img](https://example.com/a.png) and ![](https://example.com/b.jpg)"
urls := ExtractImageURLs(text)
if len(urls) != 2 {
t.Fatalf("expected 2 urls, got %d", len(urls))
}
if urls[0] != "https://example.com/a.png" {
t.Errorf("urls[0] = %q", urls[0])
}
if urls[1] != "https://example.com/b.jpg" {
t.Errorf("urls[1] = %q", urls[1])
}
}
func TestExtractImageURLs_NoImages(t *testing.T) {
urls := ExtractImageURLs("just plain text")
if len(urls) != 0 {
t.Errorf("expected 0 urls, got %d", len(urls))
}
}
func TestExtractImageURLs_RelativeURL(t *testing.T) {
text := "![img](./local.png)"
urls := ExtractImageURLs(text)
if len(urls) != 0 {
t.Errorf("expected 0 urls for relative path, got %d", len(urls))
}
}
func TestFilenameFromURL(t *testing.T) {
tests := []struct {
url string
want string
}{
{"https://example.com/photo.png", "photo.png"},
{"https://example.com/path/to/report.pdf", "report.pdf"},
{"https://example.com/file", "file"},
}
for _, tt := range tests {
got := filenameFromURL(tt.url)
if got != tt.want {
t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want)
}
}
}
func TestFilenameFromURL_WithQuery(t *testing.T) {
got := filenameFromURL("https://example.com/photo.png?token=abc")
if got != "photo.png" {
t.Errorf("got %q, want %q", got, "photo.png")
}
}
func TestStripQuery(t *testing.T) {
tests := []struct {
input string
want string
}{
{"https://example.com/a?b=c", "https://example.com/a"},
{"https://example.com/a", "https://example.com/a"},
{"https://example.com/?x=1&y=2", "https://example.com/"},
}
for _, tt := range tests {
got := stripQuery(tt.input)
if got != tt.want {
t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
@@ -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] + "..."
}
+79
View File
@@ -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 密钥保存失败' }
}
}
}
+83
View File
@@ -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()
+48 -23
View File
@@ -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
}
}
}
+101
View File
@@ -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()
+41
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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>`
}
+315
View File
@@ -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)
}
+328 -56
View File
@@ -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) {
+64 -4
View File
@@ -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()
}
+271 -58
View File
@@ -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 ||
+99
View File
@@ -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: '图片密钥保存失败' }
}
}
}
+791 -84
View File
File diff suppressed because it is too large Load Diff
+296 -12
View File
@@ -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
}
}
}
File diff suppressed because it is too large Load Diff
+234 -6
View File
@@ -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]
+37
View File
@@ -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)
}
+238
View File
@@ -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) }
}
}
+35
View File
@@ -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
}
}
File diff suppressed because it is too large Load Diff
+658
View File
@@ -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 返回错误' }
}
+392
View File
@@ -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)
}

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