mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-19 04:27:00 +08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
794880d389 | ||
|
|
d0ceeceb68 | ||
|
|
62f729d281 | ||
|
|
0d544275c0 | ||
|
|
a91b4d7a56 | ||
|
|
f9b567fba2 | ||
|
|
1f693aa3d7 | ||
|
|
8e1e166856 | ||
|
|
1275c512c4 | ||
|
|
1abaa57a0c | ||
|
|
d08db46479 | ||
|
|
eaea8d8435 | ||
|
|
1e97953d67 |
@@ -6,6 +6,7 @@ out
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
*.log*
|
*.log*
|
||||||
|
resources/connectors/wechat/
|
||||||
.omc
|
.omc
|
||||||
.codex/
|
.codex/
|
||||||
docs/design/
|
docs/design/
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
|||||||
|
|
||||||
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
||||||
|
|
||||||
> 当前版本:`v2.1.0`。macOS 支持相对稳定;Windows 已初步支持微信 4.0 数据库连接、自动获取密钥和聊天记录查看,仍在持续兼容不同微信版本与本地目录结构。
|
> 当前版本:`v2.1.2`。macOS 支持相对稳定;Windows 已初步支持微信 4.0 数据库连接、自动获取密钥和聊天记录查看,仍在持续兼容不同微信版本与本地目录结构。
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
@@ -197,3 +197,9 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
|||||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||||
- [WeFlow](https://github.com/hicccc77/WeFlow)
|
- [WeFlow](https://github.com/hicccc77/WeFlow)
|
||||||
- [chatlog](https://github.com/sjzar/chatlog)
|
- [chatlog](https://github.com/sjzar/chatlog)
|
||||||
|
|
||||||
|
## 📱 交流与反馈
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./public/二维码.jpg" alt="WechatExplorer 交流二维码" width="280" />
|
||||||
|
</p>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ extraMetadata:
|
|||||||
asarUnpack:
|
asarUnpack:
|
||||||
- resources/**
|
- resources/**
|
||||||
extraResources:
|
extraResources:
|
||||||
|
# Includes the optional WeChat connector binary for the target platform.
|
||||||
- from: resources
|
- from: resources
|
||||||
to: resources
|
to: resources
|
||||||
filter:
|
filter:
|
||||||
|
|||||||
+27
-8
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wechatexplorer",
|
"name": "wechatexplorer",
|
||||||
"version": "2.1.1",
|
"version": "2.1.4",
|
||||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"wechat",
|
"wechat",
|
||||||
@@ -25,23 +25,32 @@
|
|||||||
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
|
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
|
||||||
"cp:env": "node scripts/ensure-env.cjs",
|
"cp:env": "node scripts/ensure-env.cjs",
|
||||||
"prepare:env": "node scripts/ensure-env.cjs",
|
"prepare:env": "node scripts/ensure-env.cjs",
|
||||||
"predev": "node scripts/ensure-env.cjs",
|
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"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",
|
"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:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
|
||||||
"build:win": "npm run build && electron-builder --config electron-builder.yml --win",
|
"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": "electron-vite build && electron-builder --config electron-builder.yml --mac --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": "electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
"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:mac": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
|
"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"
|
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
"@koromix/koffi-win32-x64": "3.1.0",
|
||||||
|
"@tanstack/react-virtual": "^3.14.6",
|
||||||
"fs-extra": "^11.3.2",
|
"fs-extra": "^11.3.2",
|
||||||
"fzstd": "^0.1.1",
|
"fzstd": "^0.1.1",
|
||||||
|
"jsonrepair": "^3.15.0",
|
||||||
"koffi": "^3.1.0",
|
"koffi": "^3.1.0",
|
||||||
"openai": "^6.10.0",
|
"openai": "^6.10.0",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
@@ -71,6 +80,16 @@
|
|||||||
"vite": "^7.2.6"
|
"vite": "^7.2.6"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
"supportedArchitectures": {
|
||||||
|
"os": [
|
||||||
|
"current",
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"current",
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
},
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"electron",
|
"electron",
|
||||||
"esbuild"
|
"esbuild"
|
||||||
|
|||||||
Generated
+26
-4
@@ -10,7 +10,9 @@ specifiers:
|
|||||||
'@electron-toolkit/preload': ^3.0.2
|
'@electron-toolkit/preload': ^3.0.2
|
||||||
'@electron-toolkit/tsconfig': ^2.0.0
|
'@electron-toolkit/tsconfig': ^2.0.0
|
||||||
'@electron-toolkit/utils': ^4.0.0
|
'@electron-toolkit/utils': ^4.0.0
|
||||||
|
'@koromix/koffi-win32-x64': 3.1.0
|
||||||
'@rollup/rollup-darwin-arm64': ^4.62.2
|
'@rollup/rollup-darwin-arm64': ^4.62.2
|
||||||
|
'@tanstack/react-virtual': ^3.14.6
|
||||||
'@types/fs-extra': ^11.0.4
|
'@types/fs-extra': ^11.0.4
|
||||||
'@types/node': ^22.19.1
|
'@types/node': ^22.19.1
|
||||||
'@types/react': ^19.2.7
|
'@types/react': ^19.2.7
|
||||||
@@ -25,6 +27,7 @@ specifiers:
|
|||||||
eslint-plugin-react-refresh: ^0.4.24
|
eslint-plugin-react-refresh: ^0.4.24
|
||||||
fs-extra: ^11.3.2
|
fs-extra: ^11.3.2
|
||||||
fzstd: ^0.1.1
|
fzstd: ^0.1.1
|
||||||
|
jsonrepair: ^3.15.0
|
||||||
koffi: ^3.1.0
|
koffi: ^3.1.0
|
||||||
openai: ^6.10.0
|
openai: ^6.10.0
|
||||||
prettier: ^3.7.4
|
prettier: ^3.7.4
|
||||||
@@ -38,8 +41,11 @@ specifiers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
||||||
'@electron-toolkit/utils': 4.0.0_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
|
fs-extra: 11.3.2
|
||||||
fzstd: 0.1.1
|
fzstd: 0.1.1
|
||||||
|
jsonrepair: 3.15.0
|
||||||
koffi: 3.1.0
|
koffi: 3.1.0
|
||||||
openai: 6.10.0
|
openai: 6.10.0
|
||||||
silk-wasm: 3.7.1
|
silk-wasm: 3.7.1
|
||||||
@@ -887,7 +893,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
dev: false
|
dev: false
|
||||||
optional: true
|
|
||||||
|
|
||||||
/@malept/cross-spawn-promise/2.0.0:
|
/@malept/cross-spawn-promise/2.0.0:
|
||||||
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
|
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
|
||||||
@@ -1123,6 +1128,21 @@ packages:
|
|||||||
defer-to-connect: 2.0.1
|
defer-to-connect: 2.0.1
|
||||||
dev: true
|
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:
|
/@tootallnate/once/2.0.0:
|
||||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -3413,6 +3433,11 @@ packages:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
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:
|
/jsx-ast-utils/3.3.5:
|
||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
@@ -4036,7 +4061,6 @@ packages:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/react-is/16.13.1:
|
/react-is/16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
@@ -4050,7 +4074,6 @@ packages:
|
|||||||
/react/19.2.1:
|
/react/19.2.1:
|
||||||
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/read-binary-file-arch/1.0.6:
|
/read-binary-file-arch/1.0.6:
|
||||||
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
|
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
|
||||||
@@ -4233,7 +4256,6 @@ packages:
|
|||||||
|
|
||||||
/scheduler/0.27.0:
|
/scheduler/0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/semver/5.7.2:
|
/semver/5.7.2:
|
||||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||||
|
|||||||
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: 152 KiB |
@@ -10,6 +10,23 @@ function setPlistValue(plistPath, key, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exports.default = async function afterPack(context) {
|
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
|
if (context.electronPlatformName !== 'darwin') return
|
||||||
|
|
||||||
const productName = context.packager.appInfo.productFilename
|
const productName = context.packager.appInfo.productFilename
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(__dirname, '..')
|
||||||
|
const sourceDir = path.join(projectRoot, 'services', 'wechat-connector')
|
||||||
|
const outputRoot = path.join(projectRoot, 'resources', 'connectors', 'wechat')
|
||||||
|
|
||||||
|
function normalizePlatform(value) {
|
||||||
|
if (value === 'win32' || value === 'windows') return 'windows'
|
||||||
|
if (value === 'darwin' || value === 'macos') return 'darwin'
|
||||||
|
if (value === 'linux') return 'linux'
|
||||||
|
throw new Error(`Unsupported connector platform: ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeArch(value) {
|
||||||
|
if (value === 'x64' || value === 'amd64') return 'amd64'
|
||||||
|
if (value === 'arm64') return 'arm64'
|
||||||
|
throw new Error(`Unsupported connector architecture: ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectHostArch() {
|
||||||
|
if (process.platform !== 'darwin') return process.arch
|
||||||
|
try {
|
||||||
|
const arm64Supported = execFileSync('sysctl', ['-n', 'hw.optional.arm64'], {
|
||||||
|
encoding: 'utf8'
|
||||||
|
}).trim()
|
||||||
|
return arm64Supported === '1' ? 'arm64' : process.arch
|
||||||
|
} catch {
|
||||||
|
return process.arch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTargets() {
|
||||||
|
const platformArg = process.argv.indexOf('--platform')
|
||||||
|
const archArg = process.argv.indexOf('--arch')
|
||||||
|
const platforms = platformArg >= 0 ? process.argv[platformArg + 1].split(',') : [process.platform]
|
||||||
|
const arches = archArg >= 0 ? process.argv[archArg + 1].split(',') : [detectHostArch()]
|
||||||
|
return platforms.flatMap((platform) =>
|
||||||
|
arches.map((arch) => ({ goos: normalizePlatform(platform), goarch: normalizeArch(arch) }))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.join(sourceDir, 'go.mod'))) {
|
||||||
|
throw new Error(`Repository-local WeChat connector source is missing: ${sourceDir}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const target of parseTargets()) {
|
||||||
|
const directoryName = `${target.goos === 'windows' ? 'win32' : target.goos}-${target.goarch === 'amd64' ? 'x64' : target.goarch}`
|
||||||
|
const outputDir = path.join(outputRoot, directoryName)
|
||||||
|
const outputPath = path.join(
|
||||||
|
outputDir,
|
||||||
|
target.goos === 'windows' ? 'wechat-connector.exe' : 'wechat-connector'
|
||||||
|
)
|
||||||
|
fs.rmSync(outputDir, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true })
|
||||||
|
execFileSync('go', ['build', '-trimpath', '-o', outputPath, '.'], {
|
||||||
|
cwd: sourceDir,
|
||||||
|
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' },
|
||||||
|
stdio: 'inherit'
|
||||||
|
})
|
||||||
|
if (target.goos !== 'windows') fs.chmodSync(outputPath, 0o755)
|
||||||
|
console.log(`[build-wechat-connector] built ${directoryName}: ${outputPath}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 fastclaw-ai
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# WechatExplorer WeChat Connector
|
||||||
|
|
||||||
|
This repository-local service provides the minimal WeChat bridge required by WechatExplorer:
|
||||||
|
|
||||||
|
- QR-code login with a single persisted credential
|
||||||
|
- account discovery
|
||||||
|
- inbound long polling and authenticated webhook delivery
|
||||||
|
- local HTTP health and send endpoints
|
||||||
|
- text and local/remote media sending
|
||||||
|
|
||||||
|
The executable is managed by the Electron main process. It is not a general-purpose agent runtime and does not load external AI command-line tools.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . login --json
|
||||||
|
go run . accounts --json
|
||||||
|
go run . start --foreground --api-addr 127.0.0.1:18011 --account-id <account-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). WechatExplorer-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding WechatExplorer project.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server provides an HTTP API for sending messages.
|
||||||
|
type Server struct {
|
||||||
|
clients []*ilink.Client
|
||||||
|
addr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates an API server.
|
||||||
|
func NewServer(clients []*ilink.Client, addr string) *Server {
|
||||||
|
if addr == "" {
|
||||||
|
addr = "127.0.0.1:18011"
|
||||||
|
}
|
||||||
|
return &Server{clients: clients, addr: addr}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRequest is the JSON body for POST /api/send.
|
||||||
|
type SendRequest struct {
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
MediaURL string `json:"media_url,omitempty"` // image/video/file URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the HTTP server. Blocks until ctx is cancelled.
|
||||||
|
func (s *Server) Run(ctx context.Context) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/send", s.handleSend)
|
||||||
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintln(w, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{Addr: s.addr, Handler: mux}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
srv.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("[api] listening on %s", s.addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req SendRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.To == "" {
|
||||||
|
http.Error(w, `"to" is required`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Text == "" && req.MediaURL == "" {
|
||||||
|
http.Error(w, `"text" or "media_url" is required`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.clients) == 0 {
|
||||||
|
http.Error(w, "no accounts configured", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := s.clientForAccount(req.AccountID)
|
||||||
|
if client == nil {
|
||||||
|
http.Error(w, "requested account is not available", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Send text if provided
|
||||||
|
if req.Text != "" {
|
||||||
|
if err := messaging.SendTextReply(ctx, client, req.To, req.Text, "", ""); err != nil {
|
||||||
|
log.Printf("[api] send text failed: %v", err)
|
||||||
|
http.Error(w, "send text failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[api] sent text to %s: %q", req.To, req.Text)
|
||||||
|
|
||||||
|
// Extract and send any markdown images embedded in text
|
||||||
|
for _, imgURL := range messaging.ExtractImageURLs(req.Text) {
|
||||||
|
if err := messaging.SendMediaFromURL(ctx, client, req.To, imgURL, ""); err != nil {
|
||||||
|
log.Printf("[api] send extracted image failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[api] sent extracted image to %s: %s", req.To, imgURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send media if provided
|
||||||
|
if req.MediaURL != "" {
|
||||||
|
if err := messaging.SendMediaFromURL(ctx, client, req.To, req.MediaURL, ""); err != nil {
|
||||||
|
log.Printf("[api] send media failed: %v", err)
|
||||||
|
http.Error(w, "send media failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[api] sent media to %s: %s", req.To, req.MediaURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) clientForAccount(accountID string) *ilink.Client {
|
||||||
|
if accountID == "" {
|
||||||
|
return s.clients[0]
|
||||||
|
}
|
||||||
|
for _, client := range s.clients {
|
||||||
|
if client.BotID() == accountID {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientForAccountSelectsMatchingBot(t *testing.T) {
|
||||||
|
oldClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-old"})
|
||||||
|
newClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-new"})
|
||||||
|
server := NewServer([]*ilink.Client{oldClient, newClient}, "")
|
||||||
|
|
||||||
|
if got := server.clientForAccount("bot-new"); got != newClient {
|
||||||
|
t.Fatal("clientForAccount did not select the requested account")
|
||||||
|
}
|
||||||
|
if got := server.clientForAccount("missing"); got != nil {
|
||||||
|
t.Fatal("clientForAccount should reject an unknown account")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module github.com/Wxw-Gu/WechatExplorer/services/wechat-connector
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
rsc.io/qr v0.2.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package ilink
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
qrCodeURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3"
|
||||||
|
qrStatusURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode="
|
||||||
|
statusWait = "wait"
|
||||||
|
statusScanned = "scaned"
|
||||||
|
statusConfirmed = "confirmed"
|
||||||
|
statusExpired = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchQRCode retrieves a new QR code for login.
|
||||||
|
func FetchQRCode(ctx context.Context) (*QRCodeResponse, error) {
|
||||||
|
c := NewUnauthenticatedClient()
|
||||||
|
var resp QRCodeResponse
|
||||||
|
if err := c.doGet(ctx, qrCodeURL, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch QR code: %w", err)
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollQRStatus polls for QR code scan status until confirmed or expired.
|
||||||
|
// It calls onStatus for each status change so the caller can display progress.
|
||||||
|
func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status string)) (*Credentials, error) {
|
||||||
|
c := NewUnauthenticatedClient()
|
||||||
|
url := qrStatusURL + qrcode
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
pollCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
|
||||||
|
var resp QRStatusResponse
|
||||||
|
err := c.doGet(pollCtx, url, &resp)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Timeout is normal for long-poll, retry
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if onStatus != nil {
|
||||||
|
onStatus(resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch resp.Status {
|
||||||
|
case statusConfirmed:
|
||||||
|
creds := &Credentials{
|
||||||
|
BotToken: resp.BotToken,
|
||||||
|
ILinkBotID: resp.ILinkBotID,
|
||||||
|
BaseURL: resp.BaseURL,
|
||||||
|
ILinkUserID: resp.ILinkUserID,
|
||||||
|
}
|
||||||
|
return creds, nil
|
||||||
|
case statusExpired:
|
||||||
|
return nil, fmt.Errorf("QR code expired")
|
||||||
|
case statusWait, statusScanned:
|
||||||
|
// Continue polling
|
||||||
|
default:
|
||||||
|
// Unknown status, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountsDir returns the directory where account credentials are stored.
|
||||||
|
func AccountsDir() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
|
||||||
|
func NormalizeAccountID(raw string) string {
|
||||||
|
s := raw
|
||||||
|
for _, ch := range []string{"@", ".", ":"} {
|
||||||
|
s = filepath.Clean(s)
|
||||||
|
s = replaceAll(s, ch, "-")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceAll(s, old, new string) string {
|
||||||
|
for {
|
||||||
|
i := indexOf(s, old)
|
||||||
|
if i < 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s = s[:i] + new + s[i+len(old):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := range s {
|
||||||
|
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCredentials saves the latest credentials and removes older accounts.
|
||||||
|
// The new credential is written first so a failed login never destroys the
|
||||||
|
// previously working credential.
|
||||||
|
func SaveCredentials(creds *Credentials) error {
|
||||||
|
dir, err := AccountsDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create accounts dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := NormalizeAccountID(creds.ILinkBotID)
|
||||||
|
path := filepath.Join(dir, id+".json")
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(creds, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prune old credentials: %w", err)
|
||||||
|
}
|
||||||
|
keepPrefix := id + "."
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || strings.HasPrefix(entry.Name(), keepPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filepath.Ext(entry.Name()) != ".json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove old credential %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAllCredentials loads all saved account credentials.
|
||||||
|
func LoadAllCredentials() ([]*Credentials, error) {
|
||||||
|
dir, err := AccountsDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read accounts dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []*Credentials
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var creds Credentials
|
||||||
|
if json.Unmarshal(data, &creds) == nil && creds.BotToken != "" {
|
||||||
|
result = append(result, &creds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialsPath returns the path for display purposes.
|
||||||
|
func CredentialsPath() (string, error) {
|
||||||
|
return AccountsDir()
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package ilink
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
|
||||||
|
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
|
||||||
|
if err := SaveCredentials(old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir, err := AccountsDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json"), []byte(`{}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SaveCredentials(latest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
accounts, err := LoadAllCredentials()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(accounts) != 1 || accounts[0].ILinkBotID != latest.ILinkBotID {
|
||||||
|
t.Fatalf("accounts = %#v", accounts)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("old sync state still exists: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package ilink
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBaseURL = "https://ilinkai.weixin.qq.com"
|
||||||
|
longPollTimeout = 35 * time.Second
|
||||||
|
sendTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is an iLink HTTP API client.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
botToken string
|
||||||
|
botID string
|
||||||
|
httpClient *http.Client
|
||||||
|
wechatUIN string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new iLink API client.
|
||||||
|
func NewClient(creds *Credentials) *Client {
|
||||||
|
baseURL := creds.BaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = defaultBaseURL
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
botToken: creds.BotToken,
|
||||||
|
botID: creds.ILinkBotID,
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
wechatUIN: generateWechatUIN(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUnauthenticatedClient creates a client without credentials for login flow.
|
||||||
|
func NewUnauthenticatedClient() *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: defaultBaseURL,
|
||||||
|
httpClient: &http.Client{Timeout: 40 * time.Second},
|
||||||
|
wechatUIN: generateWechatUIN(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BotID returns the bot's user ID.
|
||||||
|
func (c *Client) BotID() string {
|
||||||
|
return c.botID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUpdates performs a long-poll for new messages.
|
||||||
|
func (c *Client) GetUpdates(ctx context.Context, buf string) (*GetUpdatesResponse, error) {
|
||||||
|
reqBody := GetUpdatesRequest{
|
||||||
|
GetUpdatesBuf: buf,
|
||||||
|
BaseInfo: BaseInfo{ChannelVersion: "1.0.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, longPollTimeout+5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var resp GetUpdatesResponse
|
||||||
|
if err := c.doPost(ctx, "/ilink/bot/getupdates", reqBody, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage sends a message through iLink.
|
||||||
|
func (c *Client) SendMessage(ctx context.Context, msg *SendMessageRequest) (*SendMessageResponse, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var resp SendMessageResponse
|
||||||
|
if err := c.doPost(ctx, "/ilink/bot/sendmessage", msg, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig fetches bot config for a user (includes typing_ticket).
|
||||||
|
func (c *Client) GetConfig(ctx context.Context, userID, contextToken string) (*GetConfigResponse, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req := GetConfigRequest{
|
||||||
|
ILinkUserID: userID,
|
||||||
|
ContextToken: contextToken,
|
||||||
|
BaseInfo: BaseInfo{},
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp GetConfigResponse
|
||||||
|
if err := c.doPost(ctx, "/ilink/bot/getconfig", req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTyping sends a typing indicator to a user.
|
||||||
|
func (c *Client) SendTyping(ctx context.Context, userID, typingTicket string, status int) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req := SendTypingRequest{
|
||||||
|
ILinkUserID: userID,
|
||||||
|
TypingTicket: typingTicket,
|
||||||
|
Status: status,
|
||||||
|
BaseInfo: BaseInfo{},
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp SendTypingResponse
|
||||||
|
if err := c.doPost(ctx, "/ilink/bot/sendtyping", req, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Ret != 0 {
|
||||||
|
return fmt.Errorf("sendtyping failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUploadURL gets a pre-signed CDN upload URL for media files.
|
||||||
|
func (c *Client) GetUploadURL(ctx context.Context, req *GetUploadURLRequest) (*GetUploadURLResponse, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var resp GetUploadURLResponse
|
||||||
|
if err := c.doPost(ctx, "/ilink/bot/getuploadurl", req, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseURL returns the base URL for CDN operations.
|
||||||
|
func (c *Client) BaseURL() string {
|
||||||
|
return c.baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doPost(ctx context.Context, path string, body interface{}, result interface{}) error {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
c.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(respBody, result); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doGet(ctx context.Context, url string, result interface{}) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(respBody, result); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setHeaders(req *http.Request) {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.botToken)
|
||||||
|
req.Header.Set("X-WECHAT-UIN", c.wechatUIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateWechatUIN() string {
|
||||||
|
var n uint32
|
||||||
|
_ = binary.Read(rand.Reader, binary.LittleEndian, &n)
|
||||||
|
s := fmt.Sprintf("%d", n)
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package ilink
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxConsecutiveFailures = 5
|
||||||
|
initialBackoff = 3 * time.Second
|
||||||
|
maxBackoff = 60 * time.Second
|
||||||
|
sessionExpiredBackoff = 5 * time.Second
|
||||||
|
errCodeSessionExpired = -14
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessageHandler is called for each received message.
|
||||||
|
type MessageHandler func(ctx context.Context, client *Client, msg WeixinMessage)
|
||||||
|
|
||||||
|
// Monitor manages the long-poll loop for receiving messages.
|
||||||
|
type Monitor struct {
|
||||||
|
client *Client
|
||||||
|
handler MessageHandler
|
||||||
|
getUpdatesBuf string
|
||||||
|
bufPath string
|
||||||
|
failures int
|
||||||
|
lastActivity time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMonitor creates a new long-poll monitor.
|
||||||
|
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accountID := NormalizeAccountID(client.BotID())
|
||||||
|
bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", accountID+".sync.json")
|
||||||
|
|
||||||
|
m := &Monitor{
|
||||||
|
client: client,
|
||||||
|
handler: handler,
|
||||||
|
bufPath: bufPath,
|
||||||
|
lastActivity: time.Now(),
|
||||||
|
}
|
||||||
|
m.loadBuf()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the long-poll loop. It blocks until ctx is cancelled.
|
||||||
|
// Automatically recovers from errors with exponential backoff.
|
||||||
|
func (m *Monitor) Run(ctx context.Context) error {
|
||||||
|
log.Println("[monitor] starting long-poll loop")
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Println("[monitor] shutting down")
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := m.client.GetUpdates(ctx, m.getUpdatesBuf)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
m.failures++
|
||||||
|
backoff := m.calcBackoff()
|
||||||
|
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
|
||||||
|
m.failures, maxConsecutiveFailures, backoff, err)
|
||||||
|
if m.failures == maxConsecutiveFailures {
|
||||||
|
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from WechatExplorer if this persists.", maxConsecutiveFailures)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(backoff):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset failure counter on any successful response
|
||||||
|
m.failures = 0
|
||||||
|
m.lastActivity = time.Now()
|
||||||
|
|
||||||
|
// Session expired — reset sync buf and reconnect silently
|
||||||
|
if resp.ErrCode == errCodeSessionExpired {
|
||||||
|
if m.getUpdatesBuf != "" {
|
||||||
|
log.Printf("[monitor] session expired, resetting sync buf")
|
||||||
|
m.getUpdatesBuf = ""
|
||||||
|
m.saveBuf()
|
||||||
|
} else {
|
||||||
|
// Sync buf already empty but still getting session expired:
|
||||||
|
// the bot token itself has expired. The user needs to re-login.
|
||||||
|
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from WechatExplorer.")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(sessionExpiredBackoff):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other server errors
|
||||||
|
if resp.Ret != 0 && resp.ErrCode != 0 {
|
||||||
|
log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update buf for next poll
|
||||||
|
if resp.GetUpdatesBuf != "" {
|
||||||
|
m.getUpdatesBuf = resp.GetUpdatesBuf
|
||||||
|
m.saveBuf()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process messages concurrently — don't block the poll loop
|
||||||
|
for _, msg := range resp.Msgs {
|
||||||
|
go m.handler(ctx, m.client, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calcBackoff returns an exponential backoff duration capped at maxBackoff.
|
||||||
|
func (m *Monitor) calcBackoff() time.Duration {
|
||||||
|
d := initialBackoff
|
||||||
|
for i := 1; i < m.failures; i++ {
|
||||||
|
d *= 2
|
||||||
|
if d > maxBackoff {
|
||||||
|
return maxBackoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
type syncData struct {
|
||||||
|
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) loadBuf() {
|
||||||
|
data, err := os.ReadFile(m.bufPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var s syncData
|
||||||
|
if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" {
|
||||||
|
m.getUpdatesBuf = s.GetUpdatesBuf
|
||||||
|
log.Printf("[monitor] loaded sync buf from %s", m.bufPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) saveBuf() {
|
||||||
|
dir := filepath.Dir(m.bufPath)
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
log.Printf("[monitor] failed to create buf dir: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf})
|
||||||
|
if err := os.WriteFile(m.bufPath, data, 0o600); err != nil {
|
||||||
|
log.Printf("[monitor] failed to save buf: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatMessageSummary returns a short description of a message for logging.
|
||||||
|
func FormatMessageSummary(msg WeixinMessage) string {
|
||||||
|
text := ""
|
||||||
|
for _, item := range msg.ItemList {
|
||||||
|
if item.Type == ItemTypeText && item.TextItem != nil {
|
||||||
|
text = item.TextItem.Text
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(text) > 50 {
|
||||||
|
text = text[:50] + "..."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package ilink
|
||||||
|
|
||||||
|
// Message types
|
||||||
|
const (
|
||||||
|
MessageTypeNone = 0
|
||||||
|
MessageTypeUser = 1
|
||||||
|
MessageTypeBot = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message states
|
||||||
|
const (
|
||||||
|
MessageStateNew = 0
|
||||||
|
MessageStateGenerating = 1
|
||||||
|
MessageStateFinish = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Item types
|
||||||
|
const (
|
||||||
|
ItemTypeNone = 0
|
||||||
|
ItemTypeText = 1
|
||||||
|
ItemTypeImage = 2
|
||||||
|
ItemTypeVoice = 3
|
||||||
|
ItemTypeFile = 4
|
||||||
|
ItemTypeVideo = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// QRCodeResponse is the response from get_bot_qrcode.
|
||||||
|
type QRCodeResponse struct {
|
||||||
|
QRCode string `json:"qrcode"`
|
||||||
|
QRCodeImgContent string `json:"qrcode_img_content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QRStatusResponse is the response from get_qrcode_status.
|
||||||
|
type QRStatusResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
BotToken string `json:"bot_token"`
|
||||||
|
ILinkBotID string `json:"ilink_bot_id"`
|
||||||
|
BaseURL string `json:"baseurl"`
|
||||||
|
ILinkUserID string `json:"ilink_user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credentials stores login session data.
|
||||||
|
type Credentials struct {
|
||||||
|
BotToken string `json:"bot_token"`
|
||||||
|
ILinkBotID string `json:"ilink_bot_id"`
|
||||||
|
BaseURL string `json:"baseurl"`
|
||||||
|
ILinkUserID string `json:"ilink_user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseInfo is included in request bodies.
|
||||||
|
type BaseInfo struct {
|
||||||
|
ChannelVersion string `json:"channel_version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUpdatesRequest is the body for getupdates.
|
||||||
|
type GetUpdatesRequest struct {
|
||||||
|
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||||
|
BaseInfo BaseInfo `json:"base_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUpdatesResponse is the response from getupdates.
|
||||||
|
type GetUpdatesResponse struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrCode int `json:"errcode,omitempty"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
Msgs []WeixinMessage `json:"msgs"`
|
||||||
|
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||||
|
LongPollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeixinMessage represents a message from WeChat.
|
||||||
|
type WeixinMessage struct {
|
||||||
|
Seq int `json:"seq,omitempty"`
|
||||||
|
MessageID int64 `json:"message_id,omitempty"`
|
||||||
|
FromUserID string `json:"from_user_id"`
|
||||||
|
ToUserID string `json:"to_user_id"`
|
||||||
|
MessageType int `json:"message_type"`
|
||||||
|
MessageState int `json:"message_state"`
|
||||||
|
ItemList []MessageItem `json:"item_list"`
|
||||||
|
ContextToken string `json:"context_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageItem is a single item in a message.
|
||||||
|
type MessageItem struct {
|
||||||
|
Type int `json:"type"`
|
||||||
|
TextItem *TextItem `json:"text_item,omitempty"`
|
||||||
|
ImageItem *ImageItem `json:"image_item,omitempty"`
|
||||||
|
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
|
||||||
|
VideoItem *VideoItem `json:"video_item,omitempty"`
|
||||||
|
FileItem *FileItem `json:"file_item,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CDN media type constants.
|
||||||
|
const (
|
||||||
|
CDNMediaTypeImage = 1
|
||||||
|
CDNMediaTypeVideo = 2
|
||||||
|
CDNMediaTypeFile = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetUploadURLRequest is the body for getuploadurl.
|
||||||
|
type GetUploadURLRequest struct {
|
||||||
|
FileKey string `json:"filekey"`
|
||||||
|
MediaType int `json:"media_type"`
|
||||||
|
ToUserID string `json:"to_user_id"`
|
||||||
|
RawSize int `json:"rawsize"`
|
||||||
|
RawFileMD5 string `json:"rawfilemd5"`
|
||||||
|
FileSize int `json:"filesize"`
|
||||||
|
NoNeedThumb bool `json:"no_need_thumb"`
|
||||||
|
AESKey string `json:"aeskey"`
|
||||||
|
BaseInfo BaseInfo `json:"base_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUploadURLResponse is the response from getuploadurl.
|
||||||
|
type GetUploadURLResponse struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
UploadParam string `json:"upload_param"`
|
||||||
|
UploadFullURL string `json:"upload_full_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextItem holds text content.
|
||||||
|
type TextItem struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaInfo holds CDN media reference for uploaded files.
|
||||||
|
type MediaInfo struct {
|
||||||
|
EncryptQueryParam string `json:"encrypt_query_param"`
|
||||||
|
AESKey string `json:"aes_key"` // base64-encoded
|
||||||
|
EncryptType int `json:"encrypt_type"` // 1 = AES-128-ECB
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoiceItem holds voice content.
|
||||||
|
type VoiceItem struct {
|
||||||
|
Media *MediaInfo `json:"media,omitempty"`
|
||||||
|
VoiceSize int `json:"voice_size,omitempty"`
|
||||||
|
EncodeType int `json:"encode_type,omitempty"` // 1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3
|
||||||
|
BitsPerSample int `json:"bits_per_sample,omitempty"`
|
||||||
|
SampleRate int `json:"sample_rate,omitempty"` // Hz
|
||||||
|
Playtime int `json:"playtime,omitempty"` // duration in milliseconds
|
||||||
|
Text string `json:"text,omitempty"` // speech-to-text transcription from WeChat
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageItem holds image content.
|
||||||
|
type ImageItem struct {
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Media *MediaInfo `json:"media,omitempty"`
|
||||||
|
MidSize int `json:"mid_size,omitempty"` // ciphertext size
|
||||||
|
}
|
||||||
|
|
||||||
|
// VideoItem holds video content.
|
||||||
|
type VideoItem struct {
|
||||||
|
Media *MediaInfo `json:"media,omitempty"`
|
||||||
|
VideoSize int `json:"video_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileItem holds file content.
|
||||||
|
type FileItem struct {
|
||||||
|
Media *MediaInfo `json:"media,omitempty"`
|
||||||
|
FileName string `json:"file_name,omitempty"`
|
||||||
|
Len string `json:"len,omitempty"` // plaintext size as string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessageRequest is the body for sendmessage.
|
||||||
|
type SendMessageRequest struct {
|
||||||
|
Msg SendMsg `json:"msg"`
|
||||||
|
BaseInfo BaseInfo `json:"base_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMsg is the message payload for sending.
|
||||||
|
type SendMsg struct {
|
||||||
|
FromUserID string `json:"from_user_id"`
|
||||||
|
ToUserID string `json:"to_user_id"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
MessageType int `json:"message_type"`
|
||||||
|
MessageState int `json:"message_state"`
|
||||||
|
ItemList []MessageItem `json:"item_list"`
|
||||||
|
ContextToken string `json:"context_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessageResponse is the response from sendmessage.
|
||||||
|
type SendMessageResponse struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing status constants.
|
||||||
|
const (
|
||||||
|
TypingStatusTyping = 1
|
||||||
|
TypingStatusCancel = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetConfigRequest is the body for getconfig.
|
||||||
|
type GetConfigRequest struct {
|
||||||
|
ILinkUserID string `json:"ilink_user_id"`
|
||||||
|
ContextToken string `json:"context_token,omitempty"`
|
||||||
|
BaseInfo BaseInfo `json:"base_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigResponse is the response from getconfig.
|
||||||
|
type GetConfigResponse struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
TypingTicket string `json:"typing_ticket,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTypingRequest is the body for sendtyping.
|
||||||
|
type SendTypingRequest struct {
|
||||||
|
ILinkUserID string `json:"ilink_user_id"`
|
||||||
|
TypingTicket string `json:"typing_ticket"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
BaseInfo BaseInfo `json:"base_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTypingResponse is the response from sendtyping.
|
||||||
|
type SendTypingResponse struct {
|
||||||
|
Ret int `json:"ret"`
|
||||||
|
ErrMsg string `json:"errmsg,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/api"
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
|
||||||
|
"rsc.io/qr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginEvent struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
QRCodeDataURL string `json:"qr_code_data_url,omitempty"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
WeChatUserID string `json:"wechat_user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountSummary struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
WeChatUserID string `json:"wechat_user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fatal(errors.New("expected one of: login, accounts, start"))
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "login":
|
||||||
|
err = runLogin(os.Args[2:])
|
||||||
|
case "accounts":
|
||||||
|
err = runAccounts(os.Args[2:])
|
||||||
|
case "start":
|
||||||
|
err = runStart(os.Args[2:])
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unknown command %q", os.Args[1])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatal(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signalContext() (context.Context, context.CancelFunc) {
|
||||||
|
return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLogin(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("login", flag.ContinueOnError)
|
||||||
|
jsonOutput := flags.Bool("json", false, "emit JSON Lines events")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := signalContext()
|
||||||
|
defer cancel()
|
||||||
|
creds, err := login(ctx, *jsonOutput)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !*jsonOutput {
|
||||||
|
fmt.Printf("WeChat account %s connected.\n", creds.ILinkBotID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(ctx context.Context, jsonOutput bool) (*ilink.Credentials, error) {
|
||||||
|
qrResponse, err := ilink.FetchQRCode(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
code, err := qr.Encode(qrResponse.QRCodeImgContent, qr.L)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode QR image: %w", err)
|
||||||
|
}
|
||||||
|
emit := func(event loginEvent) {
|
||||||
|
if jsonOutput {
|
||||||
|
_ = json.NewEncoder(os.Stdout).Encode(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(loginEvent{Status: "qrcode", QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())})
|
||||||
|
lastStatus := ""
|
||||||
|
creds, err := ilink.PollQRStatus(ctx, qrResponse.QRCode, func(status string) {
|
||||||
|
if status != lastStatus {
|
||||||
|
lastStatus = status
|
||||||
|
emit(loginEvent{Status: status})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ilink.SaveCredentials(creds); err != nil {
|
||||||
|
return nil, fmt.Errorf("save credentials: %w", err)
|
||||||
|
}
|
||||||
|
emit(loginEvent{Status: "active", AccountID: creds.ILinkBotID, WeChatUserID: creds.ILinkUserID})
|
||||||
|
return creds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAccounts(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("accounts", flag.ContinueOnError)
|
||||||
|
jsonOutput := flags.Bool("json", false, "print JSON")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
accounts, err := ilink.LoadAllCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items := make([]accountSummary, 0, len(accounts))
|
||||||
|
for _, account := range accounts {
|
||||||
|
items = append(items, accountSummary{AccountID: account.ILinkBotID, WeChatUserID: account.ILinkUserID})
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
return json.NewEncoder(os.Stdout).Encode(map[string]any{"accounts": items})
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
fmt.Printf("%s\t%s\n", item.AccountID, item.WeChatUserID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStart(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("start", flag.ContinueOnError)
|
||||||
|
_ = flags.Bool("foreground", false, "kept for host compatibility")
|
||||||
|
apiAddr := flags.String("api-addr", "127.0.0.1:18011", "local send API address")
|
||||||
|
accountID := flags.String("account-id", "", "account to start")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
accounts, err := ilink.LoadAllCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(accounts) == 0 {
|
||||||
|
return errors.New("no connected WeChat account; scan a QR code first")
|
||||||
|
}
|
||||||
|
selected := accounts[len(accounts)-1]
|
||||||
|
if *accountID != "" {
|
||||||
|
selected = nil
|
||||||
|
for _, account := range accounts {
|
||||||
|
if account.ILinkBotID == *accountID {
|
||||||
|
selected = account
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selected == nil {
|
||||||
|
return fmt.Errorf("account %q not found", *accountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := signalContext()
|
||||||
|
defer cancel()
|
||||||
|
client := ilink.NewClient(selected)
|
||||||
|
server := api.NewServer([]*ilink.Client{client}, *apiAddr)
|
||||||
|
webhookURL := strings.TrimSpace(os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL"))
|
||||||
|
webhook := messaging.NewInboundWebhook(webhookURL, os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN"))
|
||||||
|
|
||||||
|
monitor, err := ilink.NewMonitor(client, func(messageContext context.Context, source *ilink.Client, message ilink.WeixinMessage) {
|
||||||
|
if webhookURL != "" {
|
||||||
|
webhook.Dispatch(messageContext, source, message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var wait sync.WaitGroup
|
||||||
|
wait.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
if err := server.Run(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
log.Printf("[api] stopped: %v", err)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
if err := monitor.Run(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
log.Printf("[monitor] stopped: %v", err)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wait.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||||
|
|
||||||
|
// UploadedFile holds the result of a CDN upload.
|
||||||
|
type UploadedFile struct {
|
||||||
|
DownloadParam string // encrypted query param for download
|
||||||
|
AESKeyHex string // hex-encoded AES key
|
||||||
|
FileSize int // plaintext size
|
||||||
|
CipherSize int // ciphertext size
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFileToCDN encrypts and uploads a file to the WeChat CDN.
|
||||||
|
func UploadFileToCDN(ctx context.Context, client *ilink.Client, data []byte, toUserID string, mediaType int) (*UploadedFile, error) {
|
||||||
|
// Generate random filekey and AES key
|
||||||
|
filekey := make([]byte, 16)
|
||||||
|
aeskey := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(filekey); err != nil {
|
||||||
|
return nil, fmt.Errorf("generate filekey: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := rand.Read(aeskey); err != nil {
|
||||||
|
return nil, fmt.Errorf("generate aeskey: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filekeyHex := hex.EncodeToString(filekey)
|
||||||
|
aeskeyHex := hex.EncodeToString(aeskey)
|
||||||
|
|
||||||
|
// Calculate MD5 of plaintext
|
||||||
|
hash := md5.Sum(data)
|
||||||
|
rawMD5 := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
// Calculate ciphertext size (PKCS7 padding)
|
||||||
|
cipherSize := aesECBPaddedSize(len(data))
|
||||||
|
|
||||||
|
// Get upload URL from iLink API
|
||||||
|
uploadReq := &ilink.GetUploadURLRequest{
|
||||||
|
FileKey: filekeyHex,
|
||||||
|
MediaType: mediaType,
|
||||||
|
ToUserID: toUserID,
|
||||||
|
RawSize: len(data),
|
||||||
|
RawFileMD5: rawMD5,
|
||||||
|
FileSize: cipherSize,
|
||||||
|
NoNeedThumb: true,
|
||||||
|
AESKey: aeskeyHex,
|
||||||
|
BaseInfo: ilink.BaseInfo{},
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadResp, err := client.GetUploadURL(ctx, uploadReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get upload URL: %w", err)
|
||||||
|
}
|
||||||
|
if uploadResp.Ret != 0 {
|
||||||
|
return nil, fmt.Errorf("get upload URL failed: ret=%d errmsg=%s", uploadResp.Ret, uploadResp.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt data with AES-128-ECB
|
||||||
|
encrypted, err := encryptAESECB(data, aeskey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encrypt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload to CDN: prefer server-provided full URL, fall back to param-based construction
|
||||||
|
cdnURL := strings.TrimSpace(uploadResp.UploadFullURL)
|
||||||
|
if cdnURL == "" {
|
||||||
|
if uploadResp.UploadParam == "" {
|
||||||
|
return nil, fmt.Errorf("getuploadurl returned no upload URL (need upload_full_url or upload_param)")
|
||||||
|
}
|
||||||
|
cdnURL = fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s",
|
||||||
|
cdnBaseURL, url.QueryEscape(uploadResp.UploadParam), url.QueryEscape(filekeyHex))
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadParam, err := uploadToCDN(ctx, encrypted, cdnURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CDN upload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UploadedFile{
|
||||||
|
DownloadParam: downloadParam,
|
||||||
|
AESKeyHex: aeskeyHex,
|
||||||
|
FileSize: len(data),
|
||||||
|
CipherSize: cipherSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AESKeyToBase64 converts a hex AES key to base64 format for message items.
|
||||||
|
func AESKeyToBase64(hexKey string) string {
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(hexKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadFileFromCDN downloads and decrypts a file from the WeChat CDN.
|
||||||
|
func DownloadFileFromCDN(ctx context.Context, encryptQueryParam, aesKeyBase64 string) ([]byte, error) {
|
||||||
|
// Decode AES key: base64 -> hex string -> raw bytes
|
||||||
|
aesKeyHexBytes, err := base64.StdEncoding.DecodeString(aesKeyBase64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode AES key base64: %w", err)
|
||||||
|
}
|
||||||
|
aesKey, err := hex.DecodeString(string(aesKeyHexBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode AES key hex: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download encrypted data from CDN
|
||||||
|
downloadURL := fmt.Sprintf("%s/download?encrypted_query_param=%s",
|
||||||
|
cdnBaseURL, url.QueryEscape(encryptQueryParam))
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create download request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("download from CDN: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("CDN download HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read CDN response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt AES-128-ECB
|
||||||
|
return decryptAESECB(encrypted, aesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptAESECB decrypts data encrypted with AES-128-ECB and removes PKCS7 padding.
|
||||||
|
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ciphertext)%aes.BlockSize != 0 {
|
||||||
|
return nil, fmt.Errorf("ciphertext is not a multiple of block size")
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := make([]byte, len(ciphertext))
|
||||||
|
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||||
|
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove PKCS7 padding
|
||||||
|
if len(plaintext) == 0 {
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
padLen := int(plaintext[len(plaintext)-1])
|
||||||
|
if padLen > aes.BlockSize || padLen == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||||
|
}
|
||||||
|
return plaintext[:len(plaintext)-padLen], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadToCDN(ctx context.Context, encrypted []byte, cdnURL string) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(encrypted))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("CDN upload HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadParam := resp.Header.Get("X-Encrypted-Param")
|
||||||
|
if downloadParam == "" {
|
||||||
|
return "", fmt.Errorf("CDN upload: missing X-Encrypted-Param header")
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadParam, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptAESECB encrypts data using AES-128-ECB with PKCS7 padding.
|
||||||
|
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PKCS7 padding
|
||||||
|
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
|
||||||
|
padded := make([]byte, len(plaintext)+padLen)
|
||||||
|
copy(padded, plaintext)
|
||||||
|
for i := len(plaintext); i < len(padded); i++ {
|
||||||
|
padded[i] = byte(padLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECB mode: encrypt each block independently
|
||||||
|
encrypted := make([]byte, len(padded))
|
||||||
|
for i := 0; i < len(padded); i += aes.BlockSize {
|
||||||
|
block.Encrypt(encrypted[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
|
||||||
|
}
|
||||||
|
|
||||||
|
return encrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesECBPaddedSize(plaintextSize int) int {
|
||||||
|
return (plaintextSize/aes.BlockSize + 1) * aes.BlockSize
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
webhookAttempts = 3
|
||||||
|
webhookTimeout = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type InboundWebhook struct {
|
||||||
|
url string
|
||||||
|
token string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type inboundWebhookPayload struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
FromUserID string `json:"from_user_id"`
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
MessageType int `json:"message_type"`
|
||||||
|
Items []inboundWebhookItem `json:"items"`
|
||||||
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inboundWebhookItem struct {
|
||||||
|
Type int `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInboundWebhook(url, token string) *InboundWebhook {
|
||||||
|
return &InboundWebhook{
|
||||||
|
url: strings.TrimSpace(url),
|
||||||
|
token: token,
|
||||||
|
client: &http.Client{Timeout: webhookTimeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch is intentionally non-blocking so webhook failures never stall iLink polling.
|
||||||
|
func (w *InboundWebhook) Dispatch(ctx context.Context, client *ilink.Client, msg ilink.WeixinMessage) {
|
||||||
|
payload := normalizeInboundMessage(client.BotID(), msg)
|
||||||
|
go func() {
|
||||||
|
if err := w.deliver(ctx, payload); err != nil {
|
||||||
|
log.Printf("[webhook] inbound delivery failed for message %d: %v", msg.MessageID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *InboundWebhook) deliver(ctx context.Context, payload inboundWebhookPayload) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode payload: %w", err)
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 1; attempt <= webhookAttempts; attempt++ {
|
||||||
|
if attempt > 1 {
|
||||||
|
timer := time.NewTimer(time.Duration(attempt-1) * time.Second)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body))
|
||||||
|
if reqErr != nil {
|
||||||
|
return fmt.Errorf("create request: %w", reqErr)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if w.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+w.token)
|
||||||
|
}
|
||||||
|
resp, doErr := w.client.Do(req)
|
||||||
|
if doErr != nil {
|
||||||
|
lastErr = doErr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastErr = fmt.Errorf("status %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
|
||||||
|
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeInboundMessage(accountID string, msg ilink.WeixinMessage) inboundWebhookPayload {
|
||||||
|
items := make([]inboundWebhookItem, 0, len(msg.ItemList))
|
||||||
|
for _, item := range msg.ItemList {
|
||||||
|
normalized := inboundWebhookItem{Type: item.Type}
|
||||||
|
if item.TextItem != nil {
|
||||||
|
normalized.Text = item.TextItem.Text
|
||||||
|
} else if item.VoiceItem != nil {
|
||||||
|
normalized.Text = item.VoiceItem.Text
|
||||||
|
}
|
||||||
|
items = append(items, normalized)
|
||||||
|
}
|
||||||
|
return inboundWebhookPayload{
|
||||||
|
AccountID: accountID,
|
||||||
|
FromUserID: msg.FromUserID,
|
||||||
|
MessageID: msg.MessageID,
|
||||||
|
MessageType: msg.MessageType,
|
||||||
|
Items: items,
|
||||||
|
ReceivedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInboundWebhookDeliversNormalizedPayload(t *testing.T) {
|
||||||
|
var got inboundWebhookPayload
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer secret" {
|
||||||
|
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||||
|
t.Errorf("decode: %v", err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
webhook := NewInboundWebhook(server.URL, "secret")
|
||||||
|
err := webhook.deliver(context.Background(), normalizeInboundMessage("bot-new", ilink.WeixinMessage{
|
||||||
|
MessageID: 7, FromUserID: "user-1", MessageType: ilink.MessageTypeUser,
|
||||||
|
ItemList: []ilink.MessageItem{{Type: ilink.ItemTypeText, TextItem: &ilink.TextItem{Text: "最近5条消息"}}},
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deliver: %v", err)
|
||||||
|
}
|
||||||
|
if got.AccountID != "bot-new" || got.MessageID != 7 || len(got.Items) != 1 || got.Items[0].Text != "最近5条消息" {
|
||||||
|
t.Fatalf("payload = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundWebhookRetriesServerErrors(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if calls.Add(1) < 3 {
|
||||||
|
http.Error(w, "temporary", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
webhook := NewInboundWebhook(server.URL, "")
|
||||||
|
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err != nil {
|
||||||
|
t.Fatalf("deliver: %v", err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 3 {
|
||||||
|
t.Fatalf("calls = %d, want 3", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundWebhookDoesNotRetryClientErrors(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
webhook := NewInboundWebhook(server.URL, "")
|
||||||
|
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err == nil {
|
||||||
|
t.Fatal("deliver error = nil")
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatalf("calls = %d, want 1", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Code blocks: strip fences, keep code content
|
||||||
|
reCodeBlock = regexp.MustCompile("(?s)```[^\n]*\n?(.*?)```")
|
||||||
|
// Inline code: strip backticks, keep content
|
||||||
|
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||||
|
// Images: remove entirely
|
||||||
|
reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`)
|
||||||
|
// Links: keep display text only
|
||||||
|
reLink = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)
|
||||||
|
// Table separator rows: remove
|
||||||
|
reTableSep = regexp.MustCompile(`(?m)^\|[\s:|\-]+\|$`)
|
||||||
|
// Table rows: convert pipe-delimited to space-delimited
|
||||||
|
reTableRow = regexp.MustCompile(`(?m)^\|(.+)\|$`)
|
||||||
|
// Headers: remove # prefix
|
||||||
|
reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+`)
|
||||||
|
// Bold: **text** or __text__
|
||||||
|
reBold = regexp.MustCompile(`\*\*(.+?)\*\*|__(.+?)__`)
|
||||||
|
// Italic: *text* or _text_
|
||||||
|
reItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)|(?:^|[^_])_([^_]+)_(?:[^_]|$)`)
|
||||||
|
// Strikethrough: ~~text~~
|
||||||
|
reStrike = regexp.MustCompile(`~~(.+?)~~`)
|
||||||
|
// Blockquote: > prefix
|
||||||
|
reBlockquote = regexp.MustCompile(`(?m)^>\s?`)
|
||||||
|
// Horizontal rule
|
||||||
|
reHR = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`)
|
||||||
|
// Unordered list markers: -, *, +
|
||||||
|
reUL = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// MarkdownToPlainText converts markdown to readable plain text for WeChat.
|
||||||
|
func MarkdownToPlainText(text string) string {
|
||||||
|
result := text
|
||||||
|
|
||||||
|
// Code blocks: strip fences, keep code content
|
||||||
|
result = reCodeBlock.ReplaceAllStringFunc(result, func(match string) string {
|
||||||
|
parts := reCodeBlock.FindStringSubmatch(match)
|
||||||
|
if len(parts) > 1 {
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
// Images: remove entirely
|
||||||
|
result = reImage.ReplaceAllString(result, "")
|
||||||
|
|
||||||
|
// Links: keep display text only
|
||||||
|
result = reLink.ReplaceAllString(result, "$1")
|
||||||
|
|
||||||
|
// Table separator rows: remove
|
||||||
|
result = reTableSep.ReplaceAllString(result, "")
|
||||||
|
|
||||||
|
// Table rows: pipe-delimited to space-delimited
|
||||||
|
result = reTableRow.ReplaceAllStringFunc(result, func(match string) string {
|
||||||
|
parts := reTableRow.FindStringSubmatch(match)
|
||||||
|
if len(parts) > 1 {
|
||||||
|
cells := strings.Split(parts[1], "|")
|
||||||
|
for i := range cells {
|
||||||
|
cells[i] = strings.TrimSpace(cells[i])
|
||||||
|
}
|
||||||
|
return strings.Join(cells, " ")
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
// Headers: remove # prefix
|
||||||
|
result = reHeader.ReplaceAllString(result, "")
|
||||||
|
|
||||||
|
// Bold
|
||||||
|
result = reBold.ReplaceAllStringFunc(result, func(match string) string {
|
||||||
|
parts := reBold.FindStringSubmatch(match)
|
||||||
|
if parts[1] != "" {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
return parts[2]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Strikethrough
|
||||||
|
result = reStrike.ReplaceAllString(result, "$1")
|
||||||
|
|
||||||
|
// Blockquote
|
||||||
|
result = reBlockquote.ReplaceAllString(result, "")
|
||||||
|
|
||||||
|
// Horizontal rule -> empty line
|
||||||
|
result = reHR.ReplaceAllString(result, "")
|
||||||
|
|
||||||
|
// Unordered list: replace markers with "• "
|
||||||
|
result = reUL.ReplaceAllString(result, "${1}• ")
|
||||||
|
|
||||||
|
// Inline code: strip backticks (do after code blocks)
|
||||||
|
result = reInlineCode.ReplaceAllString(result, "$1")
|
||||||
|
|
||||||
|
// Clean up excessive blank lines
|
||||||
|
result = regexp.MustCompile(`\n{3,}`).ReplaceAllString(result, "\n\n")
|
||||||
|
|
||||||
|
return strings.TrimSpace(result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reMarkdownImage matches markdown image syntax: 
|
||||||
|
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
|
||||||
|
|
||||||
|
// ExtractImageURLs extracts image URLs from markdown text.
|
||||||
|
func ExtractImageURLs(text string) []string {
|
||||||
|
matches := reMarkdownImage.FindAllStringSubmatch(text, -1)
|
||||||
|
var urls []string
|
||||||
|
for _, m := range matches {
|
||||||
|
url := strings.TrimSpace(m[1])
|
||||||
|
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
|
||||||
|
urls = append(urls, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message.
|
||||||
|
func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error {
|
||||||
|
// Check if it's a local file
|
||||||
|
if _, err := os.Stat(mediaURL); err == nil {
|
||||||
|
return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken)
|
||||||
|
}
|
||||||
|
// Must be a valid HTTP URL to download
|
||||||
|
if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") {
|
||||||
|
return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL)
|
||||||
|
}
|
||||||
|
data, contentType, err := downloadFile(ctx, mediaURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("download %s: %w", mediaURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMediaFromPath reads a local file and sends it as a media message.
|
||||||
|
func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error {
|
||||||
|
if fileName == "" {
|
||||||
|
fileName = "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
cdnMediaType, itemType := classifyMedia(contentType, source)
|
||||||
|
|
||||||
|
log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID)
|
||||||
|
|
||||||
|
uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upload to CDN: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
media := &ilink.MediaInfo{
|
||||||
|
EncryptQueryParam: uploaded.DownloadParam,
|
||||||
|
AESKey: AESKeyToBase64(uploaded.AESKeyHex),
|
||||||
|
EncryptType: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
var item ilink.MessageItem
|
||||||
|
switch itemType {
|
||||||
|
case ilink.ItemTypeImage:
|
||||||
|
item = ilink.MessageItem{
|
||||||
|
Type: ilink.ItemTypeImage,
|
||||||
|
ImageItem: &ilink.ImageItem{
|
||||||
|
Media: media,
|
||||||
|
MidSize: uploaded.CipherSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case ilink.ItemTypeVideo:
|
||||||
|
item = ilink.MessageItem{
|
||||||
|
Type: ilink.ItemTypeVideo,
|
||||||
|
VideoItem: &ilink.VideoItem{
|
||||||
|
Media: media,
|
||||||
|
VideoSize: uploaded.CipherSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
item = ilink.MessageItem{
|
||||||
|
Type: ilink.ItemTypeFile,
|
||||||
|
FileItem: &ilink.FileItem{
|
||||||
|
Media: media,
|
||||||
|
FileName: fileName,
|
||||||
|
Len: fmt.Sprintf("%d", uploaded.FileSize),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &ilink.SendMessageRequest{
|
||||||
|
Msg: ilink.SendMsg{
|
||||||
|
FromUserID: client.BotID(),
|
||||||
|
ToUserID: toUserID,
|
||||||
|
ClientID: NewClientID(),
|
||||||
|
MessageType: ilink.MessageTypeBot,
|
||||||
|
MessageState: ilink.MessageStateFinish,
|
||||||
|
ItemList: []ilink.MessageItem{item},
|
||||||
|
ContextToken: contextToken,
|
||||||
|
},
|
||||||
|
BaseInfo: ilink.BaseInfo{},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.SendMessage(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send media message: %w", err)
|
||||||
|
}
|
||||||
|
if resp.Ret != 0 {
|
||||||
|
return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(ctx context.Context, url string) ([]byte, string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = inferContentType(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, contentType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) {
|
||||||
|
ct := strings.ToLower(contentType)
|
||||||
|
|
||||||
|
if strings.HasPrefix(ct, "image/") || isImageExt(url) {
|
||||||
|
return ilink.CDNMediaTypeImage, ilink.ItemTypeImage
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(ct, "video/") || isVideoExt(url) {
|
||||||
|
return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo
|
||||||
|
}
|
||||||
|
return ilink.CDNMediaTypeFile, ilink.ItemTypeFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImageExt(url string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
|
||||||
|
switch ext {
|
||||||
|
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVideoExt(url string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
|
||||||
|
switch ext {
|
||||||
|
case ".mp4", ".mov", ".webm", ".mkv", ".avi":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferContentType(url string) string {
|
||||||
|
ext := filepath.Ext(stripQuery(url))
|
||||||
|
if ct := mime.TypeByExtension(ext); ct != "" {
|
||||||
|
return ct
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
func filenameFromURL(rawURL string) string {
|
||||||
|
u := stripQuery(rawURL)
|
||||||
|
name := filepath.Base(u)
|
||||||
|
if name == "" || name == "." || name == "/" {
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripQuery(rawURL string) string {
|
||||||
|
if i := strings.IndexByte(rawURL, '?'); i >= 0 {
|
||||||
|
return rawURL[:i]
|
||||||
|
}
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractImageURLs(t *testing.T) {
|
||||||
|
text := "check  and "
|
||||||
|
urls := ExtractImageURLs(text)
|
||||||
|
if len(urls) != 2 {
|
||||||
|
t.Fatalf("expected 2 urls, got %d", len(urls))
|
||||||
|
}
|
||||||
|
if urls[0] != "https://example.com/a.png" {
|
||||||
|
t.Errorf("urls[0] = %q", urls[0])
|
||||||
|
}
|
||||||
|
if urls[1] != "https://example.com/b.jpg" {
|
||||||
|
t.Errorf("urls[1] = %q", urls[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractImageURLs_NoImages(t *testing.T) {
|
||||||
|
urls := ExtractImageURLs("just plain text")
|
||||||
|
if len(urls) != 0 {
|
||||||
|
t.Errorf("expected 0 urls, got %d", len(urls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractImageURLs_RelativeURL(t *testing.T) {
|
||||||
|
text := ""
|
||||||
|
urls := ExtractImageURLs(text)
|
||||||
|
if len(urls) != 0 {
|
||||||
|
t.Errorf("expected 0 urls for relative path, got %d", len(urls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilenameFromURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"https://example.com/photo.png", "photo.png"},
|
||||||
|
{"https://example.com/path/to/report.pdf", "report.pdf"},
|
||||||
|
{"https://example.com/file", "file"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := filenameFromURL(tt.url)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilenameFromURL_WithQuery(t *testing.T) {
|
||||||
|
got := filenameFromURL("https://example.com/photo.png?token=abc")
|
||||||
|
if got != "photo.png" {
|
||||||
|
t.Errorf("got %q, want %q", got, "photo.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripQuery(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"https://example.com/a?b=c", "https://example.com/a"},
|
||||||
|
{"https://example.com/a", "https://example.com/a"},
|
||||||
|
{"https://example.com/?x=1&y=2", "https://example.com/"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := stripQuery(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewClientID generates a new unique client ID for message correlation.
|
||||||
|
func NewClientID() string {
|
||||||
|
return uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTypingState sends a typing indicator to a user via the iLink sendtyping API.
|
||||||
|
// It first fetches a typing_ticket via getconfig, then sends the typing status.
|
||||||
|
func SendTypingState(ctx context.Context, client *ilink.Client, userID, contextToken string) error {
|
||||||
|
// Get typing ticket
|
||||||
|
configResp, err := client.GetConfig(ctx, userID, contextToken)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get config for typing: %w", err)
|
||||||
|
}
|
||||||
|
if configResp.TypingTicket == "" {
|
||||||
|
return fmt.Errorf("no typing_ticket returned from getconfig")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send typing
|
||||||
|
if err := client.SendTyping(ctx, userID, configResp.TypingTicket, ilink.TypingStatusTyping); err != nil {
|
||||||
|
return fmt.Errorf("send typing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[sender] sent typing indicator to %s", userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTextReply sends a text reply to a user through the iLink API.
|
||||||
|
// If clientID is empty, a new one is generated.
|
||||||
|
func SendTextReply(ctx context.Context, client *ilink.Client, toUserID, text, contextToken, clientID string) error {
|
||||||
|
if clientID == "" {
|
||||||
|
clientID = NewClientID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert markdown to plain text for WeChat display
|
||||||
|
plainText := MarkdownToPlainText(text)
|
||||||
|
|
||||||
|
req := &ilink.SendMessageRequest{
|
||||||
|
Msg: ilink.SendMsg{
|
||||||
|
FromUserID: client.BotID(),
|
||||||
|
ToUserID: toUserID,
|
||||||
|
ClientID: clientID,
|
||||||
|
MessageType: ilink.MessageTypeBot,
|
||||||
|
MessageState: ilink.MessageStateFinish,
|
||||||
|
ItemList: []ilink.MessageItem{
|
||||||
|
{
|
||||||
|
Type: ilink.ItemTypeText,
|
||||||
|
TextItem: &ilink.TextItem{
|
||||||
|
Text: plainText,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ContextToken: contextToken,
|
||||||
|
},
|
||||||
|
BaseInfo: ilink.BaseInfo{},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.SendMessage(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Ret != 0 {
|
||||||
|
return fmt.Errorf("send message failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[sender] sent reply to %s: %q", toUserID, truncate(text, 50))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
@@ -0,0 +1,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()
|
||||||
+64
-4
@@ -9,6 +9,8 @@ import {
|
|||||||
} from './services/chat-service'
|
} from './services/chat-service'
|
||||||
import { exportGroupReport } from './group-report-service'
|
import { exportGroupReport } from './group-report-service'
|
||||||
import { GroupReportExportRequest } from '../shared/group-report'
|
import { GroupReportExportRequest } from '../shared/group-report'
|
||||||
|
import { generateAgentGroupReport } from './services/agent-group-report-service'
|
||||||
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
import { safeError, safeLog, safeWarn } from './safe-log'
|
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||||
|
|
||||||
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||||
@@ -157,7 +159,9 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
if (keyword) {
|
if (keyword) {
|
||||||
const lower = keyword.toLowerCase()
|
const lower = keyword.toLowerCase()
|
||||||
groups = groups.filter(
|
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 })
|
sendJson(res, 200, { count: groups.length, chatrooms: groups })
|
||||||
@@ -236,13 +240,62 @@ const routes: Record<string, RouteHandler> = {
|
|||||||
try {
|
try {
|
||||||
request = JSON.parse(body) as GroupReportExportRequest
|
request = JSON.parse(body) as GroupReportExportRequest
|
||||||
} catch (error) {
|
} 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) {
|
if (!request?.report || !request?.metadata) {
|
||||||
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
||||||
}
|
}
|
||||||
const result = await exportGroupReport(request)
|
const result = await exportGroupReport(request)
|
||||||
sendJson(res, result.success ? 200 : 500, result)
|
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 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> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
@@ -326,7 +383,10 @@ export const apiServer = {
|
|||||||
return { ...singletonState }
|
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) {
|
if (singleton) {
|
||||||
return this.getState()
|
return this.getState()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import crypto from 'crypto'
|
|||||||
import os from 'os'
|
import os from 'os'
|
||||||
import { Wcdb4Client } from './wcdb4-client'
|
import { Wcdb4Client } from './wcdb4-client'
|
||||||
|
|
||||||
|
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
|
||||||
|
const imageDecryptLog = (...args: unknown[]): void => {
|
||||||
|
if (imageDecryptDebugEnabled) console.log(...args)
|
||||||
|
}
|
||||||
|
|
||||||
export class ImageDecryptService {
|
export class ImageDecryptService {
|
||||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||||
|
|
||||||
@@ -41,7 +46,7 @@ export class ImageDecryptService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!existsSync(accountRoot)) {
|
if (!existsSync(accountRoot)) {
|
||||||
console.log('[ImageDecrypt] account root not found:', accountRoot)
|
imageDecryptLog('[ImageDecrypt] account root not found:', accountRoot)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +66,7 @@ export class ImageDecryptService {
|
|||||||
.sort((a, b) => b.mtime - a.mtime)
|
.sort((a, b) => b.mtime - a.mtime)
|
||||||
|
|
||||||
if (accounts.length === 0) {
|
if (accounts.length === 0) {
|
||||||
console.log('[ImageDecrypt] no accounts found')
|
imageDecryptLog('[ImageDecrypt] no accounts found')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +88,7 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
||||||
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
||||||
console.log('[ImageDecrypt] findImageFile:', {
|
imageDecryptLog('[ImageDecrypt] findImageFile:', {
|
||||||
md5: normalizedMd5,
|
md5: normalizedMd5,
|
||||||
imageDatName: normalizedDatName,
|
imageDatName: normalizedDatName,
|
||||||
accountDir,
|
accountDir,
|
||||||
@@ -96,7 +101,7 @@ export class ImageDecryptService {
|
|||||||
if (fullPath && existsSync(fullPath)) {
|
if (fullPath && existsSync(fullPath)) {
|
||||||
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
|
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
|
||||||
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
||||||
console.log('[ImageDecrypt] hardlink hit:', selected)
|
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
|
||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +110,7 @@ export class ImageDecryptService {
|
|||||||
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
|
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
|
||||||
const attachDir = join(accountDir, 'msg', 'attach')
|
const attachDir = join(accountDir, 'msg', 'attach')
|
||||||
if (!existsSync(attachDir)) {
|
if (!existsSync(attachDir)) {
|
||||||
console.log('[ImageDecrypt] attach dir not found:', attachDir)
|
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
||||||
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
|
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +125,7 @@ export class ImageDecryptService {
|
|||||||
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
|
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
|
||||||
if (legacyHit) return legacyHit
|
if (legacyHit) return legacyHit
|
||||||
|
|
||||||
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +151,7 @@ export class ImageDecryptService {
|
|||||||
]
|
]
|
||||||
const found = this.getLargestExistingPath(candidates, allowThumbnail)
|
const found = this.getLargestExistingPath(candidates, allowThumbnail)
|
||||||
if (found) {
|
if (found) {
|
||||||
console.log('[ImageDecrypt] prefix path hit:', found)
|
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,14 +180,14 @@ export class ImageDecryptService {
|
|||||||
allowThumbnail
|
allowThumbnail
|
||||||
)
|
)
|
||||||
if (found) {
|
if (found) {
|
||||||
console.log('[ImageDecrypt] found at:', found)
|
imageDecryptLog('[ImageDecrypt] found at:', found)
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('[ImageDecrypt]遍历目录失败:', e)
|
imageDecryptLog('[ImageDecrypt]遍历目录失败:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -229,7 +234,7 @@ export class ImageDecryptService {
|
|||||||
const fullPath = join(dir, entry)
|
const fullPath = join(dir, entry)
|
||||||
const stat = statSync(fullPath)
|
const stat = statSync(fullPath)
|
||||||
if (stat.isFile() && variants.has(entry.toLowerCase())) {
|
if (stat.isFile() && variants.has(entry.toLowerCase())) {
|
||||||
console.log('[ImageDecrypt] legacy path hit:', fullPath)
|
imageDecryptLog('[ImageDecrypt] legacy path hit:', fullPath)
|
||||||
return fullPath
|
return fullPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,13 +257,13 @@ export class ImageDecryptService {
|
|||||||
*/
|
*/
|
||||||
decryptImage(datPath: string): Buffer | null {
|
decryptImage(datPath: string): Buffer | null {
|
||||||
if (!existsSync(datPath)) {
|
if (!existsSync(datPath)) {
|
||||||
console.log('[ImageDecrypt] file not found:', datPath)
|
imageDecryptLog('[ImageDecrypt] file not found:', datPath)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const version = this.getDatVersion(datPath)
|
const version = this.getDatVersion(datPath)
|
||||||
console.log(
|
imageDecryptLog(
|
||||||
'[ImageDecrypt] dat version:',
|
'[ImageDecrypt] dat version:',
|
||||||
version,
|
version,
|
||||||
'file:',
|
'file:',
|
||||||
@@ -269,25 +274,25 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
let decrypted: Buffer
|
let decrypted: Buffer
|
||||||
if (version === 1) {
|
if (version === 1) {
|
||||||
console.log('[ImageDecrypt] using V1 (default AES key)')
|
imageDecryptLog('[ImageDecrypt] using V1 (default AES key)')
|
||||||
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
|
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
|
||||||
decrypted = this.decryptDatV4(datPath, key)
|
decrypted = this.decryptDatV4(datPath, key)
|
||||||
} else if (version === 2) {
|
} else if (version === 2) {
|
||||||
console.log('[ImageDecrypt] using V2 (user AES key)')
|
imageDecryptLog('[ImageDecrypt] using V2 (user AES key)')
|
||||||
if (!this.aesKey) {
|
if (!this.aesKey) {
|
||||||
console.log('[ImageDecrypt] no AES key configured')
|
imageDecryptLog('[ImageDecrypt] no AES key configured')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||||
decrypted = this.decryptDatV4(datPath, key)
|
decrypted = this.decryptDatV4(datPath, key)
|
||||||
} else {
|
} else {
|
||||||
console.log('[ImageDecrypt] unsupported dat version:', version)
|
imageDecryptLog('[ImageDecrypt] unsupported dat version:', version)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return decrypted
|
return decrypted
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ImageDecrypt] decrypt error:', error)
|
imageDecryptLog('[ImageDecrypt] decrypt error:', error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,7 +314,7 @@ export class ImageDecryptService {
|
|||||||
const unwrapped = this.unwrapWxgf(decrypted)
|
const unwrapped = this.unwrapWxgf(decrypted)
|
||||||
const ext = this.detectImageExtension(unwrapped)
|
const ext = this.detectImageExtension(unwrapped)
|
||||||
if (!ext) {
|
if (!ext) {
|
||||||
console.log('[ImageDecrypt] unknown image format')
|
imageDecryptLog('[ImageDecrypt] unknown image format')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +351,7 @@ export class ImageDecryptService {
|
|||||||
const data = this.decryptImageToBase64(candidate)
|
const data = this.decryptImageToBase64(candidate)
|
||||||
if (data) return { data, filePath: candidate }
|
if (data) return { data, filePath: candidate }
|
||||||
}
|
}
|
||||||
console.warn('[ImageDecrypt] all variants failed:', this.uniq(candidates))
|
imageDecryptLog('[ImageDecrypt] all variants failed:', this.uniq(candidates))
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-6
@@ -68,6 +68,9 @@ import {
|
|||||||
saveCachedMessages
|
saveCachedMessages
|
||||||
} from './services/bootstrap-cache'
|
} from './services/bootstrap-cache'
|
||||||
import { installSafeConsole } from './safe-log'
|
import { installSafeConsole } from './safe-log'
|
||||||
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
|
import { appLogger } from './app-logger'
|
||||||
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
|
||||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||||
@@ -140,6 +143,31 @@ function createWindow(): void {
|
|||||||
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
|
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||||
|
appLogger.write({
|
||||||
|
level: 'info',
|
||||||
|
scope: 'lifecycle',
|
||||||
|
message: 'WechatExplorer 启动',
|
||||||
|
details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() }
|
||||||
|
})
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
appLogger.write({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'main-process',
|
||||||
|
message: error.message,
|
||||||
|
details: { stack: error.stack }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
appLogger.write({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'main-process',
|
||||||
|
message: reason instanceof Error ? reason.message : 'Promise 未处理拒绝',
|
||||||
|
details: {
|
||||||
|
stack: reason instanceof Error ? reason.stack : undefined,
|
||||||
|
reason: reason instanceof Error ? undefined : String(reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
|
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
|
||||||
// per process. Bootstrap native once here so any later Wcdb4Client instance
|
// per process. Bootstrap native once here so any later Wcdb4Client instance
|
||||||
@@ -165,6 +193,9 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
// IPC test
|
// IPC test
|
||||||
ipcMain.on('ping', () => console.log('pong'))
|
ipcMain.on('ping', () => console.log('pong'))
|
||||||
|
ipcMain.handle('app-log:write', (_, entry: AppLogEntry) => appLogger.write(entry))
|
||||||
|
ipcMain.handle('app-log:getPath', () => appLogger.logPath)
|
||||||
|
ipcMain.handle('app-log:reveal', () => appLogger.reveal())
|
||||||
|
|
||||||
ipcMain.handle('db:init', async (_, key: string) => {
|
ipcMain.handle('db:init', async (_, key: string) => {
|
||||||
if (dbInitInFlight) return dbInitInFlight
|
if (dbInitInFlight) return dbInitInFlight
|
||||||
@@ -555,9 +586,9 @@ app.whenReady().then(async () => {
|
|||||||
if (!provider || !model) {
|
if (!provider || !model) {
|
||||||
return { success: false, error: '当前 AI 模型不存在' }
|
return { success: false, error: '当前 AI 模型不存在' }
|
||||||
}
|
}
|
||||||
if (!model.capabilities.vision) {
|
// Capability metadata is stored per machine. A model verified on macOS
|
||||||
return { success: false, error: '当前模型不支持图片理解' }
|
// may still be unmarked on Windows, so do not reject before making the
|
||||||
}
|
// real multimodal request. The provider response remains authoritative.
|
||||||
// request 来自 renderer,imageHash 是 md5(优先)或 sha256(...),dataUrl 在内部算出
|
// request 来自 renderer,imageHash 是 md5(优先)或 sha256(...),dataUrl 在内部算出
|
||||||
// 这里直接调 service,dataUrl 由 renderer 通过 window.api.getImage 拿到再传进来
|
// 这里直接调 service,dataUrl 由 renderer 通过 window.api.getImage 拿到再传进来
|
||||||
return imageInsightService.analyze(request)
|
return imageInsightService.analyze(request)
|
||||||
@@ -654,9 +685,10 @@ app.whenReady().then(async () => {
|
|||||||
return error ? { success: false, error } : { success: true }
|
return error ? { success: false, error } : { success: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:disconnect', () => {
|
ipcMain.handle('db:disconnect', (_, options?: { closeNative?: boolean }) => {
|
||||||
if (!chat.isReady()) return { success: false, error: '数据库当前未连接' }
|
// 断开操作保持幂等:渲染进程可能已标记断开,或主进程连接已先行失效。
|
||||||
chat.setChatDb(null)
|
// 即使当前未就绪,也应让用户正常返回登录页。
|
||||||
|
if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -698,6 +730,25 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('agent-hub:getStatus', () => agentHubService.getStatus())
|
||||||
|
ipcMain.handle('agent-hub:getLogs', () => agentHubService.getLogs())
|
||||||
|
ipcMain.handle('agent-hub:clearLogs', () => agentHubService.clearLogs())
|
||||||
|
ipcMain.handle('agent-hub:startLogin', () => agentHubService.startLogin())
|
||||||
|
ipcMain.handle('agent-hub:cancelLogin', () => agentHubService.cancelLogin())
|
||||||
|
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
|
||||||
|
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
|
||||||
|
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
const result = await dialog.showOpenDialog(window!, {
|
||||||
|
title: '选择要测试发送的图片',
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [
|
||||||
|
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
|
||||||
|
{ name: '所有文件', extensions: ['*'] }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
||||||
|
})
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
@@ -707,6 +758,8 @@ app.whenReady().then(async () => {
|
|||||||
await apiServer.start(settings.apiHost, settings.apiPort)
|
await apiServer.start(settings.apiHost, settings.apiPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await agentHubService.start(settings)
|
||||||
|
|
||||||
if (TRAY_MODE) {
|
if (TRAY_MODE) {
|
||||||
app.dock?.hide()
|
app.dock?.hide()
|
||||||
setupTray()
|
setupTray()
|
||||||
@@ -730,6 +783,7 @@ app.on('window-all-closed', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.on('before-quit', async () => {
|
app.on('before-quit', async () => {
|
||||||
|
agentHubService.stop()
|
||||||
chat.setChatDb(null)
|
chat.setChatDb(null)
|
||||||
await apiServer.stop().catch(() => undefined)
|
await apiServer.stop().catch(() => undefined)
|
||||||
if (tray) {
|
if (tray) {
|
||||||
|
|||||||
@@ -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
@@ -195,6 +195,9 @@ export class AIProviderService {
|
|||||||
const imageError = validateVisionImage(imagePart.dataUrl)
|
const imageError = validateVisionImage(imagePart.dataUrl)
|
||||||
if (imageError) throw new Error(imageError)
|
if (imageError) throw new Error(imageError)
|
||||||
const result = await this.request(messages as AIMessage[], options)
|
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 }
|
return { success: true, ...result }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: safeAIError(error) }
|
return { success: false, error: safeAIError(error) }
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ interface BootstrapCacheFile {
|
|||||||
const CACHE_VERSION = 1
|
const CACHE_VERSION = 1
|
||||||
const MAX_MESSAGE_BUCKETS = 24
|
const MAX_MESSAGE_BUCKETS = 24
|
||||||
const MAX_MESSAGES_PER_BUCKET = 1200
|
const MAX_MESSAGES_PER_BUCKET = 1200
|
||||||
|
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 {
|
function normalizeRoot(accountRoot?: string): string {
|
||||||
return String(accountRoot || '').trim()
|
return String(accountRoot || '').trim()
|
||||||
@@ -43,19 +47,26 @@ function getCacheFile(accountRoot?: string): string {
|
|||||||
.update(`${process.platform}:${normalizedRoot}`)
|
.update(`${process.platform}:${normalizedRoot}`)
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
.slice(0, 16)
|
.slice(0, 16)
|
||||||
return path.join(app.getPath('userData'), 'cache', 'bootstrap', `${process.platform}-${hash}.json`)
|
return path.join(
|
||||||
|
app.getPath('userData'),
|
||||||
|
'cache',
|
||||||
|
'bootstrap',
|
||||||
|
`${process.platform}-${hash}.json`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||||
const normalizedRoot = normalizeRoot(accountRoot)
|
const normalizedRoot = normalizeRoot(accountRoot)
|
||||||
if (!normalizedRoot) return null
|
if (!normalizedRoot) return null
|
||||||
const file = getCacheFile(normalizedRoot)
|
const file = getCacheFile(normalizedRoot)
|
||||||
|
const cached = memoryCache.get(file)
|
||||||
|
if (cached) return cached
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(file)) return null
|
if (!fs.existsSync(file)) return null
|
||||||
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
|
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
|
||||||
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
|
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
|
||||||
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
|
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
|
||||||
return {
|
const result: BootstrapCacheFile = {
|
||||||
version: CACHE_VERSION,
|
version: CACHE_VERSION,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
accountRoot: normalizedRoot,
|
accountRoot: normalizedRoot,
|
||||||
@@ -64,6 +75,8 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
|||||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
||||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
|
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
|
||||||
}
|
}
|
||||||
|
memoryCache.set(file, result)
|
||||||
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[BootstrapCache] read failed:', error)
|
console.warn('[BootstrapCache] read failed:', error)
|
||||||
return null
|
return null
|
||||||
@@ -71,28 +84,48 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeCacheFile(cache: BootstrapCacheFile): void {
|
function writeCacheFile(cache: BootstrapCacheFile): void {
|
||||||
try {
|
const file = getCacheFile(cache.accountRoot)
|
||||||
const file = getCacheFile(cache.accountRoot)
|
memoryCache.set(file, cache)
|
||||||
fs.ensureDirSync(path.dirname(file))
|
const existingTimer = writeTimers.get(file)
|
||||||
fs.writeJsonSync(file, cache, { spaces: 2 })
|
if (existingTimer) clearTimeout(existingTimer)
|
||||||
} catch (error) {
|
writeTimers.set(
|
||||||
console.warn('[BootstrapCache] write failed:', error)
|
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 {
|
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
|
||||||
const normalizedRoot = normalizeRoot(accountRoot)
|
const normalizedRoot = normalizeRoot(accountRoot)
|
||||||
if (!normalizedRoot) return null
|
if (!normalizedRoot) return null
|
||||||
return (
|
const existing = readCacheFile(normalizedRoot)
|
||||||
readCacheFile(normalizedRoot) || {
|
if (existing) return existing
|
||||||
version: CACHE_VERSION,
|
const created: BootstrapCacheFile = {
|
||||||
platform: process.platform,
|
version: CACHE_VERSION,
|
||||||
accountRoot: normalizedRoot,
|
platform: process.platform,
|
||||||
updatedAt: Date.now(),
|
accountRoot: normalizedRoot,
|
||||||
contacts: [],
|
updatedAt: Date.now(),
|
||||||
messages: {}
|
contacts: [],
|
||||||
}
|
messages: {}
|
||||||
)
|
}
|
||||||
|
memoryCache.set(getCacheFile(normalizedRoot), created)
|
||||||
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
|
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
|
||||||
@@ -144,7 +177,9 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact
|
|||||||
)
|
)
|
||||||
const nameByUsername = new Map(
|
const nameByUsername = new Map(
|
||||||
cache.contacts
|
cache.contacts
|
||||||
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
|
.filter(
|
||||||
|
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
|
||||||
|
)
|
||||||
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
||||||
)
|
)
|
||||||
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
|
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
|
||||||
@@ -179,7 +214,9 @@ export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]):
|
|||||||
)
|
)
|
||||||
const nameByUsername = new Map(
|
const nameByUsername = new Map(
|
||||||
(cache.contacts || [])
|
(cache.contacts || [])
|
||||||
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
|
.filter(
|
||||||
|
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
|
||||||
|
)
|
||||||
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
||||||
)
|
)
|
||||||
cache.contacts = contacts.map((contact) => ({
|
cache.contacts = contacts.map((contact) => ({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '../../shared/local-api-test'
|
} from '../../shared/local-api-test'
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 10_000
|
const REQUEST_TIMEOUT_MS = 10_000
|
||||||
|
const GROUP_REPORT_TIMEOUT_MS = 180_000
|
||||||
const MAX_BODY_SIZE = 512 * 1024
|
const MAX_BODY_SIZE = 512 * 1024
|
||||||
|
|
||||||
function isEndpointId(value: unknown): value is LocalApiEndpointId {
|
function isEndpointId(value: unknown): value is LocalApiEndpointId {
|
||||||
@@ -122,7 +123,9 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
const timeoutMs =
|
||||||
|
endpointId === 'agent-group-report' ? GROUP_REPORT_TIMEOUT_MS : REQUEST_TIMEOUT_MS
|
||||||
|
request.setTimeout(timeoutMs, () => {
|
||||||
request.destroy(new Error('请求超时'))
|
request.destroy(new Error('请求超时'))
|
||||||
finish({
|
finish({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -132,7 +135,7 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
|
|||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
responseSize: 0,
|
responseSize: 0,
|
||||||
errorCode: 'TIMEOUT',
|
errorCode: 'TIMEOUT',
|
||||||
error: '请求超时(10 秒)'
|
error: `请求超时(${Math.round(timeoutMs / 1000)} 秒)`
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
request.on('error', (error: NodeJS.ErrnoException) => {
|
request.on('error', (error: NodeJS.ErrnoException) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { app } from 'electron'
|
|||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
|
import { discoverWindowsDbRoots } from '../windows-db-root-discovery'
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
dbRoot: string
|
dbRoot: string
|
||||||
@@ -12,6 +13,8 @@ export interface AppSettings {
|
|||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
imageKeyFallbackDisabled: boolean
|
imageKeyFallbackDisabled: boolean
|
||||||
|
autoLogin: boolean
|
||||||
|
autoLoginPreferenceSet: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultDbRoot(): string {
|
function getDefaultDbRoot(): string {
|
||||||
@@ -35,14 +38,7 @@ function getDefaultDbRootCandidates(home: string): string[] {
|
|||||||
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const drive of getWindowsDrives()) {
|
candidates.push(...discoverWindowsDbRoots())
|
||||||
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
|
|
||||||
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
|
|
||||||
for (const child of listDirectories(`${drive}:\\`)) {
|
|
||||||
candidates.push(path.join(child, 'xwechat_files'))
|
|
||||||
candidates.push(path.join(child, 'WeChat Files'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return unique(candidates)
|
return unique(candidates)
|
||||||
}
|
}
|
||||||
@@ -66,32 +62,6 @@ function getWeflowDbPathCandidates(home: string): string[] {
|
|||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWindowsDrives(): string[] {
|
|
||||||
const drives: string[] = []
|
|
||||||
for (let code = 67; code <= 90; code += 1) {
|
|
||||||
const drive = String.fromCharCode(code)
|
|
||||||
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
|
|
||||||
}
|
|
||||||
return drives
|
|
||||||
}
|
|
||||||
|
|
||||||
function listDirectories(root: string): string[] {
|
|
||||||
try {
|
|
||||||
return fs
|
|
||||||
.readdirSync(root)
|
|
||||||
.map((name) => path.join(root, name))
|
|
||||||
.filter((candidate) => {
|
|
||||||
try {
|
|
||||||
return fs.statSync(candidate).isDirectory()
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function unique(values: string[]): string[] {
|
function unique(values: string[]): string[] {
|
||||||
return Array.from(new Set(values))
|
return Array.from(new Set(values))
|
||||||
}
|
}
|
||||||
@@ -118,7 +88,13 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
imageKeyRoot: defaultDbRoot,
|
imageKeyRoot: defaultDbRoot,
|
||||||
imageXorKey: '',
|
imageXorKey: '',
|
||||||
imageAesKey: '',
|
imageAesKey: '',
|
||||||
imageKeyFallbackDisabled: false
|
imageKeyFallbackDisabled: false,
|
||||||
|
autoLogin: ['1', 'true', 'yes', 'on'].includes(
|
||||||
|
String(import.meta.env.VITE_AUTO_LOGIN || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
),
|
||||||
|
autoLoginPreferenceSet: false
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_FILE = path.join(
|
const SETTINGS_FILE = path.join(
|
||||||
@@ -138,6 +114,12 @@ export function loadSettings(): AppSettings {
|
|||||||
if (fs.existsSync(SETTINGS_FILE)) {
|
if (fs.existsSync(SETTINGS_FILE)) {
|
||||||
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
|
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
|
||||||
cache = { ...DEFAULT_SETTINGS, ...raw }
|
cache = { ...DEFAULT_SETTINGS, ...raw }
|
||||||
|
if (raw.autoLogin === undefined) {
|
||||||
|
const hasSavedDatabaseKey = fs.existsSync(
|
||||||
|
path.join(app.getPath('userData'), 'wechat-db-key.bin')
|
||||||
|
)
|
||||||
|
if (hasSavedDatabaseKey) cache.autoLogin = true
|
||||||
|
}
|
||||||
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
|
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
|
||||||
cache.dbRoot = getDefaultDbRoot()
|
cache.dbRoot = getDefaultDbRoot()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
|
import { discoverWindowsDbRoots } from './windows-db-root-discovery'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { createRequire } from 'module'
|
import { createRequire } from 'module'
|
||||||
import { createConnection, Socket } from 'net'
|
import { createConnection, Socket } from 'net'
|
||||||
@@ -303,14 +304,7 @@ export class Wcdb4Client {
|
|||||||
path.join(home, 'WeChat Files'),
|
path.join(home, 'WeChat Files'),
|
||||||
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
||||||
]
|
]
|
||||||
for (const drive of Wcdb4Client.getWindowsDrives()) {
|
candidates.push(...discoverWindowsDbRoots())
|
||||||
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
|
|
||||||
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
|
|
||||||
for (const child of Wcdb4Client.listDirectories(`${drive}:\\`)) {
|
|
||||||
candidates.push(path.join(child, 'xwechat_files'))
|
|
||||||
candidates.push(path.join(child, 'WeChat Files'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(new Set(candidates))
|
return Array.from(new Set(candidates))
|
||||||
}
|
}
|
||||||
return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')]
|
return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')]
|
||||||
@@ -335,32 +329,6 @@ export class Wcdb4Client {
|
|||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getWindowsDrives(): string[] {
|
|
||||||
const drives: string[] = []
|
|
||||||
for (let code = 67; code <= 90; code += 1) {
|
|
||||||
const drive = String.fromCharCode(code)
|
|
||||||
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
|
|
||||||
}
|
|
||||||
return drives
|
|
||||||
}
|
|
||||||
|
|
||||||
private static listDirectories(root: string): string[] {
|
|
||||||
try {
|
|
||||||
return fs
|
|
||||||
.readdirSync(root)
|
|
||||||
.map((name) => path.join(root, name))
|
|
||||||
.filter((candidate) => {
|
|
||||||
try {
|
|
||||||
return fs.statSync(candidate).isDirectory()
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static hasDbStorage(candidate: string): boolean {
|
private static hasDbStorage(candidate: string): boolean {
|
||||||
try {
|
try {
|
||||||
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
|
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const DB_ROOT_NAMES = new Set(['xwechat_files', 'wechat files'])
|
||||||
|
const SKIPPED_DIRECTORY_NAMES = new Set([
|
||||||
|
'$recycle.bin',
|
||||||
|
'system volume information',
|
||||||
|
'windows',
|
||||||
|
'program files',
|
||||||
|
'program files (x86)',
|
||||||
|
'programdata',
|
||||||
|
'recovery'
|
||||||
|
])
|
||||||
|
const MAX_VISITED_DIRECTORIES_PER_DRIVE = 20_000
|
||||||
|
let cachedDiscoveredRoots: string[] | null = null
|
||||||
|
|
||||||
|
function unique(values: string[]): string[] {
|
||||||
|
return Array.from(new Set(values.map((value) => path.normalize(value))))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWindowsDrives(): string[] {
|
||||||
|
if (process.platform !== 'win32') return []
|
||||||
|
const drives: string[] = []
|
||||||
|
for (let code = 67; code <= 90; code += 1) {
|
||||||
|
const root = `${String.fromCharCode(code)}:\\`
|
||||||
|
if (fs.existsSync(root)) drives.push(root)
|
||||||
|
}
|
||||||
|
return drives
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanWindowsDbRoots(driveRoots: string[], maxDepth = 3): string[] {
|
||||||
|
const results: string[] = []
|
||||||
|
|
||||||
|
for (const driveRoot of driveRoots) {
|
||||||
|
const queue: Array<{ directory: string; depth: number }> = [{ directory: driveRoot, depth: 0 }]
|
||||||
|
let visited = 0
|
||||||
|
|
||||||
|
while (queue.length > 0 && visited < MAX_VISITED_DIRECTORIES_PER_DRIVE) {
|
||||||
|
const current = queue.shift()
|
||||||
|
if (!current || current.depth >= maxDepth) continue
|
||||||
|
|
||||||
|
let entries: fs.Dirent[]
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current.directory, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
|
||||||
|
const lowered = entry.name.toLowerCase()
|
||||||
|
const fullPath = path.join(current.directory, entry.name)
|
||||||
|
const depth = current.depth + 1
|
||||||
|
visited += 1
|
||||||
|
|
||||||
|
if (DB_ROOT_NAMES.has(lowered)) {
|
||||||
|
results.push(fullPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (depth < maxDepth && !SKIPPED_DIRECTORY_NAMES.has(lowered)) {
|
||||||
|
queue.push({ directory: fullPath, depth })
|
||||||
|
}
|
||||||
|
if (visited >= MAX_VISITED_DIRECTORIES_PER_DRIVE) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unique(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverWindowsDbRoots(): string[] {
|
||||||
|
if (!cachedDiscoveredRoots) {
|
||||||
|
cachedDiscoveredRoots = scanWindowsDbRoots(getWindowsDrives(), 3)
|
||||||
|
}
|
||||||
|
return [...cachedDiscoveredRoots]
|
||||||
|
}
|
||||||
Vendored
+26
-1
@@ -37,6 +37,8 @@ import type {
|
|||||||
ImageCandidateQuery,
|
ImageCandidateQuery,
|
||||||
ImageInsight
|
ImageInsight
|
||||||
} from '../shared/image-insight'
|
} from '../shared/image-insight'
|
||||||
|
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||||
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
@@ -70,6 +72,9 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
electron: ElectronAPI
|
electron: ElectronAPI
|
||||||
api: {
|
api: {
|
||||||
|
writeAppLog: (entry: AppLogEntry) => Promise<void>
|
||||||
|
getAppLogPath: () => Promise<string>
|
||||||
|
revealAppLog: () => Promise<void>
|
||||||
initDb: (
|
initDb: (
|
||||||
key: string
|
key: string
|
||||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||||
@@ -186,6 +191,8 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
autoLogin: boolean
|
||||||
|
autoLoginPreferenceSet: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -210,6 +217,8 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
autoLogin: boolean
|
||||||
|
autoLoginPreferenceSet: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -222,6 +231,8 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
autoLogin: boolean
|
||||||
|
autoLoginPreferenceSet: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}>
|
}>
|
||||||
@@ -232,6 +243,8 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
autoLogin: boolean
|
||||||
|
autoLoginPreferenceSet: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -252,7 +265,9 @@ declare global {
|
|||||||
}>
|
}>
|
||||||
selectDbRoot: () => Promise<{ canceled: boolean; path?: string }>
|
selectDbRoot: () => Promise<{ canceled: boolean; path?: string }>
|
||||||
openAccountRoot: () => Promise<{ success: boolean; error?: string }>
|
openAccountRoot: () => Promise<{ success: boolean; error?: string }>
|
||||||
disconnectDb: () => Promise<{ success: boolean; error?: string }>
|
disconnectDb: (options?: {
|
||||||
|
closeNative?: boolean
|
||||||
|
}) => Promise<{ success: boolean; error?: string }>
|
||||||
apiStatus: () => Promise<{
|
apiStatus: () => Promise<{
|
||||||
running: boolean
|
running: boolean
|
||||||
host: string
|
host: string
|
||||||
@@ -298,6 +313,16 @@ declare global {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
limit?: number
|
limit?: number
|
||||||
) => Promise<{ success: boolean; insights: ImageInsight[] }>
|
) => Promise<{ success: boolean; insights: ImageInsight[] }>
|
||||||
|
getAgentHubStatus: () => Promise<AgentHubStatus>
|
||||||
|
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
||||||
|
clearAgentHubLogs: () => Promise<void>
|
||||||
|
startAgentHubLogin: () => Promise<AgentHubActionResult>
|
||||||
|
cancelAgentHubLogin: () => Promise<AgentHubActionResult>
|
||||||
|
reconnectAgentHub: () => Promise<AgentHubActionResult>
|
||||||
|
disconnectAgentHub: () => Promise<AgentHubActionResult>
|
||||||
|
selectAgentHubTestImage: () => Promise<{ canceled: boolean; path?: string }>
|
||||||
|
onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => () => void
|
||||||
|
onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-3
@@ -15,9 +15,14 @@ import type {
|
|||||||
ImageCandidateQuery,
|
ImageCandidateQuery,
|
||||||
ImageInsight
|
ImageInsight
|
||||||
} from '../shared/image-insight'
|
} from '../shared/image-insight'
|
||||||
|
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||||
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
|
||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
|
writeAppLog: (entry: AppLogEntry) => ipcRenderer.invoke('app-log:write', entry),
|
||||||
|
getAppLogPath: () => ipcRenderer.invoke('app-log:getPath'),
|
||||||
|
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
|
||||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||||
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||||
@@ -105,7 +110,8 @@ const api = {
|
|||||||
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
|
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
|
||||||
selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'),
|
selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'),
|
||||||
openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'),
|
openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'),
|
||||||
disconnectDb: () => ipcRenderer.invoke('db:disconnect'),
|
disconnectDb: (options?: { closeNative?: boolean }) =>
|
||||||
|
ipcRenderer.invoke('db:disconnect', options),
|
||||||
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
|
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
|
||||||
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
|
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
|
||||||
apiStop: () => ipcRenderer.invoke('api:stop'),
|
apiStop: () => ipcRenderer.invoke('api:stop'),
|
||||||
@@ -119,7 +125,9 @@ const api = {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// AI 图片理解基础设施(ImageInsightService)
|
// AI 图片理解基础设施(ImageInsightService)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
imageListCandidates: (query: ImageCandidateQuery): Promise<{
|
imageListCandidates: (
|
||||||
|
query: ImageCandidateQuery
|
||||||
|
): Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
candidates: ImageCandidate[]
|
candidates: ImageCandidate[]
|
||||||
error?: string
|
error?: string
|
||||||
@@ -132,7 +140,27 @@ const api = {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
limit?: number
|
limit?: number
|
||||||
): Promise<{ success: boolean; insights: ImageInsight[] }> =>
|
): Promise<{ success: boolean; insights: ImageInsight[] }> =>
|
||||||
ipcRenderer.invoke('image:listInsights', sessionId, limit)
|
ipcRenderer.invoke('image:listInsights', sessionId, limit),
|
||||||
|
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
||||||
|
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
|
||||||
|
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
|
||||||
|
startAgentHubLogin: () => ipcRenderer.invoke('agent-hub:startLogin'),
|
||||||
|
cancelAgentHubLogin: () => ipcRenderer.invoke('agent-hub:cancelLogin'),
|
||||||
|
reconnectAgentHub: () => ipcRenderer.invoke('agent-hub:reconnect'),
|
||||||
|
disconnectAgentHub: () => ipcRenderer.invoke('agent-hub:disconnect'),
|
||||||
|
selectAgentHubTestImage: () => ipcRenderer.invoke('agent-hub:selectTestImage'),
|
||||||
|
onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, status: AgentHubStatus): void =>
|
||||||
|
callback(status)
|
||||||
|
ipcRenderer.on('agent-hub:status', listener)
|
||||||
|
return () => ipcRenderer.removeListener('agent-hub:status', listener)
|
||||||
|
},
|
||||||
|
onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, entry: AgentHubLogEntry): void =>
|
||||||
|
callback(entry)
|
||||||
|
ipcRenderer.on('agent-hub:log', listener)
|
||||||
|
return () => ipcRenderer.removeListener('agent-hub:log', listener)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
|
|||||||
+88
-127
@@ -4,6 +4,7 @@ import ChatWindow from './components/ChatWindow'
|
|||||||
import { AppShell } from './components/layout/AppShell'
|
import { AppShell } from './components/layout/AppShell'
|
||||||
import { ApiWorkspace } from './features/api-center/ApiWorkspace'
|
import { ApiWorkspace } from './features/api-center/ApiWorkspace'
|
||||||
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
|
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
|
||||||
|
import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace'
|
||||||
import type { SettingsCategoryId } from './features/settings/model/types'
|
import type { SettingsCategoryId } from './features/settings/model/types'
|
||||||
import type { AIRuntimeModelConfig } from '../../shared/ai-provider'
|
import type { AIRuntimeModelConfig } from '../../shared/ai-provider'
|
||||||
import { AppPage } from './components/layout/navigation'
|
import { AppPage } from './components/layout/navigation'
|
||||||
@@ -18,33 +19,14 @@ import type { GeneratedReportRecord, ReportWorkspaceView } from './components/re
|
|||||||
import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration'
|
import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration'
|
||||||
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
|
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
|
||||||
import { Contact, Message } from '../../shared/types'
|
import { Contact, Message } from '../../shared/types'
|
||||||
|
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 260
|
const SIDEBAR_MIN_WIDTH = 260
|
||||||
const SIDEBAR_MAX_WIDTH = 380
|
const SIDEBAR_MAX_WIDTH = 380
|
||||||
|
|
||||||
function EyeIcon({ hidden }: { hidden: boolean }): React.ReactElement {
|
function getDevelopmentDatabaseKey(): string {
|
||||||
return (
|
if (!import.meta.env.DEV) return ''
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
return String(import.meta.env.VITE_DB_KEY || '').trim()
|
||||||
<path
|
|
||||||
d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
|
||||||
{hidden && (
|
|
||||||
<path
|
|
||||||
d="M4 4l16 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelfInfo {
|
interface SelfInfo {
|
||||||
@@ -57,12 +39,6 @@ interface SelfInfo {
|
|||||||
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
||||||
const VIEW_MESSAGE_LIMIT = 600
|
const VIEW_MESSAGE_LIMIT = 600
|
||||||
const AUTO_LOGIN_ENABLED = ['1', 'true', 'yes', 'on'].includes(
|
|
||||||
String(import.meta.env.VITE_AUTO_LOGIN || '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
)
|
|
||||||
|
|
||||||
const getMessageIdentity = (message: Message): string => {
|
const getMessageIdentity = (message: Message): string => {
|
||||||
if (message.localId) return `local:${message.localId}`
|
if (message.localId) return `local:${message.localId}`
|
||||||
if (message.id) return `id:${message.id}`
|
if (message.id) return `id:${message.id}`
|
||||||
@@ -159,7 +135,7 @@ const sortMessagesChronologically = (items: Message[]): Message[] =>
|
|||||||
function App(): React.ReactElement {
|
function App(): React.ReactElement {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
||||||
const [dbKey, setDbKey] = useState(import.meta.env.VITE_DB_KEY || '')
|
const [dbKey, setDbKey] = useState(getDevelopmentDatabaseKey)
|
||||||
const [contacts, setContacts] = useState<Contact[]>([])
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
@@ -173,6 +149,9 @@ function App(): React.ReactElement {
|
|||||||
const [showDbKey, setShowDbKey] = useState(false)
|
const [showDbKey, setShowDbKey] = useState(false)
|
||||||
const [dbRootInput, setDbRootInput] = useState('')
|
const [dbRootInput, setDbRootInput] = useState('')
|
||||||
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
||||||
|
const [databaseConnectionMode, setDatabaseConnectionMode] = useState<DatabaseConnectionMode>(
|
||||||
|
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
|
||||||
|
)
|
||||||
const [activePage, setActivePage] = useState<AppPage>('archive')
|
const [activePage, setActivePage] = useState<AppPage>('archive')
|
||||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||||
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
||||||
@@ -256,18 +235,25 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
const waitForPaint = (): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, 80))
|
const waitForPaint = (): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, 80))
|
||||||
|
|
||||||
const refreshSelfInfo = async (): Promise<void> => {
|
const refreshSelfInfo = async (attempts = 1): Promise<SelfInfo | null> => {
|
||||||
try {
|
let lastError: unknown
|
||||||
const result = await window.api.getSelf()
|
for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
|
||||||
if (result.ready) {
|
try {
|
||||||
setSelfInfo(result.info)
|
const result = await window.api.getSelf()
|
||||||
} else {
|
if (result.ready && result.info) {
|
||||||
setSelfInfo(null)
|
setSelfInfo(result.info)
|
||||||
|
return result.info
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
if (attempt + 1 < attempts) {
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 180))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.warn('[SelfInfo] 加载失败:', error)
|
|
||||||
setSelfInfo(null)
|
|
||||||
}
|
}
|
||||||
|
if (lastError) console.warn('[SelfInfo] 加载失败:', lastError)
|
||||||
|
setSelfInfo(null)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadBootstrapCache = async (): Promise<boolean> => {
|
const loadBootstrapCache = async (): Promise<boolean> => {
|
||||||
@@ -330,7 +316,7 @@ function App(): React.ReactElement {
|
|||||||
usernames.length ? 55 : 90
|
usernames.length ? 55 : 90
|
||||||
)
|
)
|
||||||
let loadedCount = 0
|
let loadedCount = 0
|
||||||
const chunkSize = 8
|
const chunkSize = 32
|
||||||
for (let index = 0; index < usernames.length; index += chunkSize) {
|
for (let index = 0; index < usernames.length; index += chunkSize) {
|
||||||
if (runId !== contactAvatarHydrationRunRef.current) return
|
if (runId !== contactAvatarHydrationRunRef.current) return
|
||||||
const chunk = usernames.slice(index, index + chunkSize)
|
const chunk = usernames.slice(index, index + chunkSize)
|
||||||
@@ -361,7 +347,7 @@ function App(): React.ReactElement {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Contacts] avatar hydrate failed:', error)
|
console.warn('[Contacts] avatar hydrate failed:', error)
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 150))
|
await new Promise((resolve) => window.setTimeout(resolve, 50))
|
||||||
}
|
}
|
||||||
if (usernames.length) onProgress?.('头像加载完成', 90)
|
if (usernames.length) onProgress?.('头像加载完成', 90)
|
||||||
}
|
}
|
||||||
@@ -369,16 +355,15 @@ function App(): React.ReactElement {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
const attemptAutoConnect = async (): Promise<void> => {
|
const attemptAutoConnect = async (): Promise<void> => {
|
||||||
|
const settingsResult = await window.api.getSettings()
|
||||||
// 预填已保存的微信聊天文件路径
|
// 预填已保存的微信聊天文件路径
|
||||||
try {
|
if (active && settingsResult.settings.dbRoot) {
|
||||||
const settings = await window.api.getSettings()
|
setDbRootInput(settingsResult.settings.dbRoot)
|
||||||
if (active && settings?.settings?.dbRoot) setDbRootInput(settings.settings.dbRoot)
|
|
||||||
} catch {
|
|
||||||
// 忽略读取设置失败,继续走密钥流程
|
|
||||||
}
|
}
|
||||||
// 优先级 1: 构建期环境变量 VITE_DB_KEY(本地开发用)
|
const autoLoginEnabled = settingsResult.settings.autoLogin
|
||||||
const envKey = String(import.meta.env.VITE_DB_KEY || '').trim()
|
// 开发环境允许使用 VITE_DB_KEY;生产安装包只能读取目标电脑自己的 safeStorage。
|
||||||
// 优先级 2: 上一次保存到 safeStorage 的密钥
|
const envKey = getDevelopmentDatabaseKey()
|
||||||
|
// 生产环境以及未配置开发密钥时,读取上一次保存到 safeStorage 的密钥。
|
||||||
let savedKey = ''
|
let savedKey = ''
|
||||||
if (!envKey) {
|
if (!envKey) {
|
||||||
const result = await window.api.getSavedDbKey()
|
const result = await window.api.getSavedDbKey()
|
||||||
@@ -386,14 +371,18 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
const key = envKey || savedKey
|
const key = envKey || savedKey
|
||||||
if (!key) {
|
if (!key) {
|
||||||
if (active) setBootState('login')
|
if (active) {
|
||||||
|
setDatabaseConnectionMode('automatic')
|
||||||
|
setBootState('login')
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (active) {
|
if (active) {
|
||||||
setDbKey(key)
|
setDbKey(key)
|
||||||
|
setDatabaseConnectionMode('manual')
|
||||||
setAutoConnectSource(envKey ? 'env' : 'saved')
|
setAutoConnectSource(envKey ? 'env' : 'saved')
|
||||||
setDbKeyStatus(
|
setDbKeyStatus(
|
||||||
AUTO_LOGIN_ENABLED
|
autoLoginEnabled
|
||||||
? envKey
|
? envKey
|
||||||
? '检测到环境变量中的密钥,正在自动连接...'
|
? '检测到环境变量中的密钥,正在自动连接...'
|
||||||
: '已加载安全保存的密钥,正在自动连接...'
|
: '已加载安全保存的密钥,正在自动连接...'
|
||||||
@@ -402,21 +391,24 @@ function App(): React.ReactElement {
|
|||||||
: '已加载安全保存的密钥,请手动点击 Connect'
|
: '已加载安全保存的密钥,请手动点击 Connect'
|
||||||
)
|
)
|
||||||
setDbKeyStatusKind('normal')
|
setDbKeyStatusKind('normal')
|
||||||
setBootState(AUTO_LOGIN_ENABLED ? 'connecting' : 'login')
|
setBootState(autoLoginEnabled ? 'connecting' : 'login')
|
||||||
}
|
}
|
||||||
if (!AUTO_LOGIN_ENABLED) return
|
if (!autoLoginEnabled) return
|
||||||
try {
|
try {
|
||||||
const result = await window.api.initDb(key)
|
const result = await window.api.initDb(key)
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const success = typeof result === 'boolean' ? result : result.success
|
const success = typeof result === 'boolean' ? result : result.success
|
||||||
if (success) {
|
if (success) {
|
||||||
|
if (!settingsResult.settings.autoLoginPreferenceSet) {
|
||||||
|
void window.api.setSettings({ autoLogin: true })
|
||||||
|
}
|
||||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||||
setIsAuthenticated(true)
|
|
||||||
setIsDatabaseConnected(true)
|
setIsDatabaseConnected(true)
|
||||||
setDbKeyStatus('已自动连接')
|
setDbKeyStatus('已自动连接')
|
||||||
setDbKeyStatusKind('success')
|
setDbKeyStatusKind('success')
|
||||||
await loadContacts()
|
await loadContacts()
|
||||||
void refreshSelfInfo()
|
await refreshSelfInfo(3)
|
||||||
|
setIsAuthenticated(true)
|
||||||
} else {
|
} else {
|
||||||
const error = typeof result === 'boolean' ? '' : result.error
|
const error = typeof result === 'boolean' ? '' : result.error
|
||||||
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
|
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
|
||||||
@@ -486,16 +478,24 @@ function App(): React.ReactElement {
|
|||||||
detail: '正在读取本地缓存',
|
detail: '正在读取本地缓存',
|
||||||
percent: 25
|
percent: 25
|
||||||
})
|
})
|
||||||
const hasBootstrapCache = await loadBootstrapCache()
|
await loadBootstrapCache()
|
||||||
// 持久化手动输入的密钥,供下次启动继续使用
|
// 持久化手动输入的密钥,供下次启动继续使用
|
||||||
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
||||||
|
void window.api.getSettings().then((current) => {
|
||||||
|
if (!current.settings.autoLoginPreferenceSet) {
|
||||||
|
void window.api.setSettings({ autoLogin: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
setStartupProgress({
|
setStartupProgress({
|
||||||
title: '正在加载账号信息...',
|
title: '正在加载账号信息...',
|
||||||
subtitle: '即将进入 WechatExplorer',
|
subtitle: '即将进入 WechatExplorer',
|
||||||
detail: '正在读取当前账号信息',
|
detail: '正在读取联系人和当前账号',
|
||||||
percent: 95
|
percent: 70
|
||||||
})
|
})
|
||||||
void refreshSelfInfo()
|
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
|
||||||
|
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
|
||||||
|
await loadContacts({ waitForAvatars: false })
|
||||||
|
await refreshSelfInfo(3)
|
||||||
setStartupProgress({
|
setStartupProgress({
|
||||||
title: '加载完成',
|
title: '加载完成',
|
||||||
subtitle: '正在进入主页面',
|
subtitle: '正在进入主页面',
|
||||||
@@ -507,7 +507,6 @@ function App(): React.ReactElement {
|
|||||||
setBootState('login')
|
setBootState('login')
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setStartupProgress(null)
|
setStartupProgress(null)
|
||||||
if (!hasBootstrapCache) void loadContacts({ waitForAvatars: false })
|
|
||||||
}, 500)
|
}, 500)
|
||||||
} else {
|
} else {
|
||||||
const error = typeof result === 'boolean' ? '' : result.error
|
const error = typeof result === 'boolean' ? '' : result.error
|
||||||
@@ -612,6 +611,7 @@ function App(): React.ReactElement {
|
|||||||
throw new Error(result.error || '获取密钥失败')
|
throw new Error(result.error || '获取密钥失败')
|
||||||
}
|
}
|
||||||
setDbKey(result.key)
|
setDbKey(result.key)
|
||||||
|
setDatabaseConnectionMode('manual')
|
||||||
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
|
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
|
||||||
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
|
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -627,6 +627,7 @@ function App(): React.ReactElement {
|
|||||||
const result = await window.api.pasteAndSaveDbKey()
|
const result = await window.api.pasteAndSaveDbKey()
|
||||||
if (result.success && result.key) {
|
if (result.success && result.key) {
|
||||||
setDbKey(result.key)
|
setDbKey(result.key)
|
||||||
|
setDatabaseConnectionMode('manual')
|
||||||
setDbKeyStatus('已从剪贴板粘贴并安全保存')
|
setDbKeyStatus('已从剪贴板粘贴并安全保存')
|
||||||
setDbKeyStatusKind('success')
|
setDbKeyStatusKind('success')
|
||||||
} else {
|
} else {
|
||||||
@@ -644,6 +645,7 @@ function App(): React.ReactElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setDbKey('')
|
setDbKey('')
|
||||||
|
setDatabaseConnectionMode('automatic')
|
||||||
setDbKeyStatus('已清除保存的密钥')
|
setDbKeyStatus('已清除保存的密钥')
|
||||||
setDbKeyStatusKind('normal')
|
setDbKeyStatusKind('normal')
|
||||||
}
|
}
|
||||||
@@ -652,6 +654,7 @@ function App(): React.ReactElement {
|
|||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
setIsDatabaseConnected(false)
|
setIsDatabaseConnected(false)
|
||||||
setBootState('login')
|
setBootState('login')
|
||||||
|
setDatabaseConnectionMode(dbKey ? 'manual' : 'automatic')
|
||||||
setActivePage('archive')
|
setActivePage('archive')
|
||||||
setSettingsCategory('database-key')
|
setSettingsCategory('database-key')
|
||||||
setSelectedContact(null)
|
setSelectedContact(null)
|
||||||
@@ -1036,9 +1039,9 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderPlaceholderPage = (
|
const renderPlaceholderPage = (
|
||||||
page: Exclude<AppPage, 'archive' | 'report'>
|
page: Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>
|
||||||
): React.ReactElement => {
|
): React.ReactElement => {
|
||||||
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
|
const labels: Record<Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>, string> = {
|
||||||
search: '检索',
|
search: '检索',
|
||||||
export: '导出',
|
export: '导出',
|
||||||
api: 'API',
|
api: 'API',
|
||||||
@@ -1168,6 +1171,8 @@ function App(): React.ReactElement {
|
|||||||
return renderArchiveWorkspace()
|
return renderArchiveWorkspace()
|
||||||
case 'report':
|
case 'report':
|
||||||
return renderReportWorkspace()
|
return renderReportWorkspace()
|
||||||
|
case 'agent-hub':
|
||||||
|
return <AgentHubWorkspace />
|
||||||
case 'api':
|
case 'api':
|
||||||
return (
|
return (
|
||||||
<ApiWorkspace
|
<ApiWorkspace
|
||||||
@@ -1273,70 +1278,26 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="login-modal">
|
<DatabaseConnectionPage
|
||||||
<div className="login-box">
|
platform={window.electron.process.platform}
|
||||||
<h2>Enter WeChat DB Key</h2>
|
mode={databaseConnectionMode}
|
||||||
<div className="login-input-wrapper">
|
dbKey={dbKey}
|
||||||
<input
|
dbRoot={dbRootInput}
|
||||||
type={showDbKey ? 'text' : 'password'}
|
showDbKey={showDbKey}
|
||||||
className="login-input"
|
isFetching={isFetchingDbKey}
|
||||||
value={dbKey}
|
status={dbKeyStatus}
|
||||||
onChange={(e) => setDbKey(e.target.value)}
|
statusKind={dbKeyStatusKind}
|
||||||
placeholder="Key (e.g. 0x...)"
|
showMacKeyFaq={showMacKeyFaq}
|
||||||
/>
|
macKeyFaqUrl={MAC_KEY_FAQ_URL}
|
||||||
<button
|
onModeChange={setDatabaseConnectionMode}
|
||||||
type="button"
|
onDbKeyChange={setDbKey}
|
||||||
className="login-input-toggle"
|
onDbRootChange={setDbRootInput}
|
||||||
onClick={() => setShowDbKey(!showDbKey)}
|
onToggleDbKey={() => setShowDbKey((visible) => !visible)}
|
||||||
title={showDbKey ? '隐藏密钥' : '显示密钥'}
|
onAutoGetKey={handleAutoGetDbKey}
|
||||||
>
|
onManualConnect={() => handleLogin()}
|
||||||
<EyeIcon hidden={showDbKey} />
|
onPasteKey={handlePasteAndSaveDbKey}
|
||||||
</button>
|
onClearKey={handleClearSavedDbKey}
|
||||||
</div>
|
/>
|
||||||
{window.electron.process.platform === 'win32' && (
|
|
||||||
<div className="login-input-wrapper">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="login-input"
|
|
||||||
value={dbRootInput}
|
|
||||||
onChange={(e) => setDbRootInput(e.target.value)}
|
|
||||||
placeholder="微信聊天文件路径 (如 D:\\Tencent\\WeChat\\xwechat_files)"
|
|
||||||
spellCheck={false}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="login-btn login-btn-secondary"
|
|
||||||
onClick={handleAutoGetDbKey}
|
|
||||||
disabled={isFetchingDbKey}
|
|
||||||
>
|
|
||||||
{isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
|
|
||||||
</button>
|
|
||||||
<button className="login-btn login-btn-secondary" onClick={handlePasteAndSaveDbKey}>
|
|
||||||
粘贴并安全保存
|
|
||||||
</button>
|
|
||||||
<button className="login-btn" onClick={() => handleLogin()}>
|
|
||||||
Connect
|
|
||||||
</button>
|
|
||||||
<button className="login-clear-btn" onClick={handleClearSavedDbKey}>
|
|
||||||
清除已保存密钥
|
|
||||||
</button>
|
|
||||||
{dbKeyStatus && (
|
|
||||||
<div className={`login-key-status ${dbKeyStatusKind}`}>{dbKeyStatus}</div>
|
|
||||||
)}
|
|
||||||
{showMacKeyFaq && (
|
|
||||||
<a
|
|
||||||
className="login-key-help-link"
|
|
||||||
href={MAC_KEY_FAQ_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
查看 macOS 获取密钥排障指引
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3332
-249
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,6 @@ interface ChatWindowProps {
|
|||||||
isAiLoading?: boolean
|
isAiLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_RENDERED_MESSAGES = 600
|
|
||||||
const DATE_RANGE_LABELS: Record<string, string> = {
|
const DATE_RANGE_LABELS: Record<string, string> = {
|
||||||
today: '今天',
|
today: '今天',
|
||||||
yesterday: '昨日',
|
yesterday: '昨日',
|
||||||
@@ -242,12 +241,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
return typeMatch && contentMatch
|
return typeMatch && contentMatch
|
||||||
})
|
})
|
||||||
}, [messages, contentFilter])
|
}, [messages, contentFilter])
|
||||||
const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES)
|
|
||||||
const renderedMessages = React.useMemo(
|
|
||||||
() => filteredMessages.slice(-MAX_RENDERED_MESSAGES),
|
|
||||||
[filteredMessages]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!contact) return <EmptyConversationState />
|
if (!contact) return <EmptyConversationState />
|
||||||
|
|
||||||
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
||||||
@@ -272,8 +265,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
<DataTrustBar messageCount={messages.length} />
|
<DataTrustBar messageCount={messages.length} />
|
||||||
<MessageList
|
<MessageList
|
||||||
contact={contact}
|
contact={contact}
|
||||||
messages={renderedMessages}
|
messages={filteredMessages}
|
||||||
hiddenMessageCount={hiddenMessageCount}
|
hiddenMessageCount={0}
|
||||||
isLoadingMessages={isLoadingMessages}
|
isLoadingMessages={isLoadingMessages}
|
||||||
isGroupChat={isGroupChat}
|
isGroupChat={isGroupChat}
|
||||||
showAvatar={showAvatar}
|
showAvatar={showAvatar}
|
||||||
@@ -283,7 +276,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onImageClick={openImagePreview}
|
onImageClick={openImagePreview}
|
||||||
/>
|
/>
|
||||||
<ChatStatusBar
|
<ChatStatusBar
|
||||||
count={renderedMessages.length}
|
count={filteredMessages.length}
|
||||||
showAvatar={showAvatar}
|
showAvatar={showAvatar}
|
||||||
isAtLatest={isAtLatest}
|
isAtLatest={isAtLatest}
|
||||||
onShowAvatarChange={setShowAvatar}
|
onShowAvatarChange={setShowAvatar}
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export type DatabaseConnectionMode = 'automatic' | 'manual'
|
||||||
|
export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error'
|
||||||
|
|
||||||
|
interface DatabaseConnectionPageProps {
|
||||||
|
platform: string
|
||||||
|
mode: DatabaseConnectionMode
|
||||||
|
dbKey: string
|
||||||
|
dbRoot: string
|
||||||
|
showDbKey: boolean
|
||||||
|
isFetching: boolean
|
||||||
|
status: string
|
||||||
|
statusKind: DatabaseConnectionStatusKind
|
||||||
|
showMacKeyFaq: boolean
|
||||||
|
macKeyFaqUrl: string
|
||||||
|
onModeChange: (mode: DatabaseConnectionMode) => void
|
||||||
|
onDbKeyChange: (value: string) => void
|
||||||
|
onDbRootChange: (value: string) => void
|
||||||
|
onToggleDbKey: () => void
|
||||||
|
onAutoGetKey: () => void
|
||||||
|
onManualConnect: () => void
|
||||||
|
onPasteKey: () => void
|
||||||
|
onClearKey: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineIcon({
|
||||||
|
name
|
||||||
|
}: {
|
||||||
|
name: 'shield' | 'lock' | 'cloud' | 'info' | 'database'
|
||||||
|
}): React.ReactElement {
|
||||||
|
const paths = {
|
||||||
|
shield: <path d="M12 3 5 6v5c0 4.5 2.8 7.7 7 10 4.2-2.3 7-5.5 7-10V6l-7-3Z" />,
|
||||||
|
lock: <path d="M7 10V8a5 5 0 0 1 10 0v2m-11 0h12v10H6V10Z" />,
|
||||||
|
cloud: (
|
||||||
|
<path d="m4 4 16 16M7.5 16H6a4 4 0 0 1-.5-8A6.5 6.5 0 0 1 17 6.8M18.5 10A4 4 0 0 1 18 18h-7" />
|
||||||
|
),
|
||||||
|
info: <path d="M12 8h.01M11 12h1v4h1M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z" />,
|
||||||
|
database: (
|
||||||
|
<path d="M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3Zm0 0v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6m-14 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{paths[name]}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EyeIcon({ visible }: { visible: boolean }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
{!visible && <path d="M4 4l16 16" />}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StoragePathHelp(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span className="database-login-path-help">
|
||||||
|
<span
|
||||||
|
className="database-login-path-help-icon"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-describedby="storage-path-help"
|
||||||
|
>
|
||||||
|
!
|
||||||
|
</span>
|
||||||
|
<span id="storage-path-help" className="database-login-path-tooltip" role="tooltip">
|
||||||
|
打开微信设置,在缓存管理中复制存储路径,然后粘贴到这里。
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatabaseConnectionPage({
|
||||||
|
platform,
|
||||||
|
mode,
|
||||||
|
dbKey,
|
||||||
|
dbRoot,
|
||||||
|
showDbKey,
|
||||||
|
isFetching,
|
||||||
|
status,
|
||||||
|
statusKind,
|
||||||
|
showMacKeyFaq,
|
||||||
|
macKeyFaqUrl,
|
||||||
|
onModeChange,
|
||||||
|
onDbKeyChange,
|
||||||
|
onDbRootChange,
|
||||||
|
onToggleDbKey,
|
||||||
|
onAutoGetKey,
|
||||||
|
onManualConnect,
|
||||||
|
onPasteKey,
|
||||||
|
onClearKey
|
||||||
|
}: DatabaseConnectionPageProps): React.ReactElement {
|
||||||
|
const isMac = platform === 'darwin'
|
||||||
|
const defaultPath = isMac
|
||||||
|
? '~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/'
|
||||||
|
: 'C:\\Users\\...\\WeChat Files\\Msg'
|
||||||
|
const keyIsValid = /^[0-9a-f]{64}$/i.test(dbKey.trim().replace(/^0x/i, ''))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="database-login-page">
|
||||||
|
<section className="database-login-brand" aria-label="WechatExplorer 产品说明">
|
||||||
|
<div className="database-login-brand-content">
|
||||||
|
<div className="database-login-logo" aria-hidden="true">
|
||||||
|
<LineIcon name="database" />
|
||||||
|
</div>
|
||||||
|
<h1>WechatExplorer</h1>
|
||||||
|
<p className="database-login-tagline">你的本地微信聊天档案</p>
|
||||||
|
<p className="database-login-description">
|
||||||
|
连接本机微信数据库,开始检索、整理和分析聊天记录。
|
||||||
|
</p>
|
||||||
|
<div className="database-login-promises">
|
||||||
|
<div>
|
||||||
|
<LineIcon name="shield" />
|
||||||
|
<span>仅限本机</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<LineIcon name="lock" />
|
||||||
|
<span>加密保存</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<LineIcon name="cloud" />
|
||||||
|
<span>不会上传</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="database-login-brand-footer">LOCAL-FIRST · PRIVATE · SECURE</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="database-login-workspace" aria-label="数据库连接">
|
||||||
|
<div className="database-login-panel">
|
||||||
|
<div className="database-login-tabs" role="tablist" aria-label="连接方式">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === 'automatic'}
|
||||||
|
className={mode === 'automatic' ? 'active' : ''}
|
||||||
|
onClick={() => onModeChange('automatic')}
|
||||||
|
>
|
||||||
|
自动获取
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === 'manual'}
|
||||||
|
className={mode === 'manual' ? 'active' : ''}
|
||||||
|
onClick={() => onModeChange('manual')}
|
||||||
|
>
|
||||||
|
手动输入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'automatic' ? (
|
||||||
|
<div className="database-login-auto" role="tabpanel">
|
||||||
|
<div className={`database-login-state-card ${statusKind}`}>
|
||||||
|
<div className="database-login-state-heading">
|
||||||
|
<span className="database-login-state-icon">
|
||||||
|
<LineIcon name="info" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'}
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
{statusKind === 'error'
|
||||||
|
? status
|
||||||
|
: status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl className="database-login-diagnostics">
|
||||||
|
<div>
|
||||||
|
<dt>微信客户端</dt>
|
||||||
|
<dd>{isFetching ? '正在检测' : '等待检测'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>
|
||||||
|
存储路径
|
||||||
|
<StoragePathHelp />
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<span className="database-login-path-input-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={dbRoot}
|
||||||
|
onChange={(event) => onDbRootChange(event.target.value)}
|
||||||
|
placeholder={defaultPath}
|
||||||
|
title={dbRoot || defaultPath}
|
||||||
|
aria-label="微信数据存储路径"
|
||||||
|
spellCheck={false}
|
||||||
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
<span className="database-login-path-value" role="status">
|
||||||
|
{dbRoot || defaultPath}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>数据库状态</dt>
|
||||||
|
<dd>{statusKind === 'error' ? '无法连接' : '准备连接'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="database-login-primary"
|
||||||
|
onClick={onAutoGetKey}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
{isFetching
|
||||||
|
? '正在获取密钥…'
|
||||||
|
: statusKind === 'error'
|
||||||
|
? '重新检测'
|
||||||
|
: '自动获取密钥'}
|
||||||
|
</button>
|
||||||
|
{showMacKeyFaq && (
|
||||||
|
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
|
||||||
|
查看详情 · 连接帮助
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="database-login-manual" role="tabpanel">
|
||||||
|
<div className="database-login-field">
|
||||||
|
<label htmlFor="database-login-key">数据库密钥</label>
|
||||||
|
<div className="database-login-key-input">
|
||||||
|
<input
|
||||||
|
id="database-login-key"
|
||||||
|
type={showDbKey ? 'text' : 'password'}
|
||||||
|
value={dbKey}
|
||||||
|
onChange={(event) => onDbKeyChange(event.target.value)}
|
||||||
|
placeholder="输入或粘贴 64 位数据库密钥"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleDbKey}
|
||||||
|
title={showDbKey ? '隐藏密钥' : '显示密钥'}
|
||||||
|
>
|
||||||
|
<EyeIcon visible={showDbKey} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small>密钥通过系统安全存储加密保存在当前设备。</small>
|
||||||
|
</div>
|
||||||
|
{platform === 'win32' && (
|
||||||
|
<div className="database-login-field">
|
||||||
|
<label htmlFor="database-login-root">
|
||||||
|
微信数据目录
|
||||||
|
<StoragePathHelp />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="database-login-root"
|
||||||
|
value={dbRoot}
|
||||||
|
onChange={(event) => onDbRootChange(event.target.value)}
|
||||||
|
placeholder={defaultPath}
|
||||||
|
title={dbRoot || defaultPath}
|
||||||
|
spellCheck={false}
|
||||||
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="database-login-primary"
|
||||||
|
onClick={onManualConnect}
|
||||||
|
disabled={!keyIsValid}
|
||||||
|
>
|
||||||
|
连接数据库
|
||||||
|
</button>
|
||||||
|
<button type="button" className="database-login-secondary" onClick={onPasteKey}>
|
||||||
|
从剪贴板粘贴并安全保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="database-login-footer-actions">
|
||||||
|
<button type="button" onClick={onClearKey}>
|
||||||
|
清除已保存密钥
|
||||||
|
</button>
|
||||||
|
<span>WechatExplorer</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { Contact, Message } from '../../../../shared/types'
|
import { Contact, Message } from '../../../../shared/types'
|
||||||
import { MessageGroup } from './MessageGroup'
|
import { MessageGroup } from './MessageGroup'
|
||||||
import { buildMessageGroups } from './messageGrouping'
|
import { buildMessageGroups } from './messageGrouping'
|
||||||
@@ -29,6 +30,16 @@ export function MessageList({
|
|||||||
onImageClick
|
onImageClick
|
||||||
}: MessageListProps): React.ReactElement {
|
}: MessageListProps): React.ReactElement {
|
||||||
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
||||||
|
// TanStack Virtual intentionally exposes mutable measurement methods.
|
||||||
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: groups.length,
|
||||||
|
getScrollElement: () => listRef.current,
|
||||||
|
estimateSize: () => 96,
|
||||||
|
getItemKey: (index) => groups[index]?.id || index,
|
||||||
|
overscan: 8
|
||||||
|
})
|
||||||
|
const virtualItems = virtualizer.getVirtualItems()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-list wechat-message-list" ref={listRef} onScroll={onScroll}>
|
<div className="message-list wechat-message-list" ref={listRef} onScroll={onScroll}>
|
||||||
@@ -40,16 +51,29 @@ export function MessageList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{groups.map((group) => (
|
<div className="virtual-message-list" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||||
<MessageGroup
|
{virtualItems.map((virtualItem) => {
|
||||||
key={group.id}
|
const group = groups[virtualItem.index]
|
||||||
group={group}
|
if (!group) return null
|
||||||
contact={contact}
|
return (
|
||||||
isGroupChat={isGroupChat}
|
<div
|
||||||
showAvatar={showAvatar}
|
key={virtualItem.key}
|
||||||
onImageClick={onImageClick}
|
ref={virtualizer.measureElement}
|
||||||
/>
|
data-index={virtualItem.index}
|
||||||
))}
|
className="virtual-message-group"
|
||||||
|
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
||||||
|
>
|
||||||
|
<MessageGroup
|
||||||
|
group={group}
|
||||||
|
contact={contact}
|
||||||
|
isGroupChat={isGroupChat}
|
||||||
|
showAvatar={showAvatar}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
|
|||||||
<path d="M5.5 15.5v3h13v-3" />
|
<path d="M5.5 15.5v3h13v-3" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
case 'agent-hub':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<rect x="5" y="7" width="14" height="11" rx="3" />
|
||||||
|
<path d="M12 4.5V7" />
|
||||||
|
<circle cx="9.5" cy="12" r="1" />
|
||||||
|
<circle cx="14.5" cy="12" r="1" />
|
||||||
|
<path d="M9.5 15h5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
case 'api':
|
case 'api':
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type AppPage = 'archive' | 'search' | 'report' | 'export' | 'api' | 'settings'
|
export type AppPage = 'archive' | 'search' | 'report' | 'agent-hub' | 'export' | 'api' | 'settings'
|
||||||
|
|
||||||
export interface NavigationItem {
|
export interface NavigationItem {
|
||||||
id: AppPage
|
id: AppPage
|
||||||
@@ -9,6 +9,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
|
|||||||
{ id: 'archive', label: '档案' },
|
{ id: 'archive', label: '档案' },
|
||||||
{ id: 'search', label: '检索' },
|
{ id: 'search', label: '检索' },
|
||||||
{ id: 'report', label: '日报' },
|
{ id: 'report', label: '日报' },
|
||||||
|
{ id: 'agent-hub', label: 'Agent' },
|
||||||
{ id: 'export', label: '导出' },
|
{ id: 'export', label: '导出' },
|
||||||
{ id: 'api', label: 'API' },
|
{ id: 'api', label: 'API' },
|
||||||
{ id: 'settings', label: '设置' }
|
{ id: 'settings', label: '设置' }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
|
import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
|
||||||
|
|
||||||
interface ReportTaskStatusPanelProps {
|
interface ReportTaskStatusPanelProps {
|
||||||
@@ -27,6 +27,14 @@ export function ReportTaskStatusPanel({
|
|||||||
}: ReportTaskStatusPanelProps): React.ReactElement {
|
}: ReportTaskStatusPanelProps): React.ReactElement {
|
||||||
const activeIndex = phaseIndex(phase)
|
const activeIndex = phaseIndex(phase)
|
||||||
const completedAll = phase === 'success'
|
const completedAll = phase === 'success'
|
||||||
|
const [logPath, setLogPath] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void window.api
|
||||||
|
.getAppLogPath()
|
||||||
|
.then(setLogPath)
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="report-task-panel">
|
<aside className="report-task-panel">
|
||||||
@@ -70,6 +78,10 @@ export function ReportTaskStatusPanel({
|
|||||||
<button type="button" onClick={onRetry}>
|
<button type="button" onClick={onRetry}>
|
||||||
重试
|
重试
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => void window.api.revealAppLog()}>
|
||||||
|
打开诊断日志
|
||||||
|
</button>
|
||||||
|
{logPath && <small className="report-task-log-path">{logPath}</small>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{phase === 'success' && (
|
{phase === 'success' && (
|
||||||
|
|||||||
@@ -37,10 +37,7 @@ export function ReportViewer({
|
|||||||
return () => window.cancelAnimationFrame(frame)
|
return () => window.cancelAnimationFrame(frame)
|
||||||
}, [report?.id])
|
}, [report?.id])
|
||||||
|
|
||||||
const title = useMemo(
|
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
|
||||||
() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'),
|
|
||||||
[report]
|
|
||||||
)
|
|
||||||
|
|
||||||
const fitWidth = (): void => {
|
const fitWidth = (): void => {
|
||||||
const viewport = viewportRef.current
|
const viewport = viewportRef.current
|
||||||
@@ -108,10 +105,16 @@ export function ReportViewer({
|
|||||||
}}
|
}}
|
||||||
onLoad={(event) => {
|
onLoad={(event) => {
|
||||||
const image = event.currentTarget
|
const image = event.currentTarget
|
||||||
setNaturalSize({
|
const nextSize = {
|
||||||
width: image.naturalWidth,
|
width: image.naturalWidth,
|
||||||
height: image.naturalHeight
|
height: image.naturalHeight
|
||||||
})
|
}
|
||||||
|
setNaturalSize(nextSize)
|
||||||
|
const viewport = viewportRef.current
|
||||||
|
if (viewport && nextSize.width > viewport.clientWidth - 48) {
|
||||||
|
const fittedZoom = Math.max(0.25, (viewport.clientWidth - 48) / nextSize.width)
|
||||||
|
setZoom(Number(fittedZoom.toFixed(2)))
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setImageError('日报图片加载失败')
|
setImageError('日报图片加载失败')
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import type {
|
||||||
|
AgentHubLogEntry,
|
||||||
|
AgentHubLogSource,
|
||||||
|
AgentHubStatus,
|
||||||
|
WechatConnectorStatus
|
||||||
|
} from '../../../../shared/agent-hub'
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<WechatConnectorStatus, string> = {
|
||||||
|
checking: '正在检查',
|
||||||
|
disconnected: '未连接',
|
||||||
|
starting: '正在连接',
|
||||||
|
waiting_scan: '等待扫码',
|
||||||
|
scanned: '已扫码,等待手机确认',
|
||||||
|
online: '在线',
|
||||||
|
error: '连接异常'
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_SOURCE_LABELS: Record<AgentHubLogSource, string> = {
|
||||||
|
system: '系统',
|
||||||
|
'agent-hub': 'Agent Hub',
|
||||||
|
'wechat-connector': '微信连接器'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentHubWorkspace(): React.ReactElement {
|
||||||
|
const [status, setStatus] = React.useState<AgentHubStatus>({
|
||||||
|
hub: 'offline',
|
||||||
|
connector: 'checking',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
const [busy, setBusy] = React.useState(false)
|
||||||
|
const [logs, setLogs] = React.useState<AgentHubLogEntry[]>([])
|
||||||
|
const [logSource, setLogSource] = React.useState<'all' | AgentHubLogSource>('all')
|
||||||
|
const logBodyRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let mounted = true
|
||||||
|
void window.api.getAgentHubStatus().then((next) => {
|
||||||
|
if (mounted) setStatus(next)
|
||||||
|
})
|
||||||
|
void window.api.getAgentHubLogs().then((entries) => {
|
||||||
|
if (mounted) setLogs(entries)
|
||||||
|
})
|
||||||
|
const unsubscribe = window.api.onAgentHubStatus((next) => {
|
||||||
|
if (mounted) setStatus(next)
|
||||||
|
})
|
||||||
|
const unsubscribeLog = window.api.onAgentHubLog((entry) => {
|
||||||
|
if (mounted) setLogs((current) => [...current.slice(-799), entry])
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
mounted = false
|
||||||
|
unsubscribe()
|
||||||
|
unsubscribeLog()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const visibleLogs = logs.filter((entry) => logSource === 'all' || entry.source === logSource)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const body = logBodyRef.current
|
||||||
|
if (body) body.scrollTop = body.scrollHeight
|
||||||
|
}, [visibleLogs.length])
|
||||||
|
|
||||||
|
const copyLogs = async (): Promise<void> => {
|
||||||
|
const text = visibleLogs
|
||||||
|
.map(
|
||||||
|
(entry) =>
|
||||||
|
`${new Date(entry.timestamp).toLocaleTimeString()} [${LOG_SOURCE_LABELS[entry.source]}] [${entry.level}] ${entry.message}`
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
await window.api.copyText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearLogs = async (): Promise<void> => {
|
||||||
|
await window.api.clearAgentHubLogs()
|
||||||
|
setLogs([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const runAction = async (
|
||||||
|
action: () => Promise<{ status: AgentHubStatus; error?: string }>
|
||||||
|
): Promise<void> => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const result = await action()
|
||||||
|
setStatus(result.status)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoginFlow = ['starting', 'waiting_scan', 'scanned'].includes(status.connector)
|
||||||
|
const showQRCode = Boolean(status.qrCodeDataUrl) && status.connector !== 'online'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="agent-hub-workspace">
|
||||||
|
<header className="agent-hub-header">
|
||||||
|
<div>
|
||||||
|
<div className="agent-hub-eyebrow">WechatExplorer</div>
|
||||||
|
<h1>Agent Hub</h1>
|
||||||
|
<p>让微信机器人安全调用聊天数据与 AI 能力。</p>
|
||||||
|
</div>
|
||||||
|
<span className={`agent-hub-runtime ${status.hub}`}>
|
||||||
|
Agent Hub {status.hub === 'online' ? '运行中' : '未运行'}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="agent-hub-grid">
|
||||||
|
<section className="agent-hub-card agent-hub-login-card">
|
||||||
|
<div className="agent-hub-card-heading">
|
||||||
|
<div>
|
||||||
|
<span className="agent-hub-card-kicker">微信机器人</span>
|
||||||
|
<h2>连接微信</h2>
|
||||||
|
</div>
|
||||||
|
<span className={`agent-hub-status ${status.connector}`}>
|
||||||
|
<i aria-hidden />
|
||||||
|
{STATUS_LABELS[status.connector]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showQRCode ? (
|
||||||
|
<div className="agent-hub-qr-panel">
|
||||||
|
<div className="agent-hub-qr-frame">
|
||||||
|
<img src={status.qrCodeDataUrl} alt="微信机器人登录二维码" />
|
||||||
|
</div>
|
||||||
|
<div className="agent-hub-qr-copy">
|
||||||
|
<h3>
|
||||||
|
{status.connector === 'scanned' ? '请在手机上确认登录' : '使用微信扫描二维码'}
|
||||||
|
</h3>
|
||||||
|
<p>二维码仅用于机器人账号登录,不会读取你的微信密码。</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="agent-hub-button secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void runAction(() => window.api.cancelAgentHubLogin())}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : status.connector === 'online' ? (
|
||||||
|
<div className="agent-hub-connected">
|
||||||
|
<div className="agent-hub-connected-icon" aria-hidden>
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>微信机器人已连接</h3>
|
||||||
|
<p>{status.accountId || status.wechatUserId || '登录凭据已就绪'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="agent-hub-empty-login">
|
||||||
|
<div className="agent-hub-phone" aria-hidden>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
<h3>{status.connector === 'error' ? '连接遇到问题' : '尚未连接微信机器人'}</h3>
|
||||||
|
<p>{status.error || '扫码登录后,即可从微信向 Agent Hub 提问。'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="agent-hub-actions">
|
||||||
|
{status.connector === 'online' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="agent-hub-button secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void runAction(() => window.api.startAgentHubLogin())}
|
||||||
|
>
|
||||||
|
重新扫码登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="agent-hub-button danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void runAction(() => window.api.disconnectAgentHub())}
|
||||||
|
>
|
||||||
|
断开连接
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : !isLoginFlow ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="agent-hub-button primary"
|
||||||
|
disabled={busy || status.hub !== 'online'}
|
||||||
|
onClick={() => void runAction(() => window.api.startAgentHubLogin())}
|
||||||
|
>
|
||||||
|
{busy ? '正在获取二维码…' : '扫码登录微信机器人'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="agent-hub-card agent-hub-capability-card">
|
||||||
|
<span className="agent-hub-card-kicker">已启用能力</span>
|
||||||
|
<h2>微信数据助手</h2>
|
||||||
|
<p>机器人通过本机 Agent Hub 调用 WechatExplorer,不向公网暴露数据库。</p>
|
||||||
|
<div className="agent-hub-example">
|
||||||
|
<span>支持自然语言,可以这样问</span>
|
||||||
|
<strong>“最近 5 条消息是谁?”</strong>
|
||||||
|
<strong>“帮我看看最近跟xx聊了些什么”</strong>
|
||||||
|
<strong>“生成产品交流群今天的群聊总结图片”</strong>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<i />
|
||||||
|
本机 HTTP 通信
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i />
|
||||||
|
入站请求鉴权
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i />
|
||||||
|
消息重复保护
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i />
|
||||||
|
使用已配置 AI 理解自然语言
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i className={status.dataApi === 'online' ? '' : 'offline'} />
|
||||||
|
本地数据 API:{status.dataApi === 'online' ? '已连接' : '未连接'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i className={status.databaseReady ? '' : 'offline'} />
|
||||||
|
微信数据库:{status.databaseReady ? '可查询' : '未就绪'}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="agent-hub-card agent-hub-log-card">
|
||||||
|
<div className="agent-hub-log-heading">
|
||||||
|
<div>
|
||||||
|
<span className="agent-hub-card-kicker">故障诊断</span>
|
||||||
|
<h2>运行日志</h2>
|
||||||
|
</div>
|
||||||
|
<div className="agent-hub-log-actions">
|
||||||
|
<select
|
||||||
|
aria-label="筛选日志来源"
|
||||||
|
value={logSource}
|
||||||
|
onChange={(event) => setLogSource(event.target.value as 'all' | AgentHubLogSource)}
|
||||||
|
>
|
||||||
|
<option value="all">全部来源</option>
|
||||||
|
<option value="system">系统</option>
|
||||||
|
<option value="agent-hub">Agent Hub</option>
|
||||||
|
<option value="wechat-connector">微信连接器</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyLogs()}
|
||||||
|
disabled={visibleLogs.length === 0}
|
||||||
|
>
|
||||||
|
复制日志
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void clearLogs()}>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="agent-hub-log-body" ref={logBodyRef}>
|
||||||
|
{visibleLogs.length === 0 ? (
|
||||||
|
<div className="agent-hub-log-empty">
|
||||||
|
暂无运行日志。收到消息后,这里会显示处理到哪一步。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
visibleLogs.map((entry) => (
|
||||||
|
<div className={`agent-hub-log-line ${entry.level}`} key={entry.id}>
|
||||||
|
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
|
||||||
|
<span className={`source ${entry.source}`}>{LOG_SOURCE_LABELS[entry.source]}</span>
|
||||||
|
<code>{entry.message}</code>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="agent-hub-log-note">日志会隐藏 Token 和二维码数据,不记录你的微信密码。</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -40,6 +40,17 @@ export function ApiRequestTester({
|
|||||||
void onCopyCurl(command)
|
void onCopyCurl(command)
|
||||||
}
|
}
|
||||||
const update = (key: string, value: string): void => onParams({ ...params, [key]: value })
|
const update = (key: string, value: string): void => onParams({ ...params, [key]: value })
|
||||||
|
const selectTestImage = async (): Promise<void> => {
|
||||||
|
const result = await window.api.selectAgentHubTestImage()
|
||||||
|
if (result.canceled || !result.path) return
|
||||||
|
let payload: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(body) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
// Replace an invalid draft with a valid send-test request.
|
||||||
|
}
|
||||||
|
onBody(JSON.stringify({ ...payload, media_url: result.path }, null, 2))
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<section className="api-request-tester" id="api-request-tester">
|
<section className="api-request-tester" id="api-request-tester">
|
||||||
<div className="api-section-heading">
|
<div className="api-section-heading">
|
||||||
@@ -77,6 +88,14 @@ export function ApiRequestTester({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{endpoint.id === 'agent-send' && (
|
||||||
|
<div className="api-upload-test-row">
|
||||||
|
<button type="button" onClick={() => void selectTestImage()}>
|
||||||
|
选择测试图片
|
||||||
|
</button>
|
||||||
|
<span>选择后只会填入本地路径;点击“发送请求”才会真正发送。</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="api-tester-actions">
|
<div className="api-tester-actions">
|
||||||
<button type="button" onClick={onClear}>
|
<button type="button" onClick={onClear}>
|
||||||
清空
|
清空
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useCallback, useEffect, useReducer } from 'react'
|
import { useCallback, useEffect, useReducer } from 'react'
|
||||||
import type { Contact } from '../../../../../shared/types'
|
import type { Contact } from '../../../../../shared/types'
|
||||||
import { findEndpoint } from '../model/apiEndpoints'
|
import { findEndpoint } from '../model/apiEndpoints'
|
||||||
import { REPORT_REQUEST_PRESET } from '../model/requestPresets'
|
import {
|
||||||
|
AGENT_GROUP_REPORT_PRESET,
|
||||||
|
AGENT_SEND_PRESET,
|
||||||
|
REPORT_REQUEST_PRESET
|
||||||
|
} from '../model/requestPresets'
|
||||||
import { type AgentInstallTarget, type SkillInstallSource } from '../model/skillDistribution'
|
import { type AgentInstallTarget, type SkillInstallSource } from '../model/skillDistribution'
|
||||||
import type {
|
import type {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
@@ -66,14 +70,23 @@ function reducer(state: State, action: Action): State {
|
|||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'loaded':
|
case 'loaded':
|
||||||
return { ...state, settings: action.settings, service: action.service, skill: action.skill }
|
return { ...state, settings: action.settings, service: action.service, skill: action.skill }
|
||||||
case 'endpoint':
|
case 'endpoint': {
|
||||||
|
const preset =
|
||||||
|
action.endpointId === 'report'
|
||||||
|
? REPORT_REQUEST_PRESET
|
||||||
|
: action.endpointId === 'agent-group-report'
|
||||||
|
? AGENT_GROUP_REPORT_PRESET
|
||||||
|
: action.endpointId === 'agent-send'
|
||||||
|
? AGENT_SEND_PRESET
|
||||||
|
: state.body
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
endpointId: action.endpointId,
|
endpointId: action.endpointId,
|
||||||
params: action.talker && action.endpointId === 'chatlog' ? { talker: action.talker } : {},
|
params: action.talker && action.endpointId === 'chatlog' ? { talker: action.talker } : {},
|
||||||
body: action.endpointId === 'report' ? state.body || REPORT_REQUEST_PRESET : state.body,
|
body: preset,
|
||||||
error: ''
|
error: ''
|
||||||
}
|
}
|
||||||
|
}
|
||||||
case 'params':
|
case 'params':
|
||||||
return { ...state, params: action.params }
|
return { ...state, params: action.params }
|
||||||
case 'body':
|
case 'body':
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ export const API_ENDPOINTS: ApiEndpoint[] = [
|
|||||||
name: '群聊日报导出',
|
name: '群聊日报导出',
|
||||||
description: '通过内置模板导出群聊日报 HTML 与 PNG。',
|
description: '通过内置模板导出群聊日报 HTML 与 PNG。',
|
||||||
body: true
|
body: true
|
||||||
|
}),
|
||||||
|
endpoint('agent-status', {
|
||||||
|
name: 'Agent Hub 状态',
|
||||||
|
description: '检查 Agent Hub、微信连接器、本地数据 API 和数据库状态。'
|
||||||
|
}),
|
||||||
|
endpoint('agent-group-report', {
|
||||||
|
name: '生成群聊总结图片',
|
||||||
|
description: '读取指定群聊并生成今天、昨天或近 7 天的总结长图。',
|
||||||
|
body: true
|
||||||
|
}),
|
||||||
|
endpoint('agent-send', {
|
||||||
|
name: '微信发送测试',
|
||||||
|
description: '测试文字或本地图片发送,并区分凭证失效、连接器离线和发送成功。',
|
||||||
|
body: true
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -29,3 +29,15 @@ export const REPORT_REQUEST_PRESET = JSON.stringify(
|
|||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const AGENT_GROUP_REPORT_PRESET = JSON.stringify(
|
||||||
|
{ group: '技术交流', range: 'today' },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
|
||||||
|
export const AGENT_SEND_PRESET = JSON.stringify(
|
||||||
|
{ to: '', text: 'WechatExplorer Agent Hub 发送测试' },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
|||||||
@@ -191,7 +191,9 @@ export function useDatabaseKeyController({
|
|||||||
])
|
])
|
||||||
|
|
||||||
const returnToLogin = useCallback(async (): Promise<void> => {
|
const returnToLogin = useCallback(async (): Promise<void> => {
|
||||||
const result = await window.api.disconnectDb()
|
// macOS WCDB 的 close 会关闭进程级原生运行时,返回登录时只退出 UI 连接态。
|
||||||
|
// 用户再次点击连接后由 db:init 复用已验证的本机连接。
|
||||||
|
const result = await window.api.disconnectDb({ closeNative: false })
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
onNotice(result.error || '断开数据库连接失败')
|
onNotice(result.error || '断开数据库连接失败')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { AccountOverview } from '../account-database/AccountOverview'
|
import { AccountOverview } from '../account-database/AccountOverview'
|
||||||
import { ConnectionHealthSection } from '../account-database/ConnectionHealthSection'
|
import { ConnectionHealthSection } from '../account-database/ConnectionHealthSection'
|
||||||
import { LocalPrivacyNotice } from '../account-database/LocalPrivacyNotice'
|
import { LocalPrivacyNotice } from '../account-database/LocalPrivacyNotice'
|
||||||
@@ -25,6 +26,26 @@ export function AccountDatabasePage({
|
|||||||
onNotice: (message: string) => void
|
onNotice: (message: string) => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice })
|
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice })
|
||||||
|
const [autoLogin, setAutoLogin] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void window.api.getSettings().then((result) => {
|
||||||
|
if (active) setAutoLogin(result.settings.autoLogin)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const changeAutoLogin = async (checked: boolean): Promise<void> => {
|
||||||
|
const result = await window.api.setSettings({
|
||||||
|
autoLogin: checked,
|
||||||
|
autoLoginPreferenceSet: true
|
||||||
|
})
|
||||||
|
setAutoLogin(result.settings.autoLogin)
|
||||||
|
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="settings-page">
|
<div className="settings-page">
|
||||||
<header className="settings-page-header">
|
<header className="settings-page-header">
|
||||||
@@ -58,6 +79,20 @@ export function AccountDatabasePage({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<h2 className="settings-section-heading">启动行为</h2>
|
||||||
|
<section className="settings-card settings-auto-login-card">
|
||||||
|
<label>
|
||||||
|
<span>
|
||||||
|
<b>启动时自动连接数据库</b>
|
||||||
|
<small>使用安全存储中已保存的数据库密钥;可随时关闭。</small>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoLogin}
|
||||||
|
onChange={(event) => void changeAutoLogin(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Contact, Message } from '../../../shared/types'
|
|||||||
import {
|
import {
|
||||||
buildGroupReportInput,
|
buildGroupReportInput,
|
||||||
getSummaryDateRange,
|
getSummaryDateRange,
|
||||||
|
GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT,
|
||||||
GROUP_REPORT_SYSTEM_PROMPT,
|
GROUP_REPORT_SYSTEM_PROMPT,
|
||||||
isInternalName,
|
isInternalName,
|
||||||
parseGroupDailyReport,
|
parseGroupDailyReport,
|
||||||
@@ -108,6 +109,31 @@ const withTimeout = async <T>(
|
|||||||
const errorMessage = (error: unknown): string =>
|
const errorMessage = (error: unknown): string =>
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
|
|
||||||
|
const writeReportLog = (
|
||||||
|
level: 'info' | 'warn' | 'error',
|
||||||
|
message: string,
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
): void => {
|
||||||
|
void window.api
|
||||||
|
.writeAppLog({ level, scope: 'group-report', message, details })
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonErrorContext = (raw: string, error: unknown): Record<string, unknown> => {
|
||||||
|
const message = errorMessage(error)
|
||||||
|
const position = Number(/\bposition\s+(\d+)/i.exec(message)?.[1])
|
||||||
|
const safePosition = Number.isFinite(position) ? Math.max(0, Math.min(raw.length, position)) : 0
|
||||||
|
return {
|
||||||
|
error: message,
|
||||||
|
outputLength: raw.length,
|
||||||
|
position: Number.isFinite(position) ? position : undefined,
|
||||||
|
context:
|
||||||
|
raw.length && Number.isFinite(position)
|
||||||
|
? raw.slice(Math.max(0, safePosition - 300), Math.min(raw.length, safePosition + 300))
|
||||||
|
: raw.slice(0, 600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isGroupContact = (contact: Contact | null): boolean =>
|
const isGroupContact = (contact: Contact | null): boolean =>
|
||||||
Boolean(contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom'))
|
Boolean(contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom'))
|
||||||
|
|
||||||
@@ -136,6 +162,16 @@ const estimateTokenUsage = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mergeTokenUsage = (
|
||||||
|
first: NonNullable<ReportGenerationMetadata['tokenUsage']>,
|
||||||
|
second: NonNullable<ReportGenerationMetadata['tokenUsage']>
|
||||||
|
): NonNullable<ReportGenerationMetadata['tokenUsage']> => ({
|
||||||
|
input: (first.input || 0) + (second.input || 0),
|
||||||
|
output: (first.output || 0) + (second.output || 0),
|
||||||
|
total: (first.total || 0) + (second.total || 0),
|
||||||
|
estimated: Boolean(first.estimated || second.estimated)
|
||||||
|
})
|
||||||
|
|
||||||
const applyGroupMemberNames = async (
|
const applyGroupMemberNames = async (
|
||||||
contact: Contact,
|
contact: Contact,
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
@@ -175,13 +211,18 @@ const applyGroupMemberNames = async (
|
|||||||
const member = memberMap.get(senderId)
|
const member = memberMap.get(senderId)
|
||||||
if (!member) return message
|
if (!member) return message
|
||||||
const preferredNames: Record<ReportMemberNamePreference, string[]> = {
|
const preferredNames: Record<ReportMemberNamePreference, string[]> = {
|
||||||
groupNickname: [member.groupNickname, member.wechatNickname, member.remark, member.nickname],
|
// Keep the three modes semantically distinct. `member.nickname` may
|
||||||
wechatNickname: [member.wechatNickname, member.groupNickname, member.remark, member.nickname],
|
// already be a contact remark, so it must not leak into the first two.
|
||||||
|
groupNickname: [member.groupNickname, member.wechatNickname],
|
||||||
|
wechatNickname: [member.wechatNickname, member.groupNickname],
|
||||||
remark: [member.remark, member.groupNickname, member.wechatNickname, member.nickname]
|
remark: [member.remark, member.groupNickname, member.wechatNickname, member.nickname]
|
||||||
}
|
}
|
||||||
const name = preferredNames[preference].find((value) => value && !isInternalName(value))
|
const name = preferredNames[preference].find((value) => value && !isInternalName(value))
|
||||||
if (!name) return message
|
return {
|
||||||
return { ...message, name, img: message.img || member.avatar }
|
...message,
|
||||||
|
name: name || (preference === 'remark' ? message.name : ''),
|
||||||
|
img: message.img || member.avatar
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,11 +263,12 @@ export function useGroupReportGeneration({
|
|||||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||||
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
||||||
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
|
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
|
||||||
const [memberNamePreference, setMemberNamePreferenceState] =
|
const [memberNamePreference, setMemberNamePreferenceState] = useState<ReportMemberNamePreference>(
|
||||||
useState<ReportMemberNamePreference>(() => {
|
() => {
|
||||||
const saved = localStorage.getItem('group_report_member_name_preference')
|
const saved = localStorage.getItem('group_report_member_name_preference')
|
||||||
return saved === 'wechatNickname' || saved === 'remark' ? saved : 'groupNickname'
|
return saved === 'wechatNickname' || saved === 'remark' ? saved : 'groupNickname'
|
||||||
})
|
}
|
||||||
|
)
|
||||||
const [reportTimeoutSeconds, setReportTimeoutSecondsState] = useState<number>(() => {
|
const [reportTimeoutSeconds, setReportTimeoutSecondsState] = useState<number>(() => {
|
||||||
const saved = Number(localStorage.getItem('group_report_timeout_seconds'))
|
const saved = Number(localStorage.getItem('group_report_timeout_seconds'))
|
||||||
return Number.isFinite(saved) && saved >= 30 ? saved : 300
|
return Number.isFinite(saved) && saved >= 30 ? saved : 300
|
||||||
@@ -352,6 +394,7 @@ export function useGroupReportGeneration({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startGenerateTime = Date.now()
|
const startGenerateTime = Date.now()
|
||||||
|
let failedAt = '初始化'
|
||||||
const logs: ReportGenerationLog[] = []
|
const logs: ReportGenerationLog[] = []
|
||||||
const pushLog = (log: ReportGenerationLog): void => {
|
const pushLog = (log: ReportGenerationLog): void => {
|
||||||
logs.push(log)
|
logs.push(log)
|
||||||
@@ -382,15 +425,29 @@ export function useGroupReportGeneration({
|
|||||||
modelName: modelConfig.model,
|
modelName: modelConfig.model,
|
||||||
generationLogs: []
|
generationLogs: []
|
||||||
})
|
})
|
||||||
|
writeReportLog('info', '开始生成群聊日报', {
|
||||||
|
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
|
||||||
|
dateRange: summaryDateRange,
|
||||||
|
selectedMessageTypes: summaryMessageTypes,
|
||||||
|
providerName: modelConfig.providerName,
|
||||||
|
model: modelConfig.model,
|
||||||
|
templateId
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
failedAt = '读取聊天记录'
|
||||||
const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
|
const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
|
||||||
|
|
||||||
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
|
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
|
||||||
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
|
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
|
||||||
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
|
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
|
||||||
|
writeReportLog('info', '聊天记录读取完成', {
|
||||||
|
sourceMessageCount: sourceMessages.length,
|
||||||
|
filteredMessageCount: filteredMessages.length
|
||||||
|
})
|
||||||
|
|
||||||
setPhase('preparingInput')
|
setPhase('preparingInput')
|
||||||
|
failedAt = '整理日报输入'
|
||||||
const input = await trackStep('整理输入', async () => {
|
const input = await trackStep('整理输入', async () => {
|
||||||
const namedReportMessages = await applyGroupMemberNames(
|
const namedReportMessages = await applyGroupMemberNames(
|
||||||
sourceContact,
|
sourceContact,
|
||||||
@@ -401,6 +458,7 @@ export function useGroupReportGeneration({
|
|||||||
})
|
})
|
||||||
|
|
||||||
setPhase('requestingModel')
|
setPhase('requestingModel')
|
||||||
|
failedAt = '调用模型生成内容'
|
||||||
const aiMessages = [
|
const aiMessages = [
|
||||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||||
{ role: 'user', content: input.prompt }
|
{ role: 'user', content: input.prompt }
|
||||||
@@ -417,22 +475,82 @@ export function useGroupReportGeneration({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||||
|
writeReportLog('info', '模型响应完成', {
|
||||||
|
outputLength: result.data.length,
|
||||||
|
usage: result.usage
|
||||||
|
})
|
||||||
|
|
||||||
const tokenUsage =
|
let tokenUsage =
|
||||||
result.usage && result.usage.total
|
result.usage && result.usage.total
|
||||||
? result.usage
|
? result.usage
|
||||||
: estimateTokenUsage(aiMessages, result.data)
|
: estimateTokenUsage(aiMessages, result.data)
|
||||||
|
|
||||||
const report = parseGroupDailyReport(
|
let report: ReturnType<typeof parseGroupDailyReport>
|
||||||
result.data,
|
try {
|
||||||
input.topSpeakers,
|
report = parseGroupDailyReport(
|
||||||
input.activeTimeline,
|
result.data,
|
||||||
input.voiceLeaderboard || [],
|
input.topSpeakers,
|
||||||
input.metadata,
|
input.activeTimeline,
|
||||||
input.media
|
input.voiceLeaderboard || [],
|
||||||
)
|
input.metadata,
|
||||||
|
input.media
|
||||||
|
)
|
||||||
|
} catch (parseError) {
|
||||||
|
writeReportLog('warn', '本地修复日报 JSON 失败,尝试由模型纠正', {
|
||||||
|
...jsonErrorContext(result.data, parseError),
|
||||||
|
retry: 1
|
||||||
|
})
|
||||||
|
const repairMessages = [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT
|
||||||
|
},
|
||||||
|
{ role: 'user', content: result.data }
|
||||||
|
]
|
||||||
|
const repairResult = await trackStep('AI 修复 JSON', () =>
|
||||||
|
withTimeout(
|
||||||
|
window.api.aiChat(repairMessages, {
|
||||||
|
providerId: modelConfig.providerId,
|
||||||
|
modelId: modelConfig.model,
|
||||||
|
timeoutMs: reportTimeoutSeconds * 1000
|
||||||
|
}),
|
||||||
|
'AI 修复日报 JSON',
|
||||||
|
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (!repairResult.success || !repairResult.data) {
|
||||||
|
throw new Error(repairResult.error || 'AI 修复日报 JSON 失败', { cause: parseError })
|
||||||
|
}
|
||||||
|
const repairUsage =
|
||||||
|
repairResult.usage && repairResult.usage.total
|
||||||
|
? repairResult.usage
|
||||||
|
: estimateTokenUsage(repairMessages, repairResult.data)
|
||||||
|
tokenUsage = mergeTokenUsage(tokenUsage, repairUsage)
|
||||||
|
try {
|
||||||
|
report = parseGroupDailyReport(
|
||||||
|
repairResult.data,
|
||||||
|
input.topSpeakers,
|
||||||
|
input.activeTimeline,
|
||||||
|
input.voiceLeaderboard || [],
|
||||||
|
input.metadata,
|
||||||
|
input.media
|
||||||
|
)
|
||||||
|
writeReportLog('info', '模型已纠正日报 JSON', {
|
||||||
|
retry: 1,
|
||||||
|
outputLength: repairResult.data.length
|
||||||
|
})
|
||||||
|
} catch (retryParseError) {
|
||||||
|
writeReportLog(
|
||||||
|
'error',
|
||||||
|
'日报 JSON 重试后仍解析失败',
|
||||||
|
jsonErrorContext(repairResult.data, retryParseError)
|
||||||
|
)
|
||||||
|
throw retryParseError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setPhase('exportingReport')
|
setPhase('exportingReport')
|
||||||
|
failedAt = '导出 HTML 与 PNG'
|
||||||
const exported = await withTimeout(
|
const exported = await withTimeout(
|
||||||
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
|
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
|
||||||
'日报图片导出'
|
'日报图片导出'
|
||||||
@@ -468,8 +586,19 @@ export function useGroupReportGeneration({
|
|||||||
generationLogs: [...logs]
|
generationLogs: [...logs]
|
||||||
})
|
})
|
||||||
setPhase('success')
|
setPhase('success')
|
||||||
|
writeReportLog('info', '群聊日报生成成功', {
|
||||||
|
durationMs: Date.now() - startGenerateTime,
|
||||||
|
htmlPath: exported.htmlPath,
|
||||||
|
pngPath: exported.pngPath
|
||||||
|
})
|
||||||
} catch (generateError) {
|
} catch (generateError) {
|
||||||
setError(errorMessage(generateError))
|
const message = errorMessage(generateError)
|
||||||
|
writeReportLog('error', '群聊日报生成失败', {
|
||||||
|
error: message,
|
||||||
|
failedAt,
|
||||||
|
durationMs: Date.now() - startGenerateTime
|
||||||
|
})
|
||||||
|
setError(message)
|
||||||
setPhase('error')
|
setPhase('error')
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -479,6 +608,7 @@ export function useGroupReportGeneration({
|
|||||||
modelConfig,
|
modelConfig,
|
||||||
reportTimeoutSeconds,
|
reportTimeoutSeconds,
|
||||||
sourceContact,
|
sourceContact,
|
||||||
|
summaryDateRange,
|
||||||
summaryMessageTypes,
|
summaryMessageTypes,
|
||||||
templateId
|
templateId
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -4,6 +4,37 @@ import App from './App'
|
|||||||
import './styles/tokens.css'
|
import './styles/tokens.css'
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
void window.api
|
||||||
|
.writeAppLog({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'renderer',
|
||||||
|
message: event.message || 'Renderer 未捕获错误',
|
||||||
|
details: {
|
||||||
|
filename: event.filename,
|
||||||
|
line: event.lineno,
|
||||||
|
column: event.colno,
|
||||||
|
stack: event.error instanceof Error ? event.error.stack : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
const reason = event.reason
|
||||||
|
void window.api
|
||||||
|
.writeAppLog({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'renderer',
|
||||||
|
message: reason instanceof Error ? reason.message : 'Renderer Promise 未处理拒绝',
|
||||||
|
details: {
|
||||||
|
stack: reason instanceof Error ? reason.stack : undefined,
|
||||||
|
reason: reason instanceof Error ? undefined : String(reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -10,6 +10,34 @@ import {
|
|||||||
ReportVoiceHighlight,
|
ReportVoiceHighlight,
|
||||||
ReportVoiceLeaderboardItem
|
ReportVoiceLeaderboardItem
|
||||||
} from '../../../shared/group-report'
|
} from '../../../shared/group-report'
|
||||||
|
import type {
|
||||||
|
ImageAnalysisRequest,
|
||||||
|
ImageAnalysisResponse,
|
||||||
|
ImageCandidate,
|
||||||
|
ImageCandidateQuery
|
||||||
|
} from '../../../shared/image-insight'
|
||||||
|
|
||||||
|
interface ReportImageReadResult {
|
||||||
|
success: boolean
|
||||||
|
data?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const window: {
|
||||||
|
api: {
|
||||||
|
imageListCandidates: (query: ImageCandidateQuery) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
candidates: ImageCandidate[]
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
imageAnalyze: (request: ImageAnalysisRequest) => Promise<ImageAnalysisResponse>
|
||||||
|
getImage: (
|
||||||
|
imageMd5?: string,
|
||||||
|
imageDatNameOrThumb?: string | boolean,
|
||||||
|
sessionId?: string
|
||||||
|
) => Promise<ReportImageReadResult>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupReportTranscriptRow {
|
export interface GroupReportTranscriptRow {
|
||||||
id: string
|
id: string
|
||||||
@@ -184,6 +212,7 @@ const buildMediaSection = async (
|
|||||||
warnings: string[]
|
warnings: string[]
|
||||||
}> => {
|
}> => {
|
||||||
const warnings: string[] = []
|
const warnings: string[] = []
|
||||||
|
const rendererApi = typeof window === 'undefined' ? null : window.api
|
||||||
const rawImageCandidates = messages
|
const rawImageCandidates = messages
|
||||||
.map((message, index) => {
|
.map((message, index) => {
|
||||||
if (message.contentData?.type !== 'image') return null
|
if (message.contentData?.type !== 'image') return null
|
||||||
@@ -214,6 +243,7 @@ const buildMediaSection = async (
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
let visionGallery: ReportVisionGalleryItem[] = []
|
let visionGallery: ReportVisionGalleryItem[] = []
|
||||||
try {
|
try {
|
||||||
|
if (!rendererApi) throw new Error('后台模式不读取 Renderer 图片')
|
||||||
const sessionId = messages.find((m) => m.sessionId)?.sessionId || (contact?.md5 ?? '')
|
const sessionId = messages.find((m) => m.sessionId)?.sessionId || (contact?.md5 ?? '')
|
||||||
const startTime = messages.length ? parseTimestamp(messages[0]) : 0
|
const startTime = messages.length ? parseTimestamp(messages[0]) : 0
|
||||||
const endTime = messages.length ? parseTimestamp(messages[messages.length - 1]) : 0
|
const endTime = messages.length ? parseTimestamp(messages[messages.length - 1]) : 0
|
||||||
@@ -233,7 +263,7 @@ const buildMediaSection = async (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const candidatesResp = await window.api.imageListCandidates({
|
const candidatesResp = await rendererApi.imageListCandidates({
|
||||||
sessionId,
|
sessionId,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
@@ -249,7 +279,7 @@ const buildMediaSection = async (
|
|||||||
if (candidate.insight) return candidate.insight
|
if (candidate.insight) return candidate.insight
|
||||||
// 未命中:解密图片拿 base64 → 调 AI
|
// 未命中:解密图片拿 base64 → 调 AI
|
||||||
try {
|
try {
|
||||||
const img = await window.api.getImage(
|
const img = await rendererApi.getImage(
|
||||||
candidate.md5,
|
candidate.md5,
|
||||||
candidate.datName,
|
candidate.datName,
|
||||||
candidate.sessionId
|
candidate.sessionId
|
||||||
@@ -260,7 +290,7 @@ const buildMediaSection = async (
|
|||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const analyzeResp = await window.api.imageAnalyze({
|
const analyzeResp = await rendererApi.imageAnalyze({
|
||||||
imageHash: candidate.imageHash,
|
imageHash: candidate.imageHash,
|
||||||
imageDataUrl: img.data,
|
imageDataUrl: img.data,
|
||||||
messageId: candidate.messageId,
|
messageId: candidate.messageId,
|
||||||
@@ -306,7 +336,7 @@ const buildMediaSection = async (
|
|||||||
const orig = rawImageCandidates.find((c) => c.sourceMessageIds[0] === item.messageId)
|
const orig = rawImageCandidates.find((c) => c.sourceMessageIds[0] === item.messageId)
|
||||||
if (!orig) return item
|
if (!orig) return item
|
||||||
try {
|
try {
|
||||||
const img = await window.api.getImage(orig.md5, orig.datName, orig.sessionId)
|
const img = await rendererApi.getImage(orig.md5, orig.datName, orig.sessionId)
|
||||||
if (img.success && img.data?.startsWith('data:image/')) {
|
if (img.success && img.data?.startsWith('data:image/')) {
|
||||||
return { ...item, imageUrl: img.data }
|
return { ...item, imageUrl: img.data }
|
||||||
}
|
}
|
||||||
@@ -323,23 +353,25 @@ const buildMediaSection = async (
|
|||||||
visionGallery = []
|
visionGallery = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageCandidates = await Promise.all(
|
const imageCandidates = rendererApi
|
||||||
rawImageCandidates.map(async (item) => {
|
? await Promise.all(
|
||||||
const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
|
rawImageCandidates.map(async (item) => {
|
||||||
if (!result.success || !result.data?.startsWith('data:image/')) return null
|
const result = await rendererApi.getImage(item.md5, item.datName, item.sessionId)
|
||||||
return {
|
if (!result.success || !result.data?.startsWith('data:image/')) return null
|
||||||
sender: item.sender,
|
return {
|
||||||
time: item.time,
|
sender: item.sender,
|
||||||
imageUrl: result.data,
|
time: item.time,
|
||||||
note: item.note,
|
imageUrl: result.data,
|
||||||
stats: item.stats,
|
note: item.note,
|
||||||
inferenceLabel: '基于图片后的聊天上下文推断',
|
stats: item.stats,
|
||||||
sourceMessageIds: item.sourceMessageIds,
|
inferenceLabel: '基于图片后的聊天上下文推断',
|
||||||
replyCount: item.replyCount,
|
sourceMessageIds: item.sourceMessageIds,
|
||||||
score: item.score
|
replyCount: item.replyCount,
|
||||||
}
|
score: item.score
|
||||||
})
|
}
|
||||||
)
|
})
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
|
||||||
const gallery: ReportMediaGalleryItem[] = imageCandidates
|
const gallery: ReportMediaGalleryItem[] = imageCandidates
|
||||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ReportVoiceLeaderboardItem
|
ReportVoiceLeaderboardItem
|
||||||
} from '../../../shared/group-report'
|
} from '../../../shared/group-report'
|
||||||
import { Contact, Message } from '../../../shared/types'
|
import { Contact, Message } from '../../../shared/types'
|
||||||
|
import { jsonrepair } from 'jsonrepair'
|
||||||
import { buildGroupReportFacts } from './group-report-facts'
|
import { buildGroupReportFacts } from './group-report-facts'
|
||||||
|
|
||||||
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
|
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
|
||||||
@@ -73,6 +74,9 @@ JSON 结构必须为:
|
|||||||
- 完整版:可以保留更多候选项,但仍要去重和排序。
|
- 完整版:可以保留更多候选项,但仍要去重和排序。
|
||||||
- 如果某个字段不确定,请返回 null 或空数组,不要猜。`
|
- 如果某个字段不确定,请返回 null 或空数组,不要猜。`
|
||||||
|
|
||||||
|
export const GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT =
|
||||||
|
'你是 JSON 格式修复器。只修复输入中的 JSON 语法,不改写、删减或新增任何业务内容。只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 或解释。'
|
||||||
|
|
||||||
export interface GroupReportInput {
|
export interface GroupReportInput {
|
||||||
prompt: string
|
prompt: string
|
||||||
metadata: GroupReportMetadata
|
metadata: GroupReportMetadata
|
||||||
@@ -259,7 +263,19 @@ const extractJson = (raw: string): unknown => {
|
|||||||
const start = cleaned.indexOf('{')
|
const start = cleaned.indexOf('{')
|
||||||
const end = cleaned.lastIndexOf('}')
|
const end = cleaned.lastIndexOf('}')
|
||||||
if (start < 0 || end <= start) throw new Error('AI 未返回可解析的日报 JSON')
|
if (start < 0 || end <= start) throw new Error('AI 未返回可解析的日报 JSON')
|
||||||
return JSON.parse(cleaned.slice(start, end + 1))
|
const json = cleaned.slice(start, end + 1)
|
||||||
|
try {
|
||||||
|
return JSON.parse(json)
|
||||||
|
} catch (strictError) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonrepair(json))
|
||||||
|
} catch (repairError) {
|
||||||
|
throw new Error(
|
||||||
|
`日报 JSON 自动修复失败:${repairError instanceof Error ? repairError.message : String(repairError)}`,
|
||||||
|
{ cause: strictError }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createSignature = (...parts: Array<string | null | undefined>): string =>
|
const createSignature = (...parts: Array<string | null | undefined>): string =>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export type AgentHubRuntimeStatus = 'starting' | 'online' | 'offline' | 'error'
|
||||||
|
export type WechatConnectorStatus =
|
||||||
|
| 'checking'
|
||||||
|
| 'disconnected'
|
||||||
|
| 'starting'
|
||||||
|
| 'waiting_scan'
|
||||||
|
| 'scanned'
|
||||||
|
| 'online'
|
||||||
|
| 'error'
|
||||||
|
|
||||||
|
export interface AgentHubStatus {
|
||||||
|
hub: AgentHubRuntimeStatus
|
||||||
|
connector: WechatConnectorStatus
|
||||||
|
qrCodeDataUrl?: string
|
||||||
|
accountId?: string
|
||||||
|
wechatUserId?: string
|
||||||
|
error?: string
|
||||||
|
updatedAt: number
|
||||||
|
dataApi?: 'checking' | 'online' | 'offline'
|
||||||
|
databaseReady?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentHubActionResult {
|
||||||
|
success: boolean
|
||||||
|
status: AgentHubStatus
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentHubLogSource = 'agent-hub' | 'wechat-connector' | 'system'
|
||||||
|
export type AgentHubLogLevel = 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
|
export interface AgentHubLogEntry {
|
||||||
|
id: number
|
||||||
|
timestamp: number
|
||||||
|
source: AgentHubLogSource
|
||||||
|
level: AgentHubLogLevel
|
||||||
|
message: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type AppLogLevel = 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
|
export interface AppLogEntry {
|
||||||
|
level: AppLogLevel
|
||||||
|
scope: string
|
||||||
|
message: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
}
|
||||||
@@ -11,7 +11,10 @@ export const LOCAL_API_ENDPOINTS = {
|
|||||||
},
|
},
|
||||||
'group-snapshot': { method: 'GET', path: '/api/v1/group_snapshot', queryKeys: ['md5'] },
|
'group-snapshot': { method: 'GET', path: '/api/v1/group_snapshot', queryKeys: ['md5'] },
|
||||||
resolve: { method: 'GET', path: '/api/v1/resolve', queryKeys: ['q'] },
|
resolve: { method: 'GET', path: '/api/v1/resolve', queryKeys: ['q'] },
|
||||||
report: { method: 'POST', path: '/api/v1/report', queryKeys: [] }
|
report: { method: 'POST', path: '/api/v1/report', queryKeys: [] },
|
||||||
|
'agent-status': { method: 'GET', path: '/api/v1/agent/status', queryKeys: [] },
|
||||||
|
'agent-group-report': { method: 'POST', path: '/api/v1/agent/group-report', queryKeys: [] },
|
||||||
|
'agent-send': { method: 'POST', path: '/api/v1/agent/send', queryKeys: [] }
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type LocalApiEndpointId = keyof typeof LOCAL_API_ENDPOINTS
|
export type LocalApiEndpointId = keyof typeof LOCAL_API_ENDPOINTS
|
||||||
|
|||||||
Reference in New Issue
Block a user