merge: 合并 AI 微信 Agent 能力

This commit is contained in:
Wxw-Gu
2026-07-15 20:50:04 +08:00
46 changed files with 6576 additions and 205 deletions
+1
View File
@@ -6,6 +6,7 @@ out
.DS_Store
.eslintcache
*.log*
resources/connectors/wechat/
.omc
.codex/
docs/design/
+1
View File
@@ -16,6 +16,7 @@ extraMetadata:
asarUnpack:
- resources/**
extraResources:
# Includes the optional WeChat connector binary for the target platform.
- from: resources
to: resources
filter:
+25 -8
View File
@@ -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"
+2 -1
View File
@@ -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==}
+17
View File
@@ -10,6 +10,23 @@ function setPlistValue(plistPath, key, value) {
}
exports.default = async function afterPack(context) {
if (context.electronPlatformName === 'win32') {
const koffiNative = path.join(
context.appOutDir,
'resources',
'app.asar.unpacked',
'node_modules',
'@koromix',
'koffi-win32-x64',
'win32_x64',
'koffi.node'
)
if (!existsSync(koffiNative)) {
throw new Error(`Missing Windows Koffi native module: ${koffiNative}`)
}
return
}
if (context.electronPlatformName !== 'darwin') return
const productName = context.packager.appInfo.productFilename
+65
View File
@@ -0,0 +1,65 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const projectRoot = path.resolve(__dirname, '..')
const sourceDir = path.join(projectRoot, 'services', 'wechat-connector')
const outputRoot = path.join(projectRoot, 'resources', 'connectors', 'wechat')
function normalizePlatform(value) {
if (value === 'win32' || value === 'windows') return 'windows'
if (value === 'darwin' || value === 'macos') return 'darwin'
if (value === 'linux') return 'linux'
throw new Error(`Unsupported connector platform: ${value}`)
}
function normalizeArch(value) {
if (value === 'x64' || value === 'amd64') return 'amd64'
if (value === 'arm64') return 'arm64'
throw new Error(`Unsupported connector architecture: ${value}`)
}
function detectHostArch() {
if (process.platform !== 'darwin') return process.arch
try {
const arm64Supported = execFileSync('sysctl', ['-n', 'hw.optional.arm64'], {
encoding: 'utf8'
}).trim()
return arm64Supported === '1' ? 'arm64' : process.arch
} catch {
return process.arch
}
}
function parseTargets() {
const platformArg = process.argv.indexOf('--platform')
const archArg = process.argv.indexOf('--arch')
const platforms = platformArg >= 0 ? process.argv[platformArg + 1].split(',') : [process.platform]
const arches = archArg >= 0 ? process.argv[archArg + 1].split(',') : [detectHostArch()]
return platforms.flatMap((platform) =>
arches.map((arch) => ({ goos: normalizePlatform(platform), goarch: normalizeArch(arch) }))
)
}
if (!fs.existsSync(path.join(sourceDir, 'go.mod'))) {
throw new Error(`Repository-local WeChat connector source is missing: ${sourceDir}`)
}
for (const target of parseTargets()) {
const directoryName = `${target.goos === 'windows' ? 'win32' : target.goos}-${target.goarch === 'amd64' ? 'x64' : target.goarch}`
const outputDir = path.join(outputRoot, directoryName)
const outputPath = path.join(
outputDir,
target.goos === 'windows' ? 'wechat-connector.exe' : 'wechat-connector'
)
fs.rmSync(outputDir, { recursive: true, force: true })
fs.mkdirSync(outputDir, { recursive: true })
execFileSync('go', ['build', '-trimpath', '-o', outputPath, '.'], {
cwd: sourceDir,
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' },
stdio: 'inherit'
})
if (target.goos !== 'windows') fs.chmodSync(outputPath, 0o755)
console.log(`[build-wechat-connector] built ${directoryName}: ${outputPath}`)
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 fastclaw-ai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25
View File
@@ -0,0 +1,25 @@
# WechatExplorer WeChat Connector
This repository-local service provides the minimal WeChat bridge required by WechatExplorer:
- QR-code login with a single persisted credential
- account discovery
- inbound long polling and authenticated webhook delivery
- local HTTP health and send endpoints
- text and local/remote media sending
The executable is managed by the Electron main process. It is not a general-purpose agent runtime and does not load external AI command-line tools.
## Commands
```bash
go run . login --json
go run . accounts --json
go run . start --foreground --api-addr 127.0.0.1:18011 --account-id <account-id>
```
Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. A successful login is written before the older credential and synchronization state are removed, so an incomplete login cannot destroy the last working credential.
## Attribution
Low-level protocol and media transport portions are distributed under the MIT license in [LICENSE](LICENSE). WechatExplorer-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding WechatExplorer project.
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
)
// Server provides an HTTP API for sending messages.
type Server struct {
clients []*ilink.Client
addr string
}
// NewServer creates an API server.
func NewServer(clients []*ilink.Client, addr string) *Server {
if addr == "" {
addr = "127.0.0.1:18011"
}
return &Server{clients: clients, addr: addr}
}
// SendRequest is the JSON body for POST /api/send.
type SendRequest struct {
AccountID string `json:"account_id,omitempty"`
To string `json:"to"`
Text string `json:"text,omitempty"`
MediaURL string `json:"media_url,omitempty"` // image/video/file URL
}
// Run starts the HTTP server. Blocks until ctx is cancelled.
func (s *Server) Run(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/api/send", s.handleSend)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
srv := &http.Server{Addr: s.addr, Handler: mux}
go func() {
<-ctx.Done()
srv.Shutdown(context.Background())
}()
log.Printf("[api] listening on %s", s.addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req SendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.To == "" {
http.Error(w, `"to" is required`, http.StatusBadRequest)
return
}
if req.Text == "" && req.MediaURL == "" {
http.Error(w, `"text" or "media_url" is required`, http.StatusBadRequest)
return
}
if len(s.clients) == 0 {
http.Error(w, "no accounts configured", http.StatusServiceUnavailable)
return
}
client := s.clientForAccount(req.AccountID)
if client == nil {
http.Error(w, "requested account is not available", http.StatusNotFound)
return
}
ctx := r.Context()
// Send text if provided
if req.Text != "" {
if err := messaging.SendTextReply(ctx, client, req.To, req.Text, "", ""); err != nil {
log.Printf("[api] send text failed: %v", err)
http.Error(w, "send text failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent text to %s: %q", req.To, req.Text)
// Extract and send any markdown images embedded in text
for _, imgURL := range messaging.ExtractImageURLs(req.Text) {
if err := messaging.SendMediaFromURL(ctx, client, req.To, imgURL, ""); err != nil {
log.Printf("[api] send extracted image failed: %v", err)
} else {
log.Printf("[api] sent extracted image to %s: %s", req.To, imgURL)
}
}
}
// Send media if provided
if req.MediaURL != "" {
if err := messaging.SendMediaFromURL(ctx, client, req.To, req.MediaURL, ""); err != nil {
log.Printf("[api] send media failed: %v", err)
http.Error(w, "send media failed: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[api] sent media to %s: %s", req.To, req.MediaURL)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) clientForAccount(accountID string) *ilink.Client {
if accountID == "" {
return s.clients[0]
}
for _, client := range s.clients {
if client.BotID() == accountID {
return client
}
}
return nil
}
@@ -0,0 +1,20 @@
package api
import (
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestClientForAccountSelectsMatchingBot(t *testing.T) {
oldClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-old"})
newClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-new"})
server := NewServer([]*ilink.Client{oldClient, newClient}, "")
if got := server.clientForAccount("bot-new"); got != newClient {
t.Fatal("clientForAccount did not select the requested account")
}
if got := server.clientForAccount("missing"); got != nil {
t.Fatal("clientForAccount should reject an unknown account")
}
}
+8
View File
@@ -0,0 +1,8 @@
module github.com/Wxw-Gu/WechatExplorer/services/wechat-connector
go 1.23.0
require (
github.com/google/uuid v1.6.0
rsc.io/qr v0.2.0
)
+4
View File
@@ -0,0 +1,4 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+197
View File
@@ -0,0 +1,197 @@
package ilink
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const (
qrCodeURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3"
qrStatusURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode="
statusWait = "wait"
statusScanned = "scaned"
statusConfirmed = "confirmed"
statusExpired = "expired"
)
// FetchQRCode retrieves a new QR code for login.
func FetchQRCode(ctx context.Context) (*QRCodeResponse, error) {
c := NewUnauthenticatedClient()
var resp QRCodeResponse
if err := c.doGet(ctx, qrCodeURL, &resp); err != nil {
return nil, fmt.Errorf("fetch QR code: %w", err)
}
return &resp, nil
}
// PollQRStatus polls for QR code scan status until confirmed or expired.
// It calls onStatus for each status change so the caller can display progress.
func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status string)) (*Credentials, error) {
c := NewUnauthenticatedClient()
url := qrStatusURL + qrcode
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
pollCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
var resp QRStatusResponse
err := c.doGet(pollCtx, url, &resp)
cancel()
if err != nil {
// Timeout is normal for long-poll, retry
if ctx.Err() != nil {
return nil, ctx.Err()
}
continue
}
if onStatus != nil {
onStatus(resp.Status)
}
switch resp.Status {
case statusConfirmed:
creds := &Credentials{
BotToken: resp.BotToken,
ILinkBotID: resp.ILinkBotID,
BaseURL: resp.BaseURL,
ILinkUserID: resp.ILinkUserID,
}
return creds, nil
case statusExpired:
return nil, fmt.Errorf("QR code expired")
case statusWait, statusScanned:
// Continue polling
default:
// Unknown status, continue
}
}
}
// AccountsDir returns the directory where account credentials are stored.
func AccountsDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts"), nil
}
// NormalizeAccountID converts raw bot ID to filesystem-safe format.
func NormalizeAccountID(raw string) string {
s := raw
for _, ch := range []string{"@", ".", ":"} {
s = filepath.Clean(s)
s = replaceAll(s, ch, "-")
}
return s
}
func replaceAll(s, old, new string) string {
for {
i := indexOf(s, old)
if i < 0 {
return s
}
s = s[:i] + new + s[i+len(old):]
}
}
func indexOf(s, sub string) int {
for i := range s {
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// SaveCredentials saves the latest credentials and removes older accounts.
// The new credential is written first so a failed login never destroys the
// previously working credential.
func SaveCredentials(creds *Credentials) error {
dir, err := AccountsDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create accounts dir: %w", err)
}
id := NormalizeAccountID(creds.ILinkBotID)
path := filepath.Join(dir, id+".json")
data, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write credentials: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("prune old credentials: %w", err)
}
keepPrefix := id + "."
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), keepPrefix) {
continue
}
if filepath.Ext(entry.Name()) != ".json" {
continue
}
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove old credential %s: %w", entry.Name(), err)
}
}
return nil
}
// LoadAllCredentials loads all saved account credentials.
func LoadAllCredentials() ([]*Credentials, error) {
dir, err := AccountsDir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read accounts dir: %w", err)
}
var result []*Credentials
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var creds Credentials
if json.Unmarshal(data, &creds) == nil && creds.BotToken != "" {
result = append(result, &creds)
}
}
return result, nil
}
// CredentialsPath returns the path for display purposes.
func CredentialsPath() (string, error) {
return AccountsDir()
}
@@ -0,0 +1,36 @@
package ilink
import (
"os"
"path/filepath"
"testing"
)
func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) {
t.Setenv("HOME", t.TempDir())
old := &Credentials{ILinkBotID: "bot-old@im.bot", BotToken: "old-token"}
latest := &Credentials{ILinkBotID: "bot-new@im.bot", BotToken: "new-token"}
if err := SaveCredentials(old); err != nil {
t.Fatal(err)
}
dir, err := AccountsDir()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json"), []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
if err := SaveCredentials(latest); err != nil {
t.Fatal(err)
}
accounts, err := LoadAllCredentials()
if err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].ILinkBotID != latest.ILinkBotID {
t.Fatalf("accounts = %#v", accounts)
}
if _, err := os.Stat(filepath.Join(dir, NormalizeAccountID(old.ILinkBotID)+".sync.json")); !os.IsNotExist(err) {
t.Fatalf("old sync state still exists: %v", err)
}
}
+218
View File
@@ -0,0 +1,218 @@
package ilink
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
defaultBaseURL = "https://ilinkai.weixin.qq.com"
longPollTimeout = 35 * time.Second
sendTimeout = 15 * time.Second
)
// Client is an iLink HTTP API client.
type Client struct {
baseURL string
botToken string
botID string
httpClient *http.Client
wechatUIN string
}
// NewClient creates a new iLink API client.
func NewClient(creds *Credentials) *Client {
baseURL := creds.BaseURL
if baseURL == "" {
baseURL = defaultBaseURL
}
return &Client{
baseURL: baseURL,
botToken: creds.BotToken,
botID: creds.ILinkBotID,
httpClient: &http.Client{},
wechatUIN: generateWechatUIN(),
}
}
// NewUnauthenticatedClient creates a client without credentials for login flow.
func NewUnauthenticatedClient() *Client {
return &Client{
baseURL: defaultBaseURL,
httpClient: &http.Client{Timeout: 40 * time.Second},
wechatUIN: generateWechatUIN(),
}
}
// BotID returns the bot's user ID.
func (c *Client) BotID() string {
return c.botID
}
// GetUpdates performs a long-poll for new messages.
func (c *Client) GetUpdates(ctx context.Context, buf string) (*GetUpdatesResponse, error) {
reqBody := GetUpdatesRequest{
GetUpdatesBuf: buf,
BaseInfo: BaseInfo{ChannelVersion: "1.0.0"},
}
ctx, cancel := context.WithTimeout(ctx, longPollTimeout+5*time.Second)
defer cancel()
var resp GetUpdatesResponse
if err := c.doPost(ctx, "/ilink/bot/getupdates", reqBody, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendMessage sends a message through iLink.
func (c *Client) SendMessage(ctx context.Context, msg *SendMessageRequest) (*SendMessageResponse, error) {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
var resp SendMessageResponse
if err := c.doPost(ctx, "/ilink/bot/sendmessage", msg, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetConfig fetches bot config for a user (includes typing_ticket).
func (c *Client) GetConfig(ctx context.Context, userID, contextToken string) (*GetConfigResponse, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req := GetConfigRequest{
ILinkUserID: userID,
ContextToken: contextToken,
BaseInfo: BaseInfo{},
}
var resp GetConfigResponse
if err := c.doPost(ctx, "/ilink/bot/getconfig", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendTyping sends a typing indicator to a user.
func (c *Client) SendTyping(ctx context.Context, userID, typingTicket string, status int) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req := SendTypingRequest{
ILinkUserID: userID,
TypingTicket: typingTicket,
Status: status,
BaseInfo: BaseInfo{},
}
var resp SendTypingResponse
if err := c.doPost(ctx, "/ilink/bot/sendtyping", req, &resp); err != nil {
return err
}
if resp.Ret != 0 {
return fmt.Errorf("sendtyping failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
return nil
}
// GetUploadURL gets a pre-signed CDN upload URL for media files.
func (c *Client) GetUploadURL(ctx context.Context, req *GetUploadURLRequest) (*GetUploadURLResponse, error) {
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
var resp GetUploadURLResponse
if err := c.doPost(ctx, "/ilink/bot/getuploadurl", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// BaseURL returns the base URL for CDN operations.
func (c *Client) BaseURL() string {
return c.baseURL
}
func (c *Client) doPost(ctx context.Context, path string, body interface{}, result interface{}) error {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
return nil
}
func (c *Client) doGet(ctx context.Context, url string, result interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
return nil
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
req.Header.Set("Authorization", "Bearer "+c.botToken)
req.Header.Set("X-WECHAT-UIN", c.wechatUIN)
}
func generateWechatUIN() string {
var n uint32
_ = binary.Read(rand.Reader, binary.LittleEndian, &n)
s := fmt.Sprintf("%d", n)
return base64.StdEncoding.EncodeToString([]byte(s))
}
+181
View File
@@ -0,0 +1,181 @@
package ilink
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
)
const (
maxConsecutiveFailures = 5
initialBackoff = 3 * time.Second
maxBackoff = 60 * time.Second
sessionExpiredBackoff = 5 * time.Second
errCodeSessionExpired = -14
)
// MessageHandler is called for each received message.
type MessageHandler func(ctx context.Context, client *Client, msg WeixinMessage)
// Monitor manages the long-poll loop for receiving messages.
type Monitor struct {
client *Client
handler MessageHandler
getUpdatesBuf string
bufPath string
failures int
lastActivity time.Time
}
// NewMonitor creates a new long-poll monitor.
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
accountID := NormalizeAccountID(client.BotID())
bufPath := filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts", accountID+".sync.json")
m := &Monitor{
client: client,
handler: handler,
bufPath: bufPath,
lastActivity: time.Now(),
}
m.loadBuf()
return m, nil
}
// Run starts the long-poll loop. It blocks until ctx is cancelled.
// Automatically recovers from errors with exponential backoff.
func (m *Monitor) Run(ctx context.Context) error {
log.Println("[monitor] starting long-poll loop")
for {
select {
case <-ctx.Done():
log.Println("[monitor] shutting down")
return ctx.Err()
default:
}
resp, err := m.client.GetUpdates(ctx, m.getUpdatesBuf)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
m.failures++
backoff := m.calcBackoff()
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
m.failures, maxConsecutiveFailures, backoff, err)
if m.failures == maxConsecutiveFailures {
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from WechatExplorer if this persists.", maxConsecutiveFailures)
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
// Reset failure counter on any successful response
m.failures = 0
m.lastActivity = time.Now()
// Session expired — reset sync buf and reconnect silently
if resp.ErrCode == errCodeSessionExpired {
if m.getUpdatesBuf != "" {
log.Printf("[monitor] session expired, resetting sync buf")
m.getUpdatesBuf = ""
m.saveBuf()
} else {
// Sync buf already empty but still getting session expired:
// the bot token itself has expired. The user needs to re-login.
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from WechatExplorer.")
}
select {
case <-time.After(sessionExpiredBackoff):
case <-ctx.Done():
return ctx.Err()
}
continue
}
// Other server errors
if resp.Ret != 0 && resp.ErrCode != 0 {
log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg)
continue
}
// Update buf for next poll
if resp.GetUpdatesBuf != "" {
m.getUpdatesBuf = resp.GetUpdatesBuf
m.saveBuf()
}
// Process messages concurrently — don't block the poll loop
for _, msg := range resp.Msgs {
go m.handler(ctx, m.client, msg)
}
}
}
// calcBackoff returns an exponential backoff duration capped at maxBackoff.
func (m *Monitor) calcBackoff() time.Duration {
d := initialBackoff
for i := 1; i < m.failures; i++ {
d *= 2
if d > maxBackoff {
return maxBackoff
}
}
return d
}
type syncData struct {
GetUpdatesBuf string `json:"get_updates_buf"`
}
func (m *Monitor) loadBuf() {
data, err := os.ReadFile(m.bufPath)
if err != nil {
return
}
var s syncData
if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" {
m.getUpdatesBuf = s.GetUpdatesBuf
log.Printf("[monitor] loaded sync buf from %s", m.bufPath)
}
}
func (m *Monitor) saveBuf() {
dir := filepath.Dir(m.bufPath)
if err := os.MkdirAll(dir, 0o700); err != nil {
log.Printf("[monitor] failed to create buf dir: %v", err)
return
}
data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf})
if err := os.WriteFile(m.bufPath, data, 0o600); err != nil {
log.Printf("[monitor] failed to save buf: %v", err)
}
}
// FormatMessageSummary returns a short description of a message for logging.
func FormatMessageSummary(msg WeixinMessage) string {
text := ""
for _, item := range msg.ItemList {
if item.Type == ItemTypeText && item.TextItem != nil {
text = item.TextItem.Text
break
}
}
if len(text) > 50 {
text = text[:50] + "..."
}
return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text)
}
+219
View File
@@ -0,0 +1,219 @@
package ilink
// Message types
const (
MessageTypeNone = 0
MessageTypeUser = 1
MessageTypeBot = 2
)
// Message states
const (
MessageStateNew = 0
MessageStateGenerating = 1
MessageStateFinish = 2
)
// Item types
const (
ItemTypeNone = 0
ItemTypeText = 1
ItemTypeImage = 2
ItemTypeVoice = 3
ItemTypeFile = 4
ItemTypeVideo = 5
)
// QRCodeResponse is the response from get_bot_qrcode.
type QRCodeResponse struct {
QRCode string `json:"qrcode"`
QRCodeImgContent string `json:"qrcode_img_content"`
}
// QRStatusResponse is the response from get_qrcode_status.
type QRStatusResponse struct {
Status string `json:"status"`
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
BaseURL string `json:"baseurl"`
ILinkUserID string `json:"ilink_user_id"`
}
// Credentials stores login session data.
type Credentials struct {
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
BaseURL string `json:"baseurl"`
ILinkUserID string `json:"ilink_user_id"`
}
// BaseInfo is included in request bodies.
type BaseInfo struct {
ChannelVersion string `json:"channel_version,omitempty"`
}
// GetUpdatesRequest is the body for getupdates.
type GetUpdatesRequest struct {
GetUpdatesBuf string `json:"get_updates_buf"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetUpdatesResponse is the response from getupdates.
type GetUpdatesResponse struct {
Ret int `json:"ret"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
Msgs []WeixinMessage `json:"msgs"`
GetUpdatesBuf string `json:"get_updates_buf"`
LongPollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"`
}
// WeixinMessage represents a message from WeChat.
type WeixinMessage struct {
Seq int `json:"seq,omitempty"`
MessageID int64 `json:"message_id,omitempty"`
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// MessageItem is a single item in a message.
type MessageItem struct {
Type int `json:"type"`
TextItem *TextItem `json:"text_item,omitempty"`
ImageItem *ImageItem `json:"image_item,omitempty"`
VoiceItem *VoiceItem `json:"voice_item,omitempty"`
VideoItem *VideoItem `json:"video_item,omitempty"`
FileItem *FileItem `json:"file_item,omitempty"`
}
// CDN media type constants.
const (
CDNMediaTypeImage = 1
CDNMediaTypeVideo = 2
CDNMediaTypeFile = 3
)
// GetUploadURLRequest is the body for getuploadurl.
type GetUploadURLRequest struct {
FileKey string `json:"filekey"`
MediaType int `json:"media_type"`
ToUserID string `json:"to_user_id"`
RawSize int `json:"rawsize"`
RawFileMD5 string `json:"rawfilemd5"`
FileSize int `json:"filesize"`
NoNeedThumb bool `json:"no_need_thumb"`
AESKey string `json:"aeskey"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetUploadURLResponse is the response from getuploadurl.
type GetUploadURLResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
UploadParam string `json:"upload_param"`
UploadFullURL string `json:"upload_full_url,omitempty"`
}
// TextItem holds text content.
type TextItem struct {
Text string `json:"text"`
}
// MediaInfo holds CDN media reference for uploaded files.
type MediaInfo struct {
EncryptQueryParam string `json:"encrypt_query_param"`
AESKey string `json:"aes_key"` // base64-encoded
EncryptType int `json:"encrypt_type"` // 1 = AES-128-ECB
}
// VoiceItem holds voice content.
type VoiceItem struct {
Media *MediaInfo `json:"media,omitempty"`
VoiceSize int `json:"voice_size,omitempty"`
EncodeType int `json:"encode_type,omitempty"` // 1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3
BitsPerSample int `json:"bits_per_sample,omitempty"`
SampleRate int `json:"sample_rate,omitempty"` // Hz
Playtime int `json:"playtime,omitempty"` // duration in milliseconds
Text string `json:"text,omitempty"` // speech-to-text transcription from WeChat
}
// ImageItem holds image content.
type ImageItem struct {
URL string `json:"url,omitempty"`
Media *MediaInfo `json:"media,omitempty"`
MidSize int `json:"mid_size,omitempty"` // ciphertext size
}
// VideoItem holds video content.
type VideoItem struct {
Media *MediaInfo `json:"media,omitempty"`
VideoSize int `json:"video_size,omitempty"`
}
// FileItem holds file content.
type FileItem struct {
Media *MediaInfo `json:"media,omitempty"`
FileName string `json:"file_name,omitempty"`
Len string `json:"len,omitempty"` // plaintext size as string
}
// SendMessageRequest is the body for sendmessage.
type SendMessageRequest struct {
Msg SendMsg `json:"msg"`
BaseInfo BaseInfo `json:"base_info"`
}
// SendMsg is the message payload for sending.
type SendMsg struct {
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
ClientID string `json:"client_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// SendMessageResponse is the response from sendmessage.
type SendMessageResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
}
// Typing status constants.
const (
TypingStatusTyping = 1
TypingStatusCancel = 2
)
// GetConfigRequest is the body for getconfig.
type GetConfigRequest struct {
ILinkUserID string `json:"ilink_user_id"`
ContextToken string `json:"context_token,omitempty"`
BaseInfo BaseInfo `json:"base_info"`
}
// GetConfigResponse is the response from getconfig.
type GetConfigResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
TypingTicket string `json:"typing_ticket,omitempty"`
}
// SendTypingRequest is the body for sendtyping.
type SendTypingRequest struct {
ILinkUserID string `json:"ilink_user_id"`
TypingTicket string `json:"typing_ticket"`
Status int `json:"status"`
BaseInfo BaseInfo `json:"base_info"`
}
// SendTypingResponse is the response from sendtyping.
type SendTypingResponse struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg,omitempty"`
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/api"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging"
"rsc.io/qr"
)
type loginEvent struct {
Status string `json:"status"`
QRCodeDataURL string `json:"qr_code_data_url,omitempty"`
AccountID string `json:"account_id,omitempty"`
WeChatUserID string `json:"wechat_user_id,omitempty"`
}
type accountSummary struct {
AccountID string `json:"account_id"`
WeChatUserID string `json:"wechat_user_id"`
}
func main() {
if len(os.Args) < 2 {
fatal(errors.New("expected one of: login, accounts, start"))
}
var err error
switch os.Args[1] {
case "login":
err = runLogin(os.Args[2:])
case "accounts":
err = runAccounts(os.Args[2:])
case "start":
err = runStart(os.Args[2:])
default:
err = fmt.Errorf("unknown command %q", os.Args[1])
}
if err != nil {
fatal(err)
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
func signalContext() (context.Context, context.CancelFunc) {
return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
}
func runLogin(args []string) error {
flags := flag.NewFlagSet("login", flag.ContinueOnError)
jsonOutput := flags.Bool("json", false, "emit JSON Lines events")
if err := flags.Parse(args); err != nil {
return err
}
ctx, cancel := signalContext()
defer cancel()
creds, err := login(ctx, *jsonOutput)
if err != nil {
return err
}
if !*jsonOutput {
fmt.Printf("WeChat account %s connected.\n", creds.ILinkBotID)
}
return nil
}
func login(ctx context.Context, jsonOutput bool) (*ilink.Credentials, error) {
qrResponse, err := ilink.FetchQRCode(ctx)
if err != nil {
return nil, err
}
code, err := qr.Encode(qrResponse.QRCodeImgContent, qr.L)
if err != nil {
return nil, fmt.Errorf("encode QR image: %w", err)
}
emit := func(event loginEvent) {
if jsonOutput {
_ = json.NewEncoder(os.Stdout).Encode(event)
}
}
emit(loginEvent{Status: "qrcode", QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())})
lastStatus := ""
creds, err := ilink.PollQRStatus(ctx, qrResponse.QRCode, func(status string) {
if status != lastStatus {
lastStatus = status
emit(loginEvent{Status: status})
}
})
if err != nil {
return nil, err
}
if err := ilink.SaveCredentials(creds); err != nil {
return nil, fmt.Errorf("save credentials: %w", err)
}
emit(loginEvent{Status: "active", AccountID: creds.ILinkBotID, WeChatUserID: creds.ILinkUserID})
return creds, nil
}
func runAccounts(args []string) error {
flags := flag.NewFlagSet("accounts", flag.ContinueOnError)
jsonOutput := flags.Bool("json", false, "print JSON")
if err := flags.Parse(args); err != nil {
return err
}
accounts, err := ilink.LoadAllCredentials()
if err != nil {
return err
}
items := make([]accountSummary, 0, len(accounts))
for _, account := range accounts {
items = append(items, accountSummary{AccountID: account.ILinkBotID, WeChatUserID: account.ILinkUserID})
}
if *jsonOutput {
return json.NewEncoder(os.Stdout).Encode(map[string]any{"accounts": items})
}
for _, item := range items {
fmt.Printf("%s\t%s\n", item.AccountID, item.WeChatUserID)
}
return nil
}
func runStart(args []string) error {
flags := flag.NewFlagSet("start", flag.ContinueOnError)
_ = flags.Bool("foreground", false, "kept for host compatibility")
apiAddr := flags.String("api-addr", "127.0.0.1:18011", "local send API address")
accountID := flags.String("account-id", "", "account to start")
if err := flags.Parse(args); err != nil {
return err
}
accounts, err := ilink.LoadAllCredentials()
if err != nil {
return err
}
if len(accounts) == 0 {
return errors.New("no connected WeChat account; scan a QR code first")
}
selected := accounts[len(accounts)-1]
if *accountID != "" {
selected = nil
for _, account := range accounts {
if account.ILinkBotID == *accountID {
selected = account
break
}
}
if selected == nil {
return fmt.Errorf("account %q not found", *accountID)
}
}
ctx, cancel := signalContext()
defer cancel()
client := ilink.NewClient(selected)
server := api.NewServer([]*ilink.Client{client}, *apiAddr)
webhookURL := strings.TrimSpace(os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL"))
webhook := messaging.NewInboundWebhook(webhookURL, os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN"))
monitor, err := ilink.NewMonitor(client, func(messageContext context.Context, source *ilink.Client, message ilink.WeixinMessage) {
if webhookURL != "" {
webhook.Dispatch(messageContext, source, message)
}
})
if err != nil {
return err
}
var wait sync.WaitGroup
wait.Add(2)
go func() {
defer wait.Done()
if err := server.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[api] stopped: %v", err)
cancel()
}
}()
go func() {
defer wait.Done()
if err := monitor.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("[monitor] stopped: %v", err)
cancel()
}
}()
wait.Wait()
return nil
}
+232
View File
@@ -0,0 +1,232 @@
package messaging
import (
"bytes"
"context"
"crypto/aes"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
const cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
// UploadedFile holds the result of a CDN upload.
type UploadedFile struct {
DownloadParam string // encrypted query param for download
AESKeyHex string // hex-encoded AES key
FileSize int // plaintext size
CipherSize int // ciphertext size
}
// UploadFileToCDN encrypts and uploads a file to the WeChat CDN.
func UploadFileToCDN(ctx context.Context, client *ilink.Client, data []byte, toUserID string, mediaType int) (*UploadedFile, error) {
// Generate random filekey and AES key
filekey := make([]byte, 16)
aeskey := make([]byte, 16)
if _, err := rand.Read(filekey); err != nil {
return nil, fmt.Errorf("generate filekey: %w", err)
}
if _, err := rand.Read(aeskey); err != nil {
return nil, fmt.Errorf("generate aeskey: %w", err)
}
filekeyHex := hex.EncodeToString(filekey)
aeskeyHex := hex.EncodeToString(aeskey)
// Calculate MD5 of plaintext
hash := md5.Sum(data)
rawMD5 := hex.EncodeToString(hash[:])
// Calculate ciphertext size (PKCS7 padding)
cipherSize := aesECBPaddedSize(len(data))
// Get upload URL from iLink API
uploadReq := &ilink.GetUploadURLRequest{
FileKey: filekeyHex,
MediaType: mediaType,
ToUserID: toUserID,
RawSize: len(data),
RawFileMD5: rawMD5,
FileSize: cipherSize,
NoNeedThumb: true,
AESKey: aeskeyHex,
BaseInfo: ilink.BaseInfo{},
}
uploadResp, err := client.GetUploadURL(ctx, uploadReq)
if err != nil {
return nil, fmt.Errorf("get upload URL: %w", err)
}
if uploadResp.Ret != 0 {
return nil, fmt.Errorf("get upload URL failed: ret=%d errmsg=%s", uploadResp.Ret, uploadResp.ErrMsg)
}
// Encrypt data with AES-128-ECB
encrypted, err := encryptAESECB(data, aeskey)
if err != nil {
return nil, fmt.Errorf("encrypt: %w", err)
}
// Upload to CDN: prefer server-provided full URL, fall back to param-based construction
cdnURL := strings.TrimSpace(uploadResp.UploadFullURL)
if cdnURL == "" {
if uploadResp.UploadParam == "" {
return nil, fmt.Errorf("getuploadurl returned no upload URL (need upload_full_url or upload_param)")
}
cdnURL = fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s",
cdnBaseURL, url.QueryEscape(uploadResp.UploadParam), url.QueryEscape(filekeyHex))
}
downloadParam, err := uploadToCDN(ctx, encrypted, cdnURL)
if err != nil {
return nil, fmt.Errorf("CDN upload: %w", err)
}
return &UploadedFile{
DownloadParam: downloadParam,
AESKeyHex: aeskeyHex,
FileSize: len(data),
CipherSize: cipherSize,
}, nil
}
// AESKeyToBase64 converts a hex AES key to base64 format for message items.
func AESKeyToBase64(hexKey string) string {
return base64.StdEncoding.EncodeToString([]byte(hexKey))
}
// DownloadFileFromCDN downloads and decrypts a file from the WeChat CDN.
func DownloadFileFromCDN(ctx context.Context, encryptQueryParam, aesKeyBase64 string) ([]byte, error) {
// Decode AES key: base64 -> hex string -> raw bytes
aesKeyHexBytes, err := base64.StdEncoding.DecodeString(aesKeyBase64)
if err != nil {
return nil, fmt.Errorf("decode AES key base64: %w", err)
}
aesKey, err := hex.DecodeString(string(aesKeyHexBytes))
if err != nil {
return nil, fmt.Errorf("decode AES key hex: %w", err)
}
// Download encrypted data from CDN
downloadURL := fmt.Sprintf("%s/download?encrypted_query_param=%s",
cdnBaseURL, url.QueryEscape(encryptQueryParam))
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download from CDN: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("CDN download HTTP %d: %s", resp.StatusCode, string(body))
}
encrypted, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read CDN response: %w", err)
}
// Decrypt AES-128-ECB
return decryptAESECB(encrypted, aesKey)
}
// decryptAESECB decrypts data encrypted with AES-128-ECB and removes PKCS7 padding.
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext is not a multiple of block size")
}
plaintext := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
// Remove PKCS7 padding
if len(plaintext) == 0 {
return plaintext, nil
}
padLen := int(plaintext[len(plaintext)-1])
if padLen > aes.BlockSize || padLen == 0 {
return nil, fmt.Errorf("invalid PKCS7 padding")
}
return plaintext[:len(plaintext)-padLen], nil
}
func uploadToCDN(ctx context.Context, encrypted []byte, cdnURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(encrypted))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/octet-stream")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("CDN upload HTTP %d: %s", resp.StatusCode, string(body))
}
downloadParam := resp.Header.Get("X-Encrypted-Param")
if downloadParam == "" {
return "", fmt.Errorf("CDN upload: missing X-Encrypted-Param header")
}
return downloadParam, nil
}
// encryptAESECB encrypts data using AES-128-ECB with PKCS7 padding.
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// PKCS7 padding
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
padded := make([]byte, len(plaintext)+padLen)
copy(padded, plaintext)
for i := len(plaintext); i < len(padded); i++ {
padded[i] = byte(padLen)
}
// ECB mode: encrypt each block independently
encrypted := make([]byte, len(padded))
for i := 0; i < len(padded); i += aes.BlockSize {
block.Encrypt(encrypted[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
}
return encrypted, nil
}
func aesECBPaddedSize(plaintextSize int) int {
return (plaintextSize/aes.BlockSize + 1) * aes.BlockSize
}
@@ -0,0 +1,121 @@
package messaging
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
const (
webhookAttempts = 3
webhookTimeout = 5 * time.Second
)
type InboundWebhook struct {
url string
token string
client *http.Client
}
type inboundWebhookPayload struct {
AccountID string `json:"account_id"`
FromUserID string `json:"from_user_id"`
MessageID int64 `json:"message_id"`
MessageType int `json:"message_type"`
Items []inboundWebhookItem `json:"items"`
ReceivedAt time.Time `json:"received_at"`
}
type inboundWebhookItem struct {
Type int `json:"type"`
Text string `json:"text,omitempty"`
}
func NewInboundWebhook(url, token string) *InboundWebhook {
return &InboundWebhook{
url: strings.TrimSpace(url),
token: token,
client: &http.Client{Timeout: webhookTimeout},
}
}
// Dispatch is intentionally non-blocking so webhook failures never stall iLink polling.
func (w *InboundWebhook) Dispatch(ctx context.Context, client *ilink.Client, msg ilink.WeixinMessage) {
payload := normalizeInboundMessage(client.BotID(), msg)
go func() {
if err := w.deliver(ctx, payload); err != nil {
log.Printf("[webhook] inbound delivery failed for message %d: %v", msg.MessageID, err)
}
}()
}
func (w *InboundWebhook) deliver(ctx context.Context, payload inboundWebhookPayload) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encode payload: %w", err)
}
var lastErr error
for attempt := 1; attempt <= webhookAttempts; attempt++ {
if attempt > 1 {
timer := time.NewTimer(time.Duration(attempt-1) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body))
if reqErr != nil {
return fmt.Errorf("create request: %w", reqErr)
}
req.Header.Set("Content-Type", "application/json")
if w.token != "" {
req.Header.Set("Authorization", "Bearer "+w.token)
}
resp, doErr := w.client.Do(req)
if doErr != nil {
lastErr = doErr
continue
}
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
lastErr = fmt.Errorf("status %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
break
}
}
return lastErr
}
func normalizeInboundMessage(accountID string, msg ilink.WeixinMessage) inboundWebhookPayload {
items := make([]inboundWebhookItem, 0, len(msg.ItemList))
for _, item := range msg.ItemList {
normalized := inboundWebhookItem{Type: item.Type}
if item.TextItem != nil {
normalized.Text = item.TextItem.Text
} else if item.VoiceItem != nil {
normalized.Text = item.VoiceItem.Text
}
items = append(items, normalized)
}
return inboundWebhookPayload{
AccountID: accountID,
FromUserID: msg.FromUserID,
MessageID: msg.MessageID,
MessageType: msg.MessageType,
Items: items,
ReceivedAt: time.Now().UTC(),
}
}
@@ -0,0 +1,75 @@
package messaging
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
func TestInboundWebhookDeliversNormalizedPayload(t *testing.T) {
var got inboundWebhookPayload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "secret")
err := webhook.deliver(context.Background(), normalizeInboundMessage("bot-new", ilink.WeixinMessage{
MessageID: 7, FromUserID: "user-1", MessageType: ilink.MessageTypeUser,
ItemList: []ilink.MessageItem{{Type: ilink.ItemTypeText, TextItem: &ilink.TextItem{Text: "最近5条消息"}}},
}))
if err != nil {
t.Fatalf("deliver: %v", err)
}
if got.AccountID != "bot-new" || got.MessageID != 7 || len(got.Items) != 1 || got.Items[0].Text != "最近5条消息" {
t.Fatalf("payload = %#v", got)
}
}
func TestInboundWebhookRetriesServerErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) < 3 {
http.Error(w, "temporary", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "")
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err != nil {
t.Fatalf("deliver: %v", err)
}
if calls.Load() != 3 {
t.Fatalf("calls = %d, want 3", calls.Load())
}
}
func TestInboundWebhookDoesNotRetryClientErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer server.Close()
webhook := NewInboundWebhook(server.URL, "")
if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err == nil {
t.Fatal("deliver error = nil")
}
if calls.Load() != 1 {
t.Fatalf("calls = %d, want 1", calls.Load())
}
}
@@ -0,0 +1,103 @@
package messaging
import (
"regexp"
"strings"
)
var (
// Code blocks: strip fences, keep code content
reCodeBlock = regexp.MustCompile("(?s)```[^\n]*\n?(.*?)```")
// Inline code: strip backticks, keep content
reInlineCode = regexp.MustCompile("`([^`]+)`")
// Images: remove entirely
reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`)
// Links: keep display text only
reLink = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)
// Table separator rows: remove
reTableSep = regexp.MustCompile(`(?m)^\|[\s:|\-]+\|$`)
// Table rows: convert pipe-delimited to space-delimited
reTableRow = regexp.MustCompile(`(?m)^\|(.+)\|$`)
// Headers: remove # prefix
reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+`)
// Bold: **text** or __text__
reBold = regexp.MustCompile(`\*\*(.+?)\*\*|__(.+?)__`)
// Italic: *text* or _text_
reItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)|(?:^|[^_])_([^_]+)_(?:[^_]|$)`)
// Strikethrough: ~~text~~
reStrike = regexp.MustCompile(`~~(.+?)~~`)
// Blockquote: > prefix
reBlockquote = regexp.MustCompile(`(?m)^>\s?`)
// Horizontal rule
reHR = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`)
// Unordered list markers: -, *, +
reUL = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
)
// MarkdownToPlainText converts markdown to readable plain text for WeChat.
func MarkdownToPlainText(text string) string {
result := text
// Code blocks: strip fences, keep code content
result = reCodeBlock.ReplaceAllStringFunc(result, func(match string) string {
parts := reCodeBlock.FindStringSubmatch(match)
if len(parts) > 1 {
return strings.TrimSpace(parts[1])
}
return match
})
// Images: remove entirely
result = reImage.ReplaceAllString(result, "")
// Links: keep display text only
result = reLink.ReplaceAllString(result, "$1")
// Table separator rows: remove
result = reTableSep.ReplaceAllString(result, "")
// Table rows: pipe-delimited to space-delimited
result = reTableRow.ReplaceAllStringFunc(result, func(match string) string {
parts := reTableRow.FindStringSubmatch(match)
if len(parts) > 1 {
cells := strings.Split(parts[1], "|")
for i := range cells {
cells[i] = strings.TrimSpace(cells[i])
}
return strings.Join(cells, " ")
}
return match
})
// Headers: remove # prefix
result = reHeader.ReplaceAllString(result, "")
// Bold
result = reBold.ReplaceAllStringFunc(result, func(match string) string {
parts := reBold.FindStringSubmatch(match)
if parts[1] != "" {
return parts[1]
}
return parts[2]
})
// Strikethrough
result = reStrike.ReplaceAllString(result, "$1")
// Blockquote
result = reBlockquote.ReplaceAllString(result, "")
// Horizontal rule -> empty line
result = reHR.ReplaceAllString(result, "")
// Unordered list: replace markers with "• "
result = reUL.ReplaceAllString(result, "${1}• ")
// Inline code: strip backticks (do after code blocks)
result = reInlineCode.ReplaceAllString(result, "$1")
// Clean up excessive blank lines
result = regexp.MustCompile(`\n{3,}`).ReplaceAllString(result, "\n\n")
return strings.TrimSpace(result)
}
@@ -0,0 +1,221 @@
package messaging
import (
"context"
"fmt"
"io"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
)
// reMarkdownImage matches markdown image syntax: ![alt](url)
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
// ExtractImageURLs extracts image URLs from markdown text.
func ExtractImageURLs(text string) []string {
matches := reMarkdownImage.FindAllStringSubmatch(text, -1)
var urls []string
for _, m := range matches {
url := strings.TrimSpace(m[1])
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
urls = append(urls, url)
}
}
return urls
}
// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message.
func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error {
// Check if it's a local file
if _, err := os.Stat(mediaURL); err == nil {
return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken)
}
// Must be a valid HTTP URL to download
if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") {
return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL)
}
data, contentType, err := downloadFile(ctx, mediaURL)
if err != nil {
return fmt.Errorf("download %s: %w", mediaURL, err)
}
return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken)
}
// SendMediaFromPath reads a local file and sends it as a media message.
func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken)
}
func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error {
if fileName == "" {
fileName = "file"
}
cdnMediaType, itemType := classifyMedia(contentType, source)
log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID)
uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType)
if err != nil {
return fmt.Errorf("upload to CDN: %w", err)
}
media := &ilink.MediaInfo{
EncryptQueryParam: uploaded.DownloadParam,
AESKey: AESKeyToBase64(uploaded.AESKeyHex),
EncryptType: 1,
}
var item ilink.MessageItem
switch itemType {
case ilink.ItemTypeImage:
item = ilink.MessageItem{
Type: ilink.ItemTypeImage,
ImageItem: &ilink.ImageItem{
Media: media,
MidSize: uploaded.CipherSize,
},
}
case ilink.ItemTypeVideo:
item = ilink.MessageItem{
Type: ilink.ItemTypeVideo,
VideoItem: &ilink.VideoItem{
Media: media,
VideoSize: uploaded.CipherSize,
},
}
default:
item = ilink.MessageItem{
Type: ilink.ItemTypeFile,
FileItem: &ilink.FileItem{
Media: media,
FileName: fileName,
Len: fmt.Sprintf("%d", uploaded.FileSize),
},
}
}
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: NewClientID(),
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{item},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send media message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source)
return nil
}
func downloadFile(ctx context.Context, url string) ([]byte, string, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = inferContentType(url)
}
return data, contentType, nil
}
func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) {
ct := strings.ToLower(contentType)
if strings.HasPrefix(ct, "image/") || isImageExt(url) {
return ilink.CDNMediaTypeImage, ilink.ItemTypeImage
}
if strings.HasPrefix(ct, "video/") || isVideoExt(url) {
return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo
}
return ilink.CDNMediaTypeFile, ilink.ItemTypeFile
}
func isImageExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
return true
}
return false
}
func isVideoExt(url string) bool {
ext := strings.ToLower(filepath.Ext(stripQuery(url)))
switch ext {
case ".mp4", ".mov", ".webm", ".mkv", ".avi":
return true
}
return false
}
func inferContentType(url string) string {
ext := filepath.Ext(stripQuery(url))
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
func filenameFromURL(rawURL string) string {
u := stripQuery(rawURL)
name := filepath.Base(u)
if name == "" || name == "." || name == "/" {
return "file"
}
return name
}
func stripQuery(rawURL string) string {
if i := strings.IndexByte(rawURL, '?'); i >= 0 {
return rawURL[:i]
}
return rawURL
}
@@ -0,0 +1,73 @@
package messaging
import "testing"
func TestExtractImageURLs(t *testing.T) {
text := "check ![img](https://example.com/a.png) and ![](https://example.com/b.jpg)"
urls := ExtractImageURLs(text)
if len(urls) != 2 {
t.Fatalf("expected 2 urls, got %d", len(urls))
}
if urls[0] != "https://example.com/a.png" {
t.Errorf("urls[0] = %q", urls[0])
}
if urls[1] != "https://example.com/b.jpg" {
t.Errorf("urls[1] = %q", urls[1])
}
}
func TestExtractImageURLs_NoImages(t *testing.T) {
urls := ExtractImageURLs("just plain text")
if len(urls) != 0 {
t.Errorf("expected 0 urls, got %d", len(urls))
}
}
func TestExtractImageURLs_RelativeURL(t *testing.T) {
text := "![img](./local.png)"
urls := ExtractImageURLs(text)
if len(urls) != 0 {
t.Errorf("expected 0 urls for relative path, got %d", len(urls))
}
}
func TestFilenameFromURL(t *testing.T) {
tests := []struct {
url string
want string
}{
{"https://example.com/photo.png", "photo.png"},
{"https://example.com/path/to/report.pdf", "report.pdf"},
{"https://example.com/file", "file"},
}
for _, tt := range tests {
got := filenameFromURL(tt.url)
if got != tt.want {
t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want)
}
}
}
func TestFilenameFromURL_WithQuery(t *testing.T) {
got := filenameFromURL("https://example.com/photo.png?token=abc")
if got != "photo.png" {
t.Errorf("got %q, want %q", got, "photo.png")
}
}
func TestStripQuery(t *testing.T) {
tests := []struct {
input string
want string
}{
{"https://example.com/a?b=c", "https://example.com/a"},
{"https://example.com/a", "https://example.com/a"},
{"https://example.com/?x=1&y=2", "https://example.com/"},
}
for _, tt := range tests {
got := stripQuery(tt.input)
if got != tt.want {
t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
@@ -0,0 +1,86 @@
package messaging
import (
"context"
"fmt"
"log"
"github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink"
"github.com/google/uuid"
)
// NewClientID generates a new unique client ID for message correlation.
func NewClientID() string {
return uuid.New().String()
}
// SendTypingState sends a typing indicator to a user via the iLink sendtyping API.
// It first fetches a typing_ticket via getconfig, then sends the typing status.
func SendTypingState(ctx context.Context, client *ilink.Client, userID, contextToken string) error {
// Get typing ticket
configResp, err := client.GetConfig(ctx, userID, contextToken)
if err != nil {
return fmt.Errorf("get config for typing: %w", err)
}
if configResp.TypingTicket == "" {
return fmt.Errorf("no typing_ticket returned from getconfig")
}
// Send typing
if err := client.SendTyping(ctx, userID, configResp.TypingTicket, ilink.TypingStatusTyping); err != nil {
return fmt.Errorf("send typing: %w", err)
}
log.Printf("[sender] sent typing indicator to %s", userID)
return nil
}
// SendTextReply sends a text reply to a user through the iLink API.
// If clientID is empty, a new one is generated.
func SendTextReply(ctx context.Context, client *ilink.Client, toUserID, text, contextToken, clientID string) error {
if clientID == "" {
clientID = NewClientID()
}
// Convert markdown to plain text for WeChat display
plainText := MarkdownToPlainText(text)
req := &ilink.SendMessageRequest{
Msg: ilink.SendMsg{
FromUserID: client.BotID(),
ToUserID: toUserID,
ClientID: clientID,
MessageType: ilink.MessageTypeBot,
MessageState: ilink.MessageStateFinish,
ItemList: []ilink.MessageItem{
{
Type: ilink.ItemTypeText,
TextItem: &ilink.TextItem{
Text: plainText,
},
},
},
ContextToken: contextToken,
},
BaseInfo: ilink.BaseInfo{},
}
resp, err := client.SendMessage(ctx, req)
if err != nil {
return fmt.Errorf("send message: %w", err)
}
if resp.Ret != 0 {
return fmt.Errorf("send message failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg)
}
log.Printf("[sender] sent reply to %s: %q", toUserID, truncate(text, 50))
return nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+64 -4
View File
@@ -9,6 +9,8 @@ import {
} from './services/chat-service'
import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report'
import { generateAgentGroupReport } from './services/agent-group-report-service'
import { agentHubService } from './services/agent-hub-service'
import { safeError, safeLog, safeWarn } from './safe-log'
export const DEFAULT_HTTP_HOST = '127.0.0.1'
@@ -157,7 +159,9 @@ const routes: Record<string, RouteHandler> = {
if (keyword) {
const lower = keyword.toLowerCase()
groups = groups.filter(
(c) => c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
(c) =>
c.m_nsNickName.toLowerCase().includes(lower) ||
c.m_nsUsrName.toLowerCase().includes(lower)
)
}
sendJson(res, 200, { count: groups.length, chatrooms: groups })
@@ -236,13 +240,62 @@ const routes: Record<string, RouteHandler> = {
try {
request = JSON.parse(body) as GroupReportExportRequest
} catch (error) {
return sendError(res, 400, '请求体 JSON 解析失败', error instanceof Error ? error.message : String(error))
return sendError(
res,
400,
'请求体 JSON 解析失败',
error instanceof Error ? error.message : String(error)
)
}
if (!request?.report || !request?.metadata) {
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
}
const result = await exportGroupReport(request)
sendJson(res, result.success ? 200 : 500, result)
},
'/api/v1/agent/group-report': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
let request: { group?: string; range?: 'today' | 'yesterday' | '7days' }
try {
request = JSON.parse(typeof body === 'string' ? body : '{}')
} catch {
return sendError(res, 400, '请求体 JSON 解析失败')
}
const result = await generateAgentGroupReport({
group: request.group || '',
range: request.range
})
sendJson(res, result.success ? 200 : 400, result)
},
'/api/v1/agent/status': ({ res }) => {
const status = agentHubService.getStatus()
sendJson(res, 200, {
ok: status.hub === 'online' && status.connector === 'online',
hub: status.hub,
connector: status.connector,
dataApi: status.dataApi,
databaseReady: status.databaseReady,
accountId: status.accountId
})
},
'/api/v1/agent/send': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
let request: { to?: string; text?: string; media_url?: string }
try {
request = JSON.parse(typeof body === 'string' ? body : '{}')
} catch {
return sendError(res, 400, '请求体 JSON 解析失败')
}
const result = await agentHubService.testSend({
to: request.to,
text: request.text,
mediaUrl: request.media_url
})
sendJson(res, result.success ? 200 : result.status === 'token_expired' ? 401 : 503, result)
}
}
@@ -311,7 +364,11 @@ export interface ApiServerState {
}
let singleton: HttpServerHandle | null = null
let singletonState: ApiServerState = { running: false, host: DEFAULT_HTTP_HOST, port: DEFAULT_HTTP_PORT }
let singletonState: ApiServerState = {
running: false,
host: DEFAULT_HTTP_HOST,
port: DEFAULT_HTTP_PORT
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
@@ -326,7 +383,10 @@ export const apiServer = {
return { ...singletonState }
},
async start(host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT): Promise<ApiServerState> {
async start(
host: string = DEFAULT_HTTP_HOST,
port: number = DEFAULT_HTTP_PORT
): Promise<ApiServerState> {
if (singleton) {
return this.getState()
}
+23
View File
@@ -68,6 +68,7 @@ import {
saveCachedMessages
} from './services/bootstrap-cache'
import { installSafeConsole } from './safe-log'
import { agentHubService } from './services/agent-hub-service'
// electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
@@ -698,6 +699,25 @@ app.whenReady().then(async () => {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
ipcMain.handle('agent-hub:getStatus', () => agentHubService.getStatus())
ipcMain.handle('agent-hub:getLogs', () => agentHubService.getLogs())
ipcMain.handle('agent-hub:clearLogs', () => agentHubService.clearLogs())
ipcMain.handle('agent-hub:startLogin', () => agentHubService.startLogin())
ipcMain.handle('agent-hub:cancelLogin', () => agentHubService.cancelLogin())
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(window!, {
title: '选择要测试发送的图片',
properties: ['openFile'],
filters: [
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
{ name: '所有文件', extensions: ['*'] }
]
})
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
})
createWindow()
@@ -707,6 +727,8 @@ app.whenReady().then(async () => {
await apiServer.start(settings.apiHost, settings.apiPort)
}
await agentHubService.start(settings)
if (TRAY_MODE) {
app.dock?.hide()
setupTray()
@@ -730,6 +752,7 @@ app.on('window-all-closed', () => {
})
app.on('before-quit', async () => {
agentHubService.stop()
chat.setChatDb(null)
await apiServer.stop().catch(() => undefined)
if (tray) {
@@ -0,0 +1,89 @@
import type { Contact, Message } from '../../shared/types'
import { exportGroupReport } from '../group-report-service'
import { getGroupSnapshot, listMessages, resolveMd5 } from './chat-service'
import { AIProviderService } from './ai-provider-service'
import {
buildGroupReportInput,
getSummaryDateRange,
GROUP_REPORT_SYSTEM_PROMPT,
isInternalName,
parseGroupDailyReport,
type SummaryDateRange
} from '../../renderer/src/utils/group-report'
const aiProvider = new AIProviderService()
export interface AgentGroupReportRequest {
group: string
range?: SummaryDateRange
}
export interface AgentGroupReportResult {
success: boolean
groupName?: string
pngPath?: string
messageCount?: number
error?: string
}
export async function generateAgentGroupReport(
request: AgentGroupReportRequest
): Promise<AgentGroupReportResult> {
const query = String(request.group || '')
.trim()
.replace(/群聊?$/, '')
.trim()
if (!query) return { success: false, error: '缺少群聊名称' }
const contact = resolveMd5(query)
if (!contact) return { success: false, error: `没有找到群聊“${query}` }
if (contact.type !== 'group' && !contact.m_nsUsrName.endsWith('@chatroom')) {
return { success: false, error: `${query}”不是群聊` }
}
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
const { startTime, endTime } = getSummaryDateRange(range)
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
const snapshot = getGroupSnapshot(contact.md5)
if (snapshot) {
const members = new Map(
snapshot.members.map((member) => [
member.wxid,
{ name: member.nickname, avatar: member.avatar }
])
)
messages = messages.map((message) => {
if (!isInternalName(message.name)) return message
const member = members.get(String(message.senderId || message.name || ''))
return member?.name
? { ...message, name: member.name, img: message.img || member.avatar }
: message
})
}
const input = await buildGroupReportInput(messages, contact as Contact, true, 'full')
const ai = await aiProvider.chat([
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
{ role: 'user', content: input.prompt }
])
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
const report = parseGroupDailyReport(
ai.data,
input.topSpeakers,
input.activeTimeline,
input.voiceLeaderboard,
input.metadata,
input.media
)
const exported = await exportGroupReport({ report, metadata: input.metadata })
if (!exported.success || !exported.pngPath) {
return { success: false, error: exported.error || '总结图片生成失败' }
}
return {
success: true,
groupName: input.metadata.groupName,
pngPath: exported.pngPath,
messageCount: messages.length
}
}
+746
View File
@@ -0,0 +1,746 @@
import { app, BrowserWindow } from 'electron'
import { ChildProcess, execFile, spawn } from 'child_process'
import { randomBytes, timingSafeEqual } from 'crypto'
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'
import { dirname, join } from 'path'
import { promisify } from 'util'
import type {
AgentHubActionResult,
AgentHubLogEntry,
AgentHubLogLevel,
AgentHubLogSource,
AgentHubStatus
} from '../../shared/agent-hub'
import type { AppSettings } from './settings-store'
import { generateAgentGroupReport } from './agent-group-report-service'
import { isReady, listMessages, listRecentChat, resolveMd5 } from './chat-service'
const execFileAsync = promisify(execFile)
const HEALTH_INTERVAL_MS = 5_000
const HUB_ADDR = '127.0.0.1:5300'
const HUB_HOST = '127.0.0.1'
const HUB_PORT = 5300
const CONNECTOR_ADDR = '127.0.0.1:18011'
const MAX_LOG_ENTRIES = 800
interface InboundMessage {
account_id?: string
from_user_id?: string
message_id?: string | number
items?: Array<{ type?: number; text?: string }>
}
interface GroupReportIntent {
group: string
range: 'today' | 'yesterday' | '7days'
}
interface ContactChatIntent {
contact: string
limit: number
}
function resolveBundledBinary(
resourceSegments: string[],
executable: string,
packaged = app.isPackaged,
platform = process.platform,
arch = process.arch
): string {
const relativeSegments = [...resourceSegments, `${platform}-${arch}`, executable]
const packagedPath = join(process.resourcesPath, 'resources', ...relativeSegments)
const developmentPath = join(app.getAppPath(), 'resources', ...relativeSegments)
const candidates = packaged ? [packagedPath, developmentPath] : [developmentPath, packagedPath]
return candidates.find((candidate) => existsSync(candidate)) || candidates[0]
}
export function resolveWechatConnectorBinaryPath(
packaged = app.isPackaged,
platform = process.platform,
arch = process.arch
): string {
return resolveBundledBinary(
['connectors', 'wechat'],
platform === 'win32' ? 'wechat-connector.exe' : 'wechat-connector',
packaged,
platform,
arch
)
}
class AgentHubService {
private hubServer: Server | null = null
private connectorChild: ChildProcess | null = null
private loginChild: ChildProcess | null = null
private stopping = false
private healthTimer: NodeJS.Timeout | null = null
private logs: AgentHubLogEntry[] = []
private nextLogId = 1
private readonly processedMessages = new Map<string, number>()
private readonly inboundToken =
process.env['AGENT_HUB_INBOUND_TOKEN'] || randomBytes(32).toString('hex')
private status: AgentHubStatus = {
hub: 'offline',
connector: 'checking',
dataApi: 'checking',
updatedAt: Date.now()
}
async start(settings: AppSettings): Promise<boolean> {
void settings
this.stopping = false
const hubStarted = await this.startHub()
await this.initializeConnector()
return hubStarted
}
getStatus(): AgentHubStatus {
return { ...this.status }
}
getLogs(): AgentHubLogEntry[] {
return [...this.logs]
}
clearLogs(): void {
this.logs = []
try {
writeFileSync(this.logFilePath(), '', 'utf8')
} catch {
// The live log remains usable when the persistent file cannot be cleared.
}
this.addLog('system', 'info', '运行日志已清空')
}
async testSend(input: { to?: string; text?: string; mediaUrl?: string }): Promise<{
success: boolean
status: 'sent' | 'token_expired' | 'connector_offline' | 'invalid_request' | 'send_failed'
message: string
}> {
const to = String(input.to || this.status.wechatUserId || '').trim()
const text = String(input.text || '').trim()
const mediaUrl = String(input.mediaUrl || '').trim()
if (!to || (!text && !mediaUrl)) {
return {
success: false,
status: 'invalid_request',
message: '请填写接收者以及文字或图片路径'
}
}
try {
const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
account_id: this.status.accountId,
to,
text: text || undefined,
media_url: mediaUrl || undefined
}),
signal: AbortSignal.timeout(30_000)
})
const body = await response.text()
if (response.ok) {
this.addLog('system', 'info', 'API 页面发送测试成功')
return { success: true, status: 'sent', message: '发送成功' }
}
const expired = /token|session|expired|unauthorized/i.test(body)
return {
success: false,
status: expired ? 'token_expired' : 'send_failed',
message: expired
? '微信登录凭证已失效,请重新扫码登录'
: `发送失败:${body || response.status}`
}
} catch (error) {
return {
success: false,
status: 'connector_offline',
message: `微信连接器不可用:${error instanceof Error ? error.message : String(error)}`
}
}
}
async startLogin(): Promise<AgentHubActionResult> {
if (this.loginChild && this.loginChild.exitCode === null) {
return { success: true, status: this.getStatus() }
}
const executable = resolveWechatConnectorBinaryPath()
if (!existsSync(executable)) {
return this.fail(`微信连接器不存在:${executable}`)
}
this.stopConnector()
this.patchStatus({ connector: 'starting', qrCodeDataUrl: undefined, error: undefined })
const child = spawn(executable, ['login', '--json'], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
this.loginChild = child
this.addLog('wechat-connector', 'info', '已启动扫码登录流程')
let stdoutBuffer = ''
let stderr = ''
child.stdout?.on('data', (data: Buffer) => {
stdoutBuffer += data.toString()
const lines = stdoutBuffer.split(/\r?\n/)
stdoutBuffer = lines.pop() || ''
for (const line of lines) this.handleLoginEvent(line)
})
child.stderr?.on('data', (data: Buffer) => {
stderr += data.toString()
this.addProcessOutput('wechat-connector', 'warn', data.toString())
})
child.once('error', (error) => {
this.addLog('wechat-connector', 'error', `登录进程错误:${error.message}`)
this.patchStatus({ connector: 'error', error: error.message })
})
child.once('exit', (code) => {
if (this.loginChild === child) this.loginChild = null
if (
code !== 0 &&
this.status.connector !== 'online' &&
this.status.connector !== 'disconnected' &&
!this.stopping
) {
this.patchStatus({ connector: 'error', error: stderr.trim() || `登录进程退出:${code}` })
}
})
return { success: true, status: this.getStatus() }
}
cancelLogin(): AgentHubActionResult {
if (this.loginChild && this.loginChild.exitCode === null) this.loginChild.kill()
this.loginChild = null
this.patchStatus({ connector: 'disconnected', qrCodeDataUrl: undefined, error: undefined })
return { success: true, status: this.getStatus() }
}
async reconnect(): Promise<AgentHubActionResult> {
const accounts = await this.loadAccounts()
if (accounts.length === 0) return this.startLogin()
this.startConnector(accounts.at(-1)!)
return { success: true, status: this.getStatus() }
}
disconnect(): AgentHubActionResult {
this.stopConnector()
this.patchStatus({ connector: 'disconnected', error: undefined })
return { success: true, status: this.getStatus() }
}
stop(): void {
this.stopping = true
this.clearHealthCheck()
if (this.loginChild && this.loginChild.exitCode === null) this.loginChild.kill()
this.loginChild = null
this.stopConnector()
const hubServer = this.hubServer
this.hubServer = null
hubServer?.close()
this.patchStatus({ hub: 'offline' })
}
private async startHub(): Promise<boolean> {
if (this.hubServer) return true
this.patchStatus({ hub: 'starting' })
const server = createServer((request, response) => {
void this.handleHubRequest(request, response).catch((error) => {
this.addLog('agent-hub', 'error', `请求处理失败:${this.errorMessage(error)}`)
this.sendHubJson(response, 500, { error: 'internal error' })
})
})
this.hubServer = server
return new Promise((resolve) => {
const fail = (error: Error): void => {
if (this.hubServer === server) this.hubServer = null
this.patchStatus({ hub: 'error', error: error.message })
this.addLog('agent-hub', 'error', `TypeScript 服务启动失败:${error.message}`)
resolve(false)
}
server.once('error', fail)
server.listen(HUB_PORT, HUB_HOST, () => {
server.off('error', fail)
server.on('error', (error) => {
this.patchStatus({ hub: 'error', error: error.message })
this.addLog('agent-hub', 'error', error.message)
})
this.patchStatus({ hub: 'online', error: undefined })
this.addLog('system', 'info', `Agent Hub TypeScript 服务已启动(${HUB_ADDR}`)
this.scheduleHealthCheck()
resolve(true)
})
})
}
private async handleHubRequest(
request: IncomingMessage,
response: ServerResponse
): Promise<void> {
const url = new URL(request.url || '/', `http://${HUB_ADDR}`)
if (request.method === 'GET' && url.pathname === '/health') {
return this.sendHubJson(response, 200, {
status: 'ok',
service: 'agent-hub',
runtime: 'typescript'
})
}
if (request.method !== 'POST' || url.pathname !== '/v1/connectors/wechat/inbound') {
return this.sendHubJson(response, 404, { error: 'not found' })
}
if (!this.authorized(request.headers.authorization)) {
return this.sendHubJson(response, 401, { error: 'unauthorized' })
}
let inbound: InboundMessage
try {
inbound = JSON.parse(await this.readHubBody(request)) as InboundMessage
} catch {
return this.sendHubJson(response, 400, { error: 'invalid request' })
}
const from = String(inbound.from_user_id || '').trim()
if (!from) return this.sendHubJson(response, 400, { error: 'from_user_id is required' })
const messageId = String(inbound.message_id || '')
this.cleanProcessedMessages()
if (messageId && this.processedMessages.has(messageId)) {
return this.sendHubJson(response, 200, { status: 'duplicate' })
}
const text = (inbound.items || [])
.filter((item) => item.type === 1 && item.text?.trim())
.map((item) => item.text!.trim())
.join(' ')
this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`)
const reportIntent = this.matchGroupReportIntent(text)
if (reportIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog(
'agent-hub',
'info',
`匹配群聊总结:${reportIntent.group}${reportIntent.range}`
)
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch((error) => {
this.addLog('agent-hub', 'warn', `等待提示发送失败:${this.errorMessage(error)}`)
})
void this.generateAndSendReport(inbound, reportIntent)
return this.sendHubJson(response, 202, { status: 'generating' })
}
const contactChatIntent = this.matchContactChatIntent(text)
if (contactChatIntent) {
if (messageId) this.processedMessages.set(messageId, Date.now())
await this.replyContactChat(inbound, contactChatIntent)
return this.sendHubJson(response, 200, { status: 'ok' })
}
const limit = this.matchRecentChatIntent(text)
if (limit === null) {
this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图')
return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' })
}
if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
const items = listRecentChat(limit)
const lines = items.map((item, index) => {
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
return `${index + 1}. ${name}${item.type === 'group' ? '群聊' : '联系人'}`
})
const reply = lines.length
? `最近 ${items.length} 个会话:\n${lines.join('\n')}`
: '暂时没有找到最近会话。'
try {
await this.sendConnector(inbound, reply)
} catch (error) {
this.addLog('agent-hub', 'error', `回复发送失败:${this.errorMessage(error)}`)
return this.sendHubJson(response, 502, { error: 'reply delivery failed' })
}
if (messageId) this.processedMessages.set(messageId, Date.now())
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
this.sendHubJson(response, 200, { status: 'ok' })
}
private async replyContactChat(
inbound: InboundMessage,
intent: ContactChatIntent
): Promise<void> {
if (!isReady()) {
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
return
}
const contact = resolveMd5(intent.contact)
if (!contact || contact.type !== 'user') {
this.addLog('agent-hub', 'info', `没有匹配到联系人:${intent.contact}`)
await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`)
return
}
this.addLog(
'agent-hub',
'info',
`匹配联系人聊天查询:${contact.m_nsNickName}(最近 ${intent.limit} 条)`
)
const messages = listMessages(contact.md5, undefined, undefined, { limit: intent.limit })
const recent = messages.slice(-intent.limit)
const lines = recent.map((message) => {
const speaker = message.isSender ? '我' : contact.m_nsNickName
const content = this.describeChatMessage(message.content, message.type)
return `${speaker}${content}`
})
const reply = lines.length
? `我和${contact.m_nsNickName}最近聊了这些:\n${lines.join('\n')}`
: `暂时没有找到和${contact.m_nsNickName}的聊天记录。`
await this.sendConnector(inbound, reply)
this.addLog('agent-hub', 'info', `联系人聊天回复已发送(${recent.length} 条)`)
}
private describeChatMessage(content: string, type: string): string {
const normalized = String(content || '')
.replace(/\s+/g, ' ')
.trim()
if (normalized) return normalized.length > 100 ? `${normalized.slice(0, 100)}` : normalized
const label = String(type || '消息').replace(/^普通文本$/, '消息')
return `[${label}]`
}
private async generateAndSendReport(
inbound: InboundMessage,
intent: GroupReportIntent
): Promise<void> {
try {
const result = await generateAgentGroupReport({ group: intent.group, range: intent.range })
if (!result.success || !result.pngPath) throw new Error(result.error || '群聊总结生成失败')
await this.sendConnector(
inbound,
`已生成${result.groupName || intent.group}的群聊总结(${result.messageCount || 0} 条消息),正在发送图片。`
)
await this.sendConnector(inbound, undefined, result.pngPath)
this.addLog('agent-hub', 'info', `群聊总结图片已发送:${result.groupName || intent.group}`)
} catch (error) {
const message = this.errorMessage(error)
this.addLog('agent-hub', 'error', `群聊总结生成失败:${message}`)
await this.sendConnector(inbound, `群聊总结生成失败:${message}`).catch(() => undefined)
}
}
private async sendConnector(
inbound: InboundMessage,
text?: string,
mediaUrl?: string
): Promise<void> {
const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
account_id: inbound.account_id,
to: inbound.from_user_id,
text,
media_url: mediaUrl
}),
signal: AbortSignal.timeout(mediaUrl ? 60_000 : 30_000)
})
if (!response.ok) throw new Error((await response.text()) || `HTTP ${response.status}`)
}
private matchRecentChatIntent(text: string): number | null {
const normalized = text.replace(/\s+/g, '')
if (!normalized.includes('最近') || !/(消息|会话|聊天)/.test(normalized)) return null
const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5)
return Math.max(1, Math.min(20, limit))
}
private matchContactChatIntent(text: string): ContactChatIntent | null {
const normalized = text.replace(/\s+/g, '').replace(/[,。!??:]/g, '')
if (!normalized.includes('最近') || !/(聊|消息|会话)/.test(normalized)) return null
const patterns = [
/(?:看一下|看看|查一下|查询)?我和(.+?)最近(?:\d{1,2}条)?(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/,
/(?:看一下|看看|查一下|查询)?(?:我)?最近(?:\d{1,2}条)?和(.+?)(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/,
/(?:看一下|看看|查一下|查询)?和(.+?)最近(?:\d{1,2}条)?(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/
]
const contact = patterns
.map((pattern) => normalized.match(pattern)?.[1]?.trim())
.find((value): value is string => Boolean(value))
if (!contact) return null
const limit = Number(normalized.match(/最近(\d{1,2})条/)?.[1] || 10)
return { contact, limit: Math.max(1, Math.min(20, limit)) }
}
private matchGroupReportIntent(text: string): GroupReportIntent | null {
const normalized = text.trim()
if (!normalized.includes('群') || !/(总结|日报|报告)/.test(normalized)) return null
const range = /(7天|七天|一周)/.test(normalized)
? '7days'
: /(昨天|昨日)/.test(normalized)
? 'yesterday'
: 'today'
const group = normalized
.replace(
/请|帮我|生成|做一份|做个|今天的|今日的|今天|今日|昨天的|昨日的|昨天|昨日|最近7天的|最近七天的|最近7天|最近七天|近7天的|近七天的|近7天|近七天|消息|聊天记录|聊天|群聊总结|群总结|群日报|群报告|总结|日报|报告|图片|长图/g,
''
)
.replace(/[,。!??:]/g, '')
.trim()
.replace(/成$/, '')
.replace(/群$/, '')
.trim()
return group ? { group, range } : null
}
private authorized(header: string | undefined): boolean {
if (!header?.startsWith('Bearer ')) return false
const expected = Buffer.from(this.inboundToken)
const provided = Buffer.from(header.slice(7))
return expected.length === provided.length && timingSafeEqual(expected, provided)
}
private readHubBody(request: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
let size = 0
request.on('data', (chunk: Buffer) => {
size += chunk.length
if (size > 1024 * 1024) {
reject(new Error('request too large'))
request.destroy()
return
}
chunks.push(chunk)
})
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
request.on('error', reject)
})
}
private sendHubJson(response: ServerResponse, status: number, payload: unknown): void {
if (response.writableEnded) return
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
response.end(JSON.stringify(payload))
}
private cleanProcessedMessages(): void {
const cutoff = Date.now() - 10 * 60_000
for (const [id, timestamp] of this.processedMessages) {
if (timestamp < cutoff) this.processedMessages.delete(id)
}
}
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
private async initializeConnector(): Promise<void> {
this.patchStatus({ connector: 'checking' })
try {
const accounts = await this.loadAccounts()
if (accounts.length === 0) {
this.patchStatus({ connector: 'disconnected' })
return
}
this.startConnector(accounts.at(-1)!)
} catch (error) {
this.patchStatus({
connector: 'error',
error: error instanceof Error ? error.message : String(error)
})
}
}
private async loadAccounts(): Promise<{ accountId: string; wechatUserId: string }[]> {
const executable = resolveWechatConnectorBinaryPath()
if (!existsSync(executable)) throw new Error(`微信连接器不存在:${executable}`)
const { stdout } = await execFileAsync(executable, ['accounts', '--json'], {
windowsHide: true,
timeout: 10_000
})
const parsed = JSON.parse(stdout) as {
accounts?: { account_id: string; wechat_user_id: string }[]
}
return (parsed.accounts || []).map((account) => ({
accountId: account.account_id,
wechatUserId: account.wechat_user_id
}))
}
private startConnector(account: { accountId: string; wechatUserId: string }): void {
if (this.connectorChild && this.connectorChild.exitCode === null) return
const executable = resolveWechatConnectorBinaryPath()
this.patchStatus({
connector: 'starting',
accountId: account.accountId,
wechatUserId: account.wechatUserId,
qrCodeDataUrl: undefined,
error: undefined
})
const child = spawn(
executable,
['start', '--foreground', '--api-addr', CONNECTOR_ADDR, '--account-id', account.accountId],
{
env: {
...process.env,
WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL: `http://${HUB_ADDR}/v1/connectors/wechat/inbound`,
WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN: this.inboundToken,
WECHAT_CONNECTOR_INBOUND_WEBHOOK_ONLY: 'true'
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
)
this.connectorChild = child
this.addLog('system', 'info', `正在启动微信连接器(账号 ${account.accountId}`)
child.stdout?.on('data', (data: Buffer) => this.handleConnectorOutput('info', data.toString()))
child.stderr?.on('data', (data: Buffer) => this.handleConnectorOutput('warn', data.toString()))
child.once('spawn', () => {
this.addLog('system', 'info', `微信连接器已启动(PID ${child.pid}`)
this.patchStatus({ connector: 'online' })
})
child.once('error', (error) => {
this.addLog('wechat-connector', 'error', error.message)
this.patchStatus({ connector: 'error', error: error.message })
})
child.once('exit', (code) => {
if (this.connectorChild === child) this.connectorChild = null
this.addLog('system', code === 0 ? 'info' : 'error', `微信连接器已退出(code=${code}`)
if (!this.stopping && this.status.connector !== 'disconnected') {
this.patchStatus({ connector: 'error', error: `微信连接器退出:${code}` })
}
})
}
private stopConnector(): void {
const child = this.connectorChild
this.connectorChild = null
if (child && child.exitCode === null) child.kill()
}
private handleLoginEvent(line: string): void {
if (!line.trim()) return
try {
const event = JSON.parse(line) as {
status: string
qr_code_data_url?: string
account_id?: string
wechat_user_id?: string
}
switch (event.status) {
case 'qrcode':
case 'wait':
this.patchStatus({
connector: 'waiting_scan',
qrCodeDataUrl: event.qr_code_data_url || this.status.qrCodeDataUrl
})
break
case 'scaned':
this.patchStatus({ connector: 'scanned' })
break
case 'confirmed':
this.patchStatus({ connector: 'starting' })
break
case 'expired':
this.patchStatus({ connector: 'error', error: '二维码已过期,请重新获取' })
break
case 'active': {
const account = {
accountId: event.account_id || '',
wechatUserId: event.wechat_user_id || ''
}
this.patchStatus({ ...account, connector: 'starting', qrCodeDataUrl: undefined })
this.startConnector(account)
break
}
}
} catch (error) {
console.warn('[AgentHub] invalid login event:', line, error)
}
}
private patchStatus(patch: Partial<AgentHubStatus>): void {
this.status = { ...this.status, ...patch, updatedAt: Date.now() }
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('agent-hub:status', this.getStatus())
}
}
private addProcessOutput(
source: AgentHubLogSource,
level: AgentHubLogLevel,
output: string
): void {
for (const line of output.split(/\r?\n/)) {
if (line.trim()) this.addLog(source, level, line)
}
}
private handleConnectorOutput(level: AgentHubLogLevel, output: string): void {
this.addProcessOutput('wechat-connector', level, output)
if (/session expired/i.test(output)) {
this.addLog('system', 'error', '当前微信机器人登录已失效,需要重新扫码登录')
this.patchStatus({ connector: 'error', error: '当前登录已失效,请重新扫码登录' })
this.stopConnector()
}
}
private addLog(source: AgentHubLogSource, level: AgentHubLogLevel, rawMessage: string): void {
const message = this.redactLog(rawMessage).trim()
if (!message) return
const entry: AgentHubLogEntry = {
id: this.nextLogId++,
timestamp: Date.now(),
source,
level,
message
}
this.logs.push(entry)
if (this.logs.length > MAX_LOG_ENTRIES) this.logs.splice(0, this.logs.length - MAX_LOG_ENTRIES)
try {
const path = this.logFilePath()
mkdirSync(dirname(path), { recursive: true })
appendFileSync(
path,
`${new Date(entry.timestamp).toISOString()} [${source}] [${level}] ${message}\n`,
'utf8'
)
} catch {
// Do not interrupt message handling because log persistence failed.
}
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('agent-hub:log', entry)
}
}
private redactLog(message: string): string {
return message
.replace(/Bearer\s+[A-Za-z0-9._~-]+/gi, 'Bearer [已隐藏]')
.replace(/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/gi, 'data:image/[二维码已隐藏]')
.replace(/(token[=:\s]+)[^\s,}]+/gi, '$1[已隐藏]')
}
private logFilePath(): string {
return join(app.getPath('logs'), 'agent-hub.log')
}
private fail(error: string): AgentHubActionResult {
this.patchStatus({ connector: 'error', error })
return { success: false, status: this.getStatus(), error }
}
private scheduleHealthCheck(): void {
this.clearHealthCheck()
this.healthTimer = setInterval(() => this.checkDataApi(), HEALTH_INTERVAL_MS)
this.checkDataApi()
}
private checkDataApi(): void {
const ready = isReady()
this.patchStatus({ dataApi: 'online', databaseReady: ready })
}
private clearHealthCheck(): void {
if (this.healthTimer) clearInterval(this.healthTimer)
this.healthTimer = null
}
}
export const agentHubService = new AgentHubService()
+5 -2
View File
@@ -8,6 +8,7 @@ import {
} from '../../shared/local-api-test'
const REQUEST_TIMEOUT_MS = 10_000
const GROUP_REPORT_TIMEOUT_MS = 180_000
const MAX_BODY_SIZE = 512 * 1024
function isEndpointId(value: unknown): value is LocalApiEndpointId {
@@ -122,7 +123,9 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
})
}
)
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
const timeoutMs =
endpointId === 'agent-group-report' ? GROUP_REPORT_TIMEOUT_MS : REQUEST_TIMEOUT_MS
request.setTimeout(timeoutMs, () => {
request.destroy(new Error('请求超时'))
finish({
ok: false,
@@ -132,7 +135,7 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
durationMs: Date.now() - startedAt,
responseSize: 0,
errorCode: 'TIMEOUT',
error: '请求超时(10 秒)'
error: `请求超时(${Math.round(timeoutMs / 1000)} 秒)`
})
})
request.on('error', (error: NodeJS.ErrnoException) => {
+15 -1
View File
@@ -12,6 +12,8 @@ export interface AppSettings {
imageXorKey: string
imageAesKey: string
imageKeyFallbackDisabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
}
function getDefaultDbRoot(): string {
@@ -118,7 +120,13 @@ const DEFAULT_SETTINGS: AppSettings = {
imageKeyRoot: defaultDbRoot,
imageXorKey: '',
imageAesKey: '',
imageKeyFallbackDisabled: false
imageKeyFallbackDisabled: false,
autoLogin: ['1', 'true', 'yes', 'on'].includes(
String(import.meta.env.VITE_AUTO_LOGIN || '')
.trim()
.toLowerCase()
),
autoLoginPreferenceSet: false
}
const SETTINGS_FILE = path.join(
@@ -138,6 +146,12 @@ export function loadSettings(): AppSettings {
if (fs.existsSync(SETTINGS_FILE)) {
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
cache = { ...DEFAULT_SETTINGS, ...raw }
if (raw.autoLogin === undefined) {
const hasSavedDatabaseKey = fs.existsSync(
path.join(app.getPath('userData'), 'wechat-db-key.bin')
)
if (hasSavedDatabaseKey) cache.autoLogin = true
}
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
cache.dbRoot = getDefaultDbRoot()
}
+19
View File
@@ -37,6 +37,7 @@ import type {
ImageCandidateQuery,
ImageInsight
} from '../shared/image-insight'
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
export type ParsedContent =
| { type: 'text'; content: string }
@@ -186,6 +187,8 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
imageAesKey: string
}
@@ -210,6 +213,8 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
imageAesKey: string
}
@@ -222,6 +227,8 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
imageAesKey: string
}>
@@ -232,6 +239,8 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
imageAesKey: string
}
@@ -298,6 +307,16 @@ declare global {
sessionId: string,
limit?: number
) => Promise<{ success: boolean; insights: ImageInsight[] }>
getAgentHubStatus: () => Promise<AgentHubStatus>
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
clearAgentHubLogs: () => Promise<void>
startAgentHubLogin: () => Promise<AgentHubActionResult>
cancelAgentHubLogin: () => Promise<AgentHubActionResult>
reconnectAgentHub: () => Promise<AgentHubActionResult>
disconnectAgentHub: () => Promise<AgentHubActionResult>
selectAgentHubTestImage: () => Promise<{ canceled: boolean; path?: string }>
onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => () => void
onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => () => void
}
}
}
+22 -1
View File
@@ -15,6 +15,7 @@ import type {
ImageCandidateQuery,
ImageInsight
} from '../shared/image-insight'
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
// 渲染器的自定义 API
const api = {
@@ -132,7 +133,27 @@ const api = {
sessionId: string,
limit?: number
): Promise<{ success: boolean; insights: ImageInsight[] }> =>
ipcRenderer.invoke('image:listInsights', sessionId, limit)
ipcRenderer.invoke('image:listInsights', sessionId, limit),
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
getAgentHubLogs: () => ipcRenderer.invoke('agent-hub:getLogs'),
clearAgentHubLogs: () => ipcRenderer.invoke('agent-hub:clearLogs'),
startAgentHubLogin: () => ipcRenderer.invoke('agent-hub:startLogin'),
cancelAgentHubLogin: () => ipcRenderer.invoke('agent-hub:cancelLogin'),
reconnectAgentHub: () => ipcRenderer.invoke('agent-hub:reconnect'),
disconnectAgentHub: () => ipcRenderer.invoke('agent-hub:disconnect'),
selectAgentHubTestImage: () => ipcRenderer.invoke('agent-hub:selectTestImage'),
onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => {
const listener = (_event: Electron.IpcRendererEvent, status: AgentHubStatus): void =>
callback(status)
ipcRenderer.on('agent-hub:status', listener)
return () => ipcRenderer.removeListener('agent-hub:status', listener)
},
onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => {
const listener = (_event: Electron.IpcRendererEvent, entry: AgentHubLogEntry): void =>
callback(entry)
ipcRenderer.on('agent-hub:log', listener)
return () => ipcRenderer.removeListener('agent-hub:log', listener)
}
}
if (process.contextIsolated) {
+29 -20
View File
@@ -4,6 +4,7 @@ import ChatWindow from './components/ChatWindow'
import { AppShell } from './components/layout/AppShell'
import { ApiWorkspace } from './features/api-center/ApiWorkspace'
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
import { AgentHubWorkspace } from './features/agent-hub/AgentHubWorkspace'
import type { SettingsCategoryId } from './features/settings/model/types'
import type { AIRuntimeModelConfig } from '../../shared/ai-provider'
import { AppPage } from './components/layout/navigation'
@@ -22,6 +23,11 @@ import { Contact, Message } from '../../shared/types'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
function getDevelopmentDatabaseKey(): string {
if (!import.meta.env.DEV) return ''
return String(import.meta.env.VITE_DB_KEY || '').trim()
}
function EyeIcon({ hidden }: { hidden: boolean }): React.ReactElement {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -57,12 +63,6 @@ interface SelfInfo {
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
const VIEW_MESSAGE_LIMIT = 600
const AUTO_LOGIN_ENABLED = ['1', 'true', 'yes', 'on'].includes(
String(import.meta.env.VITE_AUTO_LOGIN || '')
.trim()
.toLowerCase()
)
const getMessageIdentity = (message: Message): string => {
if (message.localId) return `local:${message.localId}`
if (message.id) return `id:${message.id}`
@@ -159,7 +159,7 @@ const sortMessagesChronologically = (items: Message[]): Message[] =>
function App(): React.ReactElement {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
const [dbKey, setDbKey] = useState(import.meta.env.VITE_DB_KEY || '')
const [dbKey, setDbKey] = useState(getDevelopmentDatabaseKey)
const [contacts, setContacts] = useState<Contact[]>([])
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
const [messages, setMessages] = useState<Message[]>([])
@@ -369,16 +369,15 @@ function App(): React.ReactElement {
React.useEffect(() => {
let active = true
const attemptAutoConnect = async (): Promise<void> => {
const settingsResult = await window.api.getSettings()
// 预填已保存的微信聊天文件路径
try {
const settings = await window.api.getSettings()
if (active && settings?.settings?.dbRoot) setDbRootInput(settings.settings.dbRoot)
} catch {
// 忽略读取设置失败,继续走密钥流程
if (active && settingsResult.settings.dbRoot) {
setDbRootInput(settingsResult.settings.dbRoot)
}
// 优先级 1: 构建期环境变量 VITE_DB_KEY(本地开发用)
const envKey = String(import.meta.env.VITE_DB_KEY || '').trim()
// 优先级 2: 上一次保存到 safeStorage 的密钥
const autoLoginEnabled = settingsResult.settings.autoLogin
// 开发环境允许使用 VITE_DB_KEY;生产安装包只能读取目标电脑自己的 safeStorage。
const envKey = getDevelopmentDatabaseKey()
// 生产环境以及未配置开发密钥时,读取上一次保存到 safeStorage 的密钥。
let savedKey = ''
if (!envKey) {
const result = await window.api.getSavedDbKey()
@@ -393,7 +392,7 @@ function App(): React.ReactElement {
setDbKey(key)
setAutoConnectSource(envKey ? 'env' : 'saved')
setDbKeyStatus(
AUTO_LOGIN_ENABLED
autoLoginEnabled
? envKey
? '检测到环境变量中的密钥,正在自动连接...'
: '已加载安全保存的密钥,正在自动连接...'
@@ -402,14 +401,17 @@ function App(): React.ReactElement {
: '已加载安全保存的密钥,请手动点击 Connect'
)
setDbKeyStatusKind('normal')
setBootState(AUTO_LOGIN_ENABLED ? 'connecting' : 'login')
setBootState(autoLoginEnabled ? 'connecting' : 'login')
}
if (!AUTO_LOGIN_ENABLED) return
if (!autoLoginEnabled) return
try {
const result = await window.api.initDb(key)
if (!active) return
const success = typeof result === 'boolean' ? result : result.success
if (success) {
if (!settingsResult.settings.autoLoginPreferenceSet) {
void window.api.setSettings({ autoLogin: true })
}
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsAuthenticated(true)
setIsDatabaseConnected(true)
@@ -489,6 +491,11 @@ function App(): React.ReactElement {
const hasBootstrapCache = await loadBootstrapCache()
// 持久化手动输入的密钥,供下次启动继续使用
void window.api.saveDbKey(keyToUse).catch(() => undefined)
void window.api.getSettings().then((current) => {
if (!current.settings.autoLoginPreferenceSet) {
void window.api.setSettings({ autoLogin: true })
}
})
setStartupProgress({
title: '正在加载账号信息...',
subtitle: '即将进入 WechatExplorer',
@@ -1036,9 +1043,9 @@ function App(): React.ReactElement {
}
const renderPlaceholderPage = (
page: Exclude<AppPage, 'archive' | 'report'>
page: Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>
): React.ReactElement => {
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
const labels: Record<Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>, string> = {
search: '检索',
export: '导出',
api: 'API',
@@ -1168,6 +1175,8 @@ function App(): React.ReactElement {
return renderArchiveWorkspace()
case 'report':
return renderReportWorkspace()
case 'agent-hub':
return <AgentHubWorkspace />
case 'api':
return (
<ApiWorkspace
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,16 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
<path d="M5.5 15.5v3h13v-3" />
</svg>
)
case 'agent-hub':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="5" y="7" width="14" height="11" rx="3" />
<path d="M12 4.5V7" />
<circle cx="9.5" cy="12" r="1" />
<circle cx="14.5" cy="12" r="1" />
<path d="M9.5 15h5" />
</svg>
)
case 'api':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -1,4 +1,4 @@
export type AppPage = 'archive' | 'search' | 'report' | 'export' | 'api' | 'settings'
export type AppPage = 'archive' | 'search' | 'report' | 'agent-hub' | 'export' | 'api' | 'settings'
export interface NavigationItem {
id: AppPage
@@ -9,6 +9,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
{ id: 'archive', label: '档案' },
{ id: 'search', label: '检索' },
{ id: 'report', label: '日报' },
{ id: 'agent-hub', label: 'Agent' },
{ id: 'export', label: '导出' },
{ id: 'api', label: 'API' },
{ id: 'settings', label: '设置' }
@@ -0,0 +1,275 @@
import React from 'react'
import type {
AgentHubLogEntry,
AgentHubLogSource,
AgentHubStatus,
WechatConnectorStatus
} from '../../../../shared/agent-hub'
const STATUS_LABELS: Record<WechatConnectorStatus, string> = {
checking: '正在检查',
disconnected: '未连接',
starting: '正在连接',
waiting_scan: '等待扫码',
scanned: '已扫码,等待手机确认',
online: '在线',
error: '连接异常'
}
const LOG_SOURCE_LABELS: Record<AgentHubLogSource, string> = {
system: '系统',
'agent-hub': 'Agent Hub',
'wechat-connector': '微信连接器'
}
export function AgentHubWorkspace(): React.ReactElement {
const [status, setStatus] = React.useState<AgentHubStatus>({
hub: 'offline',
connector: 'checking',
updatedAt: Date.now()
})
const [busy, setBusy] = React.useState(false)
const [logs, setLogs] = React.useState<AgentHubLogEntry[]>([])
const [logSource, setLogSource] = React.useState<'all' | AgentHubLogSource>('all')
const logBodyRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
let mounted = true
void window.api.getAgentHubStatus().then((next) => {
if (mounted) setStatus(next)
})
void window.api.getAgentHubLogs().then((entries) => {
if (mounted) setLogs(entries)
})
const unsubscribe = window.api.onAgentHubStatus((next) => {
if (mounted) setStatus(next)
})
const unsubscribeLog = window.api.onAgentHubLog((entry) => {
if (mounted) setLogs((current) => [...current.slice(-799), entry])
})
return () => {
mounted = false
unsubscribe()
unsubscribeLog()
}
}, [])
const visibleLogs = logs.filter((entry) => logSource === 'all' || entry.source === logSource)
React.useEffect(() => {
const body = logBodyRef.current
if (body) body.scrollTop = body.scrollHeight
}, [visibleLogs.length])
const copyLogs = async (): Promise<void> => {
const text = visibleLogs
.map(
(entry) =>
`${new Date(entry.timestamp).toLocaleTimeString()} [${LOG_SOURCE_LABELS[entry.source]}] [${entry.level}] ${entry.message}`
)
.join('\n')
await window.api.copyText(text)
}
const clearLogs = async (): Promise<void> => {
await window.api.clearAgentHubLogs()
setLogs([])
}
const runAction = async (
action: () => Promise<{ status: AgentHubStatus; error?: string }>
): Promise<void> => {
setBusy(true)
try {
const result = await action()
setStatus(result.status)
} finally {
setBusy(false)
}
}
const isLoginFlow = ['starting', 'waiting_scan', 'scanned'].includes(status.connector)
const showQRCode = Boolean(status.qrCodeDataUrl) && status.connector !== 'online'
return (
<div className="agent-hub-workspace">
<header className="agent-hub-header">
<div>
<div className="agent-hub-eyebrow">WechatExplorer</div>
<h1>Agent Hub</h1>
<p> AI </p>
</div>
<span className={`agent-hub-runtime ${status.hub}`}>
Agent Hub {status.hub === 'online' ? '运行中' : '未运行'}
</span>
</header>
<div className="agent-hub-grid">
<section className="agent-hub-card agent-hub-login-card">
<div className="agent-hub-card-heading">
<div>
<span className="agent-hub-card-kicker"></span>
<h2></h2>
</div>
<span className={`agent-hub-status ${status.connector}`}>
<i aria-hidden />
{STATUS_LABELS[status.connector]}
</span>
</div>
{showQRCode ? (
<div className="agent-hub-qr-panel">
<div className="agent-hub-qr-frame">
<img src={status.qrCodeDataUrl} alt="微信机器人登录二维码" />
</div>
<div className="agent-hub-qr-copy">
<h3>
{status.connector === 'scanned' ? '请在手机上确认登录' : '使用微信扫描二维码'}
</h3>
<p></p>
<button
type="button"
className="agent-hub-button secondary"
disabled={busy}
onClick={() => void runAction(() => window.api.cancelAgentHubLogin())}
>
</button>
</div>
</div>
) : status.connector === 'online' ? (
<div className="agent-hub-connected">
<div className="agent-hub-connected-icon" aria-hidden>
</div>
<div>
<h3></h3>
<p>{status.accountId || status.wechatUserId || '登录凭据已就绪'}</p>
</div>
</div>
) : (
<div className="agent-hub-empty-login">
<div className="agent-hub-phone" aria-hidden>
<span />
</div>
<h3>{status.connector === 'error' ? '连接遇到问题' : '尚未连接微信机器人'}</h3>
<p>{status.error || '扫码登录后,即可从微信向 Agent Hub 提问。'}</p>
</div>
)}
<div className="agent-hub-actions">
{status.connector === 'online' ? (
<>
<button
type="button"
className="agent-hub-button secondary"
disabled={busy}
onClick={() => void runAction(() => window.api.startAgentHubLogin())}
>
</button>
<button
type="button"
className="agent-hub-button danger"
disabled={busy}
onClick={() => void runAction(() => window.api.disconnectAgentHub())}
>
</button>
</>
) : !isLoginFlow ? (
<button
type="button"
className="agent-hub-button primary"
disabled={busy || status.hub !== 'online'}
onClick={() => void runAction(() => window.api.startAgentHubLogin())}
>
{busy ? '正在获取二维码…' : '扫码登录微信机器人'}
</button>
) : null}
</div>
</section>
<aside className="agent-hub-card agent-hub-capability-card">
<span className="agent-hub-card-kicker"></span>
<h2></h2>
<p> Agent Hub WechatExplorer</p>
<div className="agent-hub-example">
<span></span>
<strong> 5 </strong>
<strong></strong>
</div>
<ul>
<li>
<i />
HTTP
</li>
<li>
<i />
</li>
<li>
<i />
</li>
<li>
<i className={status.dataApi === 'online' ? '' : 'offline'} />
API{status.dataApi === 'online' ? '已连接' : '未连接'}
</li>
<li>
<i className={status.databaseReady ? '' : 'offline'} />
{status.databaseReady ? '可查询' : '未就绪'}
</li>
</ul>
</aside>
</div>
<section className="agent-hub-card agent-hub-log-card">
<div className="agent-hub-log-heading">
<div>
<span className="agent-hub-card-kicker"></span>
<h2></h2>
</div>
<div className="agent-hub-log-actions">
<select
aria-label="筛选日志来源"
value={logSource}
onChange={(event) => setLogSource(event.target.value as 'all' | AgentHubLogSource)}
>
<option value="all"></option>
<option value="system"></option>
<option value="agent-hub">Agent Hub</option>
<option value="wechat-connector"></option>
</select>
<button
type="button"
onClick={() => void copyLogs()}
disabled={visibleLogs.length === 0}
>
</button>
<button type="button" onClick={() => void clearLogs()}>
</button>
</div>
</div>
<div className="agent-hub-log-body" ref={logBodyRef}>
{visibleLogs.length === 0 ? (
<div className="agent-hub-log-empty">
</div>
) : (
visibleLogs.map((entry) => (
<div className={`agent-hub-log-line ${entry.level}`} key={entry.id}>
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
<span className={`source ${entry.source}`}>{LOG_SOURCE_LABELS[entry.source]}</span>
<code>{entry.message}</code>
</div>
))
)}
</div>
<p className="agent-hub-log-note"> Token </p>
</section>
</div>
)
}
@@ -40,6 +40,17 @@ export function ApiRequestTester({
void onCopyCurl(command)
}
const update = (key: string, value: string): void => onParams({ ...params, [key]: value })
const selectTestImage = async (): Promise<void> => {
const result = await window.api.selectAgentHubTestImage()
if (result.canceled || !result.path) return
let payload: Record<string, unknown> = {}
try {
payload = JSON.parse(body) as Record<string, unknown>
} catch {
// Replace an invalid draft with a valid send-test request.
}
onBody(JSON.stringify({ ...payload, media_url: result.path }, null, 2))
}
return (
<section className="api-request-tester" id="api-request-tester">
<div className="api-section-heading">
@@ -77,6 +88,14 @@ export function ApiRequestTester({
/>
</label>
)}
{endpoint.id === 'agent-send' && (
<div className="api-upload-test-row">
<button type="button" onClick={() => void selectTestImage()}>
</button>
<span></span>
</div>
)}
<div className="api-tester-actions">
<button type="button" onClick={onClear}>
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useReducer } from 'react'
import type { Contact } from '../../../../../shared/types'
import { findEndpoint } from '../model/apiEndpoints'
import { REPORT_REQUEST_PRESET } from '../model/requestPresets'
import {
AGENT_GROUP_REPORT_PRESET,
AGENT_SEND_PRESET,
REPORT_REQUEST_PRESET
} from '../model/requestPresets'
import { type AgentInstallTarget, type SkillInstallSource } from '../model/skillDistribution'
import type {
ApiResponse,
@@ -66,14 +70,23 @@ function reducer(state: State, action: Action): State {
switch (action.type) {
case 'loaded':
return { ...state, settings: action.settings, service: action.service, skill: action.skill }
case 'endpoint':
case 'endpoint': {
const preset =
action.endpointId === 'report'
? REPORT_REQUEST_PRESET
: action.endpointId === 'agent-group-report'
? AGENT_GROUP_REPORT_PRESET
: action.endpointId === 'agent-send'
? AGENT_SEND_PRESET
: state.body
return {
...state,
endpointId: action.endpointId,
params: action.talker && action.endpointId === 'chatlog' ? { talker: action.talker } : {},
body: action.endpointId === 'report' ? state.body || REPORT_REQUEST_PRESET : state.body,
body: preset,
error: ''
}
}
case 'params':
return { ...state, params: action.params }
case 'body':
@@ -62,6 +62,20 @@ export const API_ENDPOINTS: ApiEndpoint[] = [
name: '群聊日报导出',
description: '通过内置模板导出群聊日报 HTML 与 PNG。',
body: true
}),
endpoint('agent-status', {
name: 'Agent Hub 状态',
description: '检查 Agent Hub、微信连接器、本地数据 API 和数据库状态。'
}),
endpoint('agent-group-report', {
name: '生成群聊总结图片',
description: '读取指定群聊并生成今天、昨天或近 7 天的总结长图。',
body: true
}),
endpoint('agent-send', {
name: '微信发送测试',
description: '测试文字或本地图片发送,并区分凭证失效、连接器离线和发送成功。',
body: true
})
]
@@ -29,3 +29,15 @@ export const REPORT_REQUEST_PRESET = JSON.stringify(
null,
2
)
export const AGENT_GROUP_REPORT_PRESET = JSON.stringify(
{ group: '技术交流', range: 'today' },
null,
2
)
export const AGENT_SEND_PRESET = JSON.stringify(
{ to: '', text: 'WechatExplorer Agent Hub 发送测试' },
null,
2
)
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { AccountOverview } from '../account-database/AccountOverview'
import { ConnectionHealthSection } from '../account-database/ConnectionHealthSection'
import { LocalPrivacyNotice } from '../account-database/LocalPrivacyNotice'
@@ -25,6 +26,26 @@ export function AccountDatabasePage({
onNotice: (message: string) => void
}): React.ReactElement {
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice })
const [autoLogin, setAutoLogin] = useState(false)
useEffect(() => {
let active = true
void window.api.getSettings().then((result) => {
if (active) setAutoLogin(result.settings.autoLogin)
})
return () => {
active = false
}
}, [])
const changeAutoLogin = async (checked: boolean): Promise<void> => {
const result = await window.api.setSettings({
autoLogin: checked,
autoLoginPreferenceSet: true
})
setAutoLogin(result.settings.autoLogin)
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
}
return (
<div className="settings-page">
<header className="settings-page-header">
@@ -58,6 +79,20 @@ export function AccountDatabasePage({
: undefined
}
/>
<h2 className="settings-section-heading"></h2>
<section className="settings-card settings-auto-login-card">
<label>
<span>
<b></b>
<small>使</small>
</span>
<input
type="checkbox"
checked={autoLogin}
onChange={(event) => void changeAutoLogin(event.target.checked)}
/>
</label>
</section>
</div>
</div>
</div>
+53 -21
View File
@@ -10,6 +10,34 @@ import {
ReportVoiceHighlight,
ReportVoiceLeaderboardItem
} from '../../../shared/group-report'
import type {
ImageAnalysisRequest,
ImageAnalysisResponse,
ImageCandidate,
ImageCandidateQuery
} from '../../../shared/image-insight'
interface ReportImageReadResult {
success: boolean
data?: string
error?: string
}
declare const window: {
api: {
imageListCandidates: (query: ImageCandidateQuery) => Promise<{
success: boolean
candidates: ImageCandidate[]
error?: string
}>
imageAnalyze: (request: ImageAnalysisRequest) => Promise<ImageAnalysisResponse>
getImage: (
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string
) => Promise<ReportImageReadResult>
}
}
export interface GroupReportTranscriptRow {
id: string
@@ -184,6 +212,7 @@ const buildMediaSection = async (
warnings: string[]
}> => {
const warnings: string[] = []
const rendererApi = typeof window === 'undefined' ? null : window.api
const rawImageCandidates = messages
.map((message, index) => {
if (message.contentData?.type !== 'image') return null
@@ -214,6 +243,7 @@ const buildMediaSection = async (
// ============================================================
let visionGallery: ReportVisionGalleryItem[] = []
try {
if (!rendererApi) throw new Error('后台模式不读取 Renderer 图片')
const sessionId = messages.find((m) => m.sessionId)?.sessionId || (contact?.md5 ?? '')
const startTime = messages.length ? parseTimestamp(messages[0]) : 0
const endTime = messages.length ? parseTimestamp(messages[messages.length - 1]) : 0
@@ -233,7 +263,7 @@ const buildMediaSection = async (
}
})
const candidatesResp = await window.api.imageListCandidates({
const candidatesResp = await rendererApi.imageListCandidates({
sessionId,
startTime,
endTime,
@@ -249,7 +279,7 @@ const buildMediaSection = async (
if (candidate.insight) return candidate.insight
// 未命中:解密图片拿 base64 → 调 AI
try {
const img = await window.api.getImage(
const img = await rendererApi.getImage(
candidate.md5,
candidate.datName,
candidate.sessionId
@@ -260,7 +290,7 @@ const buildMediaSection = async (
)
return null
}
const analyzeResp = await window.api.imageAnalyze({
const analyzeResp = await rendererApi.imageAnalyze({
imageHash: candidate.imageHash,
imageDataUrl: img.data,
messageId: candidate.messageId,
@@ -306,7 +336,7 @@ const buildMediaSection = async (
const orig = rawImageCandidates.find((c) => c.sourceMessageIds[0] === item.messageId)
if (!orig) return item
try {
const img = await window.api.getImage(orig.md5, orig.datName, orig.sessionId)
const img = await rendererApi.getImage(orig.md5, orig.datName, orig.sessionId)
if (img.success && img.data?.startsWith('data:image/')) {
return { ...item, imageUrl: img.data }
}
@@ -323,23 +353,25 @@ const buildMediaSection = async (
visionGallery = []
}
const imageCandidates = await Promise.all(
rawImageCandidates.map(async (item) => {
const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
if (!result.success || !result.data?.startsWith('data:image/')) return null
return {
sender: item.sender,
time: item.time,
imageUrl: result.data,
note: item.note,
stats: item.stats,
inferenceLabel: '基于图片后的聊天上下文推断',
sourceMessageIds: item.sourceMessageIds,
replyCount: item.replyCount,
score: item.score
}
})
)
const imageCandidates = rendererApi
? await Promise.all(
rawImageCandidates.map(async (item) => {
const result = await rendererApi.getImage(item.md5, item.datName, item.sessionId)
if (!result.success || !result.data?.startsWith('data:image/')) return null
return {
sender: item.sender,
time: item.time,
imageUrl: result.data,
note: item.note,
stats: item.stats,
inferenceLabel: '基于图片后的聊天上下文推断',
sourceMessageIds: item.sourceMessageIds,
replyCount: item.replyCount,
score: item.score
}
})
)
: []
const gallery: ReportMediaGalleryItem[] = imageCandidates
.filter((item): item is NonNullable<typeof item> => Boolean(item))
+38
View File
@@ -0,0 +1,38 @@
export type AgentHubRuntimeStatus = 'starting' | 'online' | 'offline' | 'error'
export type WechatConnectorStatus =
| 'checking'
| 'disconnected'
| 'starting'
| 'waiting_scan'
| 'scanned'
| 'online'
| 'error'
export interface AgentHubStatus {
hub: AgentHubRuntimeStatus
connector: WechatConnectorStatus
qrCodeDataUrl?: string
accountId?: string
wechatUserId?: string
error?: string
updatedAt: number
dataApi?: 'checking' | 'online' | 'offline'
databaseReady?: boolean
}
export interface AgentHubActionResult {
success: boolean
status: AgentHubStatus
error?: string
}
export type AgentHubLogSource = 'agent-hub' | 'wechat-connector' | 'system'
export type AgentHubLogLevel = 'info' | 'warn' | 'error'
export interface AgentHubLogEntry {
id: number
timestamp: number
source: AgentHubLogSource
level: AgentHubLogLevel
message: string
}
+4 -1
View File
@@ -11,7 +11,10 @@ export const LOCAL_API_ENDPOINTS = {
},
'group-snapshot': { method: 'GET', path: '/api/v1/group_snapshot', queryKeys: ['md5'] },
resolve: { method: 'GET', path: '/api/v1/resolve', queryKeys: ['q'] },
report: { method: 'POST', path: '/api/v1/report', queryKeys: [] }
report: { method: 'POST', path: '/api/v1/report', queryKeys: [] },
'agent-status': { method: 'GET', path: '/api/v1/agent/status', queryKeys: [] },
'agent-group-report': { method: 'POST', path: '/api/v1/agent/group-report', queryKeys: [] },
'agent-send': { method: 'POST', path: '/api/v1/agent/send', queryKeys: [] }
} as const
export type LocalApiEndpointId = keyof typeof LOCAL_API_ENDPOINTS