diff --git a/.gitignore b/.gitignore index d196c22..9527636 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ out .DS_Store .eslintcache *.log* +resources/connectors/wechat/ .omc .codex/ docs/design/ diff --git a/electron-builder.yml b/electron-builder.yml index 04b427c..094c3fb 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -16,6 +16,7 @@ extraMetadata: asarUnpack: - resources/** extraResources: + # Includes the optional WeChat connector binary for the target platform. - from: resources to: resources filter: diff --git a/package.json b/package.json index 59e6ff5..bdf821e 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 040b4e5..595d1a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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==} diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 13679d4..83ae541 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -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 diff --git a/scripts/build-wechat-connector.cjs b/scripts/build-wechat-connector.cjs new file mode 100644 index 0000000..7dce35b --- /dev/null +++ b/scripts/build-wechat-connector.cjs @@ -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}`) +} diff --git a/services/wechat-connector/LICENSE b/services/wechat-connector/LICENSE new file mode 100644 index 0000000..94250e8 --- /dev/null +++ b/services/wechat-connector/LICENSE @@ -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. diff --git a/services/wechat-connector/README.md b/services/wechat-connector/README.md new file mode 100644 index 0000000..8329350 --- /dev/null +++ b/services/wechat-connector/README.md @@ -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 +``` + +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. diff --git a/services/wechat-connector/api/server.go b/services/wechat-connector/api/server.go new file mode 100644 index 0000000..5ee86ae --- /dev/null +++ b/services/wechat-connector/api/server.go @@ -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 +} diff --git a/services/wechat-connector/api/server_test.go b/services/wechat-connector/api/server_test.go new file mode 100644 index 0000000..0c66680 --- /dev/null +++ b/services/wechat-connector/api/server_test.go @@ -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") + } +} diff --git a/services/wechat-connector/go.mod b/services/wechat-connector/go.mod new file mode 100644 index 0000000..c59810d --- /dev/null +++ b/services/wechat-connector/go.mod @@ -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 +) diff --git a/services/wechat-connector/go.sum b/services/wechat-connector/go.sum new file mode 100644 index 0000000..23dfdfa --- /dev/null +++ b/services/wechat-connector/go.sum @@ -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= diff --git a/services/wechat-connector/ilink/auth.go b/services/wechat-connector/ilink/auth.go new file mode 100644 index 0000000..b8cad41 --- /dev/null +++ b/services/wechat-connector/ilink/auth.go @@ -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() +} diff --git a/services/wechat-connector/ilink/auth_test.go b/services/wechat-connector/ilink/auth_test.go new file mode 100644 index 0000000..fdfa6fa --- /dev/null +++ b/services/wechat-connector/ilink/auth_test.go @@ -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) + } +} diff --git a/services/wechat-connector/ilink/client.go b/services/wechat-connector/ilink/client.go new file mode 100644 index 0000000..3697d1b --- /dev/null +++ b/services/wechat-connector/ilink/client.go @@ -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)) +} diff --git a/services/wechat-connector/ilink/monitor.go b/services/wechat-connector/ilink/monitor.go new file mode 100644 index 0000000..0a775cd --- /dev/null +++ b/services/wechat-connector/ilink/monitor.go @@ -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) +} diff --git a/services/wechat-connector/ilink/types.go b/services/wechat-connector/ilink/types.go new file mode 100644 index 0000000..b388ca8 --- /dev/null +++ b/services/wechat-connector/ilink/types.go @@ -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"` +} diff --git a/services/wechat-connector/main.go b/services/wechat-connector/main.go new file mode 100644 index 0000000..09a0111 --- /dev/null +++ b/services/wechat-connector/main.go @@ -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 +} diff --git a/services/wechat-connector/messaging/cdn.go b/services/wechat-connector/messaging/cdn.go new file mode 100644 index 0000000..e0a07ea --- /dev/null +++ b/services/wechat-connector/messaging/cdn.go @@ -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 +} diff --git a/services/wechat-connector/messaging/inbound_webhook.go b/services/wechat-connector/messaging/inbound_webhook.go new file mode 100644 index 0000000..6fc4090 --- /dev/null +++ b/services/wechat-connector/messaging/inbound_webhook.go @@ -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(), + } +} diff --git a/services/wechat-connector/messaging/inbound_webhook_test.go b/services/wechat-connector/messaging/inbound_webhook_test.go new file mode 100644 index 0000000..c40a4f3 --- /dev/null +++ b/services/wechat-connector/messaging/inbound_webhook_test.go @@ -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()) + } +} diff --git a/services/wechat-connector/messaging/markdown.go b/services/wechat-connector/messaging/markdown.go new file mode 100644 index 0000000..012b47b --- /dev/null +++ b/services/wechat-connector/messaging/markdown.go @@ -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) +} diff --git a/services/wechat-connector/messaging/media.go b/services/wechat-connector/messaging/media.go new file mode 100644 index 0000000..043eeb3 --- /dev/null +++ b/services/wechat-connector/messaging/media.go @@ -0,0 +1,221 @@ +package messaging + +import ( + "context" + "fmt" + "io" + "log" + "mime" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink" +) + +// reMarkdownImage matches markdown image syntax: ![alt](url) +var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`) + +// ExtractImageURLs extracts image URLs from markdown text. +func ExtractImageURLs(text string) []string { + matches := reMarkdownImage.FindAllStringSubmatch(text, -1) + var urls []string + for _, m := range matches { + url := strings.TrimSpace(m[1]) + if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { + urls = append(urls, url) + } + } + return urls +} + +// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message. +func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error { + // Check if it's a local file + if _, err := os.Stat(mediaURL); err == nil { + return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken) + } + // Must be a valid HTTP URL to download + if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") { + return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL) + } + data, contentType, err := downloadFile(ctx, mediaURL) + if err != nil { + return fmt.Errorf("download %s: %w", mediaURL, err) + } + + return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken) +} + +// SendMediaFromPath reads a local file and sends it as a media message. +func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken) +} + +func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error { + if fileName == "" { + fileName = "file" + } + + cdnMediaType, itemType := classifyMedia(contentType, source) + + log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID) + + uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType) + if err != nil { + return fmt.Errorf("upload to CDN: %w", err) + } + + media := &ilink.MediaInfo{ + EncryptQueryParam: uploaded.DownloadParam, + AESKey: AESKeyToBase64(uploaded.AESKeyHex), + EncryptType: 1, + } + + var item ilink.MessageItem + switch itemType { + case ilink.ItemTypeImage: + item = ilink.MessageItem{ + Type: ilink.ItemTypeImage, + ImageItem: &ilink.ImageItem{ + Media: media, + MidSize: uploaded.CipherSize, + }, + } + case ilink.ItemTypeVideo: + item = ilink.MessageItem{ + Type: ilink.ItemTypeVideo, + VideoItem: &ilink.VideoItem{ + Media: media, + VideoSize: uploaded.CipherSize, + }, + } + default: + item = ilink.MessageItem{ + Type: ilink.ItemTypeFile, + FileItem: &ilink.FileItem{ + Media: media, + FileName: fileName, + Len: fmt.Sprintf("%d", uploaded.FileSize), + }, + } + } + + req := &ilink.SendMessageRequest{ + Msg: ilink.SendMsg{ + FromUserID: client.BotID(), + ToUserID: toUserID, + ClientID: NewClientID(), + MessageType: ilink.MessageTypeBot, + MessageState: ilink.MessageStateFinish, + ItemList: []ilink.MessageItem{item}, + ContextToken: contextToken, + }, + BaseInfo: ilink.BaseInfo{}, + } + + resp, err := client.SendMessage(ctx, req) + if err != nil { + return fmt.Errorf("send media message: %w", err) + } + if resp.Ret != 0 { + return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg) + } + + log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source) + return nil +} + +func downloadFile(ctx context.Context, url string) ([]byte, string, error) { + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, "", err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = inferContentType(url) + } + + return data, contentType, nil +} + +func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) { + ct := strings.ToLower(contentType) + + if strings.HasPrefix(ct, "image/") || isImageExt(url) { + return ilink.CDNMediaTypeImage, ilink.ItemTypeImage + } + if strings.HasPrefix(ct, "video/") || isVideoExt(url) { + return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo + } + return ilink.CDNMediaTypeFile, ilink.ItemTypeFile +} + +func isImageExt(url string) bool { + ext := strings.ToLower(filepath.Ext(stripQuery(url))) + switch ext { + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp": + return true + } + return false +} + +func isVideoExt(url string) bool { + ext := strings.ToLower(filepath.Ext(stripQuery(url))) + switch ext { + case ".mp4", ".mov", ".webm", ".mkv", ".avi": + return true + } + return false +} + +func inferContentType(url string) string { + ext := filepath.Ext(stripQuery(url)) + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + return "application/octet-stream" +} + +func filenameFromURL(rawURL string) string { + u := stripQuery(rawURL) + name := filepath.Base(u) + if name == "" || name == "." || name == "/" { + return "file" + } + return name +} + +func stripQuery(rawURL string) string { + if i := strings.IndexByte(rawURL, '?'); i >= 0 { + return rawURL[:i] + } + return rawURL +} diff --git a/services/wechat-connector/messaging/media_test.go b/services/wechat-connector/messaging/media_test.go new file mode 100644 index 0000000..38029b6 --- /dev/null +++ b/services/wechat-connector/messaging/media_test.go @@ -0,0 +1,73 @@ +package messaging + +import "testing" + +func TestExtractImageURLs(t *testing.T) { + text := "check ![img](https://example.com/a.png) and ![](https://example.com/b.jpg)" + urls := ExtractImageURLs(text) + if len(urls) != 2 { + t.Fatalf("expected 2 urls, got %d", len(urls)) + } + if urls[0] != "https://example.com/a.png" { + t.Errorf("urls[0] = %q", urls[0]) + } + if urls[1] != "https://example.com/b.jpg" { + t.Errorf("urls[1] = %q", urls[1]) + } +} + +func TestExtractImageURLs_NoImages(t *testing.T) { + urls := ExtractImageURLs("just plain text") + if len(urls) != 0 { + t.Errorf("expected 0 urls, got %d", len(urls)) + } +} + +func TestExtractImageURLs_RelativeURL(t *testing.T) { + text := "![img](./local.png)" + urls := ExtractImageURLs(text) + if len(urls) != 0 { + t.Errorf("expected 0 urls for relative path, got %d", len(urls)) + } +} + +func TestFilenameFromURL(t *testing.T) { + tests := []struct { + url string + want string + }{ + {"https://example.com/photo.png", "photo.png"}, + {"https://example.com/path/to/report.pdf", "report.pdf"}, + {"https://example.com/file", "file"}, + } + for _, tt := range tests { + got := filenameFromURL(tt.url) + if got != tt.want { + t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want) + } + } +} + +func TestFilenameFromURL_WithQuery(t *testing.T) { + got := filenameFromURL("https://example.com/photo.png?token=abc") + if got != "photo.png" { + t.Errorf("got %q, want %q", got, "photo.png") + } +} + +func TestStripQuery(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"https://example.com/a?b=c", "https://example.com/a"}, + {"https://example.com/a", "https://example.com/a"}, + {"https://example.com/?x=1&y=2", "https://example.com/"}, + } + for _, tt := range tests { + got := stripQuery(tt.input) + if got != tt.want { + t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/services/wechat-connector/messaging/sender.go b/services/wechat-connector/messaging/sender.go new file mode 100644 index 0000000..1bed0c8 --- /dev/null +++ b/services/wechat-connector/messaging/sender.go @@ -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] + "..." +} diff --git a/src/main/http-server.ts b/src/main/http-server.ts index 4d10c62..f7ea559 100644 --- a/src/main/http-server.ts +++ b/src/main/http-server.ts @@ -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 = { 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 = { 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 { 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 { + async start( + host: string = DEFAULT_HTTP_HOST, + port: number = DEFAULT_HTTP_PORT + ): Promise { if (singleton) { return this.getState() } diff --git a/src/main/index.ts b/src/main/index.ts index a39a1b9..c6f65e8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) { diff --git a/src/main/services/agent-group-report-service.ts b/src/main/services/agent-group-report-service.ts new file mode 100644 index 0000000..2b42852 --- /dev/null +++ b/src/main/services/agent-group-report-service.ts @@ -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 { + 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 + } +} diff --git a/src/main/services/agent-hub-service.ts b/src/main/services/agent-hub-service.ts new file mode 100644 index 0000000..b044365 --- /dev/null +++ b/src/main/services/agent-hub-service.ts @@ -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() + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): 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() diff --git a/src/main/services/local-api-test-service.ts b/src/main/services/local-api-test-service.ts index b4dad71..11f4c8f 100644 --- a/src/main/services/local-api-test-service.ts +++ b/src/main/services/local-api-test-service.ts @@ -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 { + 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 { diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index b6a14d5..5029d20 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -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 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() } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a914428..3519c27 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 + getAgentHubLogs: () => Promise + clearAgentHubLogs: () => Promise + startAgentHubLogin: () => Promise + cancelAgentHubLogin: () => Promise + reconnectAgentHub: () => Promise + disconnectAgentHub: () => Promise + selectAgentHubTestImage: () => Promise<{ canceled: boolean; path?: string }> + onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => () => void + onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => () => void } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index b7e9f96..9a7a823 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 77a5640..25882c1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 ( ) + case 'agent-hub': + return ( + + ) case 'api': return (
+
+
+
WechatExplorer
+

Agent Hub

+

让微信机器人安全调用聊天数据与 AI 能力。

+
+ + Agent Hub {status.hub === 'online' ? '运行中' : '未运行'} + +
+ +
+
+
+
+ 微信机器人 +

连接微信

+
+ + + {STATUS_LABELS[status.connector]} + +
+ + {showQRCode ? ( +
+
+ 微信机器人登录二维码 +
+
+

+ {status.connector === 'scanned' ? '请在手机上确认登录' : '使用微信扫描二维码'} +

+

二维码仅用于机器人账号登录,不会读取你的微信密码。

+ +
+
+ ) : status.connector === 'online' ? ( +
+
+ ✓ +
+
+

微信机器人已连接

+

{status.accountId || status.wechatUserId || '登录凭据已就绪'}

+
+
+ ) : ( +
+
+ +
+

{status.connector === 'error' ? '连接遇到问题' : '尚未连接微信机器人'}

+

{status.error || '扫码登录后,即可从微信向 Agent Hub 提问。'}

+
+ )} + +
+ {status.connector === 'online' ? ( + <> + + + + ) : !isLoginFlow ? ( + + ) : null} +
+
+ + +
+ +
+
+
+ 故障诊断 +

运行日志

+
+
+ + + +
+
+
+ {visibleLogs.length === 0 ? ( +
+ 暂无运行日志。收到消息后,这里会显示处理到哪一步。 +
+ ) : ( + visibleLogs.map((entry) => ( +
+ + {LOG_SOURCE_LABELS[entry.source]} + {entry.message} +
+ )) + )} +
+

日志会隐藏 Token 和二维码数据,不记录你的微信密码。

+
+
+ ) +} diff --git a/src/renderer/src/features/api-center/components/ApiRequestTester.tsx b/src/renderer/src/features/api-center/components/ApiRequestTester.tsx index e13657e..f39bf14 100644 --- a/src/renderer/src/features/api-center/components/ApiRequestTester.tsx +++ b/src/renderer/src/features/api-center/components/ApiRequestTester.tsx @@ -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 => { + const result = await window.api.selectAgentHubTestImage() + if (result.canceled || !result.path) return + let payload: Record = {} + try { + payload = JSON.parse(body) as Record + } catch { + // Replace an invalid draft with a valid send-test request. + } + onBody(JSON.stringify({ ...payload, media_url: result.path }, null, 2)) + } return (
@@ -77,6 +88,14 @@ export function ApiRequestTester({ /> )} + {endpoint.id === 'agent-send' && ( +
+ + 选择后只会填入本地路径;点击“发送请求”才会真正发送。 +
+ )}
diff --git a/src/renderer/src/utils/group-report-facts.ts b/src/renderer/src/utils/group-report-facts.ts index ec4d12f..24b688c 100644 --- a/src/renderer/src/utils/group-report-facts.ts +++ b/src/renderer/src/utils/group-report-facts.ts @@ -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 + getImage: ( + imageMd5?: string, + imageDatNameOrThumb?: string | boolean, + sessionId?: string + ) => Promise + } +} 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 => Boolean(item)) diff --git a/src/shared/agent-hub.ts b/src/shared/agent-hub.ts new file mode 100644 index 0000000..6b8c826 --- /dev/null +++ b/src/shared/agent-hub.ts @@ -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 +} diff --git a/src/shared/local-api-test.ts b/src/shared/local-api-test.ts index 0f2ffe3..b8a9852 100644 --- a/src/shared/local-api-test.ts +++ b/src/shared/local-api-test.ts @@ -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