mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
merge: 合并 AI 微信 Agent 能力
This commit is contained in:
@@ -6,6 +6,7 @@ out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
resources/connectors/wechat/
|
||||
.omc
|
||||
.codex/
|
||||
docs/design/
|
||||
|
||||
@@ -16,6 +16,7 @@ extraMetadata:
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
extraResources:
|
||||
# Includes the optional WeChat connector binary for the target platform.
|
||||
- from: resources
|
||||
to: resources
|
||||
filter:
|
||||
|
||||
+25
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wechatexplorer",
|
||||
"version": "2.1.1",
|
||||
"version": "2.1.2",
|
||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||
"keywords": [
|
||||
"wechat",
|
||||
@@ -25,19 +25,26 @@
|
||||
"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": {
|
||||
"@koromix/koffi-win32-x64": "3.1.0",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
@@ -71,6 +78,16 @@
|
||||
"vite": "^7.2.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"supportedArchitectures": {
|
||||
"os": [
|
||||
"current",
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"current",
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"esbuild"
|
||||
|
||||
Generated
+2
-1
@@ -10,6 +10,7 @@ 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
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/node': ^22.19.1
|
||||
@@ -38,6 +39,7 @@ 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
|
||||
fs-extra: 11.3.2
|
||||
fzstd: 0.1.1
|
||||
koffi: 3.1.0
|
||||
@@ -887,7 +889,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@malept/cross-spawn-promise/2.0.0:
|
||||
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
|
||||
|
||||
@@ -10,6 +10,23 @@ function setPlistValue(plistPath, key, value) {
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
if (context.electronPlatformName === 'win32') {
|
||||
const koffiNative = path.join(
|
||||
context.appOutDir,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
'@koromix',
|
||||
'koffi-win32-x64',
|
||||
'win32_x64',
|
||||
'koffi.node'
|
||||
)
|
||||
if (!existsSync(koffiNative)) {
|
||||
throw new Error(`Missing Windows Koffi native module: ${koffiNative}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (context.electronPlatformName !== 'darwin') return
|
||||
|
||||
const productName = context.packager.appInfo.productFilename
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const sourceDir = path.join(projectRoot, 'services', 'wechat-connector')
|
||||
const outputRoot = path.join(projectRoot, 'resources', 'connectors', 'wechat')
|
||||
|
||||
function normalizePlatform(value) {
|
||||
if (value === 'win32' || value === 'windows') return 'windows'
|
||||
if (value === 'darwin' || value === 'macos') return 'darwin'
|
||||
if (value === 'linux') return 'linux'
|
||||
throw new Error(`Unsupported connector platform: ${value}`)
|
||||
}
|
||||
|
||||
function normalizeArch(value) {
|
||||
if (value === 'x64' || value === 'amd64') return 'amd64'
|
||||
if (value === 'arm64') return 'arm64'
|
||||
throw new Error(`Unsupported connector architecture: ${value}`)
|
||||
}
|
||||
|
||||
function detectHostArch() {
|
||||
if (process.platform !== 'darwin') return process.arch
|
||||
try {
|
||||
const arm64Supported = execFileSync('sysctl', ['-n', 'hw.optional.arm64'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
return arm64Supported === '1' ? 'arm64' : process.arch
|
||||
} catch {
|
||||
return process.arch
|
||||
}
|
||||
}
|
||||
|
||||
function parseTargets() {
|
||||
const platformArg = process.argv.indexOf('--platform')
|
||||
const archArg = process.argv.indexOf('--arch')
|
||||
const platforms = platformArg >= 0 ? process.argv[platformArg + 1].split(',') : [process.platform]
|
||||
const arches = archArg >= 0 ? process.argv[archArg + 1].split(',') : [detectHostArch()]
|
||||
return platforms.flatMap((platform) =>
|
||||
arches.map((arch) => ({ goos: normalizePlatform(platform), goarch: normalizeArch(arch) }))
|
||||
)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(sourceDir, 'go.mod'))) {
|
||||
throw new Error(`Repository-local WeChat connector source is missing: ${sourceDir}`)
|
||||
}
|
||||
|
||||
for (const target of parseTargets()) {
|
||||
const directoryName = `${target.goos === 'windows' ? 'win32' : target.goos}-${target.goarch === 'amd64' ? 'x64' : target.goarch}`
|
||||
const outputDir = path.join(outputRoot, directoryName)
|
||||
const outputPath = path.join(
|
||||
outputDir,
|
||||
target.goos === 'windows' ? 'wechat-connector.exe' : 'wechat-connector'
|
||||
)
|
||||
fs.rmSync(outputDir, { recursive: true, force: true })
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
execFileSync('go', ['build', '-trimpath', '-o', outputPath, '.'], {
|
||||
cwd: sourceDir,
|
||||
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' },
|
||||
stdio: 'inherit'
|
||||
})
|
||||
if (target.goos !== 'windows') fs.chmodSync(outputPath, 0o755)
|
||||
console.log(`[build-wechat-connector] built ${directoryName}: ${outputPath}`)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 fastclaw-ai
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,25 @@
|
||||
# WechatExplorer WeChat Connector
|
||||
|
||||
This repository-local service provides the minimal WeChat bridge required by WechatExplorer:
|
||||
|
||||
- QR-code login with a single persisted credential
|
||||
- account discovery
|
||||
- inbound long polling and authenticated webhook delivery
|
||||
- local HTTP health and send endpoints
|
||||
- text and local/remote media sending
|
||||
|
||||
The executable is managed by the Electron main process. It is not a general-purpose agent runtime and does not load external AI command-line tools.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
go run . login --json
|
||||
go run . accounts --json
|
||||
go run . start --foreground --api-addr 127.0.0.1:18011 --account-id <account-id>
|
||||
```
|
||||
|
||||
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
|
||||
|
||||
## Attribution
|
||||
|
||||
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). WechatExplorer-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding WechatExplorer project.
|
||||
@@ -0,0 +1,135 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
|
||||
)
|
||||
|
||||
// Server provides an HTTP API for sending messages.
|
||||
type Server struct {
|
||||
clients []*ilink.Client
|
||||
addr string
|
||||
}
|
||||
|
||||
// NewServer creates an API server.
|
||||
func NewServer(clients []*ilink.Client, addr string) *Server {
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:18011"
|
||||
}
|
||||
return &Server{clients: clients, addr: addr}
|
||||
}
|
||||
|
||||
// SendRequest is the JSON body for POST /api/send.
|
||||
type SendRequest struct {
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
To string `json:"to"`
|
||||
Text string `json:"text,omitempty"`
|
||||
MediaURL string `json:"media_url,omitempty"` // image/video/file URL
|
||||
}
|
||||
|
||||
// Run starts the HTTP server. Blocks until ctx is cancelled.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/send", s.handleSend)
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintln(w, "ok")
|
||||
})
|
||||
|
||||
srv := &http.Server{Addr: s.addr, Handler: mux}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
srv.Shutdown(context.Background())
|
||||
}()
|
||||
|
||||
log.Printf("[api] listening on %s", s.addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req SendRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.To == "" {
|
||||
http.Error(w, `"to" is required`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Text == "" && req.MediaURL == "" {
|
||||
http.Error(w, `"text" or "media_url" is required`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(s.clients) == 0 {
|
||||
http.Error(w, "no accounts configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
client := s.clientForAccount(req.AccountID)
|
||||
if client == nil {
|
||||
http.Error(w, "requested account is not available", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
// Send text if provided
|
||||
if req.Text != "" {
|
||||
if err := messaging.SendTextReply(ctx, client, req.To, req.Text, "", ""); err != nil {
|
||||
log.Printf("[api] send text failed: %v", err)
|
||||
http.Error(w, "send text failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("[api] sent text to %s: %q", req.To, req.Text)
|
||||
|
||||
// Extract and send any markdown images embedded in text
|
||||
for _, imgURL := range messaging.ExtractImageURLs(req.Text) {
|
||||
if err := messaging.SendMediaFromURL(ctx, client, req.To, imgURL, ""); err != nil {
|
||||
log.Printf("[api] send extracted image failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[api] sent extracted image to %s: %s", req.To, imgURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send media if provided
|
||||
if req.MediaURL != "" {
|
||||
if err := messaging.SendMediaFromURL(ctx, client, req.To, req.MediaURL, ""); err != nil {
|
||||
log.Printf("[api] send media failed: %v", err)
|
||||
http.Error(w, "send media failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("[api] sent media to %s: %s", req.To, req.MediaURL)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) clientForAccount(accountID string) *ilink.Client {
|
||||
if accountID == "" {
|
||||
return s.clients[0]
|
||||
}
|
||||
for _, client := range s.clients {
|
||||
if client.BotID() == accountID {
|
||||
return client
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
)
|
||||
|
||||
func TestClientForAccountSelectsMatchingBot(t *testing.T) {
|
||||
oldClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-old"})
|
||||
newClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-new"})
|
||||
server := NewServer([]*ilink.Client{oldClient, newClient}, "")
|
||||
|
||||
if got := server.clientForAccount("bot-new"); got != newClient {
|
||||
t.Fatal("clientForAccount did not select the requested account")
|
||||
}
|
||||
if got := server.clientForAccount("missing"); got != nil {
|
||||
t.Fatal("clientForAccount should reject an unknown account")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
module github.com/Wxw-Gu/WechatExplorer/services/wechat-connector
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
@@ -0,0 +1,197 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
qrCodeURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3"
|
||||
qrStatusURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode="
|
||||
statusWait = "wait"
|
||||
statusScanned = "scaned"
|
||||
statusConfirmed = "confirmed"
|
||||
statusExpired = "expired"
|
||||
)
|
||||
|
||||
// FetchQRCode retrieves a new QR code for login.
|
||||
func FetchQRCode(ctx context.Context) (*QRCodeResponse, error) {
|
||||
c := NewUnauthenticatedClient()
|
||||
var resp QRCodeResponse
|
||||
if err := c.doGet(ctx, qrCodeURL, &resp); err != nil {
|
||||
return nil, fmt.Errorf("fetch QR code: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// PollQRStatus polls for QR code scan status until confirmed or expired.
|
||||
// It calls onStatus for each status change so the caller can display progress.
|
||||
func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status string)) (*Credentials, error) {
|
||||
c := NewUnauthenticatedClient()
|
||||
url := qrStatusURL + qrcode
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
pollCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
|
||||
var resp QRStatusResponse
|
||||
err := c.doGet(pollCtx, url, &resp)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
// Timeout is normal for long-poll, retry
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if onStatus != nil {
|
||||
onStatus(resp.Status)
|
||||
}
|
||||
|
||||
switch resp.Status {
|
||||
case statusConfirmed:
|
||||
creds := &Credentials{
|
||||
BotToken: resp.BotToken,
|
||||
ILinkBotID: resp.ILinkBotID,
|
||||
BaseURL: resp.BaseURL,
|
||||
ILinkUserID: resp.ILinkUserID,
|
||||
}
|
||||
return creds, nil
|
||||
case statusExpired:
|
||||
return nil, fmt.Errorf("QR code expired")
|
||||
case statusWait, statusScanned:
|
||||
// Continue polling
|
||||
default:
|
||||
// Unknown status, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AccountsDir returns the directory where account credentials are stored.
|
||||
func AccountsDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts"), nil
|
||||
}
|
||||
|
||||
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
|
||||
func NormalizeAccountID(raw string) string {
|
||||
s := raw
|
||||
for _, ch := range []string{"@", ".", ":"} {
|
||||
s = filepath.Clean(s)
|
||||
s = replaceAll(s, ch, "-")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func replaceAll(s, old, new string) string {
|
||||
for {
|
||||
i := indexOf(s, old)
|
||||
if i < 0 {
|
||||
return s
|
||||
}
|
||||
s = s[:i] + new + s[i+len(old):]
|
||||
}
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := range s {
|
||||
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// SaveCredentials saves the latest credentials and removes older accounts.
|
||||
// The new credential is written first so a failed login never destroys the
|
||||
// previously working credential.
|
||||
func SaveCredentials(creds *Credentials) error {
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create accounts dir: %w", err)
|
||||
}
|
||||
|
||||
id := NormalizeAccountID(creds.ILinkBotID)
|
||||
path := filepath.Join(dir, id+".json")
|
||||
|
||||
data, err := json.MarshalIndent(creds, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal credentials: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write credentials: %w", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prune old credentials: %w", err)
|
||||
}
|
||||
keepPrefix := id + "."
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), keepPrefix) {
|
||||
continue
|
||||
}
|
||||
if filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove old credential %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAllCredentials loads all saved account credentials.
|
||||
func LoadAllCredentials() ([]*Credentials, error) {
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read accounts dir: %w", err)
|
||||
}
|
||||
|
||||
var result []*Credentials
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var creds Credentials
|
||||
if json.Unmarshal(data, &creds) == nil && creds.BotToken != "" {
|
||||
result = append(result, &creds)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CredentialsPath returns the path for display purposes.
|
||||
func CredentialsPath() (string, error) {
|
||||
return AccountsDir()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
|
||||
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
|
||||
if err := SaveCredentials(old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, err := AccountsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json"), []byte(`{}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveCredentials(latest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts, err := LoadAllCredentials()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(accounts) != 1 || accounts[0].ILinkBotID != latest.ILinkBotID {
|
||||
t.Fatalf("accounts = %#v", accounts)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("old sync state still exists: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://ilinkai.weixin.qq.com"
|
||||
longPollTimeout = 35 * time.Second
|
||||
sendTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// Client is an iLink HTTP API client.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
botToken string
|
||||
botID string
|
||||
httpClient *http.Client
|
||||
wechatUIN string
|
||||
}
|
||||
|
||||
// NewClient creates a new iLink API client.
|
||||
func NewClient(creds *Credentials) *Client {
|
||||
baseURL := creds.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
botToken: creds.BotToken,
|
||||
botID: creds.ILinkBotID,
|
||||
httpClient: &http.Client{},
|
||||
wechatUIN: generateWechatUIN(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewUnauthenticatedClient creates a client without credentials for login flow.
|
||||
func NewUnauthenticatedClient() *Client {
|
||||
return &Client{
|
||||
baseURL: defaultBaseURL,
|
||||
httpClient: &http.Client{Timeout: 40 * time.Second},
|
||||
wechatUIN: generateWechatUIN(),
|
||||
}
|
||||
}
|
||||
|
||||
// BotID returns the bot's user ID.
|
||||
func (c *Client) BotID() string {
|
||||
return c.botID
|
||||
}
|
||||
|
||||
// GetUpdates performs a long-poll for new messages.
|
||||
func (c *Client) GetUpdates(ctx context.Context, buf string) (*GetUpdatesResponse, error) {
|
||||
reqBody := GetUpdatesRequest{
|
||||
GetUpdatesBuf: buf,
|
||||
BaseInfo: BaseInfo{ChannelVersion: "1.0.0"},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, longPollTimeout+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var resp GetUpdatesResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/getupdates", reqBody, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// SendMessage sends a message through iLink.
|
||||
func (c *Client) SendMessage(ctx context.Context, msg *SendMessageRequest) (*SendMessageResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
defer cancel()
|
||||
|
||||
var resp SendMessageResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/sendmessage", msg, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetConfig fetches bot config for a user (includes typing_ticket).
|
||||
func (c *Client) GetConfig(ctx context.Context, userID, contextToken string) (*GetConfigResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := GetConfigRequest{
|
||||
ILinkUserID: userID,
|
||||
ContextToken: contextToken,
|
||||
BaseInfo: BaseInfo{},
|
||||
}
|
||||
|
||||
var resp GetConfigResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/getconfig", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// SendTyping sends a typing indicator to a user.
|
||||
func (c *Client) SendTyping(ctx context.Context, userID, typingTicket string, status int) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := SendTypingRequest{
|
||||
ILinkUserID: userID,
|
||||
TypingTicket: typingTicket,
|
||||
Status: status,
|
||||
BaseInfo: BaseInfo{},
|
||||
}
|
||||
|
||||
var resp SendTypingResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/sendtyping", req, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Ret != 0 {
|
||||
return fmt.Errorf("sendtyping failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUploadURL gets a pre-signed CDN upload URL for media files.
|
||||
func (c *Client) GetUploadURL(ctx context.Context, req *GetUploadURLRequest) (*GetUploadURLResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
defer cancel()
|
||||
|
||||
var resp GetUploadURLResponse
|
||||
if err := c.doPost(ctx, "/ilink/bot/getuploadurl", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// BaseURL returns the base URL for CDN operations.
|
||||
func (c *Client) BaseURL() string {
|
||||
return c.baseURL
|
||||
}
|
||||
|
||||
func (c *Client) doPost(ctx context.Context, path string, body interface{}, result interface{}) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) doGet(ctx context.Context, url string, result interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||
req.Header.Set("Authorization", "Bearer "+c.botToken)
|
||||
req.Header.Set("X-WECHAT-UIN", c.wechatUIN)
|
||||
}
|
||||
|
||||
func generateWechatUIN() string {
|
||||
var n uint32
|
||||
_ = binary.Read(rand.Reader, binary.LittleEndian, &n)
|
||||
s := fmt.Sprintf("%d", n)
|
||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxConsecutiveFailures = 5
|
||||
initialBackoff = 3 * time.Second
|
||||
maxBackoff = 60 * time.Second
|
||||
sessionExpiredBackoff = 5 * time.Second
|
||||
errCodeSessionExpired = -14
|
||||
)
|
||||
|
||||
// MessageHandler is called for each received message.
|
||||
type MessageHandler func(ctx context.Context, client *Client, msg WeixinMessage)
|
||||
|
||||
// Monitor manages the long-poll loop for receiving messages.
|
||||
type Monitor struct {
|
||||
client *Client
|
||||
handler MessageHandler
|
||||
getUpdatesBuf string
|
||||
bufPath string
|
||||
failures int
|
||||
lastActivity time.Time
|
||||
}
|
||||
|
||||
// NewMonitor creates a new long-poll monitor.
|
||||
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountID := NormalizeAccountID(client.BotID())
|
||||
bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", accountID+".sync.json")
|
||||
|
||||
m := &Monitor{
|
||||
client: client,
|
||||
handler: handler,
|
||||
bufPath: bufPath,
|
||||
lastActivity: time.Now(),
|
||||
}
|
||||
m.loadBuf()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Run starts the long-poll loop. It blocks until ctx is cancelled.
|
||||
// Automatically recovers from errors with exponential backoff.
|
||||
func (m *Monitor) Run(ctx context.Context) error {
|
||||
log.Println("[monitor] starting long-poll loop")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("[monitor] shutting down")
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
resp, err := m.client.GetUpdates(ctx, m.getUpdatesBuf)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
m.failures++
|
||||
backoff := m.calcBackoff()
|
||||
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
|
||||
m.failures, maxConsecutiveFailures, backoff, err)
|
||||
if m.failures == maxConsecutiveFailures {
|
||||
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from WechatExplorer if this persists.", maxConsecutiveFailures)
|
||||
}
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset failure counter on any successful response
|
||||
m.failures = 0
|
||||
m.lastActivity = time.Now()
|
||||
|
||||
// Session expired — reset sync buf and reconnect silently
|
||||
if resp.ErrCode == errCodeSessionExpired {
|
||||
if m.getUpdatesBuf != "" {
|
||||
log.Printf("[monitor] session expired, resetting sync buf")
|
||||
m.getUpdatesBuf = ""
|
||||
m.saveBuf()
|
||||
} else {
|
||||
// Sync buf already empty but still getting session expired:
|
||||
// the bot token itself has expired. The user needs to re-login.
|
||||
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from WechatExplorer.")
|
||||
}
|
||||
select {
|
||||
case <-time.After(sessionExpiredBackoff):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Other server errors
|
||||
if resp.Ret != 0 && resp.ErrCode != 0 {
|
||||
log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update buf for next poll
|
||||
if resp.GetUpdatesBuf != "" {
|
||||
m.getUpdatesBuf = resp.GetUpdatesBuf
|
||||
m.saveBuf()
|
||||
}
|
||||
|
||||
// Process messages concurrently — don't block the poll loop
|
||||
for _, msg := range resp.Msgs {
|
||||
go m.handler(ctx, m.client, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calcBackoff returns an exponential backoff duration capped at maxBackoff.
|
||||
func (m *Monitor) calcBackoff() time.Duration {
|
||||
d := initialBackoff
|
||||
for i := 1; i < m.failures; i++ {
|
||||
d *= 2
|
||||
if d > maxBackoff {
|
||||
return maxBackoff
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type syncData struct {
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
}
|
||||
|
||||
func (m *Monitor) loadBuf() {
|
||||
data, err := os.ReadFile(m.bufPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var s syncData
|
||||
if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" {
|
||||
m.getUpdatesBuf = s.GetUpdatesBuf
|
||||
log.Printf("[monitor] loaded sync buf from %s", m.bufPath)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) saveBuf() {
|
||||
dir := filepath.Dir(m.bufPath)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
log.Printf("[monitor] failed to create buf dir: %v", err)
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf})
|
||||
if err := os.WriteFile(m.bufPath, data, 0o600); err != nil {
|
||||
log.Printf("[monitor] failed to save buf: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatMessageSummary returns a short description of a message for logging.
|
||||
func FormatMessageSummary(msg WeixinMessage) string {
|
||||
text := ""
|
||||
for _, item := range msg.ItemList {
|
||||
if item.Type == ItemTypeText && item.TextItem != nil {
|
||||
text = item.TextItem.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(text) > 50 {
|
||||
text = text[:50] + "..."
|
||||
}
|
||||
return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package ilink
|
||||
|
||||
// Message types
|
||||
const (
|
||||
MessageTypeNone = 0
|
||||
MessageTypeUser = 1
|
||||
MessageTypeBot = 2
|
||||
)
|
||||
|
||||
// Message states
|
||||
const (
|
||||
MessageStateNew = 0
|
||||
MessageStateGenerating = 1
|
||||
MessageStateFinish = 2
|
||||
)
|
||||
|
||||
// Item types
|
||||
const (
|
||||
ItemTypeNone = 0
|
||||
ItemTypeText = 1
|
||||
ItemTypeImage = 2
|
||||
ItemTypeVoice = 3
|
||||
ItemTypeFile = 4
|
||||
ItemTypeVideo = 5
|
||||
)
|
||||
|
||||
// QRCodeResponse is the response from get_bot_qrcode.
|
||||
type QRCodeResponse struct {
|
||||
QRCode string `json:"qrcode"`
|
||||
QRCodeImgContent string `json:"qrcode_img_content"`
|
||||
}
|
||||
|
||||
// QRStatusResponse is the response from get_qrcode_status.
|
||||
type QRStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
BotToken string `json:"bot_token"`
|
||||
ILinkBotID string `json:"ilink_bot_id"`
|
||||
BaseURL string `json:"baseurl"`
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
}
|
||||
|
||||
// Credentials stores login session data.
|
||||
type Credentials struct {
|
||||
BotToken string `json:"bot_token"`
|
||||
ILinkBotID string `json:"ilink_bot_id"`
|
||||
BaseURL string `json:"baseurl"`
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
}
|
||||
|
||||
// BaseInfo is included in request bodies.
|
||||
type BaseInfo struct {
|
||||
ChannelVersion string `json:"channel_version,omitempty"`
|
||||
}
|
||||
|
||||
// GetUpdatesRequest is the body for getupdates.
|
||||
type GetUpdatesRequest struct {
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// GetUpdatesResponse is the response from getupdates.
|
||||
type GetUpdatesResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrCode int `json:"errcode,omitempty"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
Msgs []WeixinMessage `json:"msgs"`
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
LongPollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
|
||||
}
|
||||
|
||||
// WeixinMessage represents a message from WeChat.
|
||||
type WeixinMessage struct {
|
||||
Seq int `json:"seq,omitempty"`
|
||||
MessageID int64 `json:"message_id,omitempty"`
|
||||
FromUserID string `json:"from_user_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
MessageType int `json:"message_type"`
|
||||
MessageState int `json:"message_state"`
|
||||
ItemList []MessageItem `json:"item_list"`
|
||||
ContextToken string `json:"context_token"`
|
||||
}
|
||||
|
||||
// MessageItem is a single item in a message.
|
||||
type MessageItem struct {
|
||||
Type int `json:"type"`
|
||||
TextItem *TextItem `json:"text_item,omitempty"`
|
||||
ImageItem *ImageItem `json:"image_item,omitempty"`
|
||||
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
|
||||
VideoItem *VideoItem `json:"video_item,omitempty"`
|
||||
FileItem *FileItem `json:"file_item,omitempty"`
|
||||
}
|
||||
|
||||
// CDN media type constants.
|
||||
const (
|
||||
CDNMediaTypeImage = 1
|
||||
CDNMediaTypeVideo = 2
|
||||
CDNMediaTypeFile = 3
|
||||
)
|
||||
|
||||
// GetUploadURLRequest is the body for getuploadurl.
|
||||
type GetUploadURLRequest struct {
|
||||
FileKey string `json:"filekey"`
|
||||
MediaType int `json:"media_type"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
RawSize int `json:"rawsize"`
|
||||
RawFileMD5 string `json:"rawfilemd5"`
|
||||
FileSize int `json:"filesize"`
|
||||
NoNeedThumb bool `json:"no_need_thumb"`
|
||||
AESKey string `json:"aeskey"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// GetUploadURLResponse is the response from getuploadurl.
|
||||
type GetUploadURLResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
UploadParam string `json:"upload_param"`
|
||||
UploadFullURL string `json:"upload_full_url,omitempty"`
|
||||
}
|
||||
|
||||
// TextItem holds text content.
|
||||
type TextItem struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// MediaInfo holds CDN media reference for uploaded files.
|
||||
type MediaInfo struct {
|
||||
EncryptQueryParam string `json:"encrypt_query_param"`
|
||||
AESKey string `json:"aes_key"` // base64-encoded
|
||||
EncryptType int `json:"encrypt_type"` // 1 = AES-128-ECB
|
||||
}
|
||||
|
||||
// VoiceItem holds voice content.
|
||||
type VoiceItem struct {
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
VoiceSize int `json:"voice_size,omitempty"`
|
||||
EncodeType int `json:"encode_type,omitempty"` // 1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3
|
||||
BitsPerSample int `json:"bits_per_sample,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"` // Hz
|
||||
Playtime int `json:"playtime,omitempty"` // duration in milliseconds
|
||||
Text string `json:"text,omitempty"` // speech-to-text transcription from WeChat
|
||||
}
|
||||
|
||||
// ImageItem holds image content.
|
||||
type ImageItem struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
MidSize int `json:"mid_size,omitempty"` // ciphertext size
|
||||
}
|
||||
|
||||
// VideoItem holds video content.
|
||||
type VideoItem struct {
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
VideoSize int `json:"video_size,omitempty"`
|
||||
}
|
||||
|
||||
// FileItem holds file content.
|
||||
type FileItem struct {
|
||||
Media *MediaInfo `json:"media,omitempty"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
Len string `json:"len,omitempty"` // plaintext size as string
|
||||
}
|
||||
|
||||
// SendMessageRequest is the body for sendmessage.
|
||||
type SendMessageRequest struct {
|
||||
Msg SendMsg `json:"msg"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// SendMsg is the message payload for sending.
|
||||
type SendMsg struct {
|
||||
FromUserID string `json:"from_user_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
MessageType int `json:"message_type"`
|
||||
MessageState int `json:"message_state"`
|
||||
ItemList []MessageItem `json:"item_list"`
|
||||
ContextToken string `json:"context_token"`
|
||||
}
|
||||
|
||||
// SendMessageResponse is the response from sendmessage.
|
||||
type SendMessageResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
}
|
||||
|
||||
// Typing status constants.
|
||||
const (
|
||||
TypingStatusTyping = 1
|
||||
TypingStatusCancel = 2
|
||||
)
|
||||
|
||||
// GetConfigRequest is the body for getconfig.
|
||||
type GetConfigRequest struct {
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
ContextToken string `json:"context_token,omitempty"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// GetConfigResponse is the response from getconfig.
|
||||
type GetConfigResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
TypingTicket string `json:"typing_ticket,omitempty"`
|
||||
}
|
||||
|
||||
// SendTypingRequest is the body for sendtyping.
|
||||
type SendTypingRequest struct {
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
TypingTicket string `json:"typing_ticket"`
|
||||
Status int `json:"status"`
|
||||
BaseInfo BaseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// SendTypingResponse is the response from sendtyping.
|
||||
type SendTypingResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrMsg string `json:"errmsg,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/api"
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
|
||||
"rsc.io/qr"
|
||||
)
|
||||
|
||||
type loginEvent struct {
|
||||
Status string `json:"status"`
|
||||
QRCodeDataURL string `json:"qr_code_data_url,omitempty"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
WeChatUserID string `json:"wechat_user_id,omitempty"`
|
||||
}
|
||||
|
||||
type accountSummary struct {
|
||||
AccountID string `json:"account_id"`
|
||||
WeChatUserID string `json:"wechat_user_id"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fatal(errors.New("expected one of: login, accounts, start"))
|
||||
}
|
||||
var err error
|
||||
switch os.Args[1] {
|
||||
case "login":
|
||||
err = runLogin(os.Args[2:])
|
||||
case "accounts":
|
||||
err = runAccounts(os.Args[2:])
|
||||
case "start":
|
||||
err = runStart(os.Args[2:])
|
||||
default:
|
||||
err = fmt.Errorf("unknown command %q", os.Args[1])
|
||||
}
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func signalContext() (context.Context, context.CancelFunc) {
|
||||
return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
}
|
||||
|
||||
func runLogin(args []string) error {
|
||||
flags := flag.NewFlagSet("login", flag.ContinueOnError)
|
||||
jsonOutput := flags.Bool("json", false, "emit JSON Lines events")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := signalContext()
|
||||
defer cancel()
|
||||
creds, err := login(ctx, *jsonOutput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !*jsonOutput {
|
||||
fmt.Printf("WeChat account %s connected.\n", creds.ILinkBotID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func login(ctx context.Context, jsonOutput bool) (*ilink.Credentials, error) {
|
||||
qrResponse, err := ilink.FetchQRCode(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, err := qr.Encode(qrResponse.QRCodeImgContent, qr.L)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode QR image: %w", err)
|
||||
}
|
||||
emit := func(event loginEvent) {
|
||||
if jsonOutput {
|
||||
_ = json.NewEncoder(os.Stdout).Encode(event)
|
||||
}
|
||||
}
|
||||
emit(loginEvent{Status: "qrcode", QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())})
|
||||
lastStatus := ""
|
||||
creds, err := ilink.PollQRStatus(ctx, qrResponse.QRCode, func(status string) {
|
||||
if status != lastStatus {
|
||||
lastStatus = status
|
||||
emit(loginEvent{Status: status})
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ilink.SaveCredentials(creds); err != nil {
|
||||
return nil, fmt.Errorf("save credentials: %w", err)
|
||||
}
|
||||
emit(loginEvent{Status: "active", AccountID: creds.ILinkBotID, WeChatUserID: creds.ILinkUserID})
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func runAccounts(args []string) error {
|
||||
flags := flag.NewFlagSet("accounts", flag.ContinueOnError)
|
||||
jsonOutput := flags.Bool("json", false, "print JSON")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
accounts, err := ilink.LoadAllCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]accountSummary, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
items = append(items, accountSummary{AccountID: account.ILinkBotID, WeChatUserID: account.ILinkUserID})
|
||||
}
|
||||
if *jsonOutput {
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"accounts": items})
|
||||
}
|
||||
for _, item := range items {
|
||||
fmt.Printf("%s\t%s\n", item.AccountID, item.WeChatUserID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStart(args []string) error {
|
||||
flags := flag.NewFlagSet("start", flag.ContinueOnError)
|
||||
_ = flags.Bool("foreground", false, "kept for host compatibility")
|
||||
apiAddr := flags.String("api-addr", "127.0.0.1:18011", "local send API address")
|
||||
accountID := flags.String("account-id", "", "account to start")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
accounts, err := ilink.LoadAllCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return errors.New("no connected WeChat account; scan a QR code first")
|
||||
}
|
||||
selected := accounts[len(accounts)-1]
|
||||
if *accountID != "" {
|
||||
selected = nil
|
||||
for _, account := range accounts {
|
||||
if account.ILinkBotID == *accountID {
|
||||
selected = account
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
return fmt.Errorf("account %q not found", *accountID)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := signalContext()
|
||||
defer cancel()
|
||||
client := ilink.NewClient(selected)
|
||||
server := api.NewServer([]*ilink.Client{client}, *apiAddr)
|
||||
webhookURL := strings.TrimSpace(os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL"))
|
||||
webhook := messaging.NewInboundWebhook(webhookURL, os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN"))
|
||||
|
||||
monitor, err := ilink.NewMonitor(client, func(messageContext context.Context, source *ilink.Client, message ilink.WeixinMessage) {
|
||||
if webhookURL != "" {
|
||||
webhook.Dispatch(messageContext, source, message)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var wait sync.WaitGroup
|
||||
wait.Add(2)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
if err := server.Run(ctx); err != nil && ctx.Err() == nil {
|
||||
log.Printf("[api] stopped: %v", err)
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
if err := monitor.Run(ctx); err != nil && ctx.Err() == nil {
|
||||
log.Printf("[monitor] stopped: %v", err)
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
wait.Wait()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
)
|
||||
|
||||
const cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
|
||||
// UploadedFile holds the result of a CDN upload.
|
||||
type UploadedFile struct {
|
||||
DownloadParam string // encrypted query param for download
|
||||
AESKeyHex string // hex-encoded AES key
|
||||
FileSize int // plaintext size
|
||||
CipherSize int // ciphertext size
|
||||
}
|
||||
|
||||
// UploadFileToCDN encrypts and uploads a file to the WeChat CDN.
|
||||
func UploadFileToCDN(ctx context.Context, client *ilink.Client, data []byte, toUserID string, mediaType int) (*UploadedFile, error) {
|
||||
// Generate random filekey and AES key
|
||||
filekey := make([]byte, 16)
|
||||
aeskey := make([]byte, 16)
|
||||
if _, err := rand.Read(filekey); err != nil {
|
||||
return nil, fmt.Errorf("generate filekey: %w", err)
|
||||
}
|
||||
if _, err := rand.Read(aeskey); err != nil {
|
||||
return nil, fmt.Errorf("generate aeskey: %w", err)
|
||||
}
|
||||
|
||||
filekeyHex := hex.EncodeToString(filekey)
|
||||
aeskeyHex := hex.EncodeToString(aeskey)
|
||||
|
||||
// Calculate MD5 of plaintext
|
||||
hash := md5.Sum(data)
|
||||
rawMD5 := hex.EncodeToString(hash[:])
|
||||
|
||||
// Calculate ciphertext size (PKCS7 padding)
|
||||
cipherSize := aesECBPaddedSize(len(data))
|
||||
|
||||
// Get upload URL from iLink API
|
||||
uploadReq := &ilink.GetUploadURLRequest{
|
||||
FileKey: filekeyHex,
|
||||
MediaType: mediaType,
|
||||
ToUserID: toUserID,
|
||||
RawSize: len(data),
|
||||
RawFileMD5: rawMD5,
|
||||
FileSize: cipherSize,
|
||||
NoNeedThumb: true,
|
||||
AESKey: aeskeyHex,
|
||||
BaseInfo: ilink.BaseInfo{},
|
||||
}
|
||||
|
||||
uploadResp, err := client.GetUploadURL(ctx, uploadReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get upload URL: %w", err)
|
||||
}
|
||||
if uploadResp.Ret != 0 {
|
||||
return nil, fmt.Errorf("get upload URL failed: ret=%d errmsg=%s", uploadResp.Ret, uploadResp.ErrMsg)
|
||||
}
|
||||
|
||||
// Encrypt data with AES-128-ECB
|
||||
encrypted, err := encryptAESECB(data, aeskey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt: %w", err)
|
||||
}
|
||||
|
||||
// Upload to CDN: prefer server-provided full URL, fall back to param-based construction
|
||||
cdnURL := strings.TrimSpace(uploadResp.UploadFullURL)
|
||||
if cdnURL == "" {
|
||||
if uploadResp.UploadParam == "" {
|
||||
return nil, fmt.Errorf("getuploadurl returned no upload URL (need upload_full_url or upload_param)")
|
||||
}
|
||||
cdnURL = fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s",
|
||||
cdnBaseURL, url.QueryEscape(uploadResp.UploadParam), url.QueryEscape(filekeyHex))
|
||||
}
|
||||
|
||||
downloadParam, err := uploadToCDN(ctx, encrypted, cdnURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CDN upload: %w", err)
|
||||
}
|
||||
|
||||
return &UploadedFile{
|
||||
DownloadParam: downloadParam,
|
||||
AESKeyHex: aeskeyHex,
|
||||
FileSize: len(data),
|
||||
CipherSize: cipherSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AESKeyToBase64 converts a hex AES key to base64 format for message items.
|
||||
func AESKeyToBase64(hexKey string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(hexKey))
|
||||
}
|
||||
|
||||
// DownloadFileFromCDN downloads and decrypts a file from the WeChat CDN.
|
||||
func DownloadFileFromCDN(ctx context.Context, encryptQueryParam, aesKeyBase64 string) ([]byte, error) {
|
||||
// Decode AES key: base64 -> hex string -> raw bytes
|
||||
aesKeyHexBytes, err := base64.StdEncoding.DecodeString(aesKeyBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode AES key base64: %w", err)
|
||||
}
|
||||
aesKey, err := hex.DecodeString(string(aesKeyHexBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode AES key hex: %w", err)
|
||||
}
|
||||
|
||||
// Download encrypted data from CDN
|
||||
downloadURL := fmt.Sprintf("%s/download?encrypted_query_param=%s",
|
||||
cdnBaseURL, url.QueryEscape(encryptQueryParam))
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download from CDN: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("CDN download HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
encrypted, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CDN response: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt AES-128-ECB
|
||||
return decryptAESECB(encrypted, aesKey)
|
||||
}
|
||||
|
||||
// decryptAESECB decrypts data encrypted with AES-128-ECB and removes PKCS7 padding.
|
||||
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext is not a multiple of block size")
|
||||
}
|
||||
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
|
||||
// Remove PKCS7 padding
|
||||
if len(plaintext) == 0 {
|
||||
return plaintext, nil
|
||||
}
|
||||
padLen := int(plaintext[len(plaintext)-1])
|
||||
if padLen > aes.BlockSize || padLen == 0 {
|
||||
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||
}
|
||||
return plaintext[:len(plaintext)-padLen], nil
|
||||
}
|
||||
|
||||
func uploadToCDN(ctx context.Context, encrypted []byte, cdnURL string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(encrypted))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("CDN upload HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
downloadParam := resp.Header.Get("X-Encrypted-Param")
|
||||
if downloadParam == "" {
|
||||
return "", fmt.Errorf("CDN upload: missing X-Encrypted-Param header")
|
||||
}
|
||||
|
||||
return downloadParam, nil
|
||||
}
|
||||
|
||||
// encryptAESECB encrypts data using AES-128-ECB with PKCS7 padding.
|
||||
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// PKCS7 padding
|
||||
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
|
||||
padded := make([]byte, len(plaintext)+padLen)
|
||||
copy(padded, plaintext)
|
||||
for i := len(plaintext); i < len(padded); i++ {
|
||||
padded[i] = byte(padLen)
|
||||
}
|
||||
|
||||
// ECB mode: encrypt each block independently
|
||||
encrypted := make([]byte, len(padded))
|
||||
for i := 0; i < len(padded); i += aes.BlockSize {
|
||||
block.Encrypt(encrypted[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
|
||||
}
|
||||
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
func aesECBPaddedSize(plaintextSize int) int {
|
||||
return (plaintextSize/aes.BlockSize + 1) * aes.BlockSize
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
)
|
||||
|
||||
const (
|
||||
webhookAttempts = 3
|
||||
webhookTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type InboundWebhook struct {
|
||||
url string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type inboundWebhookPayload struct {
|
||||
AccountID string `json:"account_id"`
|
||||
FromUserID string `json:"from_user_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
MessageType int `json:"message_type"`
|
||||
Items []inboundWebhookItem `json:"items"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
}
|
||||
|
||||
type inboundWebhookItem struct {
|
||||
Type int `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func NewInboundWebhook(url, token string) *InboundWebhook {
|
||||
return &InboundWebhook{
|
||||
url: strings.TrimSpace(url),
|
||||
token: token,
|
||||
client: &http.Client{Timeout: webhookTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch is intentionally non-blocking so webhook failures never stall iLink polling.
|
||||
func (w *InboundWebhook) Dispatch(ctx context.Context, client *ilink.Client, msg ilink.WeixinMessage) {
|
||||
payload := normalizeInboundMessage(client.BotID(), msg)
|
||||
go func() {
|
||||
if err := w.deliver(ctx, payload); err != nil {
|
||||
log.Printf("[webhook] inbound delivery failed for message %d: %v", msg.MessageID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (w *InboundWebhook) deliver(ctx context.Context, payload inboundWebhookPayload) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode payload: %w", err)
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= webhookAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
timer := time.NewTimer(time.Duration(attempt-1) * time.Second)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body))
|
||||
if reqErr != nil {
|
||||
return fmt.Errorf("create request: %w", reqErr)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if w.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+w.token)
|
||||
}
|
||||
resp, doErr := w.client.Do(req)
|
||||
if doErr != nil {
|
||||
lastErr = doErr
|
||||
continue
|
||||
}
|
||||
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("status %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func normalizeInboundMessage(accountID string, msg ilink.WeixinMessage) inboundWebhookPayload {
|
||||
items := make([]inboundWebhookItem, 0, len(msg.ItemList))
|
||||
for _, item := range msg.ItemList {
|
||||
normalized := inboundWebhookItem{Type: item.Type}
|
||||
if item.TextItem != nil {
|
||||
normalized.Text = item.TextItem.Text
|
||||
} else if item.VoiceItem != nil {
|
||||
normalized.Text = item.VoiceItem.Text
|
||||
}
|
||||
items = append(items, normalized)
|
||||
}
|
||||
return inboundWebhookPayload{
|
||||
AccountID: accountID,
|
||||
FromUserID: msg.FromUserID,
|
||||
MessageID: msg.MessageID,
|
||||
MessageType: msg.MessageType,
|
||||
Items: items,
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
)
|
||||
|
||||
func TestInboundWebhookDeliversNormalizedPayload(t *testing.T) {
|
||||
var got inboundWebhookPayload
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer secret" {
|
||||
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Errorf("decode: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
webhook := NewInboundWebhook(server.URL, "secret")
|
||||
err := webhook.deliver(context.Background(), normalizeInboundMessage("bot-new", ilink.WeixinMessage{
|
||||
MessageID: 7, FromUserID: "user-1", MessageType: ilink.MessageTypeUser,
|
||||
ItemList: []ilink.MessageItem{{Type: ilink.ItemTypeText, TextItem: &ilink.TextItem{Text: "最近5条消息"}}},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("deliver: %v", err)
|
||||
}
|
||||
if got.AccountID != "bot-new" || got.MessageID != 7 || len(got.Items) != 1 || got.Items[0].Text != "最近5条消息" {
|
||||
t.Fatalf("payload = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundWebhookRetriesServerErrors(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if calls.Add(1) < 3 {
|
||||
http.Error(w, "temporary", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
webhook := NewInboundWebhook(server.URL, "")
|
||||
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err != nil {
|
||||
t.Fatalf("deliver: %v", err)
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatalf("calls = %d, want 3", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundWebhookDoesNotRetryClientErrors(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
webhook := NewInboundWebhook(server.URL, "")
|
||||
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err == nil {
|
||||
t.Fatal("deliver error = nil")
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("calls = %d, want 1", calls.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// Code blocks: strip fences, keep code content
|
||||
reCodeBlock = regexp.MustCompile("(?s)```[^\n]*\n?(.*?)```")
|
||||
// Inline code: strip backticks, keep content
|
||||
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||
// Images: remove entirely
|
||||
reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`)
|
||||
// Links: keep display text only
|
||||
reLink = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)
|
||||
// Table separator rows: remove
|
||||
reTableSep = regexp.MustCompile(`(?m)^\|[\s:|\-]+\|$`)
|
||||
// Table rows: convert pipe-delimited to space-delimited
|
||||
reTableRow = regexp.MustCompile(`(?m)^\|(.+)\|$`)
|
||||
// Headers: remove # prefix
|
||||
reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+`)
|
||||
// Bold: **text** or __text__
|
||||
reBold = regexp.MustCompile(`\*\*(.+?)\*\*|__(.+?)__`)
|
||||
// Italic: *text* or _text_
|
||||
reItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)|(?:^|[^_])_([^_]+)_(?:[^_]|$)`)
|
||||
// Strikethrough: ~~text~~
|
||||
reStrike = regexp.MustCompile(`~~(.+?)~~`)
|
||||
// Blockquote: > prefix
|
||||
reBlockquote = regexp.MustCompile(`(?m)^>\s?`)
|
||||
// Horizontal rule
|
||||
reHR = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`)
|
||||
// Unordered list markers: -, *, +
|
||||
reUL = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
|
||||
)
|
||||
|
||||
// MarkdownToPlainText converts markdown to readable plain text for WeChat.
|
||||
func MarkdownToPlainText(text string) string {
|
||||
result := text
|
||||
|
||||
// Code blocks: strip fences, keep code content
|
||||
result = reCodeBlock.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := reCodeBlock.FindStringSubmatch(match)
|
||||
if len(parts) > 1 {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// Images: remove entirely
|
||||
result = reImage.ReplaceAllString(result, "")
|
||||
|
||||
// Links: keep display text only
|
||||
result = reLink.ReplaceAllString(result, "$1")
|
||||
|
||||
// Table separator rows: remove
|
||||
result = reTableSep.ReplaceAllString(result, "")
|
||||
|
||||
// Table rows: pipe-delimited to space-delimited
|
||||
result = reTableRow.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := reTableRow.FindStringSubmatch(match)
|
||||
if len(parts) > 1 {
|
||||
cells := strings.Split(parts[1], "|")
|
||||
for i := range cells {
|
||||
cells[i] = strings.TrimSpace(cells[i])
|
||||
}
|
||||
return strings.Join(cells, " ")
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// Headers: remove # prefix
|
||||
result = reHeader.ReplaceAllString(result, "")
|
||||
|
||||
// Bold
|
||||
result = reBold.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := reBold.FindStringSubmatch(match)
|
||||
if parts[1] != "" {
|
||||
return parts[1]
|
||||
}
|
||||
return parts[2]
|
||||
})
|
||||
|
||||
// Strikethrough
|
||||
result = reStrike.ReplaceAllString(result, "$1")
|
||||
|
||||
// Blockquote
|
||||
result = reBlockquote.ReplaceAllString(result, "")
|
||||
|
||||
// Horizontal rule -> empty line
|
||||
result = reHR.ReplaceAllString(result, "")
|
||||
|
||||
// Unordered list: replace markers with "• "
|
||||
result = reUL.ReplaceAllString(result, "${1}• ")
|
||||
|
||||
// Inline code: strip backticks (do after code blocks)
|
||||
result = reInlineCode.ReplaceAllString(result, "$1")
|
||||
|
||||
// Clean up excessive blank lines
|
||||
result = regexp.MustCompile(`\n{3,}`).ReplaceAllString(result, "\n\n")
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
)
|
||||
|
||||
// reMarkdownImage matches markdown image syntax: 
|
||||
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
|
||||
|
||||
// ExtractImageURLs extracts image URLs from markdown text.
|
||||
func ExtractImageURLs(text string) []string {
|
||||
matches := reMarkdownImage.FindAllStringSubmatch(text, -1)
|
||||
var urls []string
|
||||
for _, m := range matches {
|
||||
url := strings.TrimSpace(m[1])
|
||||
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
|
||||
urls = append(urls, url)
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message.
|
||||
func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error {
|
||||
// Check if it's a local file
|
||||
if _, err := os.Stat(mediaURL); err == nil {
|
||||
return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken)
|
||||
}
|
||||
// Must be a valid HTTP URL to download
|
||||
if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") {
|
||||
return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL)
|
||||
}
|
||||
data, contentType, err := downloadFile(ctx, mediaURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download %s: %w", mediaURL, err)
|
||||
}
|
||||
|
||||
return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken)
|
||||
}
|
||||
|
||||
// SendMediaFromPath reads a local file and sends it as a media message.
|
||||
func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken)
|
||||
}
|
||||
|
||||
func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error {
|
||||
if fileName == "" {
|
||||
fileName = "file"
|
||||
}
|
||||
|
||||
cdnMediaType, itemType := classifyMedia(contentType, source)
|
||||
|
||||
log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID)
|
||||
|
||||
uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload to CDN: %w", err)
|
||||
}
|
||||
|
||||
media := &ilink.MediaInfo{
|
||||
EncryptQueryParam: uploaded.DownloadParam,
|
||||
AESKey: AESKeyToBase64(uploaded.AESKeyHex),
|
||||
EncryptType: 1,
|
||||
}
|
||||
|
||||
var item ilink.MessageItem
|
||||
switch itemType {
|
||||
case ilink.ItemTypeImage:
|
||||
item = ilink.MessageItem{
|
||||
Type: ilink.ItemTypeImage,
|
||||
ImageItem: &ilink.ImageItem{
|
||||
Media: media,
|
||||
MidSize: uploaded.CipherSize,
|
||||
},
|
||||
}
|
||||
case ilink.ItemTypeVideo:
|
||||
item = ilink.MessageItem{
|
||||
Type: ilink.ItemTypeVideo,
|
||||
VideoItem: &ilink.VideoItem{
|
||||
Media: media,
|
||||
VideoSize: uploaded.CipherSize,
|
||||
},
|
||||
}
|
||||
default:
|
||||
item = ilink.MessageItem{
|
||||
Type: ilink.ItemTypeFile,
|
||||
FileItem: &ilink.FileItem{
|
||||
Media: media,
|
||||
FileName: fileName,
|
||||
Len: fmt.Sprintf("%d", uploaded.FileSize),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req := &ilink.SendMessageRequest{
|
||||
Msg: ilink.SendMsg{
|
||||
FromUserID: client.BotID(),
|
||||
ToUserID: toUserID,
|
||||
ClientID: NewClientID(),
|
||||
MessageType: ilink.MessageTypeBot,
|
||||
MessageState: ilink.MessageStateFinish,
|
||||
ItemList: []ilink.MessageItem{item},
|
||||
ContextToken: contextToken,
|
||||
},
|
||||
BaseInfo: ilink.BaseInfo{},
|
||||
}
|
||||
|
||||
resp, err := client.SendMessage(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send media message: %w", err)
|
||||
}
|
||||
if resp.Ret != 0 {
|
||||
return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||
}
|
||||
|
||||
log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFile(ctx context.Context, url string) ([]byte, string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = inferContentType(url)
|
||||
}
|
||||
|
||||
return data, contentType, nil
|
||||
}
|
||||
|
||||
func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) {
|
||||
ct := strings.ToLower(contentType)
|
||||
|
||||
if strings.HasPrefix(ct, "image/") || isImageExt(url) {
|
||||
return ilink.CDNMediaTypeImage, ilink.ItemTypeImage
|
||||
}
|
||||
if strings.HasPrefix(ct, "video/") || isVideoExt(url) {
|
||||
return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo
|
||||
}
|
||||
return ilink.CDNMediaTypeFile, ilink.ItemTypeFile
|
||||
}
|
||||
|
||||
func isImageExt(url string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
|
||||
switch ext {
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isVideoExt(url string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
|
||||
switch ext {
|
||||
case ".mp4", ".mov", ".webm", ".mkv", ".avi":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func inferContentType(url string) string {
|
||||
ext := filepath.Ext(stripQuery(url))
|
||||
if ct := mime.TypeByExtension(ext); ct != "" {
|
||||
return ct
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func filenameFromURL(rawURL string) string {
|
||||
u := stripQuery(rawURL)
|
||||
name := filepath.Base(u)
|
||||
if name == "" || name == "." || name == "/" {
|
||||
return "file"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func stripQuery(rawURL string) string {
|
||||
if i := strings.IndexByte(rawURL, '?'); i >= 0 {
|
||||
return rawURL[:i]
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package messaging
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractImageURLs(t *testing.T) {
|
||||
text := "check  and "
|
||||
urls := ExtractImageURLs(text)
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("expected 2 urls, got %d", len(urls))
|
||||
}
|
||||
if urls[0] != "https://example.com/a.png" {
|
||||
t.Errorf("urls[0] = %q", urls[0])
|
||||
}
|
||||
if urls[1] != "https://example.com/b.jpg" {
|
||||
t.Errorf("urls[1] = %q", urls[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractImageURLs_NoImages(t *testing.T) {
|
||||
urls := ExtractImageURLs("just plain text")
|
||||
if len(urls) != 0 {
|
||||
t.Errorf("expected 0 urls, got %d", len(urls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractImageURLs_RelativeURL(t *testing.T) {
|
||||
text := ""
|
||||
urls := ExtractImageURLs(text)
|
||||
if len(urls) != 0 {
|
||||
t.Errorf("expected 0 urls for relative path, got %d", len(urls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilenameFromURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"https://example.com/photo.png", "photo.png"},
|
||||
{"https://example.com/path/to/report.pdf", "report.pdf"},
|
||||
{"https://example.com/file", "file"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := filenameFromURL(tt.url)
|
||||
if got != tt.want {
|
||||
t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilenameFromURL_WithQuery(t *testing.T) {
|
||||
got := filenameFromURL("https://example.com/photo.png?token=abc")
|
||||
if got != "photo.png" {
|
||||
t.Errorf("got %q, want %q", got, "photo.png")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"https://example.com/a?b=c", "https://example.com/a"},
|
||||
{"https://example.com/a", "https://example.com/a"},
|
||||
{"https://example.com/?x=1&y=2", "https://example.com/"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := stripQuery(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package messaging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// NewClientID generates a new unique client ID for message correlation.
|
||||
func NewClientID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// SendTypingState sends a typing indicator to a user via the iLink sendtyping API.
|
||||
// It first fetches a typing_ticket via getconfig, then sends the typing status.
|
||||
func SendTypingState(ctx context.Context, client *ilink.Client, userID, contextToken string) error {
|
||||
// Get typing ticket
|
||||
configResp, err := client.GetConfig(ctx, userID, contextToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get config for typing: %w", err)
|
||||
}
|
||||
if configResp.TypingTicket == "" {
|
||||
return fmt.Errorf("no typing_ticket returned from getconfig")
|
||||
}
|
||||
|
||||
// Send typing
|
||||
if err := client.SendTyping(ctx, userID, configResp.TypingTicket, ilink.TypingStatusTyping); err != nil {
|
||||
return fmt.Errorf("send typing: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[sender] sent typing indicator to %s", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendTextReply sends a text reply to a user through the iLink API.
|
||||
// If clientID is empty, a new one is generated.
|
||||
func SendTextReply(ctx context.Context, client *ilink.Client, toUserID, text, contextToken, clientID string) error {
|
||||
if clientID == "" {
|
||||
clientID = NewClientID()
|
||||
}
|
||||
|
||||
// Convert markdown to plain text for WeChat display
|
||||
plainText := MarkdownToPlainText(text)
|
||||
|
||||
req := &ilink.SendMessageRequest{
|
||||
Msg: ilink.SendMsg{
|
||||
FromUserID: client.BotID(),
|
||||
ToUserID: toUserID,
|
||||
ClientID: clientID,
|
||||
MessageType: ilink.MessageTypeBot,
|
||||
MessageState: ilink.MessageStateFinish,
|
||||
ItemList: []ilink.MessageItem{
|
||||
{
|
||||
Type: ilink.ItemTypeText,
|
||||
TextItem: &ilink.TextItem{
|
||||
Text: plainText,
|
||||
},
|
||||
},
|
||||
},
|
||||
ContextToken: contextToken,
|
||||
},
|
||||
BaseInfo: ilink.BaseInfo{},
|
||||
}
|
||||
|
||||
resp, err := client.SendMessage(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send message: %w", err)
|
||||
}
|
||||
|
||||
if resp.Ret != 0 {
|
||||
return fmt.Errorf("send message failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
|
||||
}
|
||||
|
||||
log.Printf("[sender] sent reply to %s: %q", toUserID, truncate(text, 50))
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
+64
-4
@@ -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()
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
saveCachedMessages
|
||||
} from './services/bootstrap-cache'
|
||||
import { installSafeConsole } from './safe-log'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
|
||||
// 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
|
||||
@@ -698,6 +699,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 +727,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 +752,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,89 @@
|
||||
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_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 report = parseGroupDailyReport(
|
||||
ai.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard,
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
const exported = await exportGroupReport({ report, metadata: input.metadata })
|
||||
if (!exported.success || !exported.pngPath) {
|
||||
return { success: false, error: exported.error || '总结图片生成失败' }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
groupName: input.metadata.groupName,
|
||||
pngPath: exported.pngPath,
|
||||
messageCount: messages.length
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { ChildProcess, execFile, spawn } from 'child_process'
|
||||
import { randomBytes, timingSafeEqual } from 'crypto'
|
||||
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'
|
||||
import { dirname, join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import type {
|
||||
AgentHubActionResult,
|
||||
AgentHubLogEntry,
|
||||
AgentHubLogLevel,
|
||||
AgentHubLogSource,
|
||||
AgentHubStatus
|
||||
} from '../../shared/agent-hub'
|
||||
import type { AppSettings } from './settings-store'
|
||||
import { generateAgentGroupReport } from './agent-group-report-service'
|
||||
import { isReady, listMessages, listRecentChat, resolveMd5 } from './chat-service'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const HEALTH_INTERVAL_MS = 5_000
|
||||
const HUB_ADDR = '127.0.0.1:5300'
|
||||
const HUB_HOST = '127.0.0.1'
|
||||
const HUB_PORT = 5300
|
||||
const CONNECTOR_ADDR = '127.0.0.1:18011'
|
||||
const MAX_LOG_ENTRIES = 800
|
||||
|
||||
interface InboundMessage {
|
||||
account_id?: string
|
||||
from_user_id?: string
|
||||
message_id?: string | number
|
||||
items?: Array<{ type?: number; text?: string }>
|
||||
}
|
||||
|
||||
interface GroupReportIntent {
|
||||
group: string
|
||||
range: 'today' | 'yesterday' | '7days'
|
||||
}
|
||||
|
||||
interface ContactChatIntent {
|
||||
contact: string
|
||||
limit: number
|
||||
}
|
||||
|
||||
function resolveBundledBinary(
|
||||
resourceSegments: string[],
|
||||
executable: string,
|
||||
packaged = app.isPackaged,
|
||||
platform = process.platform,
|
||||
arch = process.arch
|
||||
): string {
|
||||
const relativeSegments = [...resourceSegments, `${platform}-${arch}`, executable]
|
||||
const packagedPath = join(process.resourcesPath, 'resources', ...relativeSegments)
|
||||
const developmentPath = join(app.getAppPath(), 'resources', ...relativeSegments)
|
||||
const candidates = packaged ? [packagedPath, developmentPath] : [developmentPath, packagedPath]
|
||||
return candidates.find((candidate) => existsSync(candidate)) || candidates[0]
|
||||
}
|
||||
|
||||
export function resolveWechatConnectorBinaryPath(
|
||||
packaged = app.isPackaged,
|
||||
platform = process.platform,
|
||||
arch = process.arch
|
||||
): string {
|
||||
return resolveBundledBinary(
|
||||
['connectors', 'wechat'],
|
||||
platform === 'win32' ? 'wechat-connector.exe' : 'wechat-connector',
|
||||
packaged,
|
||||
platform,
|
||||
arch
|
||||
)
|
||||
}
|
||||
|
||||
class AgentHubService {
|
||||
private hubServer: Server | null = null
|
||||
private connectorChild: ChildProcess | null = null
|
||||
private loginChild: ChildProcess | null = null
|
||||
private stopping = false
|
||||
private healthTimer: NodeJS.Timeout | null = null
|
||||
private logs: AgentHubLogEntry[] = []
|
||||
private nextLogId = 1
|
||||
private readonly processedMessages = new Map<string, number>()
|
||||
private readonly inboundToken =
|
||||
process.env['AGENT_HUB_INBOUND_TOKEN'] || randomBytes(32).toString('hex')
|
||||
private status: AgentHubStatus = {
|
||||
hub: 'offline',
|
||||
connector: 'checking',
|
||||
dataApi: 'checking',
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
async start(settings: AppSettings): Promise<boolean> {
|
||||
void settings
|
||||
this.stopping = false
|
||||
const hubStarted = await this.startHub()
|
||||
await this.initializeConnector()
|
||||
return hubStarted
|
||||
}
|
||||
|
||||
getStatus(): AgentHubStatus {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
getLogs(): AgentHubLogEntry[] {
|
||||
return [...this.logs]
|
||||
}
|
||||
|
||||
clearLogs(): void {
|
||||
this.logs = []
|
||||
try {
|
||||
writeFileSync(this.logFilePath(), '', 'utf8')
|
||||
} catch {
|
||||
// The live log remains usable when the persistent file cannot be cleared.
|
||||
}
|
||||
this.addLog('system', 'info', '运行日志已清空')
|
||||
}
|
||||
|
||||
async testSend(input: { to?: string; text?: string; mediaUrl?: string }): Promise<{
|
||||
success: boolean
|
||||
status: 'sent' | 'token_expired' | 'connector_offline' | 'invalid_request' | 'send_failed'
|
||||
message: string
|
||||
}> {
|
||||
const to = String(input.to || this.status.wechatUserId || '').trim()
|
||||
const text = String(input.text || '').trim()
|
||||
const mediaUrl = String(input.mediaUrl || '').trim()
|
||||
if (!to || (!text && !mediaUrl)) {
|
||||
return {
|
||||
success: false,
|
||||
status: 'invalid_request',
|
||||
message: '请填写接收者以及文字或图片路径'
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
account_id: this.status.accountId,
|
||||
to,
|
||||
text: text || undefined,
|
||||
media_url: mediaUrl || undefined
|
||||
}),
|
||||
signal: AbortSignal.timeout(30_000)
|
||||
})
|
||||
const body = await response.text()
|
||||
if (response.ok) {
|
||||
this.addLog('system', 'info', 'API 页面发送测试成功')
|
||||
return { success: true, status: 'sent', message: '发送成功' }
|
||||
}
|
||||
const expired = /token|session|expired|unauthorized/i.test(body)
|
||||
return {
|
||||
success: false,
|
||||
status: expired ? 'token_expired' : 'send_failed',
|
||||
message: expired
|
||||
? '微信登录凭证已失效,请重新扫码登录'
|
||||
: `发送失败:${body || response.status}`
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
status: 'connector_offline',
|
||||
message: `微信连接器不可用:${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async startLogin(): Promise<AgentHubActionResult> {
|
||||
if (this.loginChild && this.loginChild.exitCode === null) {
|
||||
return { success: true, status: this.getStatus() }
|
||||
}
|
||||
const executable = resolveWechatConnectorBinaryPath()
|
||||
if (!existsSync(executable)) {
|
||||
return this.fail(`微信连接器不存在:${executable}`)
|
||||
}
|
||||
|
||||
this.stopConnector()
|
||||
this.patchStatus({ connector: 'starting', qrCodeDataUrl: undefined, error: undefined })
|
||||
const child = spawn(executable, ['login', '--json'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
this.loginChild = child
|
||||
this.addLog('wechat-connector', 'info', '已启动扫码登录流程')
|
||||
let stdoutBuffer = ''
|
||||
let stderr = ''
|
||||
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
stdoutBuffer += data.toString()
|
||||
const lines = stdoutBuffer.split(/\r?\n/)
|
||||
stdoutBuffer = lines.pop() || ''
|
||||
for (const line of lines) this.handleLoginEvent(line)
|
||||
})
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString()
|
||||
this.addProcessOutput('wechat-connector', 'warn', data.toString())
|
||||
})
|
||||
child.once('error', (error) => {
|
||||
this.addLog('wechat-connector', 'error', `登录进程错误:${error.message}`)
|
||||
this.patchStatus({ connector: 'error', error: error.message })
|
||||
})
|
||||
child.once('exit', (code) => {
|
||||
if (this.loginChild === child) this.loginChild = null
|
||||
if (
|
||||
code !== 0 &&
|
||||
this.status.connector !== 'online' &&
|
||||
this.status.connector !== 'disconnected' &&
|
||||
!this.stopping
|
||||
) {
|
||||
this.patchStatus({ connector: 'error', error: stderr.trim() || `登录进程退出:${code}` })
|
||||
}
|
||||
})
|
||||
return { success: true, status: this.getStatus() }
|
||||
}
|
||||
|
||||
cancelLogin(): AgentHubActionResult {
|
||||
if (this.loginChild && this.loginChild.exitCode === null) this.loginChild.kill()
|
||||
this.loginChild = null
|
||||
this.patchStatus({ connector: 'disconnected', qrCodeDataUrl: undefined, error: undefined })
|
||||
return { success: true, status: this.getStatus() }
|
||||
}
|
||||
|
||||
async reconnect(): Promise<AgentHubActionResult> {
|
||||
const accounts = await this.loadAccounts()
|
||||
if (accounts.length === 0) return this.startLogin()
|
||||
this.startConnector(accounts.at(-1)!)
|
||||
return { success: true, status: this.getStatus() }
|
||||
}
|
||||
|
||||
disconnect(): AgentHubActionResult {
|
||||
this.stopConnector()
|
||||
this.patchStatus({ connector: 'disconnected', error: undefined })
|
||||
return { success: true, status: this.getStatus() }
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopping = true
|
||||
this.clearHealthCheck()
|
||||
if (this.loginChild && this.loginChild.exitCode === null) this.loginChild.kill()
|
||||
this.loginChild = null
|
||||
this.stopConnector()
|
||||
const hubServer = this.hubServer
|
||||
this.hubServer = null
|
||||
hubServer?.close()
|
||||
this.patchStatus({ hub: 'offline' })
|
||||
}
|
||||
|
||||
private async startHub(): Promise<boolean> {
|
||||
if (this.hubServer) return true
|
||||
this.patchStatus({ hub: 'starting' })
|
||||
const server = createServer((request, response) => {
|
||||
void this.handleHubRequest(request, response).catch((error) => {
|
||||
this.addLog('agent-hub', 'error', `请求处理失败:${this.errorMessage(error)}`)
|
||||
this.sendHubJson(response, 500, { error: 'internal error' })
|
||||
})
|
||||
})
|
||||
this.hubServer = server
|
||||
return new Promise((resolve) => {
|
||||
const fail = (error: Error): void => {
|
||||
if (this.hubServer === server) this.hubServer = null
|
||||
this.patchStatus({ hub: 'error', error: error.message })
|
||||
this.addLog('agent-hub', 'error', `TypeScript 服务启动失败:${error.message}`)
|
||||
resolve(false)
|
||||
}
|
||||
server.once('error', fail)
|
||||
server.listen(HUB_PORT, HUB_HOST, () => {
|
||||
server.off('error', fail)
|
||||
server.on('error', (error) => {
|
||||
this.patchStatus({ hub: 'error', error: error.message })
|
||||
this.addLog('agent-hub', 'error', error.message)
|
||||
})
|
||||
this.patchStatus({ hub: 'online', error: undefined })
|
||||
this.addLog('system', 'info', `Agent Hub TypeScript 服务已启动(${HUB_ADDR})`)
|
||||
this.scheduleHealthCheck()
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async handleHubRequest(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse
|
||||
): Promise<void> {
|
||||
const url = new URL(request.url || '/', `http://${HUB_ADDR}`)
|
||||
if (request.method === 'GET' && url.pathname === '/health') {
|
||||
return this.sendHubJson(response, 200, {
|
||||
status: 'ok',
|
||||
service: 'agent-hub',
|
||||
runtime: 'typescript'
|
||||
})
|
||||
}
|
||||
if (request.method !== 'POST' || url.pathname !== '/v1/connectors/wechat/inbound') {
|
||||
return this.sendHubJson(response, 404, { error: 'not found' })
|
||||
}
|
||||
if (!this.authorized(request.headers.authorization)) {
|
||||
return this.sendHubJson(response, 401, { error: 'unauthorized' })
|
||||
}
|
||||
|
||||
let inbound: InboundMessage
|
||||
try {
|
||||
inbound = JSON.parse(await this.readHubBody(request)) as InboundMessage
|
||||
} catch {
|
||||
return this.sendHubJson(response, 400, { error: 'invalid request' })
|
||||
}
|
||||
const from = String(inbound.from_user_id || '').trim()
|
||||
if (!from) return this.sendHubJson(response, 400, { error: 'from_user_id is required' })
|
||||
|
||||
const messageId = String(inbound.message_id || '')
|
||||
this.cleanProcessedMessages()
|
||||
if (messageId && this.processedMessages.has(messageId)) {
|
||||
return this.sendHubJson(response, 200, { status: 'duplicate' })
|
||||
}
|
||||
const text = (inbound.items || [])
|
||||
.filter((item) => item.type === 1 && item.text?.trim())
|
||||
.map((item) => item.text!.trim())
|
||||
.join(' ')
|
||||
this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`)
|
||||
|
||||
const reportIntent = this.matchGroupReportIntent(text)
|
||||
if (reportIntent) {
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
this.addLog(
|
||||
'agent-hub',
|
||||
'info',
|
||||
`匹配群聊总结:${reportIntent.group}(${reportIntent.range})`
|
||||
)
|
||||
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch((error) => {
|
||||
this.addLog('agent-hub', 'warn', `等待提示发送失败:${this.errorMessage(error)}`)
|
||||
})
|
||||
void this.generateAndSendReport(inbound, reportIntent)
|
||||
return this.sendHubJson(response, 202, { status: 'generating' })
|
||||
}
|
||||
|
||||
const contactChatIntent = this.matchContactChatIntent(text)
|
||||
if (contactChatIntent) {
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
await this.replyContactChat(inbound, contactChatIntent)
|
||||
return this.sendHubJson(response, 200, { status: 'ok' })
|
||||
}
|
||||
|
||||
const limit = this.matchRecentChatIntent(text)
|
||||
if (limit === null) {
|
||||
this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图')
|
||||
return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' })
|
||||
}
|
||||
if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
|
||||
const items = listRecentChat(limit)
|
||||
const lines = items.map((item, index) => {
|
||||
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
|
||||
return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})`
|
||||
})
|
||||
const reply = lines.length
|
||||
? `最近 ${items.length} 个会话:\n${lines.join('\n')}`
|
||||
: '暂时没有找到最近会话。'
|
||||
try {
|
||||
await this.sendConnector(inbound, reply)
|
||||
} catch (error) {
|
||||
this.addLog('agent-hub', 'error', `回复发送失败:${this.errorMessage(error)}`)
|
||||
return this.sendHubJson(response, 502, { error: 'reply delivery failed' })
|
||||
}
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
|
||||
this.sendHubJson(response, 200, { status: 'ok' })
|
||||
}
|
||||
|
||||
private async replyContactChat(
|
||||
inbound: InboundMessage,
|
||||
intent: ContactChatIntent
|
||||
): Promise<void> {
|
||||
if (!isReady()) {
|
||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
||||
return
|
||||
}
|
||||
|
||||
const contact = resolveMd5(intent.contact)
|
||||
if (!contact || contact.type !== 'user') {
|
||||
this.addLog('agent-hub', 'info', `没有匹配到联系人:${intent.contact}`)
|
||||
await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`)
|
||||
return
|
||||
}
|
||||
|
||||
this.addLog(
|
||||
'agent-hub',
|
||||
'info',
|
||||
`匹配联系人聊天查询:${contact.m_nsNickName}(最近 ${intent.limit} 条)`
|
||||
)
|
||||
const messages = listMessages(contact.md5, undefined, undefined, { limit: intent.limit })
|
||||
const recent = messages.slice(-intent.limit)
|
||||
const lines = recent.map((message) => {
|
||||
const speaker = message.isSender ? '我' : contact.m_nsNickName
|
||||
const content = this.describeChatMessage(message.content, message.type)
|
||||
return `${speaker}:${content}`
|
||||
})
|
||||
const reply = lines.length
|
||||
? `我和${contact.m_nsNickName}最近聊了这些:\n${lines.join('\n')}`
|
||||
: `暂时没有找到和${contact.m_nsNickName}的聊天记录。`
|
||||
await this.sendConnector(inbound, reply)
|
||||
this.addLog('agent-hub', 'info', `联系人聊天回复已发送(${recent.length} 条)`)
|
||||
}
|
||||
|
||||
private describeChatMessage(content: string, type: string): string {
|
||||
const normalized = String(content || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (normalized) return normalized.length > 100 ? `${normalized.slice(0, 100)}…` : normalized
|
||||
const label = String(type || '消息').replace(/^普通文本$/, '消息')
|
||||
return `[${label}]`
|
||||
}
|
||||
|
||||
private async generateAndSendReport(
|
||||
inbound: InboundMessage,
|
||||
intent: GroupReportIntent
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await generateAgentGroupReport({ group: intent.group, range: intent.range })
|
||||
if (!result.success || !result.pngPath) throw new Error(result.error || '群聊总结生成失败')
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
`已生成${result.groupName || intent.group}的群聊总结(${result.messageCount || 0} 条消息),正在发送图片。`
|
||||
)
|
||||
await this.sendConnector(inbound, undefined, result.pngPath)
|
||||
this.addLog('agent-hub', 'info', `群聊总结图片已发送:${result.groupName || intent.group}`)
|
||||
} catch (error) {
|
||||
const message = this.errorMessage(error)
|
||||
this.addLog('agent-hub', 'error', `群聊总结生成失败:${message}`)
|
||||
await this.sendConnector(inbound, `群聊总结生成失败:${message}`).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
private async sendConnector(
|
||||
inbound: InboundMessage,
|
||||
text?: string,
|
||||
mediaUrl?: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
account_id: inbound.account_id,
|
||||
to: inbound.from_user_id,
|
||||
text,
|
||||
media_url: mediaUrl
|
||||
}),
|
||||
signal: AbortSignal.timeout(mediaUrl ? 60_000 : 30_000)
|
||||
})
|
||||
if (!response.ok) throw new Error((await response.text()) || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
private matchRecentChatIntent(text: string): number | null {
|
||||
const normalized = text.replace(/\s+/g, '')
|
||||
if (!normalized.includes('最近') || !/(消息|会话|聊天)/.test(normalized)) return null
|
||||
const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5)
|
||||
return Math.max(1, Math.min(20, limit))
|
||||
}
|
||||
|
||||
private matchContactChatIntent(text: string): ContactChatIntent | null {
|
||||
const normalized = text.replace(/\s+/g, '').replace(/[,。!??::]/g, '')
|
||||
if (!normalized.includes('最近') || !/(聊|消息|会话)/.test(normalized)) return null
|
||||
|
||||
const patterns = [
|
||||
/(?:看一下|看看|查一下|查询)?我和(.+?)最近(?:\d{1,2}条)?(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/,
|
||||
/(?:看一下|看看|查一下|查询)?(?:我)?最近(?:\d{1,2}条)?和(.+?)(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/,
|
||||
/(?:看一下|看看|查一下|查询)?和(.+?)最近(?:\d{1,2}条)?(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/
|
||||
]
|
||||
const contact = patterns
|
||||
.map((pattern) => normalized.match(pattern)?.[1]?.trim())
|
||||
.find((value): value is string => Boolean(value))
|
||||
if (!contact) return null
|
||||
|
||||
const limit = Number(normalized.match(/最近(\d{1,2})条/)?.[1] || 10)
|
||||
return { contact, limit: Math.max(1, Math.min(20, limit)) }
|
||||
}
|
||||
|
||||
private matchGroupReportIntent(text: string): GroupReportIntent | null {
|
||||
const normalized = text.trim()
|
||||
if (!normalized.includes('群') || !/(总结|日报|报告)/.test(normalized)) return null
|
||||
const range = /(7天|七天|一周)/.test(normalized)
|
||||
? '7days'
|
||||
: /(昨天|昨日)/.test(normalized)
|
||||
? 'yesterday'
|
||||
: 'today'
|
||||
const group = normalized
|
||||
.replace(
|
||||
/请|帮我|生成|做一份|做个|今天的|今日的|今天|今日|昨天的|昨日的|昨天|昨日|最近7天的|最近七天的|最近7天|最近七天|近7天的|近七天的|近7天|近七天|消息|聊天记录|聊天|群聊总结|群总结|群日报|群报告|总结|日报|报告|图片|长图/g,
|
||||
''
|
||||
)
|
||||
.replace(/[,。!??::]/g, '')
|
||||
.trim()
|
||||
.replace(/成$/, '')
|
||||
.replace(/群$/, '')
|
||||
.trim()
|
||||
return group ? { group, range } : null
|
||||
}
|
||||
|
||||
private authorized(header: string | undefined): boolean {
|
||||
if (!header?.startsWith('Bearer ')) return false
|
||||
const expected = Buffer.from(this.inboundToken)
|
||||
const provided = Buffer.from(header.slice(7))
|
||||
return expected.length === provided.length && timingSafeEqual(expected, provided)
|
||||
}
|
||||
|
||||
private readHubBody(request: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
request.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length
|
||||
if (size > 1024 * 1024) {
|
||||
reject(new Error('request too large'))
|
||||
request.destroy()
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
||||
request.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
private sendHubJson(response: ServerResponse, status: number, payload: unknown): void {
|
||||
if (response.writableEnded) return
|
||||
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
response.end(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
private cleanProcessedMessages(): void {
|
||||
const cutoff = Date.now() - 10 * 60_000
|
||||
for (const [id, timestamp] of this.processedMessages) {
|
||||
if (timestamp < cutoff) this.processedMessages.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
private async initializeConnector(): Promise<void> {
|
||||
this.patchStatus({ connector: 'checking' })
|
||||
try {
|
||||
const accounts = await this.loadAccounts()
|
||||
if (accounts.length === 0) {
|
||||
this.patchStatus({ connector: 'disconnected' })
|
||||
return
|
||||
}
|
||||
this.startConnector(accounts.at(-1)!)
|
||||
} catch (error) {
|
||||
this.patchStatus({
|
||||
connector: 'error',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAccounts(): Promise<{ accountId: string; wechatUserId: string }[]> {
|
||||
const executable = resolveWechatConnectorBinaryPath()
|
||||
if (!existsSync(executable)) throw new Error(`微信连接器不存在:${executable}`)
|
||||
const { stdout } = await execFileAsync(executable, ['accounts', '--json'], {
|
||||
windowsHide: true,
|
||||
timeout: 10_000
|
||||
})
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
accounts?: { account_id: string; wechat_user_id: string }[]
|
||||
}
|
||||
return (parsed.accounts || []).map((account) => ({
|
||||
accountId: account.account_id,
|
||||
wechatUserId: account.wechat_user_id
|
||||
}))
|
||||
}
|
||||
|
||||
private startConnector(account: { accountId: string; wechatUserId: string }): void {
|
||||
if (this.connectorChild && this.connectorChild.exitCode === null) return
|
||||
const executable = resolveWechatConnectorBinaryPath()
|
||||
this.patchStatus({
|
||||
connector: 'starting',
|
||||
accountId: account.accountId,
|
||||
wechatUserId: account.wechatUserId,
|
||||
qrCodeDataUrl: undefined,
|
||||
error: undefined
|
||||
})
|
||||
const child = spawn(
|
||||
executable,
|
||||
['start', '--foreground', '--api-addr', CONNECTOR_ADDR, '--account-id', account.accountId],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL: `http://${HUB_ADDR}/v1/connectors/wechat/inbound`,
|
||||
WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN: this.inboundToken,
|
||||
WECHAT_CONNECTOR_INBOUND_WEBHOOK_ONLY: 'true'
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
this.connectorChild = child
|
||||
this.addLog('system', 'info', `正在启动微信连接器(账号 ${account.accountId})`)
|
||||
child.stdout?.on('data', (data: Buffer) => this.handleConnectorOutput('info', data.toString()))
|
||||
child.stderr?.on('data', (data: Buffer) => this.handleConnectorOutput('warn', data.toString()))
|
||||
child.once('spawn', () => {
|
||||
this.addLog('system', 'info', `微信连接器已启动(PID ${child.pid})`)
|
||||
this.patchStatus({ connector: 'online' })
|
||||
})
|
||||
child.once('error', (error) => {
|
||||
this.addLog('wechat-connector', 'error', error.message)
|
||||
this.patchStatus({ connector: 'error', error: error.message })
|
||||
})
|
||||
child.once('exit', (code) => {
|
||||
if (this.connectorChild === child) this.connectorChild = null
|
||||
this.addLog('system', code === 0 ? 'info' : 'error', `微信连接器已退出(code=${code})`)
|
||||
if (!this.stopping && this.status.connector !== 'disconnected') {
|
||||
this.patchStatus({ connector: 'error', error: `微信连接器退出:${code}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private stopConnector(): void {
|
||||
const child = this.connectorChild
|
||||
this.connectorChild = null
|
||||
if (child && child.exitCode === null) child.kill()
|
||||
}
|
||||
|
||||
private handleLoginEvent(line: string): void {
|
||||
if (!line.trim()) return
|
||||
try {
|
||||
const event = JSON.parse(line) as {
|
||||
status: string
|
||||
qr_code_data_url?: string
|
||||
account_id?: string
|
||||
wechat_user_id?: string
|
||||
}
|
||||
switch (event.status) {
|
||||
case 'qrcode':
|
||||
case 'wait':
|
||||
this.patchStatus({
|
||||
connector: 'waiting_scan',
|
||||
qrCodeDataUrl: event.qr_code_data_url || this.status.qrCodeDataUrl
|
||||
})
|
||||
break
|
||||
case 'scaned':
|
||||
this.patchStatus({ connector: 'scanned' })
|
||||
break
|
||||
case 'confirmed':
|
||||
this.patchStatus({ connector: 'starting' })
|
||||
break
|
||||
case 'expired':
|
||||
this.patchStatus({ connector: 'error', error: '二维码已过期,请重新获取' })
|
||||
break
|
||||
case 'active': {
|
||||
const account = {
|
||||
accountId: event.account_id || '',
|
||||
wechatUserId: event.wechat_user_id || ''
|
||||
}
|
||||
this.patchStatus({ ...account, connector: 'starting', qrCodeDataUrl: undefined })
|
||||
this.startConnector(account)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[AgentHub] invalid login event:', line, error)
|
||||
}
|
||||
}
|
||||
|
||||
private patchStatus(patch: Partial<AgentHubStatus>): void {
|
||||
this.status = { ...this.status, ...patch, updatedAt: Date.now() }
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send('agent-hub:status', this.getStatus())
|
||||
}
|
||||
}
|
||||
|
||||
private addProcessOutput(
|
||||
source: AgentHubLogSource,
|
||||
level: AgentHubLogLevel,
|
||||
output: string
|
||||
): void {
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
if (line.trim()) this.addLog(source, level, line)
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnectorOutput(level: AgentHubLogLevel, output: string): void {
|
||||
this.addProcessOutput('wechat-connector', level, output)
|
||||
if (/session expired/i.test(output)) {
|
||||
this.addLog('system', 'error', '当前微信机器人登录已失效,需要重新扫码登录')
|
||||
this.patchStatus({ connector: 'error', error: '当前登录已失效,请重新扫码登录' })
|
||||
this.stopConnector()
|
||||
}
|
||||
}
|
||||
|
||||
private addLog(source: AgentHubLogSource, level: AgentHubLogLevel, rawMessage: string): void {
|
||||
const message = this.redactLog(rawMessage).trim()
|
||||
if (!message) return
|
||||
const entry: AgentHubLogEntry = {
|
||||
id: this.nextLogId++,
|
||||
timestamp: Date.now(),
|
||||
source,
|
||||
level,
|
||||
message
|
||||
}
|
||||
this.logs.push(entry)
|
||||
if (this.logs.length > MAX_LOG_ENTRIES) this.logs.splice(0, this.logs.length - MAX_LOG_ENTRIES)
|
||||
try {
|
||||
const path = this.logFilePath()
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
appendFileSync(
|
||||
path,
|
||||
`${new Date(entry.timestamp).toISOString()} [${source}] [${level}] ${message}\n`,
|
||||
'utf8'
|
||||
)
|
||||
} catch {
|
||||
// Do not interrupt message handling because log persistence failed.
|
||||
}
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send('agent-hub:log', entry)
|
||||
}
|
||||
}
|
||||
|
||||
private redactLog(message: string): string {
|
||||
return message
|
||||
.replace(/Bearer\s+[A-Za-z0-9._~-]+/gi, 'Bearer [已隐藏]')
|
||||
.replace(/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/gi, 'data:image/[二维码已隐藏]')
|
||||
.replace(/(token[=:\s]+)[^\s,}]+/gi, '$1[已隐藏]')
|
||||
}
|
||||
|
||||
private logFilePath(): string {
|
||||
return join(app.getPath('logs'), 'agent-hub.log')
|
||||
}
|
||||
|
||||
private fail(error: string): AgentHubActionResult {
|
||||
this.patchStatus({ connector: 'error', error })
|
||||
return { success: false, status: this.getStatus(), error }
|
||||
}
|
||||
|
||||
private scheduleHealthCheck(): void {
|
||||
this.clearHealthCheck()
|
||||
this.healthTimer = setInterval(() => this.checkDataApi(), HEALTH_INTERVAL_MS)
|
||||
this.checkDataApi()
|
||||
}
|
||||
|
||||
private checkDataApi(): void {
|
||||
const ready = isReady()
|
||||
this.patchStatus({ dataApi: 'online', databaseReady: ready })
|
||||
}
|
||||
|
||||
private clearHealthCheck(): void {
|
||||
if (this.healthTimer) clearInterval(this.healthTimer)
|
||||
this.healthTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
export const agentHubService = new AgentHubService()
|
||||
@@ -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) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface AppSettings {
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
imageKeyFallbackDisabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
}
|
||||
|
||||
function getDefaultDbRoot(): string {
|
||||
@@ -118,7 +120,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 +146,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()
|
||||
}
|
||||
|
||||
Vendored
+19
@@ -37,6 +37,7 @@ import type {
|
||||
ImageCandidateQuery,
|
||||
ImageInsight
|
||||
} from '../shared/image-insight'
|
||||
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||
|
||||
export type ParsedContent =
|
||||
| { type: 'text'; content: string }
|
||||
@@ -186,6 +187,8 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
@@ -210,6 +213,8 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
@@ -222,6 +227,8 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}>
|
||||
@@ -232,6 +239,8 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
@@ -298,6 +307,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -15,6 +15,7 @@ import type {
|
||||
ImageCandidateQuery,
|
||||
ImageInsight
|
||||
} from '../shared/image-insight'
|
||||
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
@@ -132,7 +133,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) {
|
||||
|
||||
+29
-20
@@ -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'
|
||||
@@ -22,6 +23,11 @@ import { Contact, Message } from '../../shared/types'
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
|
||||
function getDevelopmentDatabaseKey(): string {
|
||||
if (!import.meta.env.DEV) return ''
|
||||
return String(import.meta.env.VITE_DB_KEY || '').trim()
|
||||
}
|
||||
|
||||
function EyeIcon({ hidden }: { hidden: boolean }): React.ReactElement {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
@@ -57,12 +63,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 +159,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[]>([])
|
||||
@@ -369,16 +369,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()
|
||||
@@ -393,7 +392,7 @@ function App(): React.ReactElement {
|
||||
setDbKey(key)
|
||||
setAutoConnectSource(envKey ? 'env' : 'saved')
|
||||
setDbKeyStatus(
|
||||
AUTO_LOGIN_ENABLED
|
||||
autoLoginEnabled
|
||||
? envKey
|
||||
? '检测到环境变量中的密钥,正在自动连接...'
|
||||
: '已加载安全保存的密钥,正在自动连接...'
|
||||
@@ -402,14 +401,17 @@ 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)
|
||||
@@ -489,6 +491,11 @@ function App(): React.ReactElement {
|
||||
const hasBootstrapCache = 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',
|
||||
@@ -1036,9 +1043,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 +1175,8 @@ function App(): React.ReactElement {
|
||||
return renderArchiveWorkspace()
|
||||
case 'report':
|
||||
return renderReportWorkspace()
|
||||
case 'agent-hub':
|
||||
return <AgentHubWorkspace />
|
||||
case 'api':
|
||||
return (
|
||||
<ApiWorkspace
|
||||
|
||||
+2800
-142
File diff suppressed because it is too large
Load Diff
@@ -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: '设置' }
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
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>“生成产品交流群今天的群聊总结图片”</strong>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<i />
|
||||
本机 HTTP 通信
|
||||
</li>
|
||||
<li>
|
||||
<i />
|
||||
入站请求鉴权
|
||||
</li>
|
||||
<li>
|
||||
<i />
|
||||
消息重复保护
|
||||
</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
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,23 +353,25 @@ const buildMediaSection = async (
|
||||
visionGallery = []
|
||||
}
|
||||
|
||||
const imageCandidates = await Promise.all(
|
||||
rawImageCandidates.map(async (item) => {
|
||||
const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
|
||||
if (!result.success || !result.data?.startsWith('data:image/')) return null
|
||||
return {
|
||||
sender: item.sender,
|
||||
time: item.time,
|
||||
imageUrl: result.data,
|
||||
note: item.note,
|
||||
stats: item.stats,
|
||||
inferenceLabel: '基于图片后的聊天上下文推断',
|
||||
sourceMessageIds: item.sourceMessageIds,
|
||||
replyCount: item.replyCount,
|
||||
score: item.score
|
||||
}
|
||||
})
|
||||
)
|
||||
const imageCandidates = rendererApi
|
||||
? await Promise.all(
|
||||
rawImageCandidates.map(async (item) => {
|
||||
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,
|
||||
time: item.time,
|
||||
imageUrl: result.data,
|
||||
note: item.note,
|
||||
stats: item.stats,
|
||||
inferenceLabel: '基于图片后的聊天上下文推断',
|
||||
sourceMessageIds: item.sourceMessageIds,
|
||||
replyCount: item.replyCount,
|
||||
score: item.score
|
||||
}
|
||||
})
|
||||
)
|
||||
: []
|
||||
|
||||
const gallery: ReportMediaGalleryItem[] = imageCandidates
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user