Compare commits

..
66 changed files with 8513 additions and 586 deletions
+1
View File
@@ -6,6 +6,7 @@ out
.DS_Store
.eslintcache
*.log*
resources/connectors/wechat/
.omc
.codex/
docs/design/
+7 -1
View File
@@ -9,7 +9,7 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
在微信 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)
- [WeFlow](https://github.com/hicccc77/WeFlow)
- [chatlog](https://github.com/sjzar/chatlog)
## 📱 交流与反馈
<p align="center">
<img src="./public/二维码.jpg" alt="WechatExplorer 交流二维码" width="280" />
</p>
+1
View File
@@ -16,6 +16,7 @@ extraMetadata:
asarUnpack:
- resources/**
extraResources:
# Includes the optional WeChat connector binary for the target platform.
- from: resources
to: resources
filter:
+27 -8
View File
@@ -1,6 +1,6 @@
{
"name": "wechatexplorer",
"version": "2.1.1",
"version": "2.1.4",
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
"keywords": [
"wechat",
@@ -25,23 +25,32 @@
"test:skill-install": "node scripts/test-skill-install-instruction.cjs",
"cp:env": "node scripts/ensure-env.cjs",
"prepare:env": "node scripts/ensure-env.cjs",
"predev": "node scripts/ensure-env.cjs",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64",
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64",
"build:native-services": "npm run build:wechat-connector",
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
"build:win": "npm run build && electron-builder --config electron-builder.yml --win",
"build:mac:x64": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"build:mac:arm64": "electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"release:mac": "electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
"build:win": "npm run typecheck && npm run build:wechat-connector:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64",
"build:mac:x64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64",
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
"release": "npm run release:mac && npm run release:win",
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
"release:win": "npm run typecheck && npm run build:wechat-connector:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64 --publish always",
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-win32-x64": "3.1.0",
"@tanstack/react-virtual": "^3.14.6",
"fs-extra": "^11.3.2",
"fzstd": "^0.1.1",
"jsonrepair": "^3.15.0",
"koffi": "^3.1.0",
"openai": "^6.10.0",
"silk-wasm": "^3.7.1",
@@ -71,6 +80,16 @@
"vite": "^7.2.6"
},
"pnpm": {
"supportedArchitectures": {
"os": [
"current",
"win32"
],
"cpu": [
"current",
"x64"
]
},
"onlyBuiltDependencies": [
"electron",
"esbuild"
+26 -4
View File
@@ -10,7 +10,9 @@ specifiers:
'@electron-toolkit/preload': ^3.0.2
'@electron-toolkit/tsconfig': ^2.0.0
'@electron-toolkit/utils': ^4.0.0
'@koromix/koffi-win32-x64': 3.1.0
'@rollup/rollup-darwin-arm64': ^4.62.2
'@tanstack/react-virtual': ^3.14.6
'@types/fs-extra': ^11.0.4
'@types/node': ^22.19.1
'@types/react': ^19.2.7
@@ -25,6 +27,7 @@ specifiers:
eslint-plugin-react-refresh: ^0.4.24
fs-extra: ^11.3.2
fzstd: ^0.1.1
jsonrepair: ^3.15.0
koffi: ^3.1.0
openai: ^6.10.0
prettier: ^3.7.4
@@ -38,8 +41,11 @@ specifiers:
dependencies:
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
'@koromix/koffi-win32-x64': 3.1.0
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
fs-extra: 11.3.2
fzstd: 0.1.1
jsonrepair: 3.15.0
koffi: 3.1.0
openai: 6.10.0
silk-wasm: 3.7.1
@@ -887,7 +893,6 @@ packages:
cpu: [x64]
os: [win32]
dev: false
optional: true
/@malept/cross-spawn-promise/2.0.0:
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
@@ -1123,6 +1128,21 @@ packages:
defer-to-connect: 2.0.1
dev: true
/@tanstack/react-virtual/3.14.6_bokjwhiew3ov3ffvbmafuwoalq:
resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
'@tanstack/virtual-core': 3.17.4
react: 19.2.1
react-dom: 19.2.1_react@19.2.1
dev: false
/@tanstack/virtual-core/3.17.4:
resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==}
dev: false
/@tootallnate/once/2.0.0:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
@@ -3413,6 +3433,11 @@ packages:
optionalDependencies:
graceful-fs: 4.2.11
/jsonrepair/3.15.0:
resolution: {integrity: sha512-wy8OTjwsJwQRnQJkKnMJJ9vcytRdBPAgIF/Hy6+s1dAj42BHMKiyL8JzEieIl3JY7idt8eyHwBWTO8mh/+mtwA==}
hasBin: true
dev: false
/jsx-ast-utils/3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@@ -4036,7 +4061,6 @@ packages:
dependencies:
react: 19.2.1
scheduler: 0.27.0
dev: true
/react-is/16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -4050,7 +4074,6 @@ packages:
/react/19.2.1:
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
engines: {node: '>=0.10.0'}
dev: true
/read-binary-file-arch/1.0.6:
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
@@ -4233,7 +4256,6 @@ packages:
/scheduler/0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
dev: true
/semver/5.7.2:
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

+17
View File
@@ -10,6 +10,23 @@ function setPlistValue(plistPath, key, value) {
}
exports.default = async function afterPack(context) {
if (context.electronPlatformName === 'win32') {
const koffiNative = path.join(
context.appOutDir,
'resources',
'app.asar.unpacked',
'node_modules',
'@koromix',
'koffi-win32-x64',
'win32_x64',
'koffi.node'
)
if (!existsSync(koffiNative)) {
throw new Error(`Missing Windows Koffi native module: ${koffiNative}`)
}
return
}
if (context.electronPlatformName !== 'darwin') return
const productName = context.packager.appInfo.productFilename
+65
View File
@@ -0,0 +1,65 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const projectRoot = path.resolve(__dirname, '..')
const sourceDir = path.join(projectRoot, 'services', 'wechat-connector')
const outputRoot = path.join(projectRoot, 'resources', 'connectors', 'wechat')
function normalizePlatform(value) {
if (value === 'win32' || value === 'windows') return 'windows'
if (value === 'darwin' || value === 'macos') return 'darwin'
if (value === 'linux') return 'linux'
throw new Error(`Unsupported connector platform: ${value}`)
}
function normalizeArch(value) {
if (value === 'x64' || value === 'amd64') return 'amd64'
if (value === 'arm64') return 'arm64'
throw new Error(`Unsupported connector architecture: ${value}`)
}
function detectHostArch() {
if (process.platform !== 'darwin') return process.arch
try {
const arm64Supported = execFileSync('sysctl', ['-n', 'hw.optional.arm64'], {
encoding: 'utf8'
}).trim()
return arm64Supported === '1' ? 'arm64' : process.arch
} catch {
return process.arch
}
}
function parseTargets() {
const platformArg = process.argv.indexOf('--platform')
const archArg = process.argv.indexOf('--arch')
const platforms = platformArg >= 0 ? process.argv[platformArg + 1].split(',') : [process.platform]
const arches = archArg >= 0 ? process.argv[archArg + 1].split(',') : [detectHostArch()]
return platforms.flatMap((platform) =>
arches.map((arch) => ({ goos: normalizePlatform(platform), goarch: normalizeArch(arch) }))
)
}
if (!fs.existsSync(path.join(sourceDir, 'go.mod'))) {
throw new Error(`Repository-local WeChat connector source is missing: ${sourceDir}`)
}
for (const target of parseTargets()) {
const directoryName = `${target.goos === 'windows' ? 'win32' : target.goos}-${target.goarch === 'amd64' ? 'x64' : target.goarch}`
const outputDir = path.join(outputRoot, directoryName)
const outputPath = path.join(
outputDir,
target.goos === 'windows' ? 'wechat-connector.exe' : 'wechat-connector'
)
fs.rmSync(outputDir, { recursive: true, force: true })
fs.mkdirSync(outputDir, { recursive: true })
execFileSync('go', ['build', '-trimpath', '-o', outputPath, '.'], {
cwd: sourceDir,
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' },
stdio: 'inherit'
})
if (target.goos !== 'windows') fs.chmodSync(outputPath, 0o755)
console.log(`[build-wechat-connector] built ${directoryName}: ${outputPath}`)
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 fastclaw-ai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25
View File
@@ -0,0 +1,25 @@
# WechatExplorer WeChat Connector
This repository-local service provides the minimal WeChat bridge required by WechatExplorer:
- QR-code login with a single persisted credential
- account discovery
- inbound long polling and authenticated webhook delivery
- local HTTP health and send endpoints
- text and local/remote media sending
The executable is managed by the Electron main process. It is not a general-purpose agent runtime and does not load external AI command-line tools.
## Commands
```bash
go run . login --json
go run . accounts --json
go run . start --foreground --api-addr 127.0.0.1:18011 --account-id <account-id>
```
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
## Attribution
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). WechatExplorer-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding WechatExplorer project.
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
)
// Server provides an HTTP API for sending messages.
type Server struct {
clients []*ilink.Client
addr string
}
// NewServer creates an API server.
func NewServer(clients []*ilink.Client, addr string) *Server {
if addr == "" {
addr = "127.0.0.1:18011"
}
return &Server{clients: clients, addr: addr}
}
// SendRequest is the JSON body for POST /api/send.
type SendRequest struct {
AccountID string `json:"account_id,omitempty"`
To string `json:"to"`
Text string `json:"text,omitempty"`
MediaURL string `json:"media_url,omitempty"` // image/video/file URL
}
// Run starts the HTTP server. Blocks until ctx is cancelled.
func (s *Server) Run(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/api/send", s.handleSend)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
srv := &http.Server{Addr: s.addr, Handler: mux}
go func() {
<-ctx.Done()
srv.Shutdown(context.Background())
}()
log.Printf("[api] listening on %s", s.addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req SendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.To == "" {
http.Error(w, `"to" is required`, http.StatusBadRequest)
return
}
if req.Text == "" && req.MediaURL == "" {
http.Error(w, `"text" or "media_url" is required`, http.StatusBadRequest)
return
}
if len(s.clients) == 0 {
http.Error(w, "no accounts configured", http.StatusServiceUnavailable)
return
}
client := s.clientForAccount(req.AccountID)
if client == nil {
http.Error(w, "requested account is not available", http.StatusNotFound)
return
}
ctx := r.Context()
// Send text if provided
if req.Text != "" {
if err := messaging.SendTextReply(ctx, client, req.To, req.Text, "", ""); err != nil {
log.Printf("[api] send text failed: %v", err)
http.Error(w, "send text failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent text to %s: %q", req.To, req.Text)
// Extract and send any markdown images embedded in text
for _, imgURL := range messaging.ExtractImageURLs(req.Text) {
if err := messaging.SendMediaFromURL(ctx, client, req.To, imgURL, ""); err != nil {
log.Printf("[api] send extracted image failed: %v", err)
} else {
log.Printf("[api] sent extracted image to %s: %s", req.To, imgURL)
}
}
}
// Send media if provided
if req.MediaURL != "" {
if err := messaging.SendMediaFromURL(ctx, client, req.To, req.MediaURL, ""); err != nil {
log.Printf("[api] send media failed: %v", err)
http.Error(w, "send media failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent media to %s: %s", req.To, req.MediaURL)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) clientForAccount(accountID string) *ilink.Client {
if accountID == "" {
return s.clients[0]
}
for _, client := range s.clients {
if client.BotID() == accountID {
return client
}
}
return nil
}
@@ -0,0 +1,20 @@
package api
import (
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestClientForAccountSelectsMatchingBot(t *testing.T) {
oldClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-old"})
newClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-new"})
server := NewServer([]*ilink.Client{oldClient, newClient}, "")
if got := server.clientForAccount("bot-new"); got != newClient {
t.Fatal("clientForAccount did not select the requested account")
}
if got := server.clientForAccount("missing"); got != nil {
t.Fatal("clientForAccount should reject an unknown account")
}
}
+8
View File
@@ -0,0 +1,8 @@
module github.com/Wxw-Gu/WechatExplorer/services/wechat-connector
go 1.23.0
require (
github.com/google/uuid v1.6.0
rsc.io/qr v0.2.0
)
+4
View File
@@ -0,0 +1,4 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+197
View File
@@ -0,0 +1,197 @@
package ilink
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const (
qrCodeURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3"
qrStatusURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode="
statusWait = "wait"
statusScanned = "scaned"
statusConfirmed = "confirmed"
statusExpired = "expired"
)
// FetchQRCode retrieves a new QR code for login.
func FetchQRCode(ctx context.Context) (*QRCodeResponse, error) {
c := NewUnauthenticatedClient()
var resp QRCodeResponse
if err := c.doGet(ctx, qrCodeURL, &resp); err != nil {
return nil, fmt.Errorf("fetch QR code: %w", err)
}
return &resp, nil
}
// PollQRStatus polls for QR code scan status until confirmed or expired.
// It calls onStatus for each status change so the caller can display progress.
func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status string)) (*Credentials, error) {
c := NewUnauthenticatedClient()
url := qrStatusURL + qrcode
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
pollCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
var resp QRStatusResponse
err := c.doGet(pollCtx, url, &resp)
cancel()
if err != nil {
// Timeout is normal for long-poll, retry
if ctx.Err() != nil {
return nil, ctx.Err()
}
continue
}
if onStatus != nil {
onStatus(resp.Status)
}
switch resp.Status {
case statusConfirmed:
creds := &Credentials{
BotToken: resp.BotToken,
ILinkBotID: resp.ILinkBotID,
BaseURL: resp.BaseURL,
ILinkUserID: resp.ILinkUserID,
}
return creds, nil
case statusExpired:
return nil, fmt.Errorf("QR code expired")
case statusWait, statusScanned:
// Continue polling
default:
// Unknown status, continue
}
}
}
// AccountsDir returns the directory where account credentials are stored.
func AccountsDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts"), nil
}
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
func NormalizeAccountID(raw string) string {
s := raw
for _, ch := range []string{"@", ".", ":"} {
s = filepath.Clean(s)
s = replaceAll(s, ch, "-")
}
return s
}
func replaceAll(s, old, new string) string {
for {
i := indexOf(s, old)
if i < 0 {
return s
}
s = s[:i] + new + s[i+len(old):]
}
}
func indexOf(s, sub string) int {
for i := range s {
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// SaveCredentials saves the latest credentials and removes older accounts.
// The new credential is written first so a failed login never destroys the
// previously working credential.
func SaveCredentials(creds *Credentials) error {
dir, err := AccountsDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create accounts dir: %w", err)
}
id := NormalizeAccountID(creds.ILinkBotID)
path := filepath.Join(dir, id+".json")
data, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write credentials: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("prune old credentials: %w", err)
}
keepPrefix := id + "."
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), keepPrefix) {
continue
}
if filepath.Ext(entry.Name()) != ".json" {
continue
}
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove old credential %s: %w", entry.Name(), err)
}
}
return nil
}
// LoadAllCredentials loads all saved account credentials.
func LoadAllCredentials() ([]*Credentials, error) {
dir, err := AccountsDir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read accounts dir: %w", err)
}
var result []*Credentials
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var creds Credentials
if json.Unmarshal(data, &creds) == nil && creds.BotToken != "" {
result = append(result, &creds)
}
}
return result, nil
}
// CredentialsPath returns the path for display purposes.
func CredentialsPath() (string, error) {
return AccountsDir()
}
@@ -0,0 +1,36 @@
package ilink
import (
"os"
"path/filepath"
"testing"
)
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
t.Setenv("HOME", t.TempDir())
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
if err := SaveCredentials(old); err != nil {
t.Fatal(err)
}
dir, err := AccountsDir()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json"), []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
if err := SaveCredentials(latest); err != nil {
t.Fatal(err)
}
accounts, err := LoadAllCredentials()
if err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].ILinkBotID != latest.ILinkBotID {
t.Fatalf("accounts = %#v", accounts)
}
if _, err := os.Stat(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json")); !os.IsNotExist(err) {
t.Fatalf("old sync state still exists: %v", err)
}
}
+218
View File
@@ -0,0 +1,218 @@
package ilink
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
defaultBaseURL = "https://ilinkai.weixin.qq.com"
longPollTimeout = 35 * time.Second
sendTimeout = 15 * time.Second
)
// Client is an iLink HTTP API client.
type Client struct {
baseURL string
botToken string
botID string
httpClient *http.Client
wechatUIN string
}
// NewClient creates a new iLink API client.
func NewClient(creds *Credentials) *Client {
baseURL := creds.BaseURL
if baseURL == "" {
baseURL = defaultBaseURL
}
return &Client{
baseURL: baseURL,
botToken: creds.BotToken,
botID: creds.ILinkBotID,
httpClient: &http.Client{},
wechatUIN: generateWechatUIN(),
}
}
// NewUnauthenticatedClient creates a client without credentials for login flow.
func NewUnauthenticatedClient() *Client {
return &Client{
baseURL: defaultBaseURL,
httpClient: &http.Client{Timeout: 40 * time.Second},
wechatUIN: generateWechatUIN(),
}
}
// BotID returns the bot's user ID.
func (c *Client) BotID() string {
return c.botID
}
// GetUpdates performs a long-poll for new messages.
func (c *Client) GetUpdates(ctx context.Context, buf string) (*GetUpdatesResponse, error) {
reqBody := GetUpdatesRequest{
GetUpdatesBuf: buf,
BaseInfo: BaseInfo{ChannelVersion: "1.0.0"},
}
ctx, cancel := context.WithTimeout(ctx, longPollTimeout+5*time.Second)
defer cancel()
var resp GetUpdatesResponse
if err := c.doPost(ctx, "/ilink/bot/getupdates", reqBody, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendMessage sends a message through iLink.
func (c *Client) SendMessage(ctx context.Context, msg *SendMessageRequest) (*SendMessageResponse, error) {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
var resp SendMessageResponse
if err := c.doPost(ctx, "/ilink/bot/sendmessage", msg, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetConfig fetches bot config for a user (includes typing_ticket).
func (c *Client) GetConfig(ctx context.Context, userID, contextToken string) (*GetConfigResponse, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req := GetConfigRequest{
ILinkUserID: userID,
ContextToken: contextToken,
BaseInfo: BaseInfo{},
}
var resp GetConfigResponse
if err := c.doPost(ctx, "/ilink/bot/getconfig", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendTyping sends a typing indicator to a user.
func (c *Client) SendTyping(ctx context.Context, userID, typingTicket string, status int) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req := SendTypingRequest{
ILinkUserID: userID,
TypingTicket: typingTicket,
Status: status,
BaseInfo: BaseInfo{},
}
var resp SendTypingResponse
if err := c.doPost(ctx, "/ilink/bot/sendtyping", req, &resp); err != nil {
return err
}
if resp.Ret != 0 {
return fmt.Errorf("sendtyping failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
return nil
}
// GetUploadURL gets a pre-signed CDN upload URL for media files.
func (c *Client) GetUploadURL(ctx context.Context, req *GetUploadURLRequest) (*GetUploadURLResponse, error) {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
var resp GetUploadURLResponse
if err := c.doPost(ctx, "/ilink/bot/getuploadurl", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// BaseURL returns the base URL for CDN operations.
func (c *Client) BaseURL() string {
return c.baseURL
}
func (c *Client) doPost(ctx context.Context, path string, body interface{}, result interface{}) error {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
return nil
}
func (c *Client) doGet(ctx context.Context, url string, result interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
return nil
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
req.Header.Set("Authorization", "Bearer "+c.botToken)
req.Header.Set("X-WECHAT-UIN", c.wechatUIN)
}
func generateWechatUIN() string {
var n uint32
_ = binary.Read(rand.Reader, binary.LittleEndian, &n)
s := fmt.Sprintf("%d", n)
return base64.StdEncoding.EncodeToString([]byte(s))
}
+181
View File
@@ -0,0 +1,181 @@
package ilink
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
)
const (
maxConsecutiveFailures = 5
initialBackoff = 3 * time.Second
maxBackoff = 60 * time.Second
sessionExpiredBackoff = 5 * time.Second
errCodeSessionExpired = -14
)
// MessageHandler is called for each received message.
type MessageHandler func(ctx context.Context, client *Client, msg WeixinMessage)
// Monitor manages the long-poll loop for receiving messages.
type Monitor struct {
client *Client
handler MessageHandler
getUpdatesBuf string
bufPath string
failures int
lastActivity time.Time
}
// NewMonitor creates a new long-poll monitor.
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
accountID := NormalizeAccountID(client.BotID())
bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", accountID+".sync.json")
m := &Monitor{
client: client,
handler: handler,
bufPath: bufPath,
lastActivity: time.Now(),
}
m.loadBuf()
return m, nil
}
// Run starts the long-poll loop. It blocks until ctx is cancelled.
// Automatically recovers from errors with exponential backoff.
func (m *Monitor) Run(ctx context.Context) error {
log.Println("[monitor] starting long-poll loop")
for {
select {
case <-ctx.Done():
log.Println("[monitor] shutting down")
return ctx.Err()
default:
}
resp, err := m.client.GetUpdates(ctx, m.getUpdatesBuf)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
m.failures++
backoff := m.calcBackoff()
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
m.failures, maxConsecutiveFailures, backoff, err)
if m.failures == maxConsecutiveFailures {
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from WechatExplorer if this persists.", maxConsecutiveFailures)
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
// Reset failure counter on any successful response
m.failures = 0
m.lastActivity = time.Now()
// Session expired — reset sync buf and reconnect silently
if resp.ErrCode == errCodeSessionExpired {
if m.getUpdatesBuf != "" {
log.Printf("[monitor] session expired, resetting sync buf")
m.getUpdatesBuf = ""
m.saveBuf()
} else {
// Sync buf already empty but still getting session expired:
// the bot token itself has expired. The user needs to re-login.
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from WechatExplorer.")
}
select {
case <-time.After(sessionExpiredBackoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
// Other server errors
if resp.Ret != 0 && resp.ErrCode != 0 {
log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg)
continue
}
// Update buf for next poll
if resp.GetUpdatesBuf != "" {
m.getUpdatesBuf = resp.GetUpdatesBuf
m.saveBuf()
}
// Process messages concurrently — don't block the poll loop
for _, msg := range resp.Msgs {
go m.handler(ctx, m.client, msg)
}
}
}
// calcBackoff returns an exponential backoff duration capped at maxBackoff.
func (m *Monitor) calcBackoff() time.Duration {
d := initialBackoff
for i := 1; i < m.failures; i++ {
d *= 2
if d > maxBackoff {
return maxBackoff
}
}
return d
}
type syncData struct {
GetUpdatesBuf string `json:"get_updates_buf"`
}
func (m *Monitor) loadBuf() {
data, err := os.ReadFile(m.bufPath)
if err != nil {
return
}
var s syncData
if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" {
m.getUpdatesBuf = s.GetUpdatesBuf
log.Printf("[monitor] loaded sync buf from %s", m.bufPath)
}
}
func (m *Monitor) saveBuf() {
dir := filepath.Dir(m.bufPath)
if err := os.MkdirAll(dir, 0o700); err != nil {
log.Printf("[monitor] failed to create buf dir: %v", err)
return
}
data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf})
if err := os.WriteFile(m.bufPath, data, 0o600); err != nil {
log.Printf("[monitor] failed to save buf: %v", err)
}
}
// FormatMessageSummary returns a short description of a message for logging.
func FormatMessageSummary(msg WeixinMessage) string {
text := ""
for _, item := range msg.ItemList {
if item.Type == ItemTypeText && item.TextItem != nil {
text = item.TextItem.Text
break
}
}
if len(text) > 50 {
text = text[:50] + "..."
}
return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text)
}
+219
View File
@@ -0,0 +1,219 @@
package ilink
// Message types
const (
MessageTypeNone = 0
MessageTypeUser = 1
MessageTypeBot = 2
)
// Message states
const (
MessageStateNew = 0
MessageStateGenerating = 1
MessageStateFinish = 2
)
// Item types
const (
ItemTypeNone = 0
ItemTypeText = 1
ItemTypeImage = 2
ItemTypeVoice = 3
ItemTypeFile = 4
ItemTypeVideo = 5
)
// QRCodeResponse is the response from get_bot_qrcode.
type QRCodeResponse struct {
QRCode string `json:"qrcode"`
QRCodeImgContent string `json:"qrcode_img_content"`
}
// QRStatusResponse is the response from get_qrcode_status.
type QRStatusResponse struct {
Status string `json:"status"`
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
BaseURL string `json:"baseurl"`
ILinkUserID string `json:"ilink_user_id"`
}
// Credentials stores login session data.
type Credentials struct {
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
BaseURL string `json:"baseurl"`
ILinkUserID string `json:"ilink_user_id"`
}
// BaseInfo is included in request bodies.
type BaseInfo struct {
ChannelVersion string `json:"channel_version,omitempty"`
}
// GetUpdatesRequest is the body for getupdates.
type GetUpdatesRequest struct {
GetUpdatesBuf string `json:"get_updates_buf"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetUpdatesResponse is the response from getupdates.
type GetUpdatesResponse struct {
Ret int `json:"ret"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
Msgs []WeixinMessage `json:"msgs"`
GetUpdatesBuf string `json:"get_updates_buf"`
LongPollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
}
// WeixinMessage represents a message from WeChat.
type WeixinMessage struct {
Seq int `json:"seq,omitempty"`
MessageID int64 `json:"message_id,omitempty"`
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// MessageItem is a single item in a message.
type MessageItem struct {
Type int `json:"type"`
TextItem *TextItem `json:"text_item,omitempty"`
ImageItem *ImageItem `json:"image_item,omitempty"`
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
VideoItem *VideoItem `json:"video_item,omitempty"`
FileItem *FileItem `json:"file_item,omitempty"`
}
// CDN media type constants.
const (
CDNMediaTypeImage = 1
CDNMediaTypeVideo = 2
CDNMediaTypeFile = 3
)
// GetUploadURLRequest is the body for getuploadurl.
type GetUploadURLRequest struct {
FileKey string `json:"filekey"`
MediaType int `json:"media_type"`
ToUserID string `json:"to_user_id"`
RawSize int `json:"rawsize"`
RawFileMD5 string `json:"rawfilemd5"`
FileSize int `json:"filesize"`
NoNeedThumb bool `json:"no_need_thumb"`
AESKey string `json:"aeskey"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetUploadURLResponse is the response from getuploadurl.
type GetUploadURLResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
UploadParam string `json:"upload_param"`
UploadFullURL string `json:"upload_full_url,omitempty"`
}
// TextItem holds text content.
type TextItem struct {
Text string `json:"text"`
}
// MediaInfo holds CDN media reference for uploaded files.
type MediaInfo struct {
EncryptQueryParam string `json:"encrypt_query_param"`
AESKey string `json:"aes_key"` // base64-encoded
EncryptType int `json:"encrypt_type"` // 1 = AES-128-ECB
}
// VoiceItem holds voice content.
type VoiceItem struct {
Media *MediaInfo `json:"media,omitempty"`
VoiceSize int `json:"voice_size,omitempty"`
EncodeType int `json:"encode_type,omitempty"` // 1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3
BitsPerSample int `json:"bits_per_sample,omitempty"`
SampleRate int `json:"sample_rate,omitempty"` // Hz
Playtime int `json:"playtime,omitempty"` // duration in milliseconds
Text string `json:"text,omitempty"` // speech-to-text transcription from WeChat
}
// ImageItem holds image content.
type ImageItem struct {
URL string `json:"url,omitempty"`
Media *MediaInfo `json:"media,omitempty"`
MidSize int `json:"mid_size,omitempty"` // ciphertext size
}
// VideoItem holds video content.
type VideoItem struct {
Media *MediaInfo `json:"media,omitempty"`
VideoSize int `json:"video_size,omitempty"`
}
// FileItem holds file content.
type FileItem struct {
Media *MediaInfo `json:"media,omitempty"`
FileName string `json:"file_name,omitempty"`
Len string `json:"len,omitempty"` // plaintext size as string
}
// SendMessageRequest is the body for sendmessage.
type SendMessageRequest struct {
Msg SendMsg `json:"msg"`
BaseInfo BaseInfo `json:"base_info"`
}
// SendMsg is the message payload for sending.
type SendMsg struct {
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
ClientID string `json:"client_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// SendMessageResponse is the response from sendmessage.
type SendMessageResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
}
// Typing status constants.
const (
TypingStatusTyping = 1
TypingStatusCancel = 2
)
// GetConfigRequest is the body for getconfig.
type GetConfigRequest struct {
ILinkUserID string `json:"ilink_user_id"`
ContextToken string `json:"context_token,omitempty"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetConfigResponse is the response from getconfig.
type GetConfigResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
TypingTicket string `json:"typing_ticket,omitempty"`
}
// SendTypingRequest is the body for sendtyping.
type SendTypingRequest struct {
ILinkUserID string `json:"ilink_user_id"`
TypingTicket string `json:"typing_ticket"`
Status int `json:"status"`
BaseInfo BaseInfo `json:"base_info"`
}
// SendTypingResponse is the response from sendtyping.
type SendTypingResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/api"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
"rsc.io/qr"
)
type loginEvent struct {
Status string `json:"status"`
QRCodeDataURL string `json:"qr_code_data_url,omitempty"`
AccountID string `json:"account_id,omitempty"`
WeChatUserID string `json:"wechat_user_id,omitempty"`
}
type accountSummary struct {
AccountID string `json:"account_id"`
WeChatUserID string `json:"wechat_user_id"`
}
func main() {
if len(os.Args) < 2 {
fatal(errors.New("expected one of: login, accounts, start"))
}
var err error
switch os.Args[1] {
case "login":
err = runLogin(os.Args[2:])
case "accounts":
err = runAccounts(os.Args[2:])
case "start":
err = runStart(os.Args[2:])
default:
err = fmt.Errorf("unknown command %q", os.Args[1])
}
if err != nil {
fatal(err)
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
func signalContext() (context.Context, context.CancelFunc) {
return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
}
func runLogin(args []string) error {
flags := flag.NewFlagSet("login", flag.ContinueOnError)
jsonOutput := flags.Bool("json", false, "emit JSON Lines events")
if err := flags.Parse(args); err != nil {
return err
}
ctx, cancel := signalContext()
defer cancel()
creds, err := login(ctx, *jsonOutput)
if err != nil {
return err
}
if !*jsonOutput {
fmt.Printf("WeChat account %s connected.\n", creds.ILinkBotID)
}
return nil
}
func login(ctx context.Context, jsonOutput bool) (*ilink.Credentials, error) {
qrResponse, err := ilink.FetchQRCode(ctx)
if err != nil {
return nil, err
}
code, err := qr.Encode(qrResponse.QRCodeImgContent, qr.L)
if err != nil {
return nil, fmt.Errorf("encode QR image: %w", err)
}
emit := func(event loginEvent) {
if jsonOutput {
_ = json.NewEncoder(os.Stdout).Encode(event)
}
}
emit(loginEvent{Status: "qrcode", QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())})
lastStatus := ""
creds, err := ilink.PollQRStatus(ctx, qrResponse.QRCode, func(status string) {
if status != lastStatus {
lastStatus = status
emit(loginEvent{Status: status})
}
})
if err != nil {
return nil, err
}
if err := ilink.SaveCredentials(creds); err != nil {
return nil, fmt.Errorf("save credentials: %w", err)
}
emit(loginEvent{Status: "active", AccountID: creds.ILinkBotID, WeChatUserID: creds.ILinkUserID})
return creds, nil
}
func runAccounts(args []string) error {
flags := flag.NewFlagSet("accounts", flag.ContinueOnError)
jsonOutput := flags.Bool("json", false, "print JSON")
if err := flags.Parse(args); err != nil {
return err
}
accounts, err := ilink.LoadAllCredentials()
if err != nil {
return err
}
items := make([]accountSummary, 0, len(accounts))
for _, account := range accounts {
items = append(items, accountSummary{AccountID: account.ILinkBotID, WeChatUserID: account.ILinkUserID})
}
if *jsonOutput {
return json.NewEncoder(os.Stdout).Encode(map[string]any{"accounts": items})
}
for _, item := range items {
fmt.Printf("%s\t%s\n", item.AccountID, item.WeChatUserID)
}
return nil
}
func runStart(args []string) error {
flags := flag.NewFlagSet("start", flag.ContinueOnError)
_ = flags.Bool("foreground", false, "kept for host compatibility")
apiAddr := flags.String("api-addr", "127.0.0.1:18011", "local send API address")
accountID := flags.String("account-id", "", "account to start")
if err := flags.Parse(args); err != nil {
return err
}
accounts, err := ilink.LoadAllCredentials()
if err != nil {
return err
}
if len(accounts) == 0 {
return errors.New("no connected WeChat account; scan a QR code first")
}
selected := accounts[len(accounts)-1]
if *accountID != "" {
selected = nil
for _, account := range accounts {
if account.ILinkBotID == *accountID {
selected = account
break
}
}
if selected == nil {
return fmt.Errorf("account %q not found", *accountID)
}
}
ctx, cancel := signalContext()
defer cancel()
client := ilink.NewClient(selected)
server := api.NewServer([]*ilink.Client{client}, *apiAddr)
webhookURL := strings.TrimSpace(os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL"))
webhook := messaging.NewInboundWebhook(webhookURL, os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN"))
monitor, err := ilink.NewMonitor(client, func(messageContext context.Context, source *ilink.Client, message ilink.WeixinMessage) {
if webhookURL != "" {
webhook.Dispatch(messageContext, source, message)
}
})
if err != nil {
return err
}
var wait sync.WaitGroup
wait.Add(2)
go func() {
defer wait.Done()
if err := server.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[api] stopped: %v", err)
cancel()
}
}()
go func() {
defer wait.Done()
if err := monitor.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[monitor] stopped: %v", err)
cancel()
}
}()
wait.Wait()
return nil
}
+232
View File
@@ -0,0 +1,232 @@
package messaging
import (
"bytes"
"context"
"crypto/aes"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
const cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
// UploadedFile holds the result of a CDN upload.
type UploadedFile struct {
DownloadParam string // encrypted query param for download
AESKeyHex string // hex-encoded AES key
FileSize int // plaintext size
CipherSize int // ciphertext size
}
// UploadFileToCDN encrypts and uploads a file to the WeChat CDN.
func UploadFileToCDN(ctx context.Context, client *ilink.Client, data []byte, toUserID string, mediaType int) (*UploadedFile, error) {
// Generate random filekey and AES key
filekey := make([]byte, 16)
aeskey := make([]byte, 16)
if _, err := rand.Read(filekey); err != nil {
return nil, fmt.Errorf("generate filekey: %w", err)
}
if _, err := rand.Read(aeskey); err != nil {
return nil, fmt.Errorf("generate aeskey: %w", err)
}
filekeyHex := hex.EncodeToString(filekey)
aeskeyHex := hex.EncodeToString(aeskey)
// Calculate MD5 of plaintext
hash := md5.Sum(data)
rawMD5 := hex.EncodeToString(hash[:])
// Calculate ciphertext size (PKCS7 padding)
cipherSize := aesECBPaddedSize(len(data))
// Get upload URL from iLink API
uploadReq := &ilink.GetUploadURLRequest{
FileKey: filekeyHex,
MediaType: mediaType,
ToUserID: toUserID,
RawSize: len(data),
RawFileMD5: rawMD5,
FileSize: cipherSize,
NoNeedThumb: true,
AESKey: aeskeyHex,
BaseInfo: ilink.BaseInfo{},
}
uploadResp, err := client.GetUploadURL(ctx, uploadReq)
if err != nil {
return nil, fmt.Errorf("get upload URL: %w", err)
}
if uploadResp.Ret != 0 {
return nil, fmt.Errorf("get upload URL failed: ret=%d errmsg=%s", uploadResp.Ret, uploadResp.ErrMsg)
}
// Encrypt data with AES-128-ECB
encrypted, err := encryptAESECB(data, aeskey)
if err != nil {
return nil, fmt.Errorf("encrypt: %w", err)
}
// Upload to CDN: prefer server-provided full URL, fall back to param-based construction
cdnURL := strings.TrimSpace(uploadResp.UploadFullURL)
if cdnURL == "" {
if uploadResp.UploadParam == "" {
return nil, fmt.Errorf("getuploadurl returned no upload URL (need upload_full_url or upload_param)")
}
cdnURL = fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s",
cdnBaseURL, url.QueryEscape(uploadResp.UploadParam), url.QueryEscape(filekeyHex))
}
downloadParam, err := uploadToCDN(ctx, encrypted, cdnURL)
if err != nil {
return nil, fmt.Errorf("CDN upload: %w", err)
}
return &UploadedFile{
DownloadParam: downloadParam,
AESKeyHex: aeskeyHex,
FileSize: len(data),
CipherSize: cipherSize,
}, nil
}
// AESKeyToBase64 converts a hex AES key to base64 format for message items.
func AESKeyToBase64(hexKey string) string {
return base64.StdEncoding.EncodeToString([]byte(hexKey))
}
// DownloadFileFromCDN downloads and decrypts a file from the WeChat CDN.
func DownloadFileFromCDN(ctx context.Context, encryptQueryParam, aesKeyBase64 string) ([]byte, error) {
// Decode AES key: base64 -> hex string -> raw bytes
aesKeyHexBytes, err := base64.StdEncoding.DecodeString(aesKeyBase64)
if err != nil {
return nil, fmt.Errorf("decode AES key base64: %w", err)
}
aesKey, err := hex.DecodeString(string(aesKeyHexBytes))
if err != nil {
return nil, fmt.Errorf("decode AES key hex: %w", err)
}
// Download encrypted data from CDN
downloadURL := fmt.Sprintf("%s/download?encrypted_query_param=%s",
cdnBaseURL, url.QueryEscape(encryptQueryParam))
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download from CDN: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("CDN download HTTP %d: %s", resp.StatusCode, string(body))
}
encrypted, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read CDN response: %w", err)
}
// Decrypt AES-128-ECB
return decryptAESECB(encrypted, aesKey)
}
// decryptAESECB decrypts data encrypted with AES-128-ECB and removes PKCS7 padding.
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext is not a multiple of block size")
}
plaintext := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
// Remove PKCS7 padding
if len(plaintext) == 0 {
return plaintext, nil
}
padLen := int(plaintext[len(plaintext)-1])
if padLen > aes.BlockSize || padLen == 0 {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
return plaintext[:len(plaintext)-padLen], nil
}
func uploadToCDN(ctx context.Context, encrypted []byte, cdnURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(encrypted))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/octet-stream")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("CDN upload HTTP %d: %s", resp.StatusCode, string(body))
}
downloadParam := resp.Header.Get("X-Encrypted-Param")
if downloadParam == "" {
return "", fmt.Errorf("CDN upload: missing X-Encrypted-Param header")
}
return downloadParam, nil
}
// encryptAESECB encrypts data using AES-128-ECB with PKCS7 padding.
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// PKCS7 padding
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
padded := make([]byte, len(plaintext)+padLen)
copy(padded, plaintext)
for i := len(plaintext); i < len(padded); i++ {
padded[i] = byte(padLen)
}
// ECB mode: encrypt each block independently
encrypted := make([]byte, len(padded))
for i := 0; i < len(padded); i += aes.BlockSize {
block.Encrypt(encrypted[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
}
return encrypted, nil
}
func aesECBPaddedSize(plaintextSize int) int {
return (plaintextSize/aes.BlockSize + 1) * aes.BlockSize
}
@@ -0,0 +1,121 @@
package messaging
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
const (
webhookAttempts = 3
webhookTimeout = 5 * time.Second
)
type InboundWebhook struct {
url string
token string
client *http.Client
}
type inboundWebhookPayload struct {
AccountID string `json:"account_id"`
FromUserID string `json:"from_user_id"`
MessageID int64 `json:"message_id"`
MessageType int `json:"message_type"`
Items []inboundWebhookItem `json:"items"`
ReceivedAt time.Time `json:"received_at"`
}
type inboundWebhookItem struct {
Type int `json:"type"`
Text string `json:"text,omitempty"`
}
func NewInboundWebhook(url, token string) *InboundWebhook {
return &InboundWebhook{
url: strings.TrimSpace(url),
token: token,
client: &http.Client{Timeout: webhookTimeout},
}
}
// Dispatch is intentionally non-blocking so webhook failures never stall iLink polling.
func (w *InboundWebhook) Dispatch(ctx context.Context, client *ilink.Client, msg ilink.WeixinMessage) {
payload := normalizeInboundMessage(client.BotID(), msg)
go func() {
if err := w.deliver(ctx, payload); err != nil {
log.Printf("[webhook] inbound delivery failed for message %d: %v", msg.MessageID, err)
}
}()
}
func (w *InboundWebhook) deliver(ctx context.Context, payload inboundWebhookPayload) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encode payload: %w", err)
}
var lastErr error
for attempt := 1; attempt <= webhookAttempts; attempt++ {
if attempt > 1 {
timer := time.NewTimer(time.Duration(attempt-1) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body))
if reqErr != nil {
return fmt.Errorf("create request: %w", reqErr)
}
req.Header.Set("Content-Type", "application/json")
if w.token != "" {
req.Header.Set("Authorization", "Bearer "+w.token)
}
resp, doErr := w.client.Do(req)
if doErr != nil {
lastErr = doErr
continue
}
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
lastErr = fmt.Errorf("status %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
break
}
}
return lastErr
}
func normalizeInboundMessage(accountID string, msg ilink.WeixinMessage) inboundWebhookPayload {
items := make([]inboundWebhookItem, 0, len(msg.ItemList))
for _, item := range msg.ItemList {
normalized := inboundWebhookItem{Type: item.Type}
if item.TextItem != nil {
normalized.Text = item.TextItem.Text
} else if item.VoiceItem != nil {
normalized.Text = item.VoiceItem.Text
}
items = append(items, normalized)
}
return inboundWebhookPayload{
AccountID: accountID,
FromUserID: msg.FromUserID,
MessageID: msg.MessageID,
MessageType: msg.MessageType,
Items: items,
ReceivedAt: time.Now().UTC(),
}
}
@@ -0,0 +1,75 @@
package messaging
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestInboundWebhookDeliversNormalizedPayload(t *testing.T) {
var got inboundWebhookPayload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "secret")
err := webhook.deliver(context.Background(), normalizeInboundMessage("bot-new", ilink.WeixinMessage{
MessageID: 7, FromUserID: "user-1", MessageType: ilink.MessageTypeUser,
ItemList: []ilink.MessageItem{{Type: ilink.ItemTypeText, TextItem: &ilink.TextItem{Text: "最近5条消息"}}},
}))
if err != nil {
t.Fatalf("deliver: %v", err)
}
if got.AccountID != "bot-new" || got.MessageID != 7 || len(got.Items) != 1 || got.Items[0].Text != "最近5条消息" {
t.Fatalf("payload = %#v", got)
}
}
func TestInboundWebhookRetriesServerErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) < 3 {
http.Error(w, "temporary", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "")
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err != nil {
t.Fatalf("deliver: %v", err)
}
if calls.Load() != 3 {
t.Fatalf("calls = %d, want 3", calls.Load())
}
}
func TestInboundWebhookDoesNotRetryClientErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "")
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err == nil {
t.Fatal("deliver error = nil")
}
if calls.Load() != 1 {
t.Fatalf("calls = %d, want 1", calls.Load())
}
}
@@ -0,0 +1,103 @@
package messaging
import (
"regexp"
"strings"
)
var (
// Code blocks: strip fences, keep code content
reCodeBlock = regexp.MustCompile("(?s)```[^\n]*\n?(.*?)```")
// Inline code: strip backticks, keep content
reInlineCode = regexp.MustCompile("`([^`]+)`")
// Images: remove entirely
reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`)
// Links: keep display text only
reLink = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)
// Table separator rows: remove
reTableSep = regexp.MustCompile(`(?m)^\|[\s:|\-]+\|$`)
// Table rows: convert pipe-delimited to space-delimited
reTableRow = regexp.MustCompile(`(?m)^\|(.+)\|$`)
// Headers: remove # prefix
reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+`)
// Bold: **text** or __text__
reBold = regexp.MustCompile(`\*\*(.+?)\*\*|__(.+?)__`)
// Italic: *text* or _text_
reItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)|(?:^|[^_])_([^_]+)_(?:[^_]|$)`)
// Strikethrough: ~~text~~
reStrike = regexp.MustCompile(`~~(.+?)~~`)
// Blockquote: > prefix
reBlockquote = regexp.MustCompile(`(?m)^>\s?`)
// Horizontal rule
reHR = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`)
// Unordered list markers: -, *, +
reUL = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
)
// MarkdownToPlainText converts markdown to readable plain text for WeChat.
func MarkdownToPlainText(text string) string {
result := text
// Code blocks: strip fences, keep code content
result = reCodeBlock.ReplaceAllStringFunc(result, func(match string) string {
parts := reCodeBlock.FindStringSubmatch(match)
if len(parts) > 1 {
return strings.TrimSpace(parts[1])
}
return match
})
// Images: remove entirely
result = reImage.ReplaceAllString(result, "")
// Links: keep display text only
result = reLink.ReplaceAllString(result, "$1")
// Table separator rows: remove
result = reTableSep.ReplaceAllString(result, "")
// Table rows: pipe-delimited to space-delimited
result = reTableRow.ReplaceAllStringFunc(result, func(match string) string {
parts := reTableRow.FindStringSubmatch(match)
if len(parts) > 1 {
cells := strings.Split(parts[1], "|")
for i := range cells {
cells[i] = strings.TrimSpace(cells[i])
}
return strings.Join(cells, " ")
}
return match
})
// Headers: remove # prefix
result = reHeader.ReplaceAllString(result, "")
// Bold
result = reBold.ReplaceAllStringFunc(result, func(match string) string {
parts := reBold.FindStringSubmatch(match)
if parts[1] != "" {
return parts[1]
}
return parts[2]
})
// Strikethrough
result = reStrike.ReplaceAllString(result, "$1")
// Blockquote
result = reBlockquote.ReplaceAllString(result, "")
// Horizontal rule -> empty line
result = reHR.ReplaceAllString(result, "")
// Unordered list: replace markers with "• "
result = reUL.ReplaceAllString(result, "${1}• ")
// Inline code: strip backticks (do after code blocks)
result = reInlineCode.ReplaceAllString(result, "$1")
// Clean up excessive blank lines
result = regexp.MustCompile(`\n{3,}`).ReplaceAllString(result, "\n\n")
return strings.TrimSpace(result)
}
@@ -0,0 +1,221 @@
package messaging
import (
"context"
"fmt"
"io"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
// reMarkdownImage matches markdown image syntax: ![alt](url)
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
// ExtractImageURLs extracts image URLs from markdown text.
func ExtractImageURLs(text string) []string {
matches := reMarkdownImage.FindAllStringSubmatch(text, -1)
var urls []string
for _, m := range matches {
url := strings.TrimSpace(m[1])
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
urls = append(urls, url)
}
}
return urls
}
// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message.
func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error {
// Check if it's a local file
if _, err := os.Stat(mediaURL); err == nil {
return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken)
}
// Must be a valid HTTP URL to download
if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") {
return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL)
}
data, contentType, err := downloadFile(ctx, mediaURL)
if err != nil {
return fmt.Errorf("download %s: %w", mediaURL, err)
}
return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken)
}
// SendMediaFromPath reads a local file and sends it as a media message.
func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken)
}
func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error {
if fileName == "" {
fileName = "file"
}
cdnMediaType, itemType := classifyMedia(contentType, source)
log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID)
uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType)
if err != nil {
return fmt.Errorf("upload to CDN: %w", err)
}
media := &ilink.MediaInfo{
EncryptQueryParam: uploaded.DownloadParam,
AESKey: AESKeyToBase64(uploaded.AESKeyHex),
EncryptType: 1,
}
var item ilink.MessageItem
switch itemType {
case ilink.ItemTypeImage:
item = ilink.MessageItem{
Type: ilink.ItemTypeImage,
ImageItem: &ilink.ImageItem{
Media: media,
MidSize: uploaded.CipherSize,
},
}
case ilink.ItemTypeVideo:
item = ilink.MessageItem{
Type: ilink.ItemTypeVideo,
VideoItem: &ilink.VideoItem{
Media: media,
VideoSize: uploaded.CipherSize,
},
}
default:
item = ilink.MessageItem{
Type: ilink.ItemTypeFile,
FileItem: &ilink.FileItem{
Media: media,
FileName: fileName,
Len: fmt.Sprintf("%d", uploaded.FileSize),
},
}
}
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: NewClientID(),
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{item},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send media message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source)
return nil
}
func downloadFile(ctx context.Context, url string) ([]byte, string, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = inferContentType(url)
}
return data, contentType, nil
}
func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) {
ct := strings.ToLower(contentType)
if strings.HasPrefix(ct, "image/") || isImageExt(url) {
return ilink.CDNMediaTypeImage, ilink.ItemTypeImage
}
if strings.HasPrefix(ct, "video/") || isVideoExt(url) {
return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo
}
return ilink.CDNMediaTypeFile, ilink.ItemTypeFile
}
func isImageExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
return true
}
return false
}
func isVideoExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".mp4", ".mov", ".webm", ".mkv", ".avi":
return true
}
return false
}
func inferContentType(url string) string {
ext := filepath.Ext(stripQuery(url))
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
func filenameFromURL(rawURL string) string {
u := stripQuery(rawURL)
name := filepath.Base(u)
if name == "" || name == "." || name == "/" {
return "file"
}
return name
}
func stripQuery(rawURL string) string {
if i := strings.IndexByte(rawURL, '?'); i >= 0 {
return rawURL[:i]
}
return rawURL
}
@@ -0,0 +1,73 @@
package messaging
import "testing"
func TestExtractImageURLs(t *testing.T) {
text := "check ![img](https://example.com/a.png) and ![](https://example.com/b.jpg)"
urls := ExtractImageURLs(text)
if len(urls) != 2 {
t.Fatalf("expected 2 urls, got %d", len(urls))
}
if urls[0] != "https://example.com/a.png" {
t.Errorf("urls[0] = %q", urls[0])
}
if urls[1] != "https://example.com/b.jpg" {
t.Errorf("urls[1] = %q", urls[1])
}
}
func TestExtractImageURLs_NoImages(t *testing.T) {
urls := ExtractImageURLs("just plain text")
if len(urls) != 0 {
t.Errorf("expected 0 urls, got %d", len(urls))
}
}
func TestExtractImageURLs_RelativeURL(t *testing.T) {
text := "![img](./local.png)"
urls := ExtractImageURLs(text)
if len(urls) != 0 {
t.Errorf("expected 0 urls for relative path, got %d", len(urls))
}
}
func TestFilenameFromURL(t *testing.T) {
tests := []struct {
url string
want string
}{
{"https://example.com/photo.png", "photo.png"},
{"https://example.com/path/to/report.pdf", "report.pdf"},
{"https://example.com/file", "file"},
}
for _, tt := range tests {
got := filenameFromURL(tt.url)
if got != tt.want {
t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want)
}
}
}
func TestFilenameFromURL_WithQuery(t *testing.T) {
got := filenameFromURL("https://example.com/photo.png?token=abc")
if got != "photo.png" {
t.Errorf("got %q, want %q", got, "photo.png")
}
}
func TestStripQuery(t *testing.T) {
tests := []struct {
input string
want string
}{
{"https://example.com/a?b=c", "https://example.com/a"},
{"https://example.com/a", "https://example.com/a"},
{"https://example.com/?x=1&y=2", "https://example.com/"},
}
for _, tt := range tests {
got := stripQuery(tt.input)
if got != tt.want {
t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
@@ -0,0 +1,86 @@
package messaging
import (
"context"
"fmt"
"log"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/google/uuid"
)
// NewClientID generates a new unique client ID for message correlation.
func NewClientID() string {
return uuid.New().String()
}
// SendTypingState sends a typing indicator to a user via the iLink sendtyping API.
// It first fetches a typing_ticket via getconfig, then sends the typing status.
func SendTypingState(ctx context.Context, client *ilink.Client, userID, contextToken string) error {
// Get typing ticket
configResp, err := client.GetConfig(ctx, userID, contextToken)
if err != nil {
return fmt.Errorf("get config for typing: %w", err)
}
if configResp.TypingTicket == "" {
return fmt.Errorf("no typing_ticket returned from getconfig")
}
// Send typing
if err := client.SendTyping(ctx, userID, configResp.TypingTicket, ilink.TypingStatusTyping); err != nil {
return fmt.Errorf("send typing: %w", err)
}
log.Printf("[sender] sent typing indicator to %s", userID)
return nil
}
// SendTextReply sends a text reply to a user through the iLink API.
// If clientID is empty, a new one is generated.
func SendTextReply(ctx context.Context, client *ilink.Client, toUserID, text, contextToken, clientID string) error {
if clientID == "" {
clientID = NewClientID()
}
// Convert markdown to plain text for WeChat display
plainText := MarkdownToPlainText(text)
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: clientID,
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{
{
Type: ilink.ItemTypeText,
TextItem: &ilink.TextItem{
Text: plainText,
},
},
},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send message failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[sender] sent reply to %s: %q", toUserID, truncate(text, 50))
return nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+83
View File
@@ -0,0 +1,83 @@
import { app, shell } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import type { AppLogEntry } from '../shared/app-log'
const MAX_LOG_BYTES = 5 * 1024 * 1024
const REDACTED_KEY = /(?:api[-_]?key|authorization|token|secret|password|database[-_]?key)/i
const sanitize = (value: unknown, depth = 0): unknown => {
if (depth > 4) return '[depth-limited]'
if (typeof value === 'string') {
return value
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
.slice(0, 2000)
}
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1))
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
REDACTED_KEY.test(key) ? '***' : sanitize(item, depth + 1)
])
)
}
return value
}
export class AppLogger {
private get logDir(): string {
return app.getPath('logs')
}
get logPath(): string {
return path.join(this.logDir, 'wechatexplorer.log')
}
private rotateIfNeeded(): void {
try {
if (!fs.existsSync(this.logPath) || fs.statSync(this.logPath).size < MAX_LOG_BYTES) return
const previous = `${this.logPath}.1`
if (fs.existsSync(previous)) fs.removeSync(previous)
fs.moveSync(this.logPath, previous)
} catch {
// Logging must never interrupt the application.
}
}
write(entry: AppLogEntry): void {
try {
fs.ensureDirSync(this.logDir)
this.rotateIfNeeded()
const record = {
timestamp: new Date().toISOString(),
mode: app.isPackaged ? 'packaged' : 'development',
level: entry.level,
scope: String(entry.scope || 'app').slice(0, 80),
message: String(entry.message || '').slice(0, 500),
details: sanitize(entry.details || {})
}
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
if (!app.isPackaged) {
const method =
entry.level === 'error'
? console.error
: entry.level === 'warn'
? console.warn
: console.log
method(`[${record.scope}] ${record.message}`, record.details)
}
} catch {
// Logging must never interrupt the application.
}
}
reveal(): void {
fs.ensureDirSync(this.logDir)
if (!fs.existsSync(this.logPath)) fs.writeFileSync(this.logPath, '', 'utf8')
shell.showItemInFolder(this.logPath)
}
}
export const appLogger = new AppLogger()
+64 -4
View File
@@ -9,6 +9,8 @@ import {
} from './services/chat-service'
import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report'
import { generateAgentGroupReport } from './services/agent-group-report-service'
import { agentHubService } from './services/agent-hub-service'
import { safeError, safeLog, safeWarn } from './safe-log'
export const DEFAULT_HTTP_HOST = '127.0.0.1'
@@ -157,7 +159,9 @@ const routes: Record<string, RouteHandler> = {
if (keyword) {
const lower = keyword.toLowerCase()
groups = groups.filter(
(c) => c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
(c) =>
c.m_nsNickName.toLowerCase().includes(lower) ||
c.m_nsUsrName.toLowerCase().includes(lower)
)
}
sendJson(res, 200, { count: groups.length, chatrooms: groups })
@@ -236,13 +240,62 @@ const routes: Record<string, RouteHandler> = {
try {
request = JSON.parse(body) as GroupReportExportRequest
} catch (error) {
return sendError(res, 400, '请求体 JSON 解析失败', error instanceof Error ? error.message : String(error))
return sendError(
res,
400,
'请求体 JSON 解析失败',
error instanceof Error ? error.message : String(error)
)
}
if (!request?.report || !request?.metadata) {
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
}
const result = await exportGroupReport(request)
sendJson(res, result.success ? 200 : 500, result)
},
'/api/v1/agent/group-report': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
try {
request = JSON.parse(typeof body === 'string' ? body : '{}')
} catch {
return sendError(res, 400, '请求体 JSON 解析失败')
}
const result = await generateAgentGroupReport({
group: request.group || '',
range: request.range
})
sendJson(res, result.success ? 200 : 400, result)
},
'/api/v1/agent/status': ({ res }) => {
const status = agentHubService.getStatus()
sendJson(res, 200, {
ok: status.hub === 'online' && status.connector === 'online',
hub: status.hub,
connector: status.connector,
dataApi: status.dataApi,
databaseReady: status.databaseReady,
accountId: status.accountId
})
},
'/api/v1/agent/send': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
let request: { to?: string; text?: string; media_url?: string }
try {
request = JSON.parse(typeof body === 'string' ? body : '{}')
} catch {
return sendError(res, 400, '请求体 JSON 解析失败')
}
const result = await agentHubService.testSend({
to: request.to,
text: request.text,
mediaUrl: request.media_url
})
sendJson(res, result.success ? 200 : result.status === 'token_expired' ? 401 : 503, result)
}
}
@@ -311,7 +364,11 @@ export interface ApiServerState {
}
let singleton: HttpServerHandle | null = null
let singletonState: ApiServerState = { running: false, host: DEFAULT_HTTP_HOST, port: DEFAULT_HTTP_PORT }
let singletonState: ApiServerState = {
running: false,
host: DEFAULT_HTTP_HOST,
port: DEFAULT_HTTP_PORT
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
@@ -326,7 +383,10 @@ export const apiServer = {
return { ...singletonState }
},
async start(host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT): Promise<ApiServerState> {
async start(
host: string = DEFAULT_HTTP_HOST,
port: number = DEFAULT_HTTP_PORT
): Promise<ApiServerState> {
if (singleton) {
return this.getState()
}
+24 -19
View File
@@ -4,6 +4,11 @@ import crypto from 'crypto'
import os from 'os'
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 {
private readonly defaultV1AesKey = 'cfcd208495d565ef'
@@ -41,7 +46,7 @@ export class ImageDecryptService {
)
if (!existsSync(accountRoot)) {
console.log('[ImageDecrypt] account root not found:', accountRoot)
imageDecryptLog('[ImageDecrypt] account root not found:', accountRoot)
return null
}
@@ -61,7 +66,7 @@ export class ImageDecryptService {
.sort((a, b) => b.mtime - a.mtime)
if (accounts.length === 0) {
console.log('[ImageDecrypt] no accounts found')
imageDecryptLog('[ImageDecrypt] no accounts found')
return null
}
@@ -83,7 +88,7 @@ export class ImageDecryptService {
const normalizedMd5 = this.normalizeDatBase(md5 || '')
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
console.log('[ImageDecrypt] findImageFile:', {
imageDecryptLog('[ImageDecrypt] findImageFile:', {
md5: normalizedMd5,
imageDatName: normalizedDatName,
accountDir,
@@ -96,7 +101,7 @@ export class ImageDecryptService {
if (fullPath && existsSync(fullPath)) {
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
console.log('[ImageDecrypt] hardlink hit:', selected)
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
return selected
}
}
@@ -105,7 +110,7 @@ export class ImageDecryptService {
// 尝试 WechatExplorer 的目录结构: msg/attach/{hash}/{YYYY-MM}/Img/
const attachDir = join(accountDir, 'msg', 'attach')
if (!existsSync(attachDir)) {
console.log('[ImageDecrypt] attach dir not found:', attachDir)
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
}
@@ -120,7 +125,7 @@ export class ImageDecryptService {
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
if (legacyHit) return legacyHit
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
return null
}
@@ -146,7 +151,7 @@ export class ImageDecryptService {
]
const found = this.getLargestExistingPath(candidates, allowThumbnail)
if (found) {
console.log('[ImageDecrypt] prefix path hit:', found)
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
return found
}
}
@@ -175,14 +180,14 @@ export class ImageDecryptService {
allowThumbnail
)
if (found) {
console.log('[ImageDecrypt] found at:', found)
imageDecryptLog('[ImageDecrypt] found at:', found)
return found
}
}
}
}
} catch (e) {
console.log('[ImageDecrypt]遍历目录失败:', e)
imageDecryptLog('[ImageDecrypt]遍历目录失败:', e)
}
return null
@@ -229,7 +234,7 @@ export class ImageDecryptService {
const fullPath = join(dir, entry)
const stat = statSync(fullPath)
if (stat.isFile() && variants.has(entry.toLowerCase())) {
console.log('[ImageDecrypt] legacy path hit:', fullPath)
imageDecryptLog('[ImageDecrypt] legacy path hit:', fullPath)
return fullPath
}
}
@@ -252,13 +257,13 @@ export class ImageDecryptService {
*/
decryptImage(datPath: string): Buffer | null {
if (!existsSync(datPath)) {
console.log('[ImageDecrypt] file not found:', datPath)
imageDecryptLog('[ImageDecrypt] file not found:', datPath)
return null
}
try {
const version = this.getDatVersion(datPath)
console.log(
imageDecryptLog(
'[ImageDecrypt] dat version:',
version,
'file:',
@@ -269,25 +274,25 @@ export class ImageDecryptService {
let decrypted: Buffer
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')
decrypted = this.decryptDatV4(datPath, key)
} else if (version === 2) {
console.log('[ImageDecrypt] using V2 (user AES key)')
imageDecryptLog('[ImageDecrypt] using V2 (user AES key)')
if (!this.aesKey) {
console.log('[ImageDecrypt] no AES key configured')
imageDecryptLog('[ImageDecrypt] no AES key configured')
return null
}
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
decrypted = this.decryptDatV4(datPath, key)
} else {
console.log('[ImageDecrypt] unsupported dat version:', version)
imageDecryptLog('[ImageDecrypt] unsupported dat version:', version)
return null
}
return decrypted
} catch (error) {
console.error('[ImageDecrypt] decrypt error:', error)
imageDecryptLog('[ImageDecrypt] decrypt error:', error)
return null
}
}
@@ -309,7 +314,7 @@ export class ImageDecryptService {
const unwrapped = this.unwrapWxgf(decrypted)
const ext = this.detectImageExtension(unwrapped)
if (!ext) {
console.log('[ImageDecrypt] unknown image format')
imageDecryptLog('[ImageDecrypt] unknown image format')
return null
}
@@ -346,7 +351,7 @@ export class ImageDecryptService {
const data = this.decryptImageToBase64(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
}
+60 -6
View File
@@ -68,6 +68,9 @@ import {
saveCachedMessages
} from './services/bootstrap-cache'
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.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
@@ -140,6 +143,31 @@ function createWindow(): void {
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
app.whenReady().then(async () => {
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
// per process. Bootstrap native once here so any later Wcdb4Client instance
@@ -165,6 +193,9 @@ app.whenReady().then(async () => {
// IPC test
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) => {
if (dbInitInFlight) return dbInitInFlight
@@ -555,9 +586,9 @@ app.whenReady().then(async () => {
if (!provider || !model) {
return { success: false, error: '当前 AI 模型不存在' }
}
if (!model.capabilities.vision) {
return { success: false, error: '当前模型不支持图片理解' }
}
// Capability metadata is stored per machine. A model verified on macOS
// 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 在内部算出
// 这里直接调 service,dataUrl 由 renderer 通过 window.api.getImage 拿到再传进来
return imageInsightService.analyze(request)
@@ -654,9 +685,10 @@ app.whenReady().then(async () => {
return error ? { success: false, error } : { success: true }
})
ipcMain.handle('db:disconnect', () => {
if (!chat.isReady()) return { success: false, error: '数据库当前未连接' }
chat.setChatDb(null)
ipcMain.handle('db:disconnect', (_, options?: { closeNative?: boolean }) => {
// 断开操作保持幂等:渲染进程可能已标记断开,或主进程连接已先行失效。
// 即使当前未就绪,也应让用户正常返回登录页。
if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null)
return { success: true }
})
@@ -698,6 +730,25 @@ app.whenReady().then(async () => {
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()
@@ -707,6 +758,8 @@ app.whenReady().then(async () => {
await apiServer.start(settings.apiHost, settings.apiPort)
}
await agentHubService.start(settings)
if (TRAY_MODE) {
app.dock?.hide()
setupTray()
@@ -730,6 +783,7 @@ app.on('window-all-closed', () => {
})
app.on('before-quit', async () => {
agentHubService.stop()
chat.setChatDb(null)
await apiServer.stop().catch(() => undefined)
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
+3
View File
@@ -195,6 +195,9 @@ export class AIProviderService {
const imageError = validateVisionImage(imagePart.dataUrl)
if (imageError) throw new Error(imageError)
const result = await this.request(messages as AIMessage[], options)
if (options?.providerId && options.modelId) {
this.markCapabilities(options.providerId, options.modelId, { vision: true, ocr: true })
}
return { success: true, ...result }
} catch (error) {
return { success: false, error: safeAIError(error) }
+49 -12
View File
@@ -31,6 +31,10 @@ interface BootstrapCacheFile {
const CACHE_VERSION = 1
const MAX_MESSAGE_BUCKETS = 24
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 {
return String(accountRoot || '').trim()
@@ -43,19 +47,26 @@ function getCacheFile(accountRoot?: string): string {
.update(`${process.platform}:${normalizedRoot}`)
.digest('hex')
.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 {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
const file = getCacheFile(normalizedRoot)
const cached = memoryCache.get(file)
if (cached) return cached
try {
if (!fs.existsSync(file)) return null
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
return {
const result: BootstrapCacheFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
@@ -64,6 +75,8 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
}
memoryCache.set(file, result)
return result
} catch (error) {
console.warn('[BootstrapCache] read failed:', error)
return null
@@ -71,20 +84,39 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
}
function writeCacheFile(cache: BootstrapCacheFile): void {
try {
const file = getCacheFile(cache.accountRoot)
fs.ensureDirSync(path.dirname(file))
fs.writeJsonSync(file, cache, { spaces: 2 })
} catch (error) {
memoryCache.set(file, cache)
const existingTimer = writeTimers.get(file)
if (existingTimer) clearTimeout(existingTimer)
writeTimers.set(
file,
setTimeout(() => {
writeTimers.delete(file)
const serialized = JSON.stringify(memoryCache.get(file) || cache)
const previous = writeQueues.get(file) || Promise.resolve()
const next = previous
.catch(() => undefined)
.then(async () => {
await fs.ensureDir(path.dirname(file))
await fs.writeFile(file, serialized, 'utf8')
})
.catch((error) => {
console.warn('[BootstrapCache] write failed:', error)
}
})
.finally(() => {
if (writeQueues.get(file) === next) writeQueues.delete(file)
})
writeQueues.set(file, next)
}, WRITE_DEBOUNCE_MS)
)
}
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot) return null
return (
readCacheFile(normalizedRoot) || {
const existing = readCacheFile(normalizedRoot)
if (existing) return existing
const created: BootstrapCacheFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
@@ -92,7 +124,8 @@ function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
contacts: [],
messages: {}
}
)
memoryCache.set(getCacheFile(normalizedRoot), created)
return created
}
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
@@ -144,7 +177,9 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact
)
const nameByUsername = new Map(
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])
)
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
@@ -179,7 +214,9 @@ export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]):
)
const nameByUsername = new Map(
(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])
)
cache.contacts = contacts.map((contact) => ({
+5 -2
View File
@@ -8,6 +8,7 @@ import {
} from '../../shared/local-api-test'
const REQUEST_TIMEOUT_MS = 10_000
const GROUP_REPORT_TIMEOUT_MS = 180_000
const MAX_BODY_SIZE = 512 * 1024
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('请求超时'))
finish({
ok: false,
@@ -132,7 +135,7 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
durationMs: Date.now() - startedAt,
responseSize: 0,
errorCode: 'TIMEOUT',
error: '请求超时(10 秒)'
error: `请求超时(${Math.round(timeoutMs / 1000)} 秒)`
})
})
request.on('error', (error: NodeJS.ErrnoException) => {
+17 -35
View File
@@ -2,6 +2,7 @@ import { app } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import { discoverWindowsDbRoots } from '../windows-db-root-discovery'
export interface AppSettings {
dbRoot: string
@@ -12,6 +13,8 @@ export interface AppSettings {
imageXorKey: string
imageAesKey: string
imageKeyFallbackDisabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
}
function getDefaultDbRoot(): string {
@@ -35,14 +38,7 @@ function getDefaultDbRootCandidates(home: string): string[] {
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
]
for (const drive of getWindowsDrives()) {
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'))
}
}
candidates.push(...discoverWindowsDbRoots())
return unique(candidates)
}
@@ -66,32 +62,6 @@ function getWeflowDbPathCandidates(home: string): string[] {
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[] {
return Array.from(new Set(values))
}
@@ -118,7 +88,13 @@ const DEFAULT_SETTINGS: AppSettings = {
imageKeyRoot: defaultDbRoot,
imageXorKey: '',
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(
@@ -138,6 +114,12 @@ export function loadSettings(): AppSettings {
if (fs.existsSync(SETTINGS_FILE)) {
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
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)) {
cache.dbRoot = getDefaultDbRoot()
}
+2 -34
View File
@@ -1,6 +1,7 @@
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import { discoverWindowsDbRoots } from './windows-db-root-discovery'
import crypto from 'crypto'
import { createRequire } from 'module'
import { createConnection, Socket } from 'net'
@@ -303,14 +304,7 @@ export class Wcdb4Client {
path.join(home, 'WeChat Files'),
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
]
for (const drive of Wcdb4Client.getWindowsDrives()) {
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'))
}
}
candidates.push(...discoverWindowsDbRoots())
return Array.from(new Set(candidates))
}
return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')]
@@ -335,32 +329,6 @@ export class Wcdb4Client {
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 {
try {
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
+76
View File
@@ -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]
}
+26 -1
View File
@@ -37,6 +37,8 @@ import type {
ImageCandidateQuery,
ImageInsight
} from '../shared/image-insight'
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type { AppLogEntry } from '../shared/app-log'
export type ParsedContent =
| { type: 'text'; content: string }
@@ -70,6 +72,9 @@ declare global {
interface Window {
electron: ElectronAPI
api: {
writeAppLog: (entry: AppLogEntry) => Promise<void>
getAppLogPath: () => Promise<string>
revealAppLog: () => Promise<void>
initDb: (
key: string
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
@@ -186,6 +191,8 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
imageAesKey: string
}
@@ -210,6 +217,8 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
imageAesKey: string
}
@@ -222,6 +231,8 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
imageAesKey: string
}>
@@ -232,6 +243,8 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
imageAesKey: string
}
@@ -252,7 +265,9 @@ declare global {
}>
selectDbRoot: () => Promise<{ canceled: boolean; path?: string }>
openAccountRoot: () => Promise<{ success: boolean; error?: string }>
disconnectDb: () => Promise<{ success: boolean; error?: string }>
disconnectDb: (options?: {
closeNative?: boolean
}) => Promise<{ success: boolean; error?: string }>
apiStatus: () => Promise<{
running: boolean
host: string
@@ -298,6 +313,16 @@ declare global {
sessionId: string,
limit?: number
) => 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
View File
@@ -15,9 +15,14 @@ import type {
ImageCandidateQuery,
ImageInsight
} from '../shared/image-insight'
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type { AppLogEntry } from '../shared/app-log'
// 渲染器的自定义 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),
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
@@ -105,7 +110,8 @@ const api = {
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'),
openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'),
disconnectDb: () => ipcRenderer.invoke('db:disconnect'),
disconnectDb: (options?: { closeNative?: boolean }) =>
ipcRenderer.invoke('db:disconnect', options),
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
apiStop: () => ipcRenderer.invoke('api:stop'),
@@ -119,7 +125,9 @@ const api = {
// ============================================================
// AI 图片理解基础设施(ImageInsightService)
// ============================================================
imageListCandidates: (query: ImageCandidateQuery): Promise<{
imageListCandidates: (
query: ImageCandidateQuery
): Promise<{
success: boolean
candidates: ImageCandidate[]
error?: string
@@ -132,7 +140,27 @@ const api = {
sessionId: string,
limit?: number
): 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) {
+83 -122
View File
@@ -4,6 +4,7 @@ import ChatWindow from './components/ChatWindow'
import { AppShell } from './components/layout/AppShell'
import { ApiWorkspace } from './features/api-center/ApiWorkspace'
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace'
import type { SettingsCategoryId } from './features/settings/model/types'
import type { AIRuntimeModelConfig } from '../../shared/ai-provider'
import { AppPage } from './components/layout/navigation'
@@ -18,33 +19,14 @@ import type { GeneratedReportRecord, ReportWorkspaceView } from './components/re
import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration'
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
import { Contact, Message } from '../../shared/types'
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
function EyeIcon({ hidden }: { hidden: 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"
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>
)
function getDevelopmentDatabaseKey(): string {
if (!import.meta.env.DEV) return ''
return String(import.meta.env.VITE_DB_KEY || '').trim()
}
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 MESSAGE_MONITOR_DEBOUNCE_MS = 8000
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 => {
if (message.localId) return `local:${message.localId}`
if (message.id) return `id:${message.id}`
@@ -159,7 +135,7 @@ const sortMessagesChronologically = (items: Message[]): Message[] =>
function App(): React.ReactElement {
const [isAuthenticated, setIsAuthenticated] = 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 [selectedContact, setSelectedContact] = useState<Contact | null>(null)
const [messages, setMessages] = useState<Message[]>([])
@@ -173,6 +149,9 @@ function App(): React.ReactElement {
const [showDbKey, setShowDbKey] = useState(false)
const [dbRootInput, setDbRootInput] = useState('')
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
const [databaseConnectionMode, setDatabaseConnectionMode] = useState<DatabaseConnectionMode>(
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
)
const [activePage, setActivePage] = useState<AppPage>('archive')
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
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 refreshSelfInfo = async (): Promise<void> => {
const refreshSelfInfo = async (attempts = 1): Promise<SelfInfo | null> => {
let lastError: unknown
for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
try {
const result = await window.api.getSelf()
if (result.ready) {
if (result.ready && result.info) {
setSelfInfo(result.info)
} else {
setSelfInfo(null)
return result.info
}
} catch (error) {
console.warn('[SelfInfo] 加载失败:', error)
setSelfInfo(null)
lastError = error
}
if (attempt + 1 < attempts) {
await new Promise((resolve) => window.setTimeout(resolve, 180))
}
}
if (lastError) console.warn('[SelfInfo] 加载失败:', lastError)
setSelfInfo(null)
return null
}
const loadBootstrapCache = async (): Promise<boolean> => {
@@ -330,7 +316,7 @@ function App(): React.ReactElement {
usernames.length ? 55 : 90
)
let loadedCount = 0
const chunkSize = 8
const chunkSize = 32
for (let index = 0; index < usernames.length; index += chunkSize) {
if (runId !== contactAvatarHydrationRunRef.current) return
const chunk = usernames.slice(index, index + chunkSize)
@@ -361,7 +347,7 @@ function App(): React.ReactElement {
} catch (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)
}
@@ -369,16 +355,15 @@ function App(): React.ReactElement {
React.useEffect(() => {
let active = true
const attemptAutoConnect = async (): Promise<void> => {
const settingsResult = await window.api.getSettings()
// 预填已保存的微信聊天文件路径
try {
const settings = await window.api.getSettings()
if (active && settings?.settings?.dbRoot) setDbRootInput(settings.settings.dbRoot)
} catch {
// 忽略读取设置失败,继续走密钥流程
if (active && settingsResult.settings.dbRoot) {
setDbRootInput(settingsResult.settings.dbRoot)
}
// 优先级 1: 构建期环境变量 VITE_DB_KEY(本地开发用)
const envKey = String(import.meta.env.VITE_DB_KEY || '').trim()
// 优先级 2: 上一次保存到 safeStorage 的密钥
const autoLoginEnabled = settingsResult.settings.autoLogin
// 开发环境允许使用 VITE_DB_KEY;生产安装包只能读取目标电脑自己的 safeStorage。
const envKey = getDevelopmentDatabaseKey()
// 生产环境以及未配置开发密钥时,读取上一次保存到 safeStorage 的密钥。
let savedKey = ''
if (!envKey) {
const result = await window.api.getSavedDbKey()
@@ -386,14 +371,18 @@ function App(): React.ReactElement {
}
const key = envKey || savedKey
if (!key) {
if (active) setBootState('login')
if (active) {
setDatabaseConnectionMode('automatic')
setBootState('login')
}
return
}
if (active) {
setDbKey(key)
setDatabaseConnectionMode('manual')
setAutoConnectSource(envKey ? 'env' : 'saved')
setDbKeyStatus(
AUTO_LOGIN_ENABLED
autoLoginEnabled
? envKey
? '检测到环境变量中的密钥,正在自动连接...'
: '已加载安全保存的密钥,正在自动连接...'
@@ -402,21 +391,24 @@ function App(): React.ReactElement {
: '已加载安全保存的密钥,请手动点击 Connect'
)
setDbKeyStatusKind('normal')
setBootState(AUTO_LOGIN_ENABLED ? 'connecting' : 'login')
setBootState(autoLoginEnabled ? 'connecting' : 'login')
}
if (!AUTO_LOGIN_ENABLED) return
if (!autoLoginEnabled) return
try {
const result = await window.api.initDb(key)
if (!active) return
const success = typeof result === 'boolean' ? result : result.success
if (success) {
if (!settingsResult.settings.autoLoginPreferenceSet) {
void window.api.setSettings({ autoLogin: true })
}
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsAuthenticated(true)
setIsDatabaseConnected(true)
setDbKeyStatus('已自动连接')
setDbKeyStatusKind('success')
await loadContacts()
void refreshSelfInfo()
await refreshSelfInfo(3)
setIsAuthenticated(true)
} else {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
@@ -486,16 +478,24 @@ function App(): React.ReactElement {
detail: '正在读取本地缓存',
percent: 25
})
const hasBootstrapCache = await loadBootstrapCache()
await loadBootstrapCache()
// 持久化手动输入的密钥,供下次启动继续使用
void window.api.saveDbKey(keyToUse).catch(() => undefined)
void window.api.getSettings().then((current) => {
if (!current.settings.autoLoginPreferenceSet) {
void window.api.setSettings({ autoLogin: true })
}
})
setStartupProgress({
title: '正在加载账号信息...',
subtitle: '即将进入 WechatExplorer',
detail: '正在读取当前账号信息',
percent: 95
detail: '正在读取联系人和当前账号',
percent: 70
})
void refreshSelfInfo()
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
await loadContacts({ waitForAvatars: false })
await refreshSelfInfo(3)
setStartupProgress({
title: '加载完成',
subtitle: '正在进入主页面',
@@ -507,7 +507,6 @@ function App(): React.ReactElement {
setBootState('login')
window.setTimeout(() => {
setStartupProgress(null)
if (!hasBootstrapCache) void loadContacts({ waitForAvatars: false })
}, 500)
} else {
const error = typeof result === 'boolean' ? '' : result.error
@@ -612,6 +611,7 @@ function App(): React.ReactElement {
throw new Error(result.error || '获取密钥失败')
}
setDbKey(result.key)
setDatabaseConnectionMode('manual')
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
} catch (error) {
@@ -627,6 +627,7 @@ function App(): React.ReactElement {
const result = await window.api.pasteAndSaveDbKey()
if (result.success && result.key) {
setDbKey(result.key)
setDatabaseConnectionMode('manual')
setDbKeyStatus('已从剪贴板粘贴并安全保存')
setDbKeyStatusKind('success')
} else {
@@ -644,6 +645,7 @@ function App(): React.ReactElement {
return
}
setDbKey('')
setDatabaseConnectionMode('automatic')
setDbKeyStatus('已清除保存的密钥')
setDbKeyStatusKind('normal')
}
@@ -652,6 +654,7 @@ function App(): React.ReactElement {
setIsAuthenticated(false)
setIsDatabaseConnected(false)
setBootState('login')
setDatabaseConnectionMode(dbKey ? 'manual' : 'automatic')
setActivePage('archive')
setSettingsCategory('database-key')
setSelectedContact(null)
@@ -1036,9 +1039,9 @@ function App(): React.ReactElement {
}
const renderPlaceholderPage = (
page: Exclude<AppPage, 'archive' | 'report'>
page: Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>
): React.ReactElement => {
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
const labels: Record<Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>, string> = {
search: '检索',
export: '导出',
api: 'API',
@@ -1168,6 +1171,8 @@ function App(): React.ReactElement {
return renderArchiveWorkspace()
case 'report':
return renderReportWorkspace()
case 'agent-hub':
return <AgentHubWorkspace />
case 'api':
return (
<ApiWorkspace
@@ -1273,70 +1278,26 @@ function App(): React.ReactElement {
if (!isAuthenticated) {
return (
<div className="login-modal">
<div className="login-box">
<h2>Enter WeChat DB Key</h2>
<div className="login-input-wrapper">
<input
type={showDbKey ? 'text' : 'password'}
className="login-input"
value={dbKey}
onChange={(e) => setDbKey(e.target.value)}
placeholder="Key (e.g. 0x...)"
<DatabaseConnectionPage
platform={window.electron.process.platform}
mode={databaseConnectionMode}
dbKey={dbKey}
dbRoot={dbRootInput}
showDbKey={showDbKey}
isFetching={isFetchingDbKey}
status={dbKeyStatus}
statusKind={dbKeyStatusKind}
showMacKeyFaq={showMacKeyFaq}
macKeyFaqUrl={MAC_KEY_FAQ_URL}
onModeChange={setDatabaseConnectionMode}
onDbKeyChange={setDbKey}
onDbRootChange={setDbRootInput}
onToggleDbKey={() => setShowDbKey((visible) => !visible)}
onAutoGetKey={handleAutoGetDbKey}
onManualConnect={() => handleLogin()}
onPasteKey={handlePasteAndSaveDbKey}
onClearKey={handleClearSavedDbKey}
/>
<button
type="button"
className="login-input-toggle"
onClick={() => setShowDbKey(!showDbKey)}
title={showDbKey ? '隐藏密钥' : '显示密钥'}
>
<EyeIcon hidden={showDbKey} />
</button>
</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>
)
}
File diff suppressed because it is too large Load Diff
+3 -10
View File
@@ -20,7 +20,6 @@ interface ChatWindowProps {
isAiLoading?: boolean
}
const MAX_RENDERED_MESSAGES = 600
const DATE_RANGE_LABELS: Record<string, string> = {
today: '今天',
yesterday: '昨日',
@@ -242,12 +241,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
return typeMatch && contentMatch
})
}, [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 />
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
@@ -272,8 +265,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
<DataTrustBar messageCount={messages.length} />
<MessageList
contact={contact}
messages={renderedMessages}
hiddenMessageCount={hiddenMessageCount}
messages={filteredMessages}
hiddenMessageCount={0}
isLoadingMessages={isLoadingMessages}
isGroupChat={isGroupChat}
showAvatar={showAvatar}
@@ -283,7 +276,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onImageClick={openImagePreview}
/>
<ChatStatusBar
count={renderedMessages.length}
count={filteredMessages.length}
showAvatar={showAvatar}
isAtLatest={isAtLatest}
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 { useVirtualizer } from '@tanstack/react-virtual'
import { Contact, Message } from '../../../../shared/types'
import { MessageGroup } from './MessageGroup'
import { buildMessageGroups } from './messageGrouping'
@@ -29,6 +30,16 @@ export function MessageList({
onImageClick
}: MessageListProps): React.ReactElement {
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 (
<div className="message-list wechat-message-list" ref={listRef} onScroll={onScroll}>
@@ -40,16 +51,29 @@ export function MessageList({
</div>
</div>
)}
{groups.map((group) => (
<div className="virtual-message-list" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualItems.map((virtualItem) => {
const group = groups[virtualItem.index]
if (!group) return null
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="virtual-message-group"
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
<MessageGroup
key={group.id}
group={group}
contact={contact}
isGroupChat={isGroupChat}
showAvatar={showAvatar}
onImageClick={onImageClick}
/>
))}
</div>
)
})}
</div>
<div ref={bottomRef} />
</div>
)
@@ -45,6 +45,16 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
<path d="M5.5 15.5v3h13v-3" />
</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':
return (
<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 {
id: AppPage
@@ -9,6 +9,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
{ id: 'archive', label: '档案' },
{ id: 'search', label: '检索' },
{ id: 'report', label: '日报' },
{ id: 'agent-hub', label: 'Agent' },
{ id: 'export', label: '导出' },
{ id: 'api', label: 'API' },
{ id: 'settings', label: '设置' }
@@ -1,4 +1,4 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
interface ReportTaskStatusPanelProps {
@@ -27,6 +27,14 @@ export function ReportTaskStatusPanel({
}: ReportTaskStatusPanelProps): React.ReactElement {
const activeIndex = phaseIndex(phase)
const completedAll = phase === 'success'
const [logPath, setLogPath] = useState('')
useEffect(() => {
void window.api
.getAppLogPath()
.then(setLogPath)
.catch(() => undefined)
}, [])
return (
<aside className="report-task-panel">
@@ -70,6 +78,10 @@ export function ReportTaskStatusPanel({
<button type="button" onClick={onRetry}>
</button>
<button type="button" onClick={() => void window.api.revealAppLog()}>
</button>
{logPath && <small className="report-task-log-path">{logPath}</small>}
</div>
)}
{phase === 'success' && (
@@ -37,10 +37,7 @@ export function ReportViewer({
return () => window.cancelAnimationFrame(frame)
}, [report?.id])
const title = useMemo(
() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'),
[report]
)
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
const fitWidth = (): void => {
const viewport = viewportRef.current
@@ -108,10 +105,16 @@ export function ReportViewer({
}}
onLoad={(event) => {
const image = event.currentTarget
setNaturalSize({
const nextSize = {
width: image.naturalWidth,
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={() => {
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)
}
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 (
<section className="api-request-tester" id="api-request-tester">
<div className="api-section-heading">
@@ -77,6 +88,14 @@ export function ApiRequestTester({
/>
</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">
<button type="button" onClick={onClear}>
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useReducer } from 'react'
import type { Contact } from '../../../../../shared/types'
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 {
ApiResponse,
@@ -66,14 +70,23 @@ function reducer(state: State, action: Action): State {
switch (action.type) {
case 'loaded':
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 {
...state,
endpointId: action.endpointId,
params: action.talker && action.endpointId === 'chatlog' ? { talker: action.talker } : {},
body: action.endpointId === 'report' ? state.body || REPORT_REQUEST_PRESET : state.body,
body: preset,
error: ''
}
}
case 'params':
return { ...state, params: action.params }
case 'body':
@@ -62,6 +62,20 @@ export const API_ENDPOINTS: ApiEndpoint[] = [
name: '群聊日报导出',
description: '通过内置模板导出群聊日报 HTML 与 PNG。',
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,
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 result = await window.api.disconnectDb()
// macOS WCDB 的 close 会关闭进程级原生运行时,返回登录时只退出 UI 连接态。
// 用户再次点击连接后由 db:init 复用已验证的本机连接。
const result = await window.api.disconnectDb({ closeNative: false })
if (!result.success) {
onNotice(result.error || '断开数据库连接失败')
return
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { AccountOverview } from '../account-database/AccountOverview'
import { ConnectionHealthSection } from '../account-database/ConnectionHealthSection'
import { LocalPrivacyNotice } from '../account-database/LocalPrivacyNotice'
@@ -25,6 +26,26 @@ export function AccountDatabasePage({
onNotice: (message: string) => void
}): React.ReactElement {
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 (
<div className="settings-page">
<header className="settings-page-header">
@@ -58,6 +79,20 @@ export function AccountDatabasePage({
: 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>
@@ -3,6 +3,7 @@ import { Contact, Message } from '../../../shared/types'
import {
buildGroupReportInput,
getSummaryDateRange,
GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT,
GROUP_REPORT_SYSTEM_PROMPT,
isInternalName,
parseGroupDailyReport,
@@ -108,6 +109,31 @@ const withTimeout = async <T>(
const errorMessage = (error: unknown): string =>
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 =>
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 (
contact: Contact,
messages: Message[],
@@ -175,13 +211,18 @@ const applyGroupMemberNames = async (
const member = memberMap.get(senderId)
if (!member) return message
const preferredNames: Record<ReportMemberNamePreference, string[]> = {
groupNickname: [member.groupNickname, member.wechatNickname, member.remark, member.nickname],
wechatNickname: [member.wechatNickname, member.groupNickname, member.remark, member.nickname],
// Keep the three modes semantically distinct. `member.nickname` may
// 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]
}
const name = preferredNames[preference].find((value) => value && !isInternalName(value))
if (!name) return message
return { ...message, name, img: message.img || member.avatar }
return {
...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 [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
const [memberNamePreference, setMemberNamePreferenceState] =
useState<ReportMemberNamePreference>(() => {
const [memberNamePreference, setMemberNamePreferenceState] = useState<ReportMemberNamePreference>(
() => {
const saved = localStorage.getItem('group_report_member_name_preference')
return saved === 'wechatNickname' || saved === 'remark' ? saved : 'groupNickname'
})
}
)
const [reportTimeoutSeconds, setReportTimeoutSecondsState] = useState<number>(() => {
const saved = Number(localStorage.getItem('group_report_timeout_seconds'))
return Number.isFinite(saved) && saved >= 30 ? saved : 300
@@ -352,6 +394,7 @@ export function useGroupReportGeneration({
}
const startGenerateTime = Date.now()
let failedAt = '初始化'
const logs: ReportGenerationLog[] = []
const pushLog = (log: ReportGenerationLog): void => {
logs.push(log)
@@ -382,15 +425,29 @@ export function useGroupReportGeneration({
modelName: modelConfig.model,
generationLogs: []
})
writeReportLog('info', '开始生成群聊日报', {
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
dateRange: summaryDateRange,
selectedMessageTypes: summaryMessageTypes,
providerName: modelConfig.providerName,
model: modelConfig.model,
templateId
})
try {
failedAt = '读取聊天记录'
const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
writeReportLog('info', '聊天记录读取完成', {
sourceMessageCount: sourceMessages.length,
filteredMessageCount: filteredMessages.length
})
setPhase('preparingInput')
failedAt = '整理日报输入'
const input = await trackStep('整理输入', async () => {
const namedReportMessages = await applyGroupMemberNames(
sourceContact,
@@ -401,6 +458,7 @@ export function useGroupReportGeneration({
})
setPhase('requestingModel')
failedAt = '调用模型生成内容'
const aiMessages = [
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
@@ -417,13 +475,19 @@ export function useGroupReportGeneration({
)
)
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
: estimateTokenUsage(aiMessages, result.data)
const report = parseGroupDailyReport(
let report: ReturnType<typeof parseGroupDailyReport>
try {
report = parseGroupDailyReport(
result.data,
input.topSpeakers,
input.activeTimeline,
@@ -431,8 +495,62 @@ export function useGroupReportGeneration({
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')
failedAt = '导出 HTML 与 PNG'
const exported = await withTimeout(
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
'日报图片导出'
@@ -468,8 +586,19 @@ export function useGroupReportGeneration({
generationLogs: [...logs]
})
setPhase('success')
writeReportLog('info', '群聊日报生成成功', {
durationMs: Date.now() - startGenerateTime,
htmlPath: exported.htmlPath,
pngPath: exported.pngPath
})
} catch (generateError) {
setError(errorMessage(generateError))
const message = errorMessage(generateError)
writeReportLog('error', '群聊日报生成失败', {
error: message,
failedAt,
durationMs: Date.now() - startGenerateTime
})
setError(message)
setPhase('error')
}
}, [
@@ -479,6 +608,7 @@ export function useGroupReportGeneration({
modelConfig,
reportTimeoutSeconds,
sourceContact,
summaryDateRange,
summaryMessageTypes,
templateId
])
+31
View File
@@ -4,6 +4,37 @@ import App from './App'
import './styles/tokens.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(
<React.StrictMode>
<App />
+38 -6
View File
@@ -10,6 +10,34 @@ import {
ReportVoiceHighlight,
ReportVoiceLeaderboardItem
} 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 {
id: string
@@ -184,6 +212,7 @@ const buildMediaSection = async (
warnings: string[]
}> => {
const warnings: string[] = []
const rendererApi = typeof window === 'undefined' ? null : window.api
const rawImageCandidates = messages
.map((message, index) => {
if (message.contentData?.type !== 'image') return null
@@ -214,6 +243,7 @@ const buildMediaSection = async (
// ============================================================
let visionGallery: ReportVisionGalleryItem[] = []
try {
if (!rendererApi) throw new Error('后台模式不读取 Renderer 图片')
const sessionId = messages.find((m) => m.sessionId)?.sessionId || (contact?.md5 ?? '')
const startTime = messages.length ? parseTimestamp(messages[0]) : 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,
startTime,
endTime,
@@ -249,7 +279,7 @@ const buildMediaSection = async (
if (candidate.insight) return candidate.insight
// 未命中:解密图片拿 base64 → 调 AI
try {
const img = await window.api.getImage(
const img = await rendererApi.getImage(
candidate.md5,
candidate.datName,
candidate.sessionId
@@ -260,7 +290,7 @@ const buildMediaSection = async (
)
return null
}
const analyzeResp = await window.api.imageAnalyze({
const analyzeResp = await rendererApi.imageAnalyze({
imageHash: candidate.imageHash,
imageDataUrl: img.data,
messageId: candidate.messageId,
@@ -306,7 +336,7 @@ const buildMediaSection = async (
const orig = rawImageCandidates.find((c) => c.sourceMessageIds[0] === item.messageId)
if (!orig) return item
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/')) {
return { ...item, imageUrl: img.data }
}
@@ -323,9 +353,10 @@ const buildMediaSection = async (
visionGallery = []
}
const imageCandidates = await Promise.all(
const imageCandidates = rendererApi
? await Promise.all(
rawImageCandidates.map(async (item) => {
const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
const result = await rendererApi.getImage(item.md5, item.datName, item.sessionId)
if (!result.success || !result.data?.startsWith('data:image/')) return null
return {
sender: item.sender,
@@ -340,6 +371,7 @@ const buildMediaSection = async (
}
})
)
: []
const gallery: ReportMediaGalleryItem[] = imageCandidates
.filter((item): item is NonNullable<typeof item> => Boolean(item))
+17 -1
View File
@@ -19,6 +19,7 @@ import {
ReportVoiceLeaderboardItem
} from '../../../shared/group-report'
import { Contact, Message } from '../../../shared/types'
import { jsonrepair } from 'jsonrepair'
import { buildGroupReportFacts } from './group-report-facts'
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
@@ -73,6 +74,9 @@ JSON 结构必须为:
-
- null `
export const GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT =
'你是 JSON 格式修复器。只修复输入中的 JSON 语法,不改写、删减或新增任何业务内容。只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 或解释。'
export interface GroupReportInput {
prompt: string
metadata: GroupReportMetadata
@@ -259,7 +263,19 @@ const extractJson = (raw: string): unknown => {
const start = cleaned.indexOf('{')
const end = cleaned.lastIndexOf('}')
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 =>
+38
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
export type AppLogLevel = 'info' | 'warn' | 'error'
export interface AppLogEntry {
level: AppLogLevel
scope: string
message: string
details?: Record<string, unknown>
}
+4 -1
View File
@@ -11,7 +11,10 @@ export const LOCAL_API_ENDPOINTS = {
},
'group-snapshot': { method: 'GET', path: '/api/v1/group_snapshot', queryKeys: ['md5'] },
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
export type LocalApiEndpointId = keyof typeof LOCAL_API_ENDPOINTS