diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef8d97a..7dc4643 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,11 +25,6 @@ jobs: node-version: 22 cache: pnpm - - uses: actions/setup-go@v5 - with: - go-version-file: services/wechat-connector/go.mod - cache-dependency-path: services/wechat-connector/go.sum - - name: Install dependencies run: pnpm install @@ -51,9 +46,6 @@ jobs: - name: Skill installation instruction tests run: pnpm test:skill-install - - name: WeChat connector tests - run: pnpm test:wechat-connector - - name: Build Electron test application run: pnpm test:e2e:build diff --git a/.gitignore b/.gitignore index bbe56bd..868a818 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ out coverage/ playwright-report/ test-results/ -resources/connectors/wechat/ resources/connectors/wechat-personal/ .omc .codex/ diff --git a/docs/development/local-startup-troubleshooting.md b/docs/development/local-startup-troubleshooting.md index 372a976..8a25807 100644 --- a/docs/development/local-startup-troubleshooting.md +++ b/docs/development/local-startup-troubleshooting.md @@ -6,7 +6,6 @@ 执行 `pnpm dev` 后,以下状态同时满足,说明本地开发环境已经可用: -- 控制台显示连接器已生成,例如 `resources/connectors/wechat/win32-x64/wechat-connector.exe`; - Electron 窗口已打开,或 `http://localhost:5173/` 返回 HTTP `200`; - 控制台显示 Local HTTP API 正在监听 `http://127.0.0.1:6131`。 @@ -22,18 +21,6 @@ WCDB_DEBUG_LOGS=1 pnpm dev 开启后会输出 `GETMSG-xxx` 请求耗时和 native `WCDB-EXPLAIN` 执行计划,不记录聊天正文。取消该环境变量或设为 `0` 即可关闭。 -## Go 命令找不到 - -如果 `pnpm dev` 在构建微信连接器时出现 `spawnSync go ENOENT`,先执行: - -```bash -go version -``` - -命令不可用表示当前终端的 `PATH` 没有找到 Go。Windows 默认安装位置是 `C:\Program Files\Go\bin`。确认 Go 已安装并把该目录加入系统 `PATH` 后,关闭并重新打开终端或 IDE,再重新执行 `go version` 和 `pnpm dev`。 - -如果 Go 刚完成安装,已经打开的终端不会自动继承新的环境变量;重开终端是必要步骤。不要绕过连接器构建直接启动 `electron-vite dev`,否则 Agent Hub 的微信连接器不会生成。 - ## Electron 二进制缺失或下载失败 `electron-vite dev` 报 `Electron uninstall`,或 Electron 安装器报 `fetch failed`,通常表示 `node_modules/electron/dist` 中的 Electron 二进制缺失或下载未完成。这不是应用业务代码的启动错误。 @@ -68,4 +55,4 @@ Vite 在某些 Windows 环境中只监听 IPv6 本机回环地址 `::1`。这时 ## 仍无法启动时 -保留首次错误的完整输出,并同时记录操作系统、Node.js、pnpm 和 Go 版本,以及 `pnpm install --frozen-lockfile` 与 `pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。 +保留首次错误的完整输出,并同时记录操作系统、Node.js 与 pnpm 版本,以及 `pnpm install --frozen-lockfile` 与 `pnpm dev` 的执行结果。不要提交数据库密钥、AI API Key、微信数据路径或聊天内容。 diff --git a/docs/development/overview.md b/docs/development/overview.md index e98ffc0..ef55df9 100644 --- a/docs/development/overview.md +++ b/docs/development/overview.md @@ -6,7 +6,6 @@ - Electron + React + TypeScript; - pnpm 7+; -- Go(构建微信连接器); - 平台对应的 Electron/native 构建环境。 产品文档的事实来源优先级是:当前源码 → 当前 UI/Renderer → 测试 → package/config → README/docs → 历史资料。功能、API、版本、隐私和兼容性变更时,不要只改 README。 @@ -18,7 +17,7 @@ pnpm install pnpm dev ``` -本地依赖安装、Go 环境和 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。 +本地依赖安装与 Electron 二进制下载异常,请查看[本地启动排障](./local-startup-troubleshooting.md)。 常用检查: @@ -30,7 +29,7 @@ pnpm test:integration pnpm test:e2e:build ``` -完整测试入口 `pnpm test` 还会运行 Skill 安装指令、微信连接器、构建和 Playwright 测试;需要对应平台环境。 +完整测试入口 `pnpm test` 还会运行 Skill 安装指令、构建和 Playwright 测试;需要对应平台环境。 ## 代码变更对应文档 diff --git a/electron-builder.win.yml b/electron-builder.win.yml index 9db6e81..b08db0b 100644 --- a/electron-builder.win.yml +++ b/electron-builder.win.yml @@ -35,7 +35,6 @@ extraResources: - mobile_daily_report.html - mobile_daily_report_v1.html - mobile_daily_report_v2.html - - connectors/wechat/win32-x64/** - key/win32/x64/** - runtime/win32/** - wcdb/win32/x64/** diff --git a/package.json b/package.json index 243f371..5a50389 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,13 @@ }, "main": "./out/main/index.js", "scripts": { - "test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:wechat-connector && pnpm test:e2e:build && playwright test", + "test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:e2e:build && playwright test", "format": "prettier --write .", "lint": "eslint --cache .", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", - "typecheck": "npm run typecheck:node && npm run typecheck:web", + "typecheck": "npm run typecheck:node && npm run typecheck:web && npm run typecheck:test", + "typecheck:test": "node scripts/typecheck-tests.cjs", "test:skill-install": "node scripts/test-skill-install-instruction.cjs", "cp:env": "node scripts/ensure-env.cjs", "prepare:env": "node scripts/ensure-env.cjs", @@ -41,11 +42,10 @@ "prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs", "start": "electron-vite preview", "predev": "node scripts/ensure-electron-binary.cjs", - "dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev", + "dev": "node scripts/ensure-env.cjs && electron-vite dev", "dev:update": "cross-env TRACEMEMO_UPDATE_SIMULATION=true pnpm dev", "poc:query-agent": "electron-vite build && node scripts/run-query-agent-poc.cjs", "poc:query-agent:run": "node scripts/run-query-agent-poc.cjs", - "test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...", "test:unit": "vitest run --config vitest.unit.config.ts", "test:component": "vitest run --config vitest.component.config.ts", "test:integration": "vitest run --config vitest.integration.config.ts", @@ -56,21 +56,17 @@ "test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual", "test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts", "test:smoke": "node --test tests/smoke/native-environment.test.mjs", - "build:wechat-connector": "node scripts/build-wechat-connector.cjs", - "build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64", - "build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch arm64", - "build:native-services": "npm run build:wechat-connector", - "build": "npm run typecheck && npm run build:native-services && electron-vite build", + "build": "npm run typecheck && electron-vite build", "postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs && node scripts/ensure-electron-binary.cjs", "build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir", - "build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64", - "build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64", - "build:mac:x64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch x64 && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64", + "build:win": "npm run typecheck && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64", + "build:mac:arm64": "npm run typecheck && npm run prepare:ffmpeg:mac:arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64", + "build:mac:x64": "npm run typecheck && npm run prepare:ffmpeg:mac:x64 && electron-vite build && electron-builder --config electron-builder.yml --mac --x64", "release": "npm run release:mac && npm run release:win", - "release:mac": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64,x64 && electron-vite build && npm run release:mac:arm64 && npm run release:mac:x64", + "release:mac": "npm run typecheck && electron-vite build && npm run release:mac:arm64 && npm run release:mac:x64", "release:mac:arm64": "npm run prepare:ffmpeg:mac:arm64 && electron-builder --config electron-builder.yml --mac --arm64 --publish always", "release:mac:x64": "npm run prepare:ffmpeg:mac:x64 && electron-builder --config electron-builder.yml --mac --x64 --publish always", - "release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always", + "release:win": "npm run typecheck && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always", "release:beta": "cross-env EP_PRE_RELEASE=true npm run release", "release:stable": "npm run release", "build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux" diff --git a/scripts/build-wechat-connector.cjs b/scripts/build-wechat-connector.cjs deleted file mode 100644 index 7dce35b..0000000 --- a/scripts/build-wechat-connector.cjs +++ /dev/null @@ -1,65 +0,0 @@ -/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */ -const { execFileSync } = require('node:child_process') -const fs = require('node:fs') -const path = require('node:path') - -const projectRoot = path.resolve(__dirname, '..') -const sourceDir = path.join(projectRoot, 'services', 'wechat-connector') -const outputRoot = path.join(projectRoot, 'resources', 'connectors', 'wechat') - -function normalizePlatform(value) { - if (value === 'win32' || value === 'windows') return 'windows' - if (value === 'darwin' || value === 'macos') return 'darwin' - if (value === 'linux') return 'linux' - throw new Error(`Unsupported connector platform: ${value}`) -} - -function normalizeArch(value) { - if (value === 'x64' || value === 'amd64') return 'amd64' - if (value === 'arm64') return 'arm64' - throw new Error(`Unsupported connector architecture: ${value}`) -} - -function detectHostArch() { - if (process.platform !== 'darwin') return process.arch - try { - const arm64Supported = execFileSync('sysctl', ['-n', 'hw.optional.arm64'], { - encoding: 'utf8' - }).trim() - return arm64Supported === '1' ? 'arm64' : process.arch - } catch { - return process.arch - } -} - -function parseTargets() { - const platformArg = process.argv.indexOf('--platform') - const archArg = process.argv.indexOf('--arch') - const platforms = platformArg >= 0 ? process.argv[platformArg + 1].split(',') : [process.platform] - const arches = archArg >= 0 ? process.argv[archArg + 1].split(',') : [detectHostArch()] - return platforms.flatMap((platform) => - arches.map((arch) => ({ goos: normalizePlatform(platform), goarch: normalizeArch(arch) })) - ) -} - -if (!fs.existsSync(path.join(sourceDir, 'go.mod'))) { - throw new Error(`Repository-local WeChat connector source is missing: ${sourceDir}`) -} - -for (const target of parseTargets()) { - const directoryName = `${target.goos === 'windows' ? 'win32' : target.goos}-${target.goarch === 'amd64' ? 'x64' : target.goarch}` - const outputDir = path.join(outputRoot, directoryName) - const outputPath = path.join( - outputDir, - target.goos === 'windows' ? 'wechat-connector.exe' : 'wechat-connector' - ) - fs.rmSync(outputDir, { recursive: true, force: true }) - fs.mkdirSync(outputDir, { recursive: true }) - execFileSync('go', ['build', '-trimpath', '-o', outputPath, '.'], { - cwd: sourceDir, - env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' }, - stdio: 'inherit' - }) - if (target.goos !== 'windows') fs.chmodSync(outputPath, 0o755) - console.log(`[build-wechat-connector] built ${directoryName}: ${outputPath}`) -} diff --git a/scripts/typecheck-tests.cjs b/scripts/typecheck-tests.cjs new file mode 100644 index 0000000..07babbe --- /dev/null +++ b/scripts/typecheck-tests.cjs @@ -0,0 +1,140 @@ +/* + * 测试文件的类型检查棘轮(ratchet)。 + * + * 背景:`tsconfig.node.json` / `tsconfig.web.json` 的 include 都不含 `tests/`, + * 所以测试里的类型错误对 `pnpm typecheck` 与 CI 完全不可见——已经积累了一批历史债。 + * + * 策略:**不阻塞既有债,但禁止新增**。 + * - 基线按「文件 → 错误数」记录,而不是只记总数: + * 否则在 A 文件修掉 1 条、同时在 B 文件新增 1 条会互相抵消,棘轮形同虚设。 + * - 某个文件的错误数超过基线即失败;新增了带类型错误的文件同样失败。 + * - 需要主动下调基线时用 `--update`(只在确实修好了错误之后)。 + * + * 用法: + * node scripts/typecheck-tests.cjs # 校验 + * node scripts/typecheck-tests.cjs --update # 用当前结果重写基线 + */ +/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */ +const { spawnSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') + +const projectRoot = path.resolve(__dirname, '..') +const configPath = path.join(projectRoot, 'tsconfig.test.json') +const baselinePath = path.join(projectRoot, 'tests', 'typecheck-baseline.json') +const ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/ +const MAX_REPORTED = 20 + +function runTypeScript() { + // 直接用本地 typescript 包,避免依赖 node_modules/.bin 在各平台的差异。 + const tscPath = require.resolve('typescript/bin/tsc') + const result = spawnSync( + process.execPath, + [tscPath, '--noEmit', '--pretty', 'false', '-p', configPath], + { cwd: projectRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 } + ) + if (result.error) throw result.error + return `${result.stdout || ''}${result.stderr || ''}` +} + +function relative(file) { + const rel = path.relative(projectRoot, path.resolve(projectRoot, file)) + return rel.split(path.sep).join('/') +} + +/** 解析出「文件 → 错误数」与「文件 → 错误信息列表」。 */ +function collectErrors(output) { + const counts = new Map() + const details = new Map() + for (const line of output.split('\n')) { + const match = ERROR_LINE.exec(line.trim()) + if (!match) continue + const file = relative(match[1]) + counts.set(file, (counts.get(file) ?? 0) + 1) + if (!details.has(file)) details.set(file, []) + if (details.get(file).length < 3) details.get(file).push(`${match[2]}:${match[3]} ${match[4]}`) + } + return { counts, details } +} + +function readBaseline() { + try { + const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf8')) + if (parsed && typeof parsed.files === 'object' && parsed.files !== null) return parsed + } catch { + // 基线缺失或损坏时按「空基线」处理,会在下面明确报错提示。 + } + return null +} + +function total(counts) { + let sum = 0 + for (const value of counts.values()) sum += value + return sum +} + +function main() { + if (!fs.existsSync(configPath)) { + console.error(`[typecheck:test] 缺少 ${path.relative(projectRoot, configPath)}`) + process.exit(1) + } + const { counts, details } = collectErrors(runTypeScript()) + + if (process.argv.includes('--update')) { + const files = Object.fromEntries([...counts.entries()].sort(([a], [b]) => a.localeCompare(b))) + const payload = { + note: '测试文件类型检查基线:只允许下降,不允许上升。用 node scripts/typecheck-tests.cjs --update 下调。', + total: total(counts), + files + } + fs.mkdirSync(path.dirname(baselinePath), { recursive: true }) + fs.writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') + console.log( + `[typecheck:test] 基线已更新:${payload.total} 个错误 / ${Object.keys(files).length} 个文件` + ) + return + } + + const baseline = readBaseline() + if (!baseline) { + console.error( + `[typecheck:test] 找不到基线 ${path.relative(projectRoot, baselinePath)}。\n` + + ' 首次启用请运行:node scripts/typecheck-tests.cjs --update' + ) + process.exit(1) + } + + const regressions = [] + for (const [file, count] of counts) { + const allowed = baseline.files[file] ?? 0 + if (count > allowed) regressions.push({ file, count, allowed }) + } + + const now = total(counts) + const baselineTotal = Number(baseline.total) || 0 + const improved = baselineTotal - now + + if (regressions.length > 0) { + console.error('[typecheck:test] 测试文件出现新的类型错误 ❌') + console.error(` 基线 ${baselineTotal} → 当前 ${now}(+${now - baselineTotal})\n`) + let printed = 0 + for (const item of regressions) { + console.error(` ${item.file} ${item.allowed} → ${item.count}`) + for (const line of details.get(item.file) ?? []) { + if (printed >= MAX_REPORTED) break + console.error(` ${line}`) + printed += 1 + } + } + console.error('\n 修好之后用 --update 下调基线(不要为了过检查而放宽它)。') + process.exit(1) + } + + console.log( + `[typecheck:test] PASS ✅ 当前 ${now} 个既有类型错误 / ${counts.size} 个文件` + + (improved > 0 ? `(比基线少 ${improved} 个,可运行 --update 下调)` : '') + ) + console.log(' 注意:这是棘轮,只保证「不新增」。修完历史债后可改为阻断式检查。') +} + +main() diff --git a/services/wechat-connector/LICENSE b/services/wechat-connector/LICENSE deleted file mode 100644 index 94250e8..0000000 --- a/services/wechat-connector/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 fastclaw-ai - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/services/wechat-connector/README.md b/services/wechat-connector/README.md deleted file mode 100644 index b387f8e..0000000 --- a/services/wechat-connector/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# TraceMemo WeChat Connector - -This repository-local service provides the minimal WeChat bridge required by TraceMemo: - -- QR-code login with a single persisted credential -- account discovery -- inbound long polling and authenticated webhook delivery -- local HTTP health and send endpoints -- text and local/remote media sending - -The executable is managed by the Electron main process. It is not a general-purpose agent runtime and does not load external AI command-line tools. - -## Commands - -```bash -go run . login --json -go run . accounts --json -go run . start --foreground --api-addr 127.0.0.1:18011 --account-id -``` - -Credential and synchronization state is stored under `~/.wechatexplorer/wechat-connector/accounts`. This legacy directory name is intentionally retained so upgrades can reuse existing 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). TraceMemo-specific process management, webhook contract, product UI, and Agent Hub behavior live in the surrounding TraceMemo project. diff --git a/services/wechat-connector/api/server.go b/services/wechat-connector/api/server.go deleted file mode 100644 index 5ee86ae..0000000 --- a/services/wechat-connector/api/server.go +++ /dev/null @@ -1,135 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - - "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink" - "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging" -) - -// Server provides an HTTP API for sending messages. -type Server struct { - clients []*ilink.Client - addr string -} - -// NewServer creates an API server. -func NewServer(clients []*ilink.Client, addr string) *Server { - if addr == "" { - addr = "127.0.0.1:18011" - } - return &Server{clients: clients, addr: addr} -} - -// SendRequest is the JSON body for POST /api/send. -type SendRequest struct { - AccountID string `json:"account_id,omitempty"` - To string `json:"to"` - Text string `json:"text,omitempty"` - MediaURL string `json:"media_url,omitempty"` // image/video/file URL -} - -// Run starts the HTTP server. Blocks until ctx is cancelled. -func (s *Server) Run(ctx context.Context) error { - mux := http.NewServeMux() - mux.HandleFunc("/api/send", s.handleSend) - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprintln(w, "ok") - }) - - srv := &http.Server{Addr: s.addr, Handler: mux} - - go func() { - <-ctx.Done() - srv.Shutdown(context.Background()) - }() - - log.Printf("[api] listening on %s", s.addr) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - return err - } - return nil -} - -func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "POST only", http.StatusMethodNotAllowed) - return - } - - var req SendRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) - return - } - - if req.To == "" { - http.Error(w, `"to" is required`, http.StatusBadRequest) - return - } - if req.Text == "" && req.MediaURL == "" { - http.Error(w, `"text" or "media_url" is required`, http.StatusBadRequest) - return - } - - if len(s.clients) == 0 { - http.Error(w, "no accounts configured", http.StatusServiceUnavailable) - return - } - - client := s.clientForAccount(req.AccountID) - if client == nil { - http.Error(w, "requested account is not available", http.StatusNotFound) - return - } - ctx := r.Context() - - // Send text if provided - if req.Text != "" { - if err := messaging.SendTextReply(ctx, client, req.To, req.Text, "", ""); err != nil { - log.Printf("[api] send text failed: %v", err) - http.Error(w, "send text failed: "+err.Error(), http.StatusInternalServerError) - return - } - log.Printf("[api] sent text to %s: %q", req.To, req.Text) - - // Extract and send any markdown images embedded in text - for _, imgURL := range messaging.ExtractImageURLs(req.Text) { - if err := messaging.SendMediaFromURL(ctx, client, req.To, imgURL, ""); err != nil { - log.Printf("[api] send extracted image failed: %v", err) - } else { - log.Printf("[api] sent extracted image to %s: %s", req.To, imgURL) - } - } - } - - // Send media if provided - if req.MediaURL != "" { - if err := messaging.SendMediaFromURL(ctx, client, req.To, req.MediaURL, ""); err != nil { - log.Printf("[api] send media failed: %v", err) - http.Error(w, "send media failed: "+err.Error(), http.StatusInternalServerError) - return - } - log.Printf("[api] sent media to %s: %s", req.To, req.MediaURL) - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -func (s *Server) clientForAccount(accountID string) *ilink.Client { - if accountID == "" { - return s.clients[0] - } - for _, client := range s.clients { - if client.BotID() == accountID { - return client - } - } - return nil -} diff --git a/services/wechat-connector/api/server_test.go b/services/wechat-connector/api/server_test.go deleted file mode 100644 index 0c66680..0000000 --- a/services/wechat-connector/api/server_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package api - -import ( - "testing" - - "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink" -) - -func TestClientForAccountSelectsMatchingBot(t *testing.T) { - oldClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-old"}) - newClient := ilink.NewClient(&ilink.Credentials{ILinkBotID: "bot-new"}) - server := NewServer([]*ilink.Client{oldClient, newClient}, "") - - if got := server.clientForAccount("bot-new"); got != newClient { - t.Fatal("clientForAccount did not select the requested account") - } - if got := server.clientForAccount("missing"); got != nil { - t.Fatal("clientForAccount should reject an unknown account") - } -} diff --git a/services/wechat-connector/go.mod b/services/wechat-connector/go.mod deleted file mode 100644 index c59810d..0000000 --- a/services/wechat-connector/go.mod +++ /dev/null @@ -1,8 +0,0 @@ -module github.com/Wxw-Gu/WechatExplorer/services/wechat-connector - -go 1.23.0 - -require ( - github.com/google/uuid v1.6.0 - rsc.io/qr v0.2.0 -) diff --git a/services/wechat-connector/go.sum b/services/wechat-connector/go.sum deleted file mode 100644 index 23dfdfa..0000000 --- a/services/wechat-connector/go.sum +++ /dev/null @@ -1,4 +0,0 @@ -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= -rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/services/wechat-connector/ilink/auth.go b/services/wechat-connector/ilink/auth.go deleted file mode 100644 index d4ff488..0000000 --- a/services/wechat-connector/ilink/auth.go +++ /dev/null @@ -1,236 +0,0 @@ -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 - } - } -} - -func accountsDir(rootName string) (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, rootName, "wechat-connector", "accounts"), nil -} - -// AccountsDir returns the TraceMemo directory where new credentials are stored. -func AccountsDir() (string, error) { - return accountsDir(".tracememo") -} - -// LegacyAccountsDir is read-only compatibility for v2.1.9 and earlier. -func LegacyAccountsDir() (string, error) { - return accountsDir(".wechatexplorer") -} - -func accountDirectoryForID(accountID string) (string, error) { - current, err := AccountsDir() - if err != nil { - return "", err - } - if _, err := os.Stat(filepath.Join(current, accountID+".json")); err == nil { - return current, nil - } - legacy, err := LegacyAccountsDir() - if err != nil { - return "", err - } - if _, err := os.Stat(filepath.Join(legacy, accountID+".json")); err == nil { - return legacy, nil - } - return current, 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 -} - -func loadCredentialsFromDir(dir string) ([]*Credentials, error) { - 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 -} - -// LoadAllCredentials loads TraceMemo credentials first and falls back to the -// untouched WechatExplorer directory for one-version upgrade compatibility. -func LoadAllCredentials() ([]*Credentials, error) { - current, err := AccountsDir() - if err != nil { - return nil, err - } - credentials, err := loadCredentialsFromDir(current) - if err != nil || len(credentials) > 0 { - return credentials, err - } - legacy, err := LegacyAccountsDir() - if err != nil { - return nil, err - } - return loadCredentialsFromDir(legacy) -} - -// CredentialsPath returns the path for display purposes. -func CredentialsPath() (string, error) { - return AccountsDir() -} diff --git a/services/wechat-connector/ilink/auth_test.go b/services/wechat-connector/ilink/auth_test.go deleted file mode 100644 index 6c25a0c..0000000 --- a/services/wechat-connector/ilink/auth_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package ilink - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func setTestHome(t *testing.T) string { - t.Helper() - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - return home -} - -func TestSaveCredentialsKeepsOnlyLatestAccount(t *testing.T) { - setTestHome(t) - 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) - } -} - -func TestAccountsDirUsesTraceMemoIdentity(t *testing.T) { - home := setTestHome(t) - dir, err := AccountsDir() - if err != nil { - t.Fatal(err) - } - want := filepath.Join(home, ".tracememo", "wechat-connector", "accounts") - if dir != want { - t.Fatalf("AccountsDir() = %q, want %q", dir, want) - } -} - -func TestLoadAllCredentialsFallsBackToLegacyDirectory(t *testing.T) { - setTestHome(t) - legacyDir, err := LegacyAccountsDir() - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(legacyDir, 0o700); err != nil { - t.Fatal(err) - } - legacy := &Credentials{ILinkBotID: "legacy@im.bot", BotToken: "legacy-token"} - data, err := json.Marshal(legacy) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(legacyDir, NormalizeAccountID(legacy.ILinkBotID)+".json"), data, 0o600); err != nil { - t.Fatal(err) - } - accounts, err := LoadAllCredentials() - if err != nil { - t.Fatal(err) - } - if len(accounts) != 1 || accounts[0].BotToken != legacy.BotToken { - t.Fatalf("accounts = %#v", accounts) - } - if _, err := os.Stat(legacyDir); err != nil { - t.Fatalf("legacy directory changed or removed: %v", err) - } -} diff --git a/services/wechat-connector/ilink/client.go b/services/wechat-connector/ilink/client.go deleted file mode 100644 index 3697d1b..0000000 --- a/services/wechat-connector/ilink/client.go +++ /dev/null @@ -1,218 +0,0 @@ -package ilink - -import ( - "bytes" - "context" - "crypto/rand" - "encoding/base64" - "encoding/binary" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -const ( - defaultBaseURL = "https://ilinkai.weixin.qq.com" - longPollTimeout = 35 * time.Second - sendTimeout = 15 * time.Second -) - -// Client is an iLink HTTP API client. -type Client struct { - baseURL string - botToken string - botID string - httpClient *http.Client - wechatUIN string -} - -// NewClient creates a new iLink API client. -func NewClient(creds *Credentials) *Client { - baseURL := creds.BaseURL - if baseURL == "" { - baseURL = defaultBaseURL - } - return &Client{ - baseURL: baseURL, - botToken: creds.BotToken, - botID: creds.ILinkBotID, - httpClient: &http.Client{}, - wechatUIN: generateWechatUIN(), - } -} - -// NewUnauthenticatedClient creates a client without credentials for login flow. -func NewUnauthenticatedClient() *Client { - return &Client{ - baseURL: defaultBaseURL, - httpClient: &http.Client{Timeout: 40 * time.Second}, - wechatUIN: generateWechatUIN(), - } -} - -// BotID returns the bot's user ID. -func (c *Client) BotID() string { - return c.botID -} - -// GetUpdates performs a long-poll for new messages. -func (c *Client) GetUpdates(ctx context.Context, buf string) (*GetUpdatesResponse, error) { - reqBody := GetUpdatesRequest{ - GetUpdatesBuf: buf, - BaseInfo: BaseInfo{ChannelVersion: "1.0.0"}, - } - - ctx, cancel := context.WithTimeout(ctx, longPollTimeout+5*time.Second) - defer cancel() - - var resp GetUpdatesResponse - if err := c.doPost(ctx, "/ilink/bot/getupdates", reqBody, &resp); err != nil { - return nil, err - } - return &resp, nil -} - -// SendMessage sends a message through iLink. -func (c *Client) SendMessage(ctx context.Context, msg *SendMessageRequest) (*SendMessageResponse, error) { - ctx, cancel := context.WithTimeout(ctx, sendTimeout) - defer cancel() - - var resp SendMessageResponse - if err := c.doPost(ctx, "/ilink/bot/sendmessage", msg, &resp); err != nil { - return nil, err - } - return &resp, nil -} - -// GetConfig fetches bot config for a user (includes typing_ticket). -func (c *Client) GetConfig(ctx context.Context, userID, contextToken string) (*GetConfigResponse, error) { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - req := GetConfigRequest{ - ILinkUserID: userID, - ContextToken: contextToken, - BaseInfo: BaseInfo{}, - } - - var resp GetConfigResponse - if err := c.doPost(ctx, "/ilink/bot/getconfig", req, &resp); err != nil { - return nil, err - } - return &resp, nil -} - -// SendTyping sends a typing indicator to a user. -func (c *Client) SendTyping(ctx context.Context, userID, typingTicket string, status int) error { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - req := SendTypingRequest{ - ILinkUserID: userID, - TypingTicket: typingTicket, - Status: status, - BaseInfo: BaseInfo{}, - } - - var resp SendTypingResponse - if err := c.doPost(ctx, "/ilink/bot/sendtyping", req, &resp); err != nil { - return err - } - if resp.Ret != 0 { - return fmt.Errorf("sendtyping failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg) - } - return nil -} - -// GetUploadURL gets a pre-signed CDN upload URL for media files. -func (c *Client) GetUploadURL(ctx context.Context, req *GetUploadURLRequest) (*GetUploadURLResponse, error) { - ctx, cancel := context.WithTimeout(ctx, sendTimeout) - defer cancel() - - var resp GetUploadURLResponse - if err := c.doPost(ctx, "/ilink/bot/getuploadurl", req, &resp); err != nil { - return nil, err - } - return &resp, nil -} - -// BaseURL returns the base URL for CDN operations. -func (c *Client) BaseURL() string { - return c.baseURL -} - -func (c *Client) doPost(ctx context.Context, path string, body interface{}, result interface{}) error { - data, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("marshal request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("create request: %w", err) - } - c.setHeaders(req) - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) - } - - if err := json.Unmarshal(respBody, result); err != nil { - return fmt.Errorf("unmarshal response: %w", err) - } - return nil -} - -func (c *Client) doGet(ctx context.Context, url string, result interface{}) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) - } - - if err := json.Unmarshal(respBody, result); err != nil { - return fmt.Errorf("unmarshal response: %w", err) - } - return nil -} - -func (c *Client) setHeaders(req *http.Request) { - req.Header.Set("Content-Type", "application/json") - req.Header.Set("AuthorizationType", "ilink_bot_token") - req.Header.Set("Authorization", "Bearer "+c.botToken) - req.Header.Set("X-WECHAT-UIN", c.wechatUIN) -} - -func generateWechatUIN() string { - var n uint32 - _ = binary.Read(rand.Reader, binary.LittleEndian, &n) - s := fmt.Sprintf("%d", n) - return base64.StdEncoding.EncodeToString([]byte(s)) -} diff --git a/services/wechat-connector/ilink/monitor.go b/services/wechat-connector/ilink/monitor.go deleted file mode 100644 index ad74a29..0000000 --- a/services/wechat-connector/ilink/monitor.go +++ /dev/null @@ -1,181 +0,0 @@ -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) { - accountID := NormalizeAccountID(client.BotID()) - accountsRoot, err := accountDirectoryForID(accountID) - if err != nil { - return nil, err - } - bufPath := filepath.Join(accountsRoot, 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 TraceMemo 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 TraceMemo.") - } - select { - case <-time.After(sessionExpiredBackoff): - case <-ctx.Done(): - return ctx.Err() - } - continue - } - - // Other server errors - if resp.Ret != 0 && resp.ErrCode != 0 { - log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg) - continue - } - - // Update buf for next poll - if resp.GetUpdatesBuf != "" { - m.getUpdatesBuf = resp.GetUpdatesBuf - m.saveBuf() - } - - // Process messages concurrently — don't block the poll loop - for _, msg := range resp.Msgs { - go m.handler(ctx, m.client, msg) - } - } -} - -// calcBackoff returns an exponential backoff duration capped at maxBackoff. -func (m *Monitor) calcBackoff() time.Duration { - d := initialBackoff - for i := 1; i < m.failures; i++ { - d *= 2 - if d > maxBackoff { - return maxBackoff - } - } - return d -} - -type syncData struct { - GetUpdatesBuf string `json:"get_updates_buf"` -} - -func (m *Monitor) loadBuf() { - data, err := os.ReadFile(m.bufPath) - if err != nil { - return - } - var s syncData - if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" { - m.getUpdatesBuf = s.GetUpdatesBuf - log.Printf("[monitor] loaded sync buf from %s", m.bufPath) - } -} - -func (m *Monitor) saveBuf() { - dir := filepath.Dir(m.bufPath) - if err := os.MkdirAll(dir, 0o700); err != nil { - log.Printf("[monitor] failed to create buf dir: %v", err) - return - } - data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf}) - if err := os.WriteFile(m.bufPath, data, 0o600); err != nil { - log.Printf("[monitor] failed to save buf: %v", err) - } -} - -// FormatMessageSummary returns a short description of a message for logging. -func FormatMessageSummary(msg WeixinMessage) string { - text := "" - for _, item := range msg.ItemList { - if item.Type == ItemTypeText && item.TextItem != nil { - text = item.TextItem.Text - break - } - } - if len(text) > 50 { - text = text[:50] + "..." - } - return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text) -} diff --git a/services/wechat-connector/ilink/types.go b/services/wechat-connector/ilink/types.go deleted file mode 100644 index b388ca8..0000000 --- a/services/wechat-connector/ilink/types.go +++ /dev/null @@ -1,219 +0,0 @@ -package ilink - -// Message types -const ( - MessageTypeNone = 0 - MessageTypeUser = 1 - MessageTypeBot = 2 -) - -// Message states -const ( - MessageStateNew = 0 - MessageStateGenerating = 1 - MessageStateFinish = 2 -) - -// Item types -const ( - ItemTypeNone = 0 - ItemTypeText = 1 - ItemTypeImage = 2 - ItemTypeVoice = 3 - ItemTypeFile = 4 - ItemTypeVideo = 5 -) - -// QRCodeResponse is the response from get_bot_qrcode. -type QRCodeResponse struct { - QRCode string `json:"qrcode"` - QRCodeImgContent string `json:"qrcode_img_content"` -} - -// QRStatusResponse is the response from get_qrcode_status. -type QRStatusResponse struct { - Status string `json:"status"` - BotToken string `json:"bot_token"` - ILinkBotID string `json:"ilink_bot_id"` - BaseURL string `json:"baseurl"` - ILinkUserID string `json:"ilink_user_id"` -} - -// Credentials stores login session data. -type Credentials struct { - BotToken string `json:"bot_token"` - ILinkBotID string `json:"ilink_bot_id"` - BaseURL string `json:"baseurl"` - ILinkUserID string `json:"ilink_user_id"` -} - -// BaseInfo is included in request bodies. -type BaseInfo struct { - ChannelVersion string `json:"channel_version,omitempty"` -} - -// GetUpdatesRequest is the body for getupdates. -type GetUpdatesRequest struct { - GetUpdatesBuf string `json:"get_updates_buf"` - BaseInfo BaseInfo `json:"base_info"` -} - -// GetUpdatesResponse is the response from getupdates. -type GetUpdatesResponse struct { - Ret int `json:"ret"` - ErrCode int `json:"errcode,omitempty"` - ErrMsg string `json:"errmsg,omitempty"` - Msgs []WeixinMessage `json:"msgs"` - GetUpdatesBuf string `json:"get_updates_buf"` - LongPollingTimeoutMs int `json:"longpolling_timeout_ms,omitempty"` -} - -// WeixinMessage represents a message from WeChat. -type WeixinMessage struct { - Seq int `json:"seq,omitempty"` - MessageID int64 `json:"message_id,omitempty"` - FromUserID string `json:"from_user_id"` - ToUserID string `json:"to_user_id"` - MessageType int `json:"message_type"` - MessageState int `json:"message_state"` - ItemList []MessageItem `json:"item_list"` - ContextToken string `json:"context_token"` -} - -// MessageItem is a single item in a message. -type MessageItem struct { - Type int `json:"type"` - TextItem *TextItem `json:"text_item,omitempty"` - ImageItem *ImageItem `json:"image_item,omitempty"` - VoiceItem *VoiceItem `json:"voice_item,omitempty"` - VideoItem *VideoItem `json:"video_item,omitempty"` - FileItem *FileItem `json:"file_item,omitempty"` -} - -// CDN media type constants. -const ( - CDNMediaTypeImage = 1 - CDNMediaTypeVideo = 2 - CDNMediaTypeFile = 3 -) - -// GetUploadURLRequest is the body for getuploadurl. -type GetUploadURLRequest struct { - FileKey string `json:"filekey"` - MediaType int `json:"media_type"` - ToUserID string `json:"to_user_id"` - RawSize int `json:"rawsize"` - RawFileMD5 string `json:"rawfilemd5"` - FileSize int `json:"filesize"` - NoNeedThumb bool `json:"no_need_thumb"` - AESKey string `json:"aeskey"` - BaseInfo BaseInfo `json:"base_info"` -} - -// GetUploadURLResponse is the response from getuploadurl. -type GetUploadURLResponse struct { - Ret int `json:"ret"` - ErrMsg string `json:"errmsg,omitempty"` - UploadParam string `json:"upload_param"` - UploadFullURL string `json:"upload_full_url,omitempty"` -} - -// TextItem holds text content. -type TextItem struct { - Text string `json:"text"` -} - -// MediaInfo holds CDN media reference for uploaded files. -type MediaInfo struct { - EncryptQueryParam string `json:"encrypt_query_param"` - AESKey string `json:"aes_key"` // base64-encoded - EncryptType int `json:"encrypt_type"` // 1 = AES-128-ECB -} - -// VoiceItem holds voice content. -type VoiceItem struct { - Media *MediaInfo `json:"media,omitempty"` - VoiceSize int `json:"voice_size,omitempty"` - EncodeType int `json:"encode_type,omitempty"` // 1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3 - BitsPerSample int `json:"bits_per_sample,omitempty"` - SampleRate int `json:"sample_rate,omitempty"` // Hz - Playtime int `json:"playtime,omitempty"` // duration in milliseconds - Text string `json:"text,omitempty"` // speech-to-text transcription from WeChat -} - -// ImageItem holds image content. -type ImageItem struct { - URL string `json:"url,omitempty"` - Media *MediaInfo `json:"media,omitempty"` - MidSize int `json:"mid_size,omitempty"` // ciphertext size -} - -// VideoItem holds video content. -type VideoItem struct { - Media *MediaInfo `json:"media,omitempty"` - VideoSize int `json:"video_size,omitempty"` -} - -// FileItem holds file content. -type FileItem struct { - Media *MediaInfo `json:"media,omitempty"` - FileName string `json:"file_name,omitempty"` - Len string `json:"len,omitempty"` // plaintext size as string -} - -// SendMessageRequest is the body for sendmessage. -type SendMessageRequest struct { - Msg SendMsg `json:"msg"` - BaseInfo BaseInfo `json:"base_info"` -} - -// SendMsg is the message payload for sending. -type SendMsg struct { - FromUserID string `json:"from_user_id"` - ToUserID string `json:"to_user_id"` - ClientID string `json:"client_id"` - MessageType int `json:"message_type"` - MessageState int `json:"message_state"` - ItemList []MessageItem `json:"item_list"` - ContextToken string `json:"context_token"` -} - -// SendMessageResponse is the response from sendmessage. -type SendMessageResponse struct { - Ret int `json:"ret"` - ErrMsg string `json:"errmsg,omitempty"` -} - -// Typing status constants. -const ( - TypingStatusTyping = 1 - TypingStatusCancel = 2 -) - -// GetConfigRequest is the body for getconfig. -type GetConfigRequest struct { - ILinkUserID string `json:"ilink_user_id"` - ContextToken string `json:"context_token,omitempty"` - BaseInfo BaseInfo `json:"base_info"` -} - -// GetConfigResponse is the response from getconfig. -type GetConfigResponse struct { - Ret int `json:"ret"` - ErrMsg string `json:"errmsg,omitempty"` - TypingTicket string `json:"typing_ticket,omitempty"` -} - -// SendTypingRequest is the body for sendtyping. -type SendTypingRequest struct { - ILinkUserID string `json:"ilink_user_id"` - TypingTicket string `json:"typing_ticket"` - Status int `json:"status"` - BaseInfo BaseInfo `json:"base_info"` -} - -// SendTypingResponse is the response from sendtyping. -type SendTypingResponse struct { - Ret int `json:"ret"` - ErrMsg string `json:"errmsg,omitempty"` -} diff --git a/services/wechat-connector/main.go b/services/wechat-connector/main.go deleted file mode 100644 index 09a0111..0000000 --- a/services/wechat-connector/main.go +++ /dev/null @@ -1,200 +0,0 @@ -package main - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "flag" - "fmt" - "log" - "os" - "os/signal" - "strings" - "sync" - "syscall" - - "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/api" - "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink" - "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/messaging" - "rsc.io/qr" -) - -type loginEvent struct { - Status string `json:"status"` - QRCodeDataURL string `json:"qr_code_data_url,omitempty"` - AccountID string `json:"account_id,omitempty"` - WeChatUserID string `json:"wechat_user_id,omitempty"` -} - -type accountSummary struct { - AccountID string `json:"account_id"` - WeChatUserID string `json:"wechat_user_id"` -} - -func main() { - if len(os.Args) < 2 { - fatal(errors.New("expected one of: login, accounts, start")) - } - var err error - switch os.Args[1] { - case "login": - err = runLogin(os.Args[2:]) - case "accounts": - err = runAccounts(os.Args[2:]) - case "start": - err = runStart(os.Args[2:]) - default: - err = fmt.Errorf("unknown command %q", os.Args[1]) - } - if err != nil { - fatal(err) - } -} - -func fatal(err error) { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) -} - -func signalContext() (context.Context, context.CancelFunc) { - return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) -} - -func runLogin(args []string) error { - flags := flag.NewFlagSet("login", flag.ContinueOnError) - jsonOutput := flags.Bool("json", false, "emit JSON Lines events") - if err := flags.Parse(args); err != nil { - return err - } - ctx, cancel := signalContext() - defer cancel() - creds, err := login(ctx, *jsonOutput) - if err != nil { - return err - } - if !*jsonOutput { - fmt.Printf("WeChat account %s connected.\n", creds.ILinkBotID) - } - return nil -} - -func login(ctx context.Context, jsonOutput bool) (*ilink.Credentials, error) { - qrResponse, err := ilink.FetchQRCode(ctx) - if err != nil { - return nil, err - } - code, err := qr.Encode(qrResponse.QRCodeImgContent, qr.L) - if err != nil { - return nil, fmt.Errorf("encode QR image: %w", err) - } - emit := func(event loginEvent) { - if jsonOutput { - _ = json.NewEncoder(os.Stdout).Encode(event) - } - } - emit(loginEvent{Status: "qrcode", QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())}) - lastStatus := "" - creds, err := ilink.PollQRStatus(ctx, qrResponse.QRCode, func(status string) { - if status != lastStatus { - lastStatus = status - emit(loginEvent{Status: status}) - } - }) - if err != nil { - return nil, err - } - if err := ilink.SaveCredentials(creds); err != nil { - return nil, fmt.Errorf("save credentials: %w", err) - } - emit(loginEvent{Status: "active", AccountID: creds.ILinkBotID, WeChatUserID: creds.ILinkUserID}) - return creds, nil -} - -func runAccounts(args []string) error { - flags := flag.NewFlagSet("accounts", flag.ContinueOnError) - jsonOutput := flags.Bool("json", false, "print JSON") - if err := flags.Parse(args); err != nil { - return err - } - accounts, err := ilink.LoadAllCredentials() - if err != nil { - return err - } - items := make([]accountSummary, 0, len(accounts)) - for _, account := range accounts { - items = append(items, accountSummary{AccountID: account.ILinkBotID, WeChatUserID: account.ILinkUserID}) - } - if *jsonOutput { - return json.NewEncoder(os.Stdout).Encode(map[string]any{"accounts": items}) - } - for _, item := range items { - fmt.Printf("%s\t%s\n", item.AccountID, item.WeChatUserID) - } - return nil -} - -func runStart(args []string) error { - flags := flag.NewFlagSet("start", flag.ContinueOnError) - _ = flags.Bool("foreground", false, "kept for host compatibility") - apiAddr := flags.String("api-addr", "127.0.0.1:18011", "local send API address") - accountID := flags.String("account-id", "", "account to start") - if err := flags.Parse(args); err != nil { - return err - } - accounts, err := ilink.LoadAllCredentials() - if err != nil { - return err - } - if len(accounts) == 0 { - return errors.New("no connected WeChat account; scan a QR code first") - } - selected := accounts[len(accounts)-1] - if *accountID != "" { - selected = nil - for _, account := range accounts { - if account.ILinkBotID == *accountID { - selected = account - break - } - } - if selected == nil { - return fmt.Errorf("account %q not found", *accountID) - } - } - - ctx, cancel := signalContext() - defer cancel() - client := ilink.NewClient(selected) - server := api.NewServer([]*ilink.Client{client}, *apiAddr) - webhookURL := strings.TrimSpace(os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_URL")) - webhook := messaging.NewInboundWebhook(webhookURL, os.Getenv("WECHAT_CONNECTOR_INBOUND_WEBHOOK_TOKEN")) - - monitor, err := ilink.NewMonitor(client, func(messageContext context.Context, source *ilink.Client, message ilink.WeixinMessage) { - if webhookURL != "" { - webhook.Dispatch(messageContext, source, message) - } - }) - if err != nil { - return err - } - - var wait sync.WaitGroup - wait.Add(2) - go func() { - defer wait.Done() - if err := server.Run(ctx); err != nil && ctx.Err() == nil { - log.Printf("[api] stopped: %v", err) - cancel() - } - }() - go func() { - defer wait.Done() - if err := monitor.Run(ctx); err != nil && ctx.Err() == nil { - log.Printf("[monitor] stopped: %v", err) - cancel() - } - }() - wait.Wait() - return nil -} diff --git a/services/wechat-connector/messaging/cdn.go b/services/wechat-connector/messaging/cdn.go deleted file mode 100644 index e0a07ea..0000000 --- a/services/wechat-connector/messaging/cdn.go +++ /dev/null @@ -1,232 +0,0 @@ -package messaging - -import ( - "bytes" - "context" - "crypto/aes" - "crypto/md5" - "crypto/rand" - "encoding/base64" - "encoding/hex" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink" -) - -const cdnBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c" - -// UploadedFile holds the result of a CDN upload. -type UploadedFile struct { - DownloadParam string // encrypted query param for download - AESKeyHex string // hex-encoded AES key - FileSize int // plaintext size - CipherSize int // ciphertext size -} - -// UploadFileToCDN encrypts and uploads a file to the WeChat CDN. -func UploadFileToCDN(ctx context.Context, client *ilink.Client, data []byte, toUserID string, mediaType int) (*UploadedFile, error) { - // Generate random filekey and AES key - filekey := make([]byte, 16) - aeskey := make([]byte, 16) - if _, err := rand.Read(filekey); err != nil { - return nil, fmt.Errorf("generate filekey: %w", err) - } - if _, err := rand.Read(aeskey); err != nil { - return nil, fmt.Errorf("generate aeskey: %w", err) - } - - filekeyHex := hex.EncodeToString(filekey) - aeskeyHex := hex.EncodeToString(aeskey) - - // Calculate MD5 of plaintext - hash := md5.Sum(data) - rawMD5 := hex.EncodeToString(hash[:]) - - // Calculate ciphertext size (PKCS7 padding) - cipherSize := aesECBPaddedSize(len(data)) - - // Get upload URL from iLink API - uploadReq := &ilink.GetUploadURLRequest{ - FileKey: filekeyHex, - MediaType: mediaType, - ToUserID: toUserID, - RawSize: len(data), - RawFileMD5: rawMD5, - FileSize: cipherSize, - NoNeedThumb: true, - AESKey: aeskeyHex, - BaseInfo: ilink.BaseInfo{}, - } - - uploadResp, err := client.GetUploadURL(ctx, uploadReq) - if err != nil { - return nil, fmt.Errorf("get upload URL: %w", err) - } - if uploadResp.Ret != 0 { - return nil, fmt.Errorf("get upload URL failed: ret=%d errmsg=%s", uploadResp.Ret, uploadResp.ErrMsg) - } - - // Encrypt data with AES-128-ECB - encrypted, err := encryptAESECB(data, aeskey) - if err != nil { - return nil, fmt.Errorf("encrypt: %w", err) - } - - // Upload to CDN: prefer server-provided full URL, fall back to param-based construction - cdnURL := strings.TrimSpace(uploadResp.UploadFullURL) - if cdnURL == "" { - if uploadResp.UploadParam == "" { - return nil, fmt.Errorf("getuploadurl returned no upload URL (need upload_full_url or upload_param)") - } - cdnURL = fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s", - cdnBaseURL, url.QueryEscape(uploadResp.UploadParam), url.QueryEscape(filekeyHex)) - } - - downloadParam, err := uploadToCDN(ctx, encrypted, cdnURL) - if err != nil { - return nil, fmt.Errorf("CDN upload: %w", err) - } - - return &UploadedFile{ - DownloadParam: downloadParam, - AESKeyHex: aeskeyHex, - FileSize: len(data), - CipherSize: cipherSize, - }, nil -} - -// AESKeyToBase64 converts a hex AES key to base64 format for message items. -func AESKeyToBase64(hexKey string) string { - return base64.StdEncoding.EncodeToString([]byte(hexKey)) -} - -// DownloadFileFromCDN downloads and decrypts a file from the WeChat CDN. -func DownloadFileFromCDN(ctx context.Context, encryptQueryParam, aesKeyBase64 string) ([]byte, error) { - // Decode AES key: base64 -> hex string -> raw bytes - aesKeyHexBytes, err := base64.StdEncoding.DecodeString(aesKeyBase64) - if err != nil { - return nil, fmt.Errorf("decode AES key base64: %w", err) - } - aesKey, err := hex.DecodeString(string(aesKeyHexBytes)) - if err != nil { - return nil, fmt.Errorf("decode AES key hex: %w", err) - } - - // Download encrypted data from CDN - downloadURL := fmt.Sprintf("%s/download?encrypted_query_param=%s", - cdnBaseURL, url.QueryEscape(encryptQueryParam)) - - reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, downloadURL, nil) - if err != nil { - return nil, fmt.Errorf("create download request: %w", err) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("download from CDN: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("CDN download HTTP %d: %s", resp.StatusCode, string(body)) - } - - encrypted, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read CDN response: %w", err) - } - - // Decrypt AES-128-ECB - return decryptAESECB(encrypted, aesKey) -} - -// decryptAESECB decrypts data encrypted with AES-128-ECB and removes PKCS7 padding. -func decryptAESECB(ciphertext, key []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - if len(ciphertext)%aes.BlockSize != 0 { - return nil, fmt.Errorf("ciphertext is not a multiple of block size") - } - - plaintext := make([]byte, len(ciphertext)) - for i := 0; i < len(ciphertext); i += aes.BlockSize { - block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize]) - } - - // Remove PKCS7 padding - if len(plaintext) == 0 { - return plaintext, nil - } - padLen := int(plaintext[len(plaintext)-1]) - if padLen > aes.BlockSize || padLen == 0 { - return nil, fmt.Errorf("invalid PKCS7 padding") - } - return plaintext[:len(plaintext)-padLen], nil -} - -func uploadToCDN(ctx context.Context, encrypted []byte, cdnURL string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, cdnURL, bytes.NewReader(encrypted)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/octet-stream") - - client := &http.Client{Timeout: 60 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("CDN upload HTTP %d: %s", resp.StatusCode, string(body)) - } - - downloadParam := resp.Header.Get("X-Encrypted-Param") - if downloadParam == "" { - return "", fmt.Errorf("CDN upload: missing X-Encrypted-Param header") - } - - return downloadParam, nil -} - -// encryptAESECB encrypts data using AES-128-ECB with PKCS7 padding. -func encryptAESECB(plaintext, key []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - // PKCS7 padding - padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize) - padded := make([]byte, len(plaintext)+padLen) - copy(padded, plaintext) - for i := len(plaintext); i < len(padded); i++ { - padded[i] = byte(padLen) - } - - // ECB mode: encrypt each block independently - encrypted := make([]byte, len(padded)) - for i := 0; i < len(padded); i += aes.BlockSize { - block.Encrypt(encrypted[i:i+aes.BlockSize], padded[i:i+aes.BlockSize]) - } - - return encrypted, nil -} - -func aesECBPaddedSize(plaintextSize int) int { - return (plaintextSize/aes.BlockSize + 1) * aes.BlockSize -} diff --git a/services/wechat-connector/messaging/inbound_webhook.go b/services/wechat-connector/messaging/inbound_webhook.go deleted file mode 100644 index 6fc4090..0000000 --- a/services/wechat-connector/messaging/inbound_webhook.go +++ /dev/null @@ -1,121 +0,0 @@ -package messaging - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "strings" - "time" - - "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink" -) - -const ( - webhookAttempts = 3 - webhookTimeout = 5 * time.Second -) - -type InboundWebhook struct { - url string - token string - client *http.Client -} - -type inboundWebhookPayload struct { - AccountID string `json:"account_id"` - FromUserID string `json:"from_user_id"` - MessageID int64 `json:"message_id"` - MessageType int `json:"message_type"` - Items []inboundWebhookItem `json:"items"` - ReceivedAt time.Time `json:"received_at"` -} - -type inboundWebhookItem struct { - Type int `json:"type"` - Text string `json:"text,omitempty"` -} - -func NewInboundWebhook(url, token string) *InboundWebhook { - return &InboundWebhook{ - url: strings.TrimSpace(url), - token: token, - client: &http.Client{Timeout: webhookTimeout}, - } -} - -// Dispatch is intentionally non-blocking so webhook failures never stall iLink polling. -func (w *InboundWebhook) Dispatch(ctx context.Context, client *ilink.Client, msg ilink.WeixinMessage) { - payload := normalizeInboundMessage(client.BotID(), msg) - go func() { - if err := w.deliver(ctx, payload); err != nil { - log.Printf("[webhook] inbound delivery failed for message %d: %v", msg.MessageID, err) - } - }() -} - -func (w *InboundWebhook) deliver(ctx context.Context, payload inboundWebhookPayload) error { - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("encode payload: %w", err) - } - var lastErr error - for attempt := 1; attempt <= webhookAttempts; attempt++ { - if attempt > 1 { - timer := time.NewTimer(time.Duration(attempt-1) * time.Second) - select { - case <-ctx.Done(): - timer.Stop() - return ctx.Err() - case <-timer.C: - } - } - req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body)) - if reqErr != nil { - return fmt.Errorf("create request: %w", reqErr) - } - req.Header.Set("Content-Type", "application/json") - if w.token != "" { - req.Header.Set("Authorization", "Bearer "+w.token) - } - resp, doErr := w.client.Do(req) - if doErr != nil { - lastErr = doErr - continue - } - responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - resp.Body.Close() - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return nil - } - lastErr = fmt.Errorf("status %s: %s", resp.Status, strings.TrimSpace(string(responseBody))) - if resp.StatusCode >= 400 && resp.StatusCode < 500 { - break - } - } - return lastErr -} - -func normalizeInboundMessage(accountID string, msg ilink.WeixinMessage) inboundWebhookPayload { - items := make([]inboundWebhookItem, 0, len(msg.ItemList)) - for _, item := range msg.ItemList { - normalized := inboundWebhookItem{Type: item.Type} - if item.TextItem != nil { - normalized.Text = item.TextItem.Text - } else if item.VoiceItem != nil { - normalized.Text = item.VoiceItem.Text - } - items = append(items, normalized) - } - return inboundWebhookPayload{ - AccountID: accountID, - FromUserID: msg.FromUserID, - MessageID: msg.MessageID, - MessageType: msg.MessageType, - Items: items, - ReceivedAt: time.Now().UTC(), - } -} diff --git a/services/wechat-connector/messaging/inbound_webhook_test.go b/services/wechat-connector/messaging/inbound_webhook_test.go deleted file mode 100644 index c40a4f3..0000000 --- a/services/wechat-connector/messaging/inbound_webhook_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package messaging - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink" -) - -func TestInboundWebhookDeliversNormalizedPayload(t *testing.T) { - var got inboundWebhookPayload - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer secret" { - t.Errorf("authorization = %q", r.Header.Get("Authorization")) - } - if err := json.NewDecoder(r.Body).Decode(&got); err != nil { - t.Errorf("decode: %v", err) - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - webhook := NewInboundWebhook(server.URL, "secret") - err := webhook.deliver(context.Background(), normalizeInboundMessage("bot-new", ilink.WeixinMessage{ - MessageID: 7, FromUserID: "user-1", MessageType: ilink.MessageTypeUser, - ItemList: []ilink.MessageItem{{Type: ilink.ItemTypeText, TextItem: &ilink.TextItem{Text: "最近5条消息"}}}, - })) - if err != nil { - t.Fatalf("deliver: %v", err) - } - if got.AccountID != "bot-new" || got.MessageID != 7 || len(got.Items) != 1 || got.Items[0].Text != "最近5条消息" { - t.Fatalf("payload = %#v", got) - } -} - -func TestInboundWebhookRetriesServerErrors(t *testing.T) { - var calls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - if calls.Add(1) < 3 { - http.Error(w, "temporary", http.StatusServiceUnavailable) - return - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - webhook := NewInboundWebhook(server.URL, "") - if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err != nil { - t.Fatalf("deliver: %v", err) - } - if calls.Load() != 3 { - t.Fatalf("calls = %d, want 3", calls.Load()) - } -} - -func TestInboundWebhookDoesNotRetryClientErrors(t *testing.T) { - var calls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - calls.Add(1) - http.Error(w, "unauthorized", http.StatusUnauthorized) - })) - defer server.Close() - - webhook := NewInboundWebhook(server.URL, "") - if err := webhook.deliver(context.Background(), inboundWebhookPayload{}); err == nil { - t.Fatal("deliver error = nil") - } - if calls.Load() != 1 { - t.Fatalf("calls = %d, want 1", calls.Load()) - } -} diff --git a/services/wechat-connector/messaging/markdown.go b/services/wechat-connector/messaging/markdown.go deleted file mode 100644 index 012b47b..0000000 --- a/services/wechat-connector/messaging/markdown.go +++ /dev/null @@ -1,103 +0,0 @@ -package messaging - -import ( - "regexp" - "strings" -) - -var ( - // Code blocks: strip fences, keep code content - reCodeBlock = regexp.MustCompile("(?s)```[^\n]*\n?(.*?)```") - // Inline code: strip backticks, keep content - reInlineCode = regexp.MustCompile("`([^`]+)`") - // Images: remove entirely - reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`) - // Links: keep display text only - reLink = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`) - // Table separator rows: remove - reTableSep = regexp.MustCompile(`(?m)^\|[\s:|\-]+\|$`) - // Table rows: convert pipe-delimited to space-delimited - reTableRow = regexp.MustCompile(`(?m)^\|(.+)\|$`) - // Headers: remove # prefix - reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+`) - // Bold: **text** or __text__ - reBold = regexp.MustCompile(`\*\*(.+?)\*\*|__(.+?)__`) - // Italic: *text* or _text_ - reItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)|(?:^|[^_])_([^_]+)_(?:[^_]|$)`) - // Strikethrough: ~~text~~ - reStrike = regexp.MustCompile(`~~(.+?)~~`) - // Blockquote: > prefix - reBlockquote = regexp.MustCompile(`(?m)^>\s?`) - // Horizontal rule - reHR = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`) - // Unordered list markers: -, *, + - reUL = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`) -) - -// MarkdownToPlainText converts markdown to readable plain text for WeChat. -func MarkdownToPlainText(text string) string { - result := text - - // Code blocks: strip fences, keep code content - result = reCodeBlock.ReplaceAllStringFunc(result, func(match string) string { - parts := reCodeBlock.FindStringSubmatch(match) - if len(parts) > 1 { - return strings.TrimSpace(parts[1]) - } - return match - }) - - // Images: remove entirely - result = reImage.ReplaceAllString(result, "") - - // Links: keep display text only - result = reLink.ReplaceAllString(result, "$1") - - // Table separator rows: remove - result = reTableSep.ReplaceAllString(result, "") - - // Table rows: pipe-delimited to space-delimited - result = reTableRow.ReplaceAllStringFunc(result, func(match string) string { - parts := reTableRow.FindStringSubmatch(match) - if len(parts) > 1 { - cells := strings.Split(parts[1], "|") - for i := range cells { - cells[i] = strings.TrimSpace(cells[i]) - } - return strings.Join(cells, " ") - } - return match - }) - - // Headers: remove # prefix - result = reHeader.ReplaceAllString(result, "") - - // Bold - result = reBold.ReplaceAllStringFunc(result, func(match string) string { - parts := reBold.FindStringSubmatch(match) - if parts[1] != "" { - return parts[1] - } - return parts[2] - }) - - // Strikethrough - result = reStrike.ReplaceAllString(result, "$1") - - // Blockquote - result = reBlockquote.ReplaceAllString(result, "") - - // Horizontal rule -> empty line - result = reHR.ReplaceAllString(result, "") - - // Unordered list: replace markers with "• " - result = reUL.ReplaceAllString(result, "${1}• ") - - // Inline code: strip backticks (do after code blocks) - result = reInlineCode.ReplaceAllString(result, "$1") - - // Clean up excessive blank lines - result = regexp.MustCompile(`\n{3,}`).ReplaceAllString(result, "\n\n") - - return strings.TrimSpace(result) -} diff --git a/services/wechat-connector/messaging/media.go b/services/wechat-connector/messaging/media.go deleted file mode 100644 index 043eeb3..0000000 --- a/services/wechat-connector/messaging/media.go +++ /dev/null @@ -1,221 +0,0 @@ -package messaging - -import ( - "context" - "fmt" - "io" - "log" - "mime" - "net/http" - "os" - "path/filepath" - "regexp" - "strings" - "time" - - "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink" -) - -// reMarkdownImage matches markdown image syntax: ![alt](url) -var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`) - -// ExtractImageURLs extracts image URLs from markdown text. -func ExtractImageURLs(text string) []string { - matches := reMarkdownImage.FindAllStringSubmatch(text, -1) - var urls []string - for _, m := range matches { - url := strings.TrimSpace(m[1]) - if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { - urls = append(urls, url) - } - } - return urls -} - -// SendMediaFromURL sends a local file or downloads from a URL and sends it as a media message. -func SendMediaFromURL(ctx context.Context, client *ilink.Client, toUserID, mediaURL, contextToken string) error { - // Check if it's a local file - if _, err := os.Stat(mediaURL); err == nil { - return SendMediaFromPath(ctx, client, toUserID, mediaURL, contextToken) - } - // Must be a valid HTTP URL to download - if !strings.HasPrefix(mediaURL, "http://") && !strings.HasPrefix(mediaURL, "https://") { - return fmt.Errorf("unsupported media path (not a local file and not an HTTP URL): %s", mediaURL) - } - data, contentType, err := downloadFile(ctx, mediaURL) - if err != nil { - return fmt.Errorf("download %s: %w", mediaURL, err) - } - - return sendMediaData(ctx, client, toUserID, filenameFromURL(mediaURL), mediaURL, data, contentType, contextToken) -} - -// SendMediaFromPath reads a local file and sends it as a media message. -func SendMediaFromPath(ctx context.Context, client *ilink.Client, toUserID, path, contextToken string) error { - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("read %s: %w", path, err) - } - - return sendMediaData(ctx, client, toUserID, filepath.Base(path), path, data, inferContentType(path), contextToken) -} - -func sendMediaData(ctx context.Context, client *ilink.Client, toUserID, fileName, source string, data []byte, contentType, contextToken string) error { - if fileName == "" { - fileName = "file" - } - - cdnMediaType, itemType := classifyMedia(contentType, source) - - log.Printf("[media] uploading %s (%s, %d bytes) for %s", source, contentType, len(data), toUserID) - - uploaded, err := UploadFileToCDN(ctx, client, data, toUserID, cdnMediaType) - if err != nil { - return fmt.Errorf("upload to CDN: %w", err) - } - - media := &ilink.MediaInfo{ - EncryptQueryParam: uploaded.DownloadParam, - AESKey: AESKeyToBase64(uploaded.AESKeyHex), - EncryptType: 1, - } - - var item ilink.MessageItem - switch itemType { - case ilink.ItemTypeImage: - item = ilink.MessageItem{ - Type: ilink.ItemTypeImage, - ImageItem: &ilink.ImageItem{ - Media: media, - MidSize: uploaded.CipherSize, - }, - } - case ilink.ItemTypeVideo: - item = ilink.MessageItem{ - Type: ilink.ItemTypeVideo, - VideoItem: &ilink.VideoItem{ - Media: media, - VideoSize: uploaded.CipherSize, - }, - } - default: - item = ilink.MessageItem{ - Type: ilink.ItemTypeFile, - FileItem: &ilink.FileItem{ - Media: media, - FileName: fileName, - Len: fmt.Sprintf("%d", uploaded.FileSize), - }, - } - } - - req := &ilink.SendMessageRequest{ - Msg: ilink.SendMsg{ - FromUserID: client.BotID(), - ToUserID: toUserID, - ClientID: NewClientID(), - MessageType: ilink.MessageTypeBot, - MessageState: ilink.MessageStateFinish, - ItemList: []ilink.MessageItem{item}, - ContextToken: contextToken, - }, - BaseInfo: ilink.BaseInfo{}, - } - - resp, err := client.SendMessage(ctx, req) - if err != nil { - return fmt.Errorf("send media message: %w", err) - } - if resp.Ret != 0 { - return fmt.Errorf("send media failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg) - } - - log.Printf("[media] sent %s to %s from %s", contentType, toUserID, source) - return nil -} - -func downloadFile(ctx context.Context, url string) ([]byte, string, error) { - ctx, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, "", err - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, "", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, "", err - } - - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = inferContentType(url) - } - - return data, contentType, nil -} - -func classifyMedia(contentType, url string) (cdnMediaType int, itemType int) { - ct := strings.ToLower(contentType) - - if strings.HasPrefix(ct, "image/") || isImageExt(url) { - return ilink.CDNMediaTypeImage, ilink.ItemTypeImage - } - if strings.HasPrefix(ct, "video/") || isVideoExt(url) { - return ilink.CDNMediaTypeVideo, ilink.ItemTypeVideo - } - return ilink.CDNMediaTypeFile, ilink.ItemTypeFile -} - -func isImageExt(url string) bool { - ext := strings.ToLower(filepath.Ext(stripQuery(url))) - switch ext { - case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp": - return true - } - return false -} - -func isVideoExt(url string) bool { - ext := strings.ToLower(filepath.Ext(stripQuery(url))) - switch ext { - case ".mp4", ".mov", ".webm", ".mkv", ".avi": - return true - } - return false -} - -func inferContentType(url string) string { - ext := filepath.Ext(stripQuery(url)) - if ct := mime.TypeByExtension(ext); ct != "" { - return ct - } - return "application/octet-stream" -} - -func filenameFromURL(rawURL string) string { - u := stripQuery(rawURL) - name := filepath.Base(u) - if name == "" || name == "." || name == "/" { - return "file" - } - return name -} - -func stripQuery(rawURL string) string { - if i := strings.IndexByte(rawURL, '?'); i >= 0 { - return rawURL[:i] - } - return rawURL -} diff --git a/services/wechat-connector/messaging/media_test.go b/services/wechat-connector/messaging/media_test.go deleted file mode 100644 index 38029b6..0000000 --- a/services/wechat-connector/messaging/media_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package messaging - -import "testing" - -func TestExtractImageURLs(t *testing.T) { - text := "check ![img](https://example.com/a.png) and ![](https://example.com/b.jpg)" - urls := ExtractImageURLs(text) - if len(urls) != 2 { - t.Fatalf("expected 2 urls, got %d", len(urls)) - } - if urls[0] != "https://example.com/a.png" { - t.Errorf("urls[0] = %q", urls[0]) - } - if urls[1] != "https://example.com/b.jpg" { - t.Errorf("urls[1] = %q", urls[1]) - } -} - -func TestExtractImageURLs_NoImages(t *testing.T) { - urls := ExtractImageURLs("just plain text") - if len(urls) != 0 { - t.Errorf("expected 0 urls, got %d", len(urls)) - } -} - -func TestExtractImageURLs_RelativeURL(t *testing.T) { - text := "![img](./local.png)" - urls := ExtractImageURLs(text) - if len(urls) != 0 { - t.Errorf("expected 0 urls for relative path, got %d", len(urls)) - } -} - -func TestFilenameFromURL(t *testing.T) { - tests := []struct { - url string - want string - }{ - {"https://example.com/photo.png", "photo.png"}, - {"https://example.com/path/to/report.pdf", "report.pdf"}, - {"https://example.com/file", "file"}, - } - for _, tt := range tests { - got := filenameFromURL(tt.url) - if got != tt.want { - t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want) - } - } -} - -func TestFilenameFromURL_WithQuery(t *testing.T) { - got := filenameFromURL("https://example.com/photo.png?token=abc") - if got != "photo.png" { - t.Errorf("got %q, want %q", got, "photo.png") - } -} - -func TestStripQuery(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"https://example.com/a?b=c", "https://example.com/a"}, - {"https://example.com/a", "https://example.com/a"}, - {"https://example.com/?x=1&y=2", "https://example.com/"}, - } - for _, tt := range tests { - got := stripQuery(tt.input) - if got != tt.want { - t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} diff --git a/services/wechat-connector/messaging/sender.go b/services/wechat-connector/messaging/sender.go deleted file mode 100644 index 1bed0c8..0000000 --- a/services/wechat-connector/messaging/sender.go +++ /dev/null @@ -1,86 +0,0 @@ -package messaging - -import ( - "context" - "fmt" - "log" - - "github.com/Wxw-Gu/WechatExplorer/services/wechat-connector/ilink" - "github.com/google/uuid" -) - -// NewClientID generates a new unique client ID for message correlation. -func NewClientID() string { - return uuid.New().String() -} - -// SendTypingState sends a typing indicator to a user via the iLink sendtyping API. -// It first fetches a typing_ticket via getconfig, then sends the typing status. -func SendTypingState(ctx context.Context, client *ilink.Client, userID, contextToken string) error { - // Get typing ticket - configResp, err := client.GetConfig(ctx, userID, contextToken) - if err != nil { - return fmt.Errorf("get config for typing: %w", err) - } - if configResp.TypingTicket == "" { - return fmt.Errorf("no typing_ticket returned from getconfig") - } - - // Send typing - if err := client.SendTyping(ctx, userID, configResp.TypingTicket, ilink.TypingStatusTyping); err != nil { - return fmt.Errorf("send typing: %w", err) - } - - log.Printf("[sender] sent typing indicator to %s", userID) - return nil -} - -// SendTextReply sends a text reply to a user through the iLink API. -// If clientID is empty, a new one is generated. -func SendTextReply(ctx context.Context, client *ilink.Client, toUserID, text, contextToken, clientID string) error { - if clientID == "" { - clientID = NewClientID() - } - - // Convert markdown to plain text for WeChat display - plainText := MarkdownToPlainText(text) - - req := &ilink.SendMessageRequest{ - Msg: ilink.SendMsg{ - FromUserID: client.BotID(), - ToUserID: toUserID, - ClientID: clientID, - MessageType: ilink.MessageTypeBot, - MessageState: ilink.MessageStateFinish, - ItemList: []ilink.MessageItem{ - { - Type: ilink.ItemTypeText, - TextItem: &ilink.TextItem{ - Text: plainText, - }, - }, - }, - ContextToken: contextToken, - }, - BaseInfo: ilink.BaseInfo{}, - } - - resp, err := client.SendMessage(ctx, req) - if err != nil { - return fmt.Errorf("send message: %w", err) - } - - if resp.Ret != 0 { - return fmt.Errorf("send message failed: ret=%d errmsg=%s", resp.Ret, resp.ErrMsg) - } - - log.Printf("[sender] sent reply to %s: %q", toUserID, truncate(text, 50)) - return nil -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "..." -} diff --git a/src/main/index.ts b/src/main/index.ts index a0d0830..8be05fb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -109,6 +109,8 @@ import { } from './services/bootstrap-cache' import { installSafeConsole } from './safe-log' import { agentHubService } from './services/agent-hub-service' +import { WechatConnectorService } from './services/wechat-ilink' +import { wechatSendGateway } from './services/wechat-send-gateway' import { groupExitMonitorService } from './services/group-exit-monitor-service' import { wechatActionLogService } from './services/wechat-action-log-service' import { wechatActionGateway } from './services/wechat-action-gateway' @@ -196,6 +198,12 @@ import type { // handler. Wrap console.* before any other module logs anything. installSafeConsole() +/** + * 进程内微信 iLink 连接器:inbound 回调与 outbound 发送都在主进程内完成, + * 不依赖子进程,也不开本地 HTTP 端口。 + */ +const wechatConnectorService = new WechatConnectorService() + let voiceService: VoiceService | null = null let voiceRecognition: VoiceRecognitionUseCase | null = null let voiceBatchService: VoiceBatchService | null = null @@ -774,6 +782,28 @@ app.whenReady().then(async () => { }) }) agentHubService.setQueryAgentService(queryAgentService) + // 微信 iLink 连接器直接跑在主进程内:inbound 回调与 outbound 发送都不经过本地 HTTP 桥。 + agentHubService.setWechatConnector(wechatConnectorService) + wechatSendGateway.configureIlinkSender(async (request) => { + const target = { + to: request.to, + ...(request.account_id ? { accountId: request.account_id } : {}), + ...(request.context_token ? { contextToken: request.context_token } : {}) + } + if (request.type === 'text') { + await wechatConnectorService.sendText({ ...target, text: request.msg }) + return + } + if (request.type === 'image' || request.type === 'file') { + if (/^https?:\/\//i.test(request.msg)) { + await wechatConnectorService.sendMediaUrl({ ...target, mediaUrl: request.msg }) + } else { + await wechatConnectorService.sendMediaPath({ ...target, filePath: request.msg }) + } + return + } + throw new Error('iLink 通道暂不支持发送语音') + }) knowledgeSearchService.onStatusChange((status) => { for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) window.webContents.send('knowledge:status', status) @@ -2292,6 +2322,15 @@ app.whenReady().then(async () => { 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:getConversations', () => agentHubService.listConversations()) + ipcMain.handle('agent-hub:getConversation', (_, userId: string) => + agentHubService.getConversation(String(userId || '')) + ) + ipcMain.handle('agent-hub:clearConversations', () => { + agentHubService.clearConversations() + return { success: true } + }) ipcMain.handle('wechat-personal:getStatus', () => personalWechatSendService.getStatus()) ipcMain.handle('wechat-personal:getKeepProcess', () => personalWechatSendService.getKeepOneBotProcess() diff --git a/src/main/services/agent-hub-conversation-store.ts b/src/main/services/agent-hub-conversation-store.ts new file mode 100644 index 0000000..1e3c2a4 --- /dev/null +++ b/src/main/services/agent-hub-conversation-store.ts @@ -0,0 +1,193 @@ +import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { randomUUID } from 'node:crypto' +import { + agentHubKindPlaceholder, + type AgentHubConversation, + type AgentHubConversationMessage, + type AgentHubConversationSummary, + type AgentHubMessageKind, + type AgentHubMessageStatus +} from '../../shared/agent-hub-conversation' + +/** + * Agent Hub 对话记录存储。 + * + * 设计取向: + * - 一个 JSON 文件装全部会话,按「最近活跃」排序,避免为几十个会话开目录; + * - 每个会话最多保留 N 条(默认 500),超出丢最旧的,文件不会无限增长; + * - 会话数也有上限(默认 50),只保留最近活跃的; + * - 原子写(tmp + rename)+ 0600,任何一个环节失败都不能影响真实收发。 + * + * 与 `WechatInboundInbox` 的区别:收件箱是**待处理的在途消息**(处理完即删), + * 这里是**供人回看的历史**(按上限长期保留)。 + */ + +const DEFAULT_MAX_MESSAGES_PER_CONVERSATION = 500 +const DEFAULT_MAX_CONVERSATIONS = 50 + +interface ConversationFile { + version: 1 + conversations: AgentHubConversation[] +} + +export interface AgentHubConversationStoreOptions { + filePath: () => string + maxMessagesPerConversation?: number + maxConversations?: number + now?: () => number + createId?: () => string +} + +export interface AppendInput { + userId: string + accountId?: string + direction: 'in' | 'out' + kind: AgentHubMessageKind + text: string + messageId?: string + status?: AgentHubMessageStatus + errorCode?: string + createdAt?: number +} + +export class AgentHubConversationStore { + private readonly options: AgentHubConversationStoreOptions + private readonly maxMessages: number + private readonly maxConversations: number + private cache: AgentHubConversation[] | null = null + + constructor(options: AgentHubConversationStoreOptions) { + this.options = options + this.maxMessages = options.maxMessagesPerConversation ?? DEFAULT_MAX_MESSAGES_PER_CONVERSATION + this.maxConversations = options.maxConversations ?? DEFAULT_MAX_CONVERSATIONS + } + + /** 追加一条消息,返回受影响会话的摘要与这条消息(供 UI 增量刷新)。 */ + append( + input: AppendInput + ): { summary: AgentHubConversationSummary; message: AgentHubConversationMessage } | null { + const userId = String(input.userId || '').trim() + if (!userId) return null + + const createdAt = input.createdAt ?? this.options.now?.() ?? Date.now() + const message: AgentHubConversationMessage = { + id: this.options.createId?.() ?? randomUUID(), + direction: input.direction, + kind: input.kind, + text: String(input.text ?? ''), + createdAt, + ...(input.status ? { status: input.status } : {}), + ...(input.errorCode ? { errorCode: input.errorCode } : {}), + ...(input.messageId ? { messageId: input.messageId } : {}) + } + + try { + const conversations = this.load() + const index = conversations.findIndex((item) => item.userId === userId) + if (index >= 0) { + const existing = conversations[index] + const messages = [...existing.messages, message].slice(-this.maxMessages) + conversations[index] = { + ...existing, + ...(input.accountId ? { accountId: input.accountId } : {}), + lastAt: createdAt, + messages + } + } else { + conversations.push({ + userId, + ...(input.accountId ? { accountId: input.accountId } : {}), + firstAt: createdAt, + lastAt: createdAt, + messages: [message] + }) + } + + // 只保留最近活跃的 maxConversations 个会话。 + const trimmed = conversations + .sort((left, right) => right.lastAt - left.lastAt) + .slice(0, this.maxConversations) + this.persist(trimmed) + this.cache = trimmed + const summary = this.toSummary(trimmed.find((item) => item.userId === userId) ?? null) + return summary ? { summary, message } : null + } catch (error) { + // 记录历史失败绝不能影响真实收发。 + console.warn('[AgentHubConversation] 写入对话记录失败:', error) + return null + } + } + + listSummaries(): AgentHubConversationSummary[] { + return this.load() + .slice() + .sort((left, right) => right.lastAt - left.lastAt) + .map((conversation) => this.toSummary(conversation)) + .filter((summary): summary is AgentHubConversationSummary => summary !== null) + } + + get(userId: string): AgentHubConversation | null { + const normalized = String(userId || '').trim() + if (!normalized) return null + const found = this.load().find((item) => item.userId === normalized) + if (!found) return null + return { ...found, messages: found.messages.map((message) => ({ ...message })) } + } + + clear(): void { + this.persist([]) + this.cache = [] + } + + private toSummary(conversation: AgentHubConversation | null): AgentHubConversationSummary | null { + if (!conversation) return null + const last = conversation.messages[conversation.messages.length - 1] + const preview = last ? last.text.trim() || agentHubKindPlaceholder(last.kind) : '' + return { + userId: conversation.userId, + ...(conversation.accountId ? { accountId: conversation.accountId } : {}), + firstAt: conversation.firstAt, + lastAt: conversation.lastAt, + messageCount: conversation.messages.length, + lastPreview: preview, + lastDirection: last?.direction ?? 'in' + } + } + + private load(): AgentHubConversation[] { + if (this.cache) return this.cache + let parsed: ConversationFile | null = null + try { + parsed = JSON.parse(readFileSync(this.options.filePath(), 'utf8')) as ConversationFile + } catch { + parsed = null + } + const conversations = Array.isArray(parsed?.conversations) + ? parsed!.conversations.filter((item): item is AgentHubConversation => + Boolean(item && typeof item === 'object' && String(item.userId || '').trim()) + ) + : [] + this.cache = conversations + return conversations + } + + private persist(conversations: AgentHubConversation[]): void { + const path = this.options.filePath() + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + const tempPath = `${path}.tmp-${process.pid}-${Date.now()}` + try { + writeFileSync( + tempPath, + JSON.stringify({ version: 1, conversations } satisfies ConversationFile), + { encoding: 'utf8', mode: 0o600 } + ) + chmodSync(tempPath, 0o600) + renameSync(tempPath, path) + chmodSync(path, 0o600) + } catch (error) { + rmSync(tempPath, { force: true }) + throw error + } + } +} diff --git a/src/main/services/agent-hub-service.ts b/src/main/services/agent-hub-service.ts index a1b98c3..fd09afc 100644 --- a/src/main/services/agent-hub-service.ts +++ b/src/main/services/agent-hub-service.ts @@ -1,17 +1,15 @@ import { app, BrowserWindow } from 'electron' -import { ChildProcess, execFile, spawn } from 'child_process' -import { randomBytes, timingSafeEqual } from 'crypto' -import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' -import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http' +import { mkdirSync, readFileSync, writeFileSync } from 'fs' import { dirname, join } from 'path' -import { promisify } from 'util' import type { AgentHubActionResult, AgentHubLogEntry, AgentHubLogLevel, AgentHubLogSource, - AgentHubStatus + AgentHubStatus, + WechatConnectorStatus } from '../../shared/agent-hub' +import type { WechatSendErrorCode, WechatSendResult } from '../../shared/wechat-send' import type { AppSettings } from './settings-store' import { generateAgentGroupReport } from './agent-group-report-service' import { AIProviderService } from './ai-provider-service' @@ -24,7 +22,6 @@ import { type GroupMemberChatIntent, type GroupReportIntent } from './agent-hub-routing' -import { isPackagedRuntime } from '../runtime-mode' import { getGroupSnapshot, isReady, @@ -33,20 +30,95 @@ import { listRecentChat, resolveMd5 } from './chat-service' +import { redactSecrets } from './log-redaction' +import type { + AgentHubConversation as AgentHubConversationRecord, + AgentHubConversationMessage, + AgentHubConversationSummary, + AgentHubMessageKind +} from '../../shared/agent-hub-conversation' +import { AgentHubConversationStore } from './agent-hub-conversation-store' +import { buildSendPreview } from '../../shared/wechat-send' +import { wechatSendGateway } from './wechat-send-gateway' +import { WechatInboundInbox, type WechatInboundInboxEntry } from './wechat-inbound-inbox' +import { extractInboundText } from './wechat-ilink/messages' +import { extractMarkdownImageUrls } from './wechat-ilink/markdown' +import type { + WechatConnectorAccount, + WechatConnectorPhase, + WechatInboundItem, + WechatInboundMessage, + WechatLoginEvent +} from './wechat-ilink/types' -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 +const HANDLED_MESSAGE_TTL_MS = 10 * 60_000 +const INBOUND_RETRY_DELAY_MS = 2_000 +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']) -interface InboundMessage { - account_id?: string - from_user_id?: string - message_id?: string | number - items?: Array<{ type?: number; text?: string }> +/** 连接器对 Agent Hub 暴露的最小接口;测试可注入等价实现。 */ +export interface AgentHubWechatConnectorPort { + setHost(host: { + onLog?: (level: 'info' | 'warn' | 'error', message: string) => void + onMessages?: (messages: WechatInboundMessage[]) => Promise + onPhaseChange?: (phase: WechatConnectorPhase, error?: string) => void + onLoginEvent?: (event: WechatLoginEvent) => void + }): void + listAccounts(): WechatConnectorAccount[] + startLogin(): void + cancelLogin(): void + submitVerifyCode(code: string): void + isLoginInProgress(): boolean + start(accountId: string): Promise + stop(): Promise + getPhase(): WechatConnectorPhase + getActiveAccountId(): string | undefined + resolveContextToken(accountId: string | undefined, toUserId: string): string | undefined + /** 开始微信原生「正在输入」;返回的 lease 必须 stop,且 stop 永不抛异常。 */ + beginTyping(input: { + accountId?: string + to: string + contextToken?: string + }): Promise<{ stop(): Promise }> + sendText(input: { + to: string + text: string + accountId?: string + contextToken?: string + }): Promise + sendMediaPath(input: { + to: string + filePath: string + accountId?: string + contextToken?: string + }): Promise + sendMediaUrl(input: { + to: string + mediaUrl: string + accountId?: string + contextToken?: string + }): Promise +} + +export interface AgentHubSendPort { + send(input: unknown): Promise +} + +export interface AgentHubServiceOptions { + /** 便于测试注入独立收件箱。 */ + inbox?: WechatInboundInbox + /** 便于测试注入独立对话记录存储。 */ + conversationStore?: AgentHubConversationStore + /** 入站处理失败后的重试间隔。 */ + inboundRetryDelayMs?: number +} + +/** 一次会话的寻址信息:发给谁、用哪个账号、回哪个会话线程。 */ +interface AgentHubTarget { + accountId?: string + to: string + contextToken?: string } interface AgentHubNotificationRecipient { @@ -64,47 +136,62 @@ export interface AgentHubNotificationResult { const agentAIProvider = new AIProviderService() -function resolveBundledBinary( - resourceSegments: string[], - executable: string, - packaged = isPackagedRuntime(), - 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] +/** 把入站 item 列表压成一条可展示记录的类型与文本。 */ +function describeInboundContent(items: WechatInboundItem[]): { + kind: AgentHubMessageKind + text: string +} { + const text = extractInboundText(items) + if (text) return { kind: 'text', text } + const firstTyped = items.find((item) => item.type !== 1) + switch (firstTyped?.type) { + case 2: + return { kind: 'image', text: '' } + case 3: + return { kind: 'voice', text: '' } + case 4: + return { kind: 'file', text: '' } + case 5: + return { kind: 'video', text: '' } + default: + return { kind: 'system', text: '' } + } } -export function resolveWechatConnectorBinaryPath( - packaged = isPackagedRuntime(), - platform = process.platform, - arch = process.arch -): string { - return resolveBundledBinary( - ['connectors', 'wechat'], - platform === 'win32' ? 'wechat-connector.exe' : 'wechat-connector', - packaged, - platform, - arch - ) +/** 没有连接器 / typing 不可用时的空实现:调用方无需写分支。 */ +const NOOP_TYPING_LEASE = { stop: async (): Promise => undefined } + +class AgentHubDeliveryError extends Error { + readonly errorCode?: WechatSendErrorCode + + constructor(message: string, errorCode?: WechatSendErrorCode) { + super(message) + this.name = 'AgentHubDeliveryError' + if (errorCode) this.errorCode = errorCode + } } +/** + * Agent Hub。 + * + * 入站消息由 WechatConnectorService 在进程内直接回调,出站统一走 WechatSendGateway, + * 不经过任何本地 HTTP 桥。Query Agent / 群日报 / 成员分析 / 最近聊天 / 通知接收者 + * 等业务逻辑与 transport 无关。 + */ export class AgentHubService { - private hubServer: Server | null = null - private connectorChild: ChildProcess | null = null - private loginChild: ChildProcess | null = null + private connector: AgentHubWechatConnectorPort | null = null + private sendGateway: AgentHubSendPort = wechatSendGateway + private inbox: WechatInboundInbox + private readonly conversationStore: AgentHubConversationStore + private pumping = false private stopping = false + private readonly inboundRetryDelayMs: number private healthTimer: NodeJS.Timeout | null = null private logs: AgentHubLogEntry[] = [] private nextLogId = 1 - private readonly processedMessages = new Map() + private readonly recentlyHandled = new Map() private notificationRecipient: AgentHubNotificationRecipient | null = null private notificationRecipientLoaded = false - private readonly inboundToken = - process.env['AGENT_HUB_INBOUND_TOKEN'] || randomBytes(32).toString('hex') private status: AgentHubStatus = { hub: 'offline', connector: 'checking', @@ -118,6 +205,48 @@ export class AgentHubService { */ private queryAgent: AskWechatService | null = null + constructor(options: AgentHubServiceOptions = {}) { + this.inbox = + options.inbox ?? new WechatInboundInbox({ filePath: () => this.inboundInboxPath() }) + this.inboundRetryDelayMs = options.inboundRetryDelayMs ?? INBOUND_RETRY_DELAY_MS + this.conversationStore = + options.conversationStore ?? + new AgentHubConversationStore({ filePath: () => this.conversationStorePath() }) + } + + /* ------------------------------------------------------------------ */ + /* 对话记录(收发回看) */ + /* ------------------------------------------------------------------ */ + + listConversations(): AgentHubConversationSummary[] { + return this.conversationStore.listSummaries() + } + + getConversation(userId: string): AgentHubConversationRecord | null { + return this.conversationStore.get(userId) + } + + clearConversations(): void { + this.conversationStore.clear() + this.broadcastConversationCleared() + } + + /** 注入微信 iLink 连接器(生产为 WechatConnectorService)。 */ + setWechatConnector(connector: AgentHubWechatConnectorPort): void { + this.connector = connector + connector.setHost({ + onLog: (level, message) => this.addLog('wechat-connector', level, message), + onMessages: (messages) => this.handleInboundMessages(messages), + onPhaseChange: (phase, error) => this.applyConnectorPhase(phase, error), + onLoginEvent: (event) => this.handleLoginEvent(event) + }) + } + + /** 注入统一发送入口;默认使用进程级 WechatSendGateway。 */ + setSendGateway(gateway: AgentHubSendPort): void { + this.sendGateway = gateway + } + /** * 注入生产 Query Agent Runtime(桌面与微信机器人共用同一实现,避免第二套 Query 语义)。 */ @@ -134,9 +263,16 @@ export class AgentHubService { void settings this.stopping = false this.loadNotificationRecipient() - const hubStarted = await this.startHub() + + // Hub 现在是主进程内的服务,不再有本地 HTTP 监听端口。 + this.patchStatus({ hub: 'starting' }) + this.patchStatus({ hub: 'online', error: undefined }) + this.addLog('system', 'info', 'Agent Hub 已在主进程内启动(不再使用本地 HTTP 桥)') + this.scheduleHealthCheck() + await this.initializeConnector() - return hubStarted + this.resumePendingInbound() + return true } getStatus(): AgentHubStatus { @@ -164,21 +300,13 @@ export class AgentHubService { } const accountId = this.notificationRecipient?.accountId || this.status.accountId try { - const response = await this.postConnectorMessage({ - accountId, - to, - text, - timeoutMs: 30_000 - }) - if (response.ok) return { success: true, status: 'sent', recipient: to } - const expired = /token|session|expired|unauthorized/i.test(response.body) + const result = await this.deliver({ accountId, to, text }) + if (result.success) return { success: true, status: 'sent', recipient: to } return { success: false, - status: expired ? 'token_expired' : 'send_failed', + status: this.mapDeliveryStatus(result), recipient: to, - error: expired - ? 'Agent Hub 微信连接器登录凭证已失效' - : `Agent Hub 通知发送失败:${response.body || response.status}` + error: result.error || `Agent Hub 通知发送失败(${result.error_code || 'SEND_FAILED'})` } } catch (error) { return { @@ -220,98 +348,78 @@ export class AgentHubService { } } try { - const response = await this.postConnectorMessage({ + const result = await this.deliver({ accountId: this.status.accountId, to, - text: text || undefined, - mediaUrl: mediaUrl || undefined, - timeoutMs: 30_000 + ...(text ? { text } : {}), + ...(mediaUrl ? { mediaUrl } : {}) }) - if (response.ok) { + if (result.success) { this.addLog('system', 'info', 'API 页面发送测试成功') return { success: true, status: 'sent', message: '发送成功' } } - const expired = /token|session|expired|unauthorized/i.test(response.body) return { success: false, - status: expired ? 'token_expired' : 'send_failed', - message: expired - ? '微信登录凭证已失效,请重新扫码登录' - : `发送失败:${response.body || response.status}` + status: + result.error_code === 'STALE_TOKEN' + ? 'token_expired' + : result.error_code === 'TRANSPORT_UNAVAILABLE' + ? 'connector_offline' + : result.error_code === 'INVALID_REQUEST' + ? 'invalid_request' + : 'send_failed', + message: + result.error_code === 'STALE_TOKEN' + ? '微信登录凭证已失效,请重新扫码登录' + : `发送失败:${result.error || result.error_code || 'SEND_FAILED'}` } } catch (error) { return { success: false, status: 'connector_offline', - message: `微信连接器不可用:${error instanceof Error ? error.message : String(error)}` + message: `微信连接器不可用:${this.errorMessage(error)}` } } } async startLogin(): Promise { - if (this.loginChild && this.loginChild.exitCode === null) { - return { success: true, status: this.getStatus() } - } - const executable = resolveWechatConnectorBinaryPath() - if (!existsSync(executable)) { - return this.fail(`微信连接器不存在:${executable}`) - } - - this.stopConnector() + const connector = this.connector + if (!connector) return this.fail('微信连接器未初始化') + if (connector.isLoginInProgress()) return { success: true, status: this.getStatus() } + void 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}` }) - } - }) + connector.startLogin() return { success: true, status: this.getStatus() } } cancelLogin(): AgentHubActionResult { - if (this.loginChild && this.loginChild.exitCode === null) this.loginChild.kill() - this.loginChild = null + this.connector?.cancelLogin() this.patchStatus({ connector: 'disconnected', qrCodeDataUrl: undefined, error: undefined }) return { success: true, status: this.getStatus() } } + /** 手机端要求数字配对码时由 UI 回填。 */ + submitLoginVerifyCode(code: string): AgentHubActionResult { + this.connector?.submitVerifyCode(String(code || '').trim()) + return { success: true, status: this.getStatus() } + } + async reconnect(): Promise { - const accounts = await this.loadAccounts() + const connector = this.connector + if (!connector) return this.fail('微信连接器未初始化') + const accounts = this.listConnectorAccounts() if (accounts.length === 0) return this.startLogin() - this.startConnector(accounts.at(-1)!) + try { + await connector.start(accounts[accounts.length - 1].accountId) + } catch (error) { + return this.fail(`重新连接失败:${this.errorMessage(error)}`) + } return { success: true, status: this.getStatus() } } disconnect(): AgentHubActionResult { - this.stopConnector() + void this.stopConnector() this.patchStatus({ connector: 'disconnected', error: undefined }) return { success: true, status: this.getStatus() } } @@ -319,113 +427,239 @@ export class AgentHubService { 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() + void this.connector?.stop() this.patchStatus({ hub: 'offline' }) } - private async startHub(): Promise { - if (this.hubServer) return true - this.patchStatus({ hub: 'starting' }) - const server = createServer((request, response) => { - void this.handleHubRequest(request, response).catch((error) => { - this.addLog('agent-hub', 'error', `请求处理失败:${this.errorMessage(error)}`) - this.sendHubJson(response, 500, { error: 'internal error' }) - }) - }) - this.hubServer = server - return new Promise((resolve) => { - const fail = (error: Error): void => { - if (this.hubServer === server) this.hubServer = null - this.patchStatus({ hub: 'error', error: error.message }) - this.addLog('agent-hub', 'error', `TypeScript 服务启动失败:${error.message}`) - resolve(false) + /* ------------------------------------------------------------------ */ + /* 连接器生命周期 */ + /* ------------------------------------------------------------------ */ + + private async initializeConnector(): Promise { + this.patchStatus({ connector: 'checking' }) + const connector = this.connector + if (!connector) { + this.patchStatus({ connector: 'error', error: '微信连接器未初始化' }) + return + } + try { + const accounts = this.listConnectorAccounts() + if (accounts.length === 0) { + this.patchStatus({ connector: 'disconnected', qrCodeDataUrl: undefined }) + return } - 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) + const account = accounts[accounts.length - 1] + await connector.start(account.accountId) + this.patchStatus({ + accountId: account.accountId, + wechatUserId: account.wechatUserId, + qrCodeDataUrl: undefined, + error: undefined }) + } catch (error) { + this.patchStatus({ connector: 'error', error: this.errorMessage(error) }) + this.addLog('wechat-connector', 'error', `微信连接器启动失败:${this.errorMessage(error)}`) + } + } + + private async stopConnector(): Promise { + try { + await this.connector?.stop() + } catch (error) { + this.addLog('wechat-connector', 'warn', `停止微信连接器失败:${this.errorMessage(error)}`) + } + } + + private listConnectorAccounts(): WechatConnectorAccount[] { + try { + return this.connector?.listAccounts() ?? [] + } catch (error) { + this.addLog('wechat-connector', 'error', `读取微信账号失败:${this.errorMessage(error)}`) + return [] + } + } + + private applyConnectorPhase(phase: WechatConnectorPhase, error?: string): void { + const mapped: WechatConnectorStatus = + phase === 'polling' + ? 'online' + : phase === 'stopped' + ? 'disconnected' + : phase === 'starting' + ? 'starting' + : 'error' + this.patchStatus({ + connector: mapped, + ...(mapped === 'online' ? { accountId: this.connector?.getActiveAccountId() } : {}), + ...(mapped === 'error' ? { error: error || '微信连接器异常' } : { error: undefined }) + }) + if (phase === 'stale_token') { + this.addLog('system', 'error', error || '当前微信机器人登录已失效,需要重新扫码登录') + } + } + + private handleLoginEvent(event: WechatLoginEvent): void { + switch (event.status) { + case 'qrcode': + this.patchStatus({ + connector: 'waiting_scan', + qrCodeDataUrl: event.qrCodeDataUrl, + error: undefined + }) + break + case 'wait': + this.patchStatus({ + connector: 'waiting_scan', + qrCodeDataUrl: this.status.qrCodeDataUrl + }) + break + case 'scaned': + this.patchStatus({ connector: 'scanned' }) + break + case 'confirmed': + this.patchStatus({ connector: 'starting' }) + break + case 'need_verifycode': + this.addLog('wechat-connector', 'warn', '微信要求输入数字配对码才能完成登录') + this.patchStatus({ connector: 'scanned' }) + break + case 'verify_code_blocked': + this.addLog('wechat-connector', 'warn', '配对码多次错误,正在刷新二维码') + break + case 'expired': + this.addLog('wechat-connector', 'warn', '二维码已过期,正在自动刷新') + break + case 'active': { + const account = { accountId: event.accountId, wechatUserId: event.wechatUserId } + this.patchStatus({ ...account, connector: 'starting', qrCodeDataUrl: undefined }) + this.addLog('wechat-connector', 'info', `微信账号已登录:${event.accountId}`) + void this.connector?.start(event.accountId).catch((error) => { + this.patchStatus({ connector: 'error', error: this.errorMessage(error) }) + }) + break + } + } + } + + /* ------------------------------------------------------------------ */ + /* 入站:持久化接收 → 异步处理 */ + /* ------------------------------------------------------------------ */ + + /** + * 长轮询回调。**只有这里成功返回,游标才允许推进**, + * 因此本方法只做"落盘接收",不等待 AI 处理完成。 + */ + async handleInboundMessages(messages: WechatInboundMessage[]): Promise { + if (messages.length === 0) return + this.cleanRecentlyHandled() + const fresh = messages.filter((message) => !this.isRecentlyHandled(message)) + if (fresh.length === 0) return + const accepted = this.inbox.accept(fresh) + for (const entry of accepted) { + this.rememberNotificationRecipient(entry.accountId, entry.fromUserId) + this.recordInboundConversation(entry) + } + if (accepted.length > 0) { + this.addLog('agent-hub', 'info', `已接收 ${accepted.length} 条微信消息`) + this.startPump() + } + } + + /** 启动时补处理上次退出留下的消息。 */ + private resumePendingInbound(): void { + const pending = this.inbox.pending() + if (pending.length === 0) return + this.addLog('system', 'warn', `发现 ${pending.length} 条上次未处理完的微信消息,正在补处理`) + this.startPump() + } + + private async pump(): Promise { + if (this.pumping) return + this.pumping = true + try { + for (;;) { + if (this.stopping) return + const entry = this.inbox.pending()[0] + if (!entry) return + try { + await this.processInboxEntry(entry) + this.inbox.complete(entry.key) + this.markHandled(entry) + } catch (error) { + const { attempts, abandoned } = this.safeRecordFailure(entry.key) + if (abandoned) { + this.addLog( + 'agent-hub', + 'error', + `消息处理连续失败 ${attempts} 次,已停止重试(message_id=${entry.messageId || 'unknown'}):${this.errorMessage(error)}` + ) + continue + } + this.addLog( + 'agent-hub', + 'warn', + `消息处理失败(第 ${attempts} 次),稍后重试:${this.errorMessage(error)}` + ) + await new Promise((resolve) => setTimeout(resolve, this.inboundRetryDelayMs)) + } + } + } finally { + this.pumping = false + } + } + + /** 后台补处理:任何异常都必须收敛,不能变成未捕获的 promise rejection。 */ + private startPump(): void { + void this.pump().catch((error) => { + this.addLog('agent-hub', 'error', `入站消息队列异常:${this.errorMessage(error)}`) }) } - private async handleHubRequest( - request: IncomingMessage, - response: ServerResponse - ): Promise { - const url = new URL(request.url || '/', `http://${HUB_ADDR}`) - if (request.method === 'GET' && url.pathname === '/health') { - return this.sendHubJson(response, 200, { - status: 'ok', - service: 'agent-hub', - runtime: 'typescript' - }) - } - if (request.method !== 'POST' || url.pathname !== '/v1/connectors/wechat/inbound') { - return this.sendHubJson(response, 404, { error: 'not found' }) - } - if (!this.authorized(request.headers.authorization)) { - return this.sendHubJson(response, 401, { error: 'unauthorized' }) - } - - let inbound: InboundMessage + private safeRecordFailure(key: string): { attempts: number; abandoned: boolean } { try { - inbound = JSON.parse(await this.readHubBody(request)) as InboundMessage - } catch { - return this.sendHubJson(response, 400, { error: 'invalid request' }) + return this.inbox.recordFailure(key) + } catch (error) { + this.addLog('agent-hub', 'error', `收件箱落盘失败:${this.errorMessage(error)}`) + // 落盘失败时保守处理:不重复消费同一条消息,避免活锁。 + return { attempts: 0, abandoned: true } } - const from = String(inbound.from_user_id || '').trim() - if (!from) return this.sendHubJson(response, 400, { error: 'from_user_id is required' }) - this.rememberNotificationRecipient(inbound.account_id, from) + } - const messageId = String(inbound.message_id || '') - this.cleanProcessedMessages() - if (messageId && this.processedMessages.has(messageId)) { - return this.sendHubJson(response, 200, { status: 'duplicate' }) + private async processInboxEntry(entry: WechatInboundInboxEntry): Promise { + const conversation: AgentHubTarget = { + ...(entry.accountId ? { accountId: entry.accountId } : {}), + to: entry.fromUserId, + ...(entry.contextToken ? { contextToken: entry.contextToken } : {}) } - 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 text = extractInboundText(entry.items) + this.addLog('agent-hub', 'info', `收到微信消息 message_id=${entry.messageId || 'unknown'}`) // 三路边界:明确产物 → Report / 成员分析 Action;会话列表 → 确定性能力;其余 → Query Agent。 // 注意:这里**不再**先跑意图分类 LLM,查询类问题直接进入 Query Agent(避免双重 LLM 语义系统)。 const route = resolveInboundRoute(text) if (route.kind === 'report_action') { - if (messageId) this.processedMessages.set(messageId, Date.now()) this.addLog( 'agent-hub', 'info', `匹配群聊总结:${route.intent.group}(${route.intent.range})` ) - await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch((error) => { - this.addLog('agent-hub', 'warn', `等待提示发送失败:${this.errorMessage(error)}`) - }) - void this.generateAndSendReport(inbound, route.intent) - return this.sendHubJson(response, 202, { status: 'generating' }) + await this.sendToConversation(conversation, '收到!正在生成群聊总结,请等待…').catch( + (error) => { + this.addLog('agent-hub', 'warn', `等待提示发送失败:${this.errorMessage(error)}`) + } + ) + void this.generateAndSendReport(conversation, route.intent) + return } if (route.kind === 'group_member_action') { - if (messageId) this.processedMessages.set(messageId, Date.now()) - void this.summarizeGroupMemberChat(inbound, route.intent) - return this.sendHubJson(response, 202, { status: 'generating', mode: 'group-member-summary' }) + void this.summarizeGroupMemberChat(conversation, route.intent) + return } if (route.kind === 'recent_list') { - if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' }) + if (!isReady()) throw new Error('上游查询失败:本地数据库尚未连接') const items = listRecentChat(route.limit) const lines = items.map((item, index) => { const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim() @@ -434,63 +668,61 @@ export class AgentHubService { 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()) + await this.sendToConversation(conversation, reply) this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`) - return this.sendHubJson(response, 200, { status: 'ok' }) + return } if (!text.trim()) { this.addLog('agent-hub', 'info', '消息已忽略:内容为空') - return this.sendHubJson(response, 202, { status: 'ignored', reason: 'empty text' }) + return } - if (messageId) this.processedMessages.set(messageId, Date.now()) - void this.handleKnowledgeQuery(inbound, text) - return this.sendHubJson(response, 202, { status: 'processing', mode: 'query-agent' }) + + await this.handleKnowledgeQuery(conversation, text) } /** * 查询类问题("微信里发生了什么"、普通闲聊)统一走 Query Agent Runtime。 * 失败时不回退 Report Action,只给用户明确文案。 */ - private async handleKnowledgeQuery(inbound: InboundMessage, text: string): Promise { + private async handleKnowledgeQuery(conversation: AgentHubTarget, text: string): Promise { const service = this.queryAgent if (!service) { this.addLog('agent-hub', 'error', '查询大脑尚未初始化') - await this.sendConnector(inbound, QUERY_AGENT_UNAVAILABLE_TEXT).catch(() => undefined) + await this.sendToConversation(conversation, QUERY_AGENT_UNAVAILABLE_TEXT) return } + // 普通查询不发"收到":直接用原生"正在输入"表达"在处理"。 + const typing = await this.beginTypingFor(conversation) try { - const conversationKey = `${String(inbound.account_id || '')}::${String(inbound.from_user_id || '')}` + const conversationKey = `${conversation.accountId || ''}::${conversation.to}` const result = await service.ask( { requestId: `agent-hub-${Date.now()}-${this.nextLogId}`, text }, conversationKey ) - await this.sendConnector(inbound, this.formatAIReply(queryAgentReplyText(result))) + await this.sendToConversation(conversation, this.formatAIReply(queryAgentReplyText(result))) this.addLog('agent-hub', 'info', `查询回答已发送(${result.status})`) } catch (error) { this.addLog('agent-hub', 'error', `查询处理失败:${this.errorMessage(error)}`) - await this.sendConnector(inbound, QUERY_AGENT_UNAVAILABLE_TEXT).catch(() => undefined) + await this.sendToConversation(conversation, QUERY_AGENT_UNAVAILABLE_TEXT) + } finally { + // 无论成功、Provider 异常、发送失败还是超时,都必须收掉输入状态。 + await typing.stop() } } private async summarizeGroupMemberChat( - inbound: InboundMessage, + conversation: AgentHubTarget, intent: GroupMemberChatIntent ): Promise { try { if (!isReady()) { - await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。') + await this.sendToConversation(conversation, 'TraceMemo 本地数据库尚未连接,请连接后再试。') return } const group = this.resolveGroup(intent.group) if (!group) { - await this.sendConnector(inbound, `没有找到群聊“${intent.group}”。`) + await this.sendToConversation(conversation, `没有找到群聊“${intent.group}”。`) return } const snapshot = getGroupSnapshot(group.md5) @@ -504,8 +736,8 @@ export class AgentHubService { ) ) if (!member) { - await this.sendConnector( - inbound, + await this.sendToConversation( + conversation, `没有在“${group.m_nsNickName}”找到成员“${intent.member}”。` ) return @@ -513,82 +745,100 @@ export class AgentHubService { const displayName = member.groupNickname || member.wechatNickname || member.remark || member.nickname - await this.sendConnector( - inbound, + await this.sendToConversation( + conversation, `收到!正在整理${displayName}在“${group.m_nsNickName}”的近期发言,请等待…` ) - const now = new Date() - const todayStart = Math.floor( - new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000 - ) - const startTime = - intent.range === 'today' - ? todayStart - : intent.range === 'yesterday' - ? todayStart - 24 * 60 * 60 - : Math.floor(Date.now() / 1000) - intent.days * 24 * 60 * 60 - const endTime = intent.range === 'yesterday' ? todayStart - 1 : Math.floor(Date.now() / 1000) - const aliases = new Set( - [member.groupNickname, member.wechatNickname, member.remark, member.nickname] - .map((name) => - String(name || '') + // 长任务:确认语之后叠加原生"正在输入",直到结果发出。 + const typing = await this.beginTypingFor(conversation) + try { + await this.summarizeGroupMemberChatBody(conversation, intent, group, member, displayName) + } finally { + await typing.stop() + } + } catch (error) { + this.addLog('agent-hub', 'error', `群成员发言总结失败:${this.errorMessage(error)}`) + await this.sendToConversation( + conversation, + `群成员发言总结失败:${this.errorMessage(error)}` + ).catch(() => undefined) + } + } + + /** 群成员分析的实际计算与发送;由 summarizeGroupMemberChat 包在 typing 会话里。 */ + private async summarizeGroupMemberChatBody( + conversation: AgentHubTarget, + intent: GroupMemberChatIntent, + group: NonNullable>, + member: NonNullable>['members'][number], + displayName: string + ): Promise { + const now = new Date() + const todayStart = Math.floor( + new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000 + ) + const startTime = + intent.range === 'today' + ? todayStart + : intent.range === 'yesterday' + ? todayStart - 24 * 60 * 60 + : Math.floor(Date.now() / 1000) - intent.days * 24 * 60 * 60 + const endTime = intent.range === 'yesterday' ? todayStart - 1 : Math.floor(Date.now() / 1000) + const aliases = new Set( + [member.groupNickname, member.wechatNickname, member.remark, member.nickname] + .map((name) => + String(name || '') + .trim() + .toLowerCase() + ) + .filter(Boolean) + ) + const messages = listMessages(group.md5, startTime, endTime, { limit: 10_000 }) + .filter( + (message) => + String(message.senderId || '').trim() === member.wxid || + aliases.has( + String(message.name || '') .trim() .toLowerCase() ) - .filter(Boolean) ) - const messages = listMessages(group.md5, startTime, endTime, { limit: 10_000 }) - .filter( - (message) => - String(message.senderId || '').trim() === member.wxid || - aliases.has( - String(message.name || '') - .trim() - .toLowerCase() - ) - ) - .slice(-1000) - if (!messages.length) { - await this.sendConnector( - inbound, - `所选时间范围没有找到${displayName}在“${group.m_nsNickName}”的发言。` - ) - return - } - - const transcript = messages - .map( - (message) => - `[${message.datetime}] ${this.describeChatMessage(message.content, message.type)}` - ) - .join('\n') - const summary = await agentAIProvider.chat([ - { - role: 'system', - content: - '你是擅长分析微信群聊的助手。严格依据提供的发言完成用户的原始要求,输出结构和侧重点由内容决定,不套固定模板。可以归纳人物特征、兴趣、表达习惯和群内角色,但必须区分事实与推测,为推测说明依据和不确定性,不得编造。使用适合微信阅读的中文。' - }, - { - role: 'user', - content: `用户原始要求:${intent.goal}\n分析对象:“${displayName}”在群聊“${group.m_nsNickName}”中的发言。\n时间范围:${intent.range === 'today' ? '今天' : intent.range === 'yesterday' ? '昨天' : `最近 ${intent.days} 天`}。\n共提供 ${messages.length} 条发言。\n\n发言记录:\n${transcript}` - } - ]) - if (!summary.success || !summary.data?.trim()) { - throw new Error(summary.error || 'AI 未返回总结') - } - await this.sendConnector( - inbound, - this.formatAIReply( - `${displayName}在“${group.m_nsNickName}”的发言总结(共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}` - ) - ) - this.addLog('agent-hub', 'info', `群成员发言总结已发送(${messages.length} 条)`) - } catch (error) { - this.addLog('agent-hub', 'error', `群成员发言总结失败:${this.errorMessage(error)}`) - await this.sendConnector(inbound, `群成员发言总结失败:${this.errorMessage(error)}`).catch( - () => undefined + .slice(-1000) + if (!messages.length) { + await this.sendToConversation( + conversation, + `所选时间范围没有找到${displayName}在“${group.m_nsNickName}”的发言。` ) + return } + + const transcript = messages + .map( + (message) => + `[${message.datetime}] ${this.describeChatMessage(message.content, message.type)}` + ) + .join('\n') + const summary = await agentAIProvider.chat([ + { + role: 'system', + content: + '你是擅长分析微信群聊的助手。严格依据提供的发言完成用户的原始要求,输出结构和侧重点由内容决定,不套固定模板。可以归纳人物特征、兴趣、表达习惯和群内角色,但必须区分事实与推测,为推测说明依据和不确定性,不得编造。使用适合微信阅读的中文。' + }, + { + role: 'user', + content: `用户原始要求:${intent.goal}\n分析对象:“${displayName}”在群聊“${group.m_nsNickName}”中的发言。\n时间范围:${intent.range === 'today' ? '今天' : intent.range === 'yesterday' ? '昨天' : `最近 ${intent.days} 天`}。\n共提供 ${messages.length} 条发言。\n\n发言记录:\n${transcript}` + } + ]) + if (!summary.success || !summary.data?.trim()) { + throw new Error(summary.error || 'AI 未返回总结') + } + await this.sendToConversation( + conversation, + this.formatAIReply( + `${displayName}在“${group.m_nsNickName}”的发言总结(共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}` + ) + ) + this.addLog('agent-hub', 'info', `群成员发言总结已发送(${messages.length} 条)`) } private describeChatMessage(content: string, type: string): string { @@ -610,59 +860,243 @@ export class AgentHubService { } private async generateAndSendReport( - inbound: InboundMessage, + conversation: AgentHubTarget, intent: GroupReportIntent ): Promise { + // 日报是长任务:确认语已由路由层发出,这里叠加原生"正在输入"直到图片发完。 + const typing = await this.beginTypingFor(conversation) 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, + await this.sendToConversation( + conversation, `已生成${result.groupName || intent.group}的群聊总结(${result.messageCount || 0} 条消息),正在发送图片。` ) - await this.sendConnector(inbound, undefined, result.pngPath) + await this.sendToConversation(conversation, 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) + await this.sendToConversation(conversation, `群聊总结生成失败:${message}`).catch( + () => undefined + ) + } finally { + // 生成失败、图片发送失败、超时……都必须收掉"正在输入"。 + await typing.stop() } } - private async sendConnector( - inbound: InboundMessage, + /* ------------------------------------------------------------------ */ + /* 出站:统一走 WechatSendGateway */ + /* ------------------------------------------------------------------ */ + + /** + * 开始「正在输入」。typing 是**非致命**能力:任何失败都退化成空实现, + * 业务照常执行,调用方只需要在 finally 里 stop。 + */ + private async beginTypingFor(conversation: AgentHubTarget): Promise<{ stop(): Promise }> { + const connector = this.connector + if (!connector) return NOOP_TYPING_LEASE + try { + return await connector.beginTyping({ + ...(conversation.accountId ? { accountId: conversation.accountId } : {}), + to: conversation.to, + ...(conversation.contextToken ? { contextToken: conversation.contextToken } : {}) + }) + } catch (error) { + this.addLog('agent-hub', 'warn', `开启输入状态失败:${this.errorMessage(error)}`) + return NOOP_TYPING_LEASE + } + } + + /** + * 发送一条或多条消息到会话。 + * + * `context_token` 来自入站消息,必须原样回传才能落到同一个会话线程; + * 主动发送(例如定时日报)则回退到该会话最近一次有效 token。 + */ + private async sendToConversation( + conversation: AgentHubTarget, text?: string, mediaUrl?: string ): Promise { - const response = await this.postConnectorMessage({ - accountId: inbound.account_id || this.status.accountId, - to: String(inbound.from_user_id || '').trim(), - text, - mediaUrl, - timeoutMs: mediaUrl ? 60_000 : 30_000 + const result = await this.deliver({ + accountId: conversation.accountId, + to: conversation.to, + ...(conversation.contextToken ? { contextToken: conversation.contextToken } : {}), + ...(text ? { text } : {}), + ...(mediaUrl ? { mediaUrl } : {}) }) - if (!response.ok) throw new Error(response.body || `HTTP ${response.status}`) + if (!result.success) { + throw new AgentHubDeliveryError( + result.error || `微信发送失败(${result.error_code || 'SEND_FAILED'})`, + result.error_code + ) + } } - private async postConnectorMessage(input: { + /** + * 统一投递:文本 + 文本内嵌的 Markdown 图片 + 显式媒体。 + * 每一步都经过 WechatSendGateway,因此每一步都有 Send Log。 + */ + private async deliver(input: { accountId?: string to: string text?: string mediaUrl?: string - timeoutMs: number - }): Promise<{ ok: boolean; status: number; body: string }> { - const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - account_id: input.accountId, + contextToken?: string + }): Promise { + const contextToken = this.resolveOutgoingContextToken(input) + const base = { + ...(input.accountId ? { account_id: input.accountId } : {}), + to: input.to, + transport: 'ilink' as const, + ...(contextToken ? { context_token: contextToken } : {}) + } + + let lastResult: WechatSendResult | undefined + const text = String(input.text || '').trim() + if (text) { + lastResult = await this.sendOne({ + base, to: input.to, - text: input.text, - media_url: input.mediaUrl - }), - signal: AbortSignal.timeout(input.timeoutMs) + ...(input.accountId ? { accountId: input.accountId } : {}), + type: 'text', + msg: text + }) + if (!lastResult.success) return lastResult + + // 文本里内嵌的图片补发为独立图片消息;单张失败不影响已发送的文本。 + for (const imageUrl of extractMarkdownImageUrls(text)) { + const mediaResult = await this.sendOne({ + base, + to: input.to, + ...(input.accountId ? { accountId: input.accountId } : {}), + type: 'image', + msg: imageUrl + }) + if (!mediaResult.success) { + this.addLog( + 'agent-hub', + 'warn', + `内嵌图片补发失败:${mediaResult.error_code || 'SEND_FAILED'}` + ) + } + } + } + + const mediaUrl = String(input.mediaUrl || '').trim() + if (mediaUrl) { + const mediaResult = await this.sendOne({ + base, + to: input.to, + ...(input.accountId ? { accountId: input.accountId } : {}), + type: this.isImageSource(mediaUrl) ? 'image' : 'file', + msg: mediaUrl + }) + if (!mediaResult.success) return mediaResult + lastResult = mediaResult + } + + if (!lastResult) { + return { + request_id: this.newRequestId(), + success: false, + status: 'failed', + transport: 'ilink', + duration_ms: 0, + error_code: 'INVALID_REQUEST', + error: '没有可发送的内容' + } + } + return lastResult + } + + /** + * 单次发送:统一经过 WechatSendGateway,并在同一处写入对话记录。 + * 成功与失败都记账,失败带错误码,因此 UI 里能看出"这条没发出去"。 + */ + private async sendOne(input: { + base: Record + to: string + accountId?: string + type: 'text' | 'image' | 'file' + msg: string + }): Promise { + const result = await this.sendGateway.send({ + request_id: this.newRequestId(), + ...input.base, + type: input.type, + msg: input.msg }) - return { ok: response.ok, status: response.status, body: await response.text() } + this.recordOutboundConversation({ + to: input.to, + ...(input.accountId ? { accountId: input.accountId } : {}), + type: input.type, + msg: input.msg, + success: result.success, + ...(result.error_code ? { errorCode: result.error_code } : {}) + }) + return result + } + + private isImageSource(source: string): boolean { + const withoutQuery = source.split('?')[0].split('#')[0] + const dotIndex = withoutQuery.lastIndexOf('.') + if (dotIndex < 0) return false + return IMAGE_EXTENSIONS.has(withoutQuery.slice(dotIndex).toLowerCase()) + } + + /** 显式 token 优先,其次取该会话最近一次有效 token(用于主动发送)。 */ + private resolveOutgoingContextToken(input: { + accountId?: string + to: string + contextToken?: string + }): string | undefined { + const explicit = String(input.contextToken || '').trim() + if (explicit) return explicit + try { + return this.connector?.resolveContextToken(input.accountId, input.to) + } catch { + return undefined + } + } + + private newRequestId(): string { + return `agent-hub-${Date.now()}-${this.nextLogId++}` + } + + private mapDeliveryStatus( + result: WechatSendResult + ): 'connector_offline' | 'token_expired' | 'send_failed' { + if (result.error_code === 'TRANSPORT_UNAVAILABLE') return 'connector_offline' + if (result.error_code === 'STALE_TOKEN') return 'token_expired' + return 'send_failed' + } + + /* ------------------------------------------------------------------ */ + /* 去重与日志 */ + /* ------------------------------------------------------------------ */ + + private handledKeyFor(message: WechatInboundMessage): string { + return `${message.accountId}::${message.messageId}` + } + + private isRecentlyHandled(message: WechatInboundMessage): boolean { + if (!message.messageId) return false + return this.recentlyHandled.has(this.handledKeyFor(message)) + } + + private markHandled(entry: WechatInboundInboxEntry): void { + if (!entry.messageId) return + this.recentlyHandled.set(`${entry.accountId}::${entry.messageId}`, Date.now()) + } + + private cleanRecentlyHandled(): void { + const cutoff = Date.now() - HANDLED_MESSAGE_TTL_MS + for (const [key, timestamp] of this.recentlyHandled) { + if (timestamp < cutoff) this.recentlyHandled.delete(key) + } } private resolveGroup(query: string): ReturnType { @@ -686,65 +1120,10 @@ export class AgentHubService { ) } - private authorized(header: string | undefined): boolean { - if (!header?.startsWith('Bearer ')) return false - const expected = Buffer.from(this.inboundToken) - const provided = Buffer.from(header.slice(7)) - return expected.length === provided.length && timingSafeEqual(expected, provided) - } - - private readHubBody(request: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = [] - let size = 0 - request.on('data', (chunk: Buffer) => { - size += chunk.length - if (size > 1024 * 1024) { - reject(new Error('request too large')) - request.destroy() - return - } - chunks.push(chunk) - }) - request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) - request.on('error', reject) - }) - } - - private sendHubJson(response: ServerResponse, status: number, payload: unknown): void { - if (response.writableEnded) return - response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }) - response.end(JSON.stringify(payload)) - } - - private cleanProcessedMessages(): void { - const cutoff = Date.now() - 10 * 60_000 - for (const [id, timestamp] of this.processedMessages) { - if (timestamp < cutoff) this.processedMessages.delete(id) - } - } - private errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } - private async initializeConnector(): Promise { - this.patchStatus({ connector: 'checking' }) - try { - const accounts = await this.loadAccounts() - if (accounts.length === 0) { - this.patchStatus({ connector: 'disconnected' }) - return - } - this.startConnector(accounts.at(-1)!) - } catch (error) { - this.patchStatus({ - connector: 'error', - error: error instanceof Error ? error.message : String(error) - }) - } - } - private loadNotificationRecipient(): void { if (this.notificationRecipientLoaded) return this.notificationRecipientLoaded = true @@ -790,111 +1169,65 @@ export class AgentHubService { return join(app.getPath('userData'), 'agent-hub', 'notification-recipient.json') } - 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 + private inboundInboxPath(): string { + return join(app.getPath('userData'), 'agent-hub', 'inbound-inbox.json') + } + + /** + * 对话记录文件。含完整收发正文,因此固定 0600,且永不写入日志。 + */ + private conversationStorePath(): string { + return join(app.getPath('userData'), 'agent-hub', 'conversations.json') + } + + /** 入站消息:落盘接收成功后立刻记入对话记录(即使后续处理失败,也确实收到过)。 */ + private recordInboundConversation(entry: WechatInboundInboxEntry): void { + const { kind, text } = describeInboundContent(entry.items) + const result = this.conversationStore.append({ + userId: entry.fromUserId, + ...(entry.accountId ? { accountId: entry.accountId } : {}), + direction: 'in', + kind, + text, + ...(entry.messageId ? { messageId: entry.messageId } : {}), + createdAt: entry.receivedAt }) - const parsed = JSON.parse(stdout) as { - accounts?: { account_id: string; wechat_user_id: string }[] + if (result) this.pushConversation(result.summary, result.message) + } + + /** 出站消息:无论成功失败都记,失败会带错误码,便于在 UI 里对账。 */ + private recordOutboundConversation(input: { + to: string + accountId?: string + type: 'text' | 'image' | 'voice' | 'file' + msg: string + success: boolean + errorCode?: string + }): void { + const result = this.conversationStore.append({ + userId: input.to, + ...(input.accountId ? { accountId: input.accountId } : {}), + direction: 'out', + kind: input.type === 'voice' ? 'voice' : input.type, + text: input.type === 'text' ? input.msg : buildSendPreview(input.msg, input.type), + status: input.success ? 'sent' : 'failed', + ...(input.success ? {} : { errorCode: input.errorCode || 'SEND_FAILED' }) + }) + if (result) this.pushConversation(result.summary, result.message) + } + + private pushConversation( + summary: AgentHubConversationSummary, + message: AgentHubConversationMessage + ): void { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('agent-hub:conversation', { summary, message }) } - 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 broadcastConversationCleared(): void { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('agent-hub:conversationsCleared') } } @@ -905,27 +1238,8 @@ export class AgentHubService { } } - 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() + const message = redactSecrets(rawMessage).trim() if (!message) return const entry: AgentHubLogEntry = { id: this.nextLogId++, @@ -939,11 +1253,9 @@ export class AgentHubService { try { const path = this.logFilePath() mkdirSync(dirname(path), { recursive: true }) - appendFileSync( - path, - `${new Date(entry.timestamp).toISOString()} [${source}] [${level}] ${message}\n`, - 'utf8' - ) + const line = `${new Date(entry.timestamp).toISOString()} [${source}] [${level}] ${message}\n` + writeFileSync(path, line, { encoding: 'utf8', flag: 'a' }) + // 日志采用追加写入,避免每次记日志都重写整份文件。 } catch { // Do not interrupt message handling because log persistence failed. } @@ -952,13 +1264,6 @@ export class AgentHubService { } } - 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') } diff --git a/src/main/services/log-redaction.ts b/src/main/services/log-redaction.ts new file mode 100644 index 0000000..6ec2b5b --- /dev/null +++ b/src/main/services/log-redaction.ts @@ -0,0 +1,49 @@ +import { createHash } from 'node:crypto' + +/** + * 统一日志脱敏。 + * + * 微信连接器与发送日志共用同一套规则:任何 secret 都不能以原值进入普通日志, + * 包括 bot_token、context_token、typing_ticket、AES key、二维码凭据与 Authorization 头。 + * + * 只用于自由文本日志;结构化字段(如 sha256 摘要)不要经过这里,否则会被误伤。 + */ + +const REDACTION_RULES: ReadonlyArray = [ + // Authorization 头 + [/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [已隐藏]'], + // 二维码图片内容(base64 data URL 可能长达数百 KB) + [/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/gi, 'data:image/[二维码已隐藏]'], + // JSON / query 形态的 secret 字段 + [ + /("?(?:bot_token|context_token|typing_ticket|aeskey|aes_key|access_token|refresh_token|authorization)"?\s*[:=]\s*)"?[^\s",}&]+"?/gi, + '$1"[已隐藏]"' + ], + // 兼容历史写法:token=xxx / token: xxx + [/(\btoken\s*[:=]\s*)[^\s,}]+/gi, '$1[已隐藏]'], + // iLink 上传参数 + [/(encrypted_query_param=)[^\s&"]+/gi, '$1[已隐藏]'] +] + +/** 对任意自由文本日志做脱敏。 */ +export function redactSecrets(message: string): string { + let result = String(message ?? '') + for (const [pattern, replacement] of REDACTION_RULES) { + result = result.replace(pattern, replacement) + } + return result +} + +/** 日志中只允许出现 secret 是否存在,不允许出现原值。 */ +export function describeSecretPresence(value: string | undefined | null): string { + return value ? 'present' : 'absent' +} + +/** + * 生成不可逆的短指纹,用于需要关联同一 secret 又不能落盘的诊断场景。 + * 只保留 sha256 前 8 位十六进制,无法反推原值。 + */ +export function secretFingerprint(value: string | undefined | null): string { + if (!value) return 'absent' + return createHash('sha256').update(String(value)).digest('hex').slice(0, 8) +} diff --git a/src/main/services/wechat-action-gateway.ts b/src/main/services/wechat-action-gateway.ts index 1087830..b3f5ea6 100644 --- a/src/main/services/wechat-action-gateway.ts +++ b/src/main/services/wechat-action-gateway.ts @@ -17,7 +17,7 @@ import type { WechatActionResult } from '../../shared/wechat-action' import { personalWechatCapabilityService } from './personal-wechat-capability-service' -import { personalWechatSendService } from './personal-wechat-send-service' +import { wechatSendGateway } from './wechat-send-gateway' const MAX_AUDIT_RECORDS = 500 const MAX_CONTENT_PREVIEW_LENGTH = 240 @@ -54,7 +54,8 @@ const defaultDependencies = (): Required< > > => ({ getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(), - send: (request) => personalWechatSendService.send(request), + // 高层业务审计之后仍然统一走 WechatSendGateway,保证每一次真实发送都有 Send Log。 + send: (request) => wechatSendGateway.sendPersonal(request), getUserDataPath: () => app.getPath('userData'), now: () => new Date(), wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) diff --git a/src/main/services/wechat-ilink/account-store.ts b/src/main/services/wechat-ilink/account-store.ts new file mode 100644 index 0000000..cc30853 --- /dev/null +++ b/src/main/services/wechat-ilink/account-store.ts @@ -0,0 +1,262 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, + type Dirent +} from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { ILinkCredentials } from './types' + +/** + * 账号与游标持久化。 + * + * 目录布局沿用历史命名(含 `wechat-connector` 这一层),保证升级后**不需要重新扫码**: + * ~/.tracememo/wechat-connector/accounts/.json 凭据 + * ~/.tracememo/wechat-connector/accounts/.sync.json 长轮询游标 + * ~/.tracememo/wechat-connector/accounts/.context.json 会话上下文令牌 + * + * 历史目录 ~/.wechatexplorer/wechat-connector/accounts 只读兼容,不删除、不覆写。 + */ + +const CONNECTOR_DIR_NAME = 'wechat-connector' +const ACCOUNTS_DIR_NAME = 'accounts' +const CURRENT_ROOT = '.tracememo' +const LEGACY_ROOT = '.wechatexplorer' +const FILE_MODE = 0o600 +const DIR_MODE = 0o700 + +export type HomeDirectoryResolver = () => string + +export function normalizeAccountId(raw: string): string { + const value = String(raw ?? '') + if (!value) return '' + // @ . : 统一替换为 -,使账号 ID 可以作为文件名。 + return value.replace(/[@.:]/g, '-') +} + +export function accountsDirectory(home: HomeDirectoryResolver = homedir): string { + return join(home(), CURRENT_ROOT, CONNECTOR_DIR_NAME, ACCOUNTS_DIR_NAME) +} + +export function legacyAccountsDirectory(home: HomeDirectoryResolver = homedir): string { + return join(home(), LEGACY_ROOT, CONNECTOR_DIR_NAME, ACCOUNTS_DIR_NAME) +} + +function ensureDirectory(directory: string): void { + mkdirSync(directory, { recursive: true, mode: DIR_MODE }) +} + +/** 原子写入:先写临时文件再 rename,避免进程退出留下半截 JSON。 */ +function writeJsonAtomically(path: string, value: unknown): void { + const tempPath = `${path}.tmp-${process.pid}-${Date.now()}` + try { + writeFileSync(tempPath, JSON.stringify(value, null, 2), { encoding: 'utf8', mode: FILE_MODE }) + chmodSync(tempPath, FILE_MODE) + renameSync(tempPath, path) + chmodSync(path, FILE_MODE) + } catch (error) { + try { + rmSync(tempPath, { force: true }) + } catch { + // 清理失败不影响主流程:文件本身不会覆盖有效数据。 + } + throw error + } +} + +function readJsonIfValid(path: string, validate: (value: T) => boolean): T | undefined { + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')) as T + return validate(parsed) ? parsed : undefined + } catch { + return undefined + } +} + +function isCredentials(value: unknown): value is ILinkCredentials { + const candidate = value as Partial | null + return Boolean(candidate && typeof candidate.bot_token === 'string' && candidate.bot_token) +} + +/** 找到某个账号凭据实际所在目录:优先当前目录,其次历史目录。 */ +export function resolveAccountDirectory( + accountId: string, + home: HomeDirectoryResolver = homedir +): { directory: string; legacy: boolean } { + const current = accountsDirectory(home) + if (existsSync(join(current, `${accountId}.json`))) return { directory: current, legacy: false } + const legacy = legacyAccountsDirectory(home) + if (existsSync(join(legacy, `${accountId}.json`))) return { directory: legacy, legacy: true } + return { directory: current, legacy: false } +} + +/** + * 保存新登录的凭据。 + * 先写入新凭据再清理旧账号文件,因此一次失败的登录不会摧毁上一个可用账号。 + * 同时保留同前缀的 `.sync.json` / `.context.json`。 + */ +export function saveCredentials( + credentials: ILinkCredentials, + home: HomeDirectoryResolver = homedir +): string { + const directory = accountsDirectory(home) + ensureDirectory(directory) + const accountId = normalizeAccountId(credentials.ilink_bot_id) + if (!accountId) throw new Error('登录凭据缺少 ilink_bot_id,无法保存') + + const path = join(directory, `${accountId}.json`) + writeJsonAtomically(path, credentials) + + const keepPrefix = `${accountId}.` + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) continue + if (!entry.name.endsWith('.json')) continue + if (entry.name.startsWith(keepPrefix)) continue + try { + rmSync(join(directory, entry.name), { force: true }) + } catch { + // 旧凭据清理失败不影响新凭据可用性。 + } + } + return path +} + +function loadCredentialsFromDirectory(directory: string): ILinkCredentials[] { + let entries: Dirent[] + try { + entries = readdirSync(directory, { withFileTypes: true }) + } catch { + return [] + } + const result: ILinkCredentials[] = [] + for (const entry of entries) { + if (entry.isDirectory() || !entry.name.endsWith('.json')) continue + const credential = readJsonIfValid(join(directory, entry.name), isCredentials) + if (credential) result.push(credential) + } + return result +} + +/** 当前目录优先;当前目录为空时回退到历史目录(一次性升级兼容,不迁移不删除)。 */ +export function loadAllCredentials(home: HomeDirectoryResolver = homedir): ILinkCredentials[] { + const current = loadCredentialsFromDirectory(accountsDirectory(home)) + if (current.length > 0) return current + return loadCredentialsFromDirectory(legacyAccountsDirectory(home)) +} + +export function findCredentials( + accountId: string, + home: HomeDirectoryResolver = homedir +): ILinkCredentials | undefined { + const { directory } = resolveAccountDirectory(accountId, home) + const credential = readJsonIfValid( + join(directory, `${accountId}.json`), + isCredentials + ) + if (credential) return credential + return loadAllCredentials(home).find( + (item) => normalizeAccountId(item.ilink_bot_id) === accountId || item.ilink_bot_id === accountId + ) +} + +/* ------------------------------------------------------------------ */ +/* 长轮询游标 */ +/* ------------------------------------------------------------------ */ + +interface SyncRecord { + get_updates_buf: string +} + +/** + * 游标按账号隔离保存。切换账号时绝不沿用上一个账号的游标。 + */ +export function loadCursor(accountId: string, home: HomeDirectoryResolver = homedir): string { + const { directory } = resolveAccountDirectory(accountId, home) + const record = readJsonIfValid(join(directory, `${accountId}.sync.json`), (value) => + Boolean(value && typeof value.get_updates_buf === 'string') + ) + return record?.get_updates_buf ?? '' +} + +export function saveCursor( + accountId: string, + getUpdatesBuf: string, + home: HomeDirectoryResolver = homedir +): void { + // 必须写到 loadCursor 实际读取的目录:如果凭据仍在历史目录(尚未迁移), + // 写进当前目录会导致游标永远读不回来,长轮询就会反复重投同一条消息。 + const { directory } = resolveAccountDirectory(accountId, home) + ensureDirectory(directory) + writeJsonAtomically(join(directory, `${accountId}.sync.json`), { + get_updates_buf: getUpdatesBuf + } satisfies SyncRecord) +} + +export function clearCursor(accountId: string, home: HomeDirectoryResolver = homedir): void { + const { directory } = resolveAccountDirectory(accountId, home) + try { + rmSync(join(directory, `${accountId}.sync.json`), { force: true }) + } catch { + // 游标清理失败不阻断重连流程。 + } +} + +/* ------------------------------------------------------------------ */ +/* 会话上下文令牌 */ +/* ------------------------------------------------------------------ */ + +interface ContextRecord { + tokens: Record +} + +/** + * context_token 属于**会话上下文**,不是账号长期凭据。 + * 按「账号 + 用户」保存最近一次有效值,用于重启后的主动发送(例如定时日报)。 + * 永不写入日志,文件权限 0600。 + */ +export function saveContextToken( + accountId: string, + toUserId: string, + contextToken: string, + home: HomeDirectoryResolver = homedir, + now: () => number = Date.now +): void { + if (!contextToken) return + const { directory } = resolveAccountDirectory(accountId, home) + ensureDirectory(directory) + const path = join(directory, `${accountId}.context.json`) + const existing = readJsonIfValid(path, (value) => + Boolean(value && typeof value.tokens === 'object' && value.tokens !== null) + ) ?? { tokens: {} } + + const tokens: ContextRecord['tokens'] = { + ...existing.tokens, + [toUserId]: { context_token: contextToken, updated_at: now() } + } + + // 只保留最近 500 条,避免文件无限增长。 + const entries = Object.entries(tokens).sort( + (left, right) => right[1].updated_at - left[1].updated_at + ) + const trimmed = Object.fromEntries(entries.slice(0, 500)) + writeJsonAtomically(path, { tokens: trimmed } satisfies ContextRecord) +} + +export function loadContextToken( + accountId: string, + toUserId: string, + home: HomeDirectoryResolver = homedir +): string | undefined { + const { directory } = resolveAccountDirectory(accountId, home) + const record = readJsonIfValid( + join(directory, `${accountId}.context.json`), + (value) => Boolean(value && typeof value.tokens === 'object' && value.tokens !== null) + ) + return record?.tokens?.[toUserId]?.context_token || undefined +} diff --git a/src/main/services/wechat-ilink/auth.ts b/src/main/services/wechat-ilink/auth.ts new file mode 100644 index 0000000..7b8f56b --- /dev/null +++ b/src/main/services/wechat-ilink/auth.ts @@ -0,0 +1,293 @@ +import { ILinkError } from './errors' +import { ILinkClient, type FetchLike } from './client' +import { loadAllCredentials, saveCredentials, type HomeDirectoryResolver } from './account-store' +import { + ILINK_DEFAULT_BASE_URL, + ILINK_QR_STATUS_TIMEOUT_MS, + type ILinkCredentials, + type ILinkQrCodeResponse, + type ILinkQrStatusResponse, + type WechatLoginEvent +} from './types' + +const QR_CODE_PATH = '/ilink/bot/get_bot_qrcode?bot_type=3' +const QR_STATUS_PATH = '/ilink/bot/get_qrcode_status' + +/** 官方客户端策略(非协议常量):本地二维码 TTL 约 5 分钟,整体等待约 480 秒。 */ +const QR_SESSION_TTL_MS = 5 * 60_000 +const LOGIN_DEADLINE_MS = 480_000 +const MAX_QR_REFRESHES = 3 +/** 等待用户输入数字配对码的上限。 */ +const VERIFY_CODE_WAIT_MS = 60_000 + +export type QrEncoder = (content: string) => Promise + +export interface QrLoginDependencies { + fetchImpl?: FetchLike + home?: HomeDirectoryResolver + baseUrl?: string + signal?: AbortSignal + onEvent?: (event: WechatLoginEvent) => void + /** 把二维码内容渲染成可直接给 的 data URL。 */ + qrEncoder?: QrEncoder + /** 手机端要求数字配对码时,由宿主提供;返回 undefined 表示放弃这次登录。 */ + verifyCodeProvider?: () => Promise + now?: () => number + sleep?: (milliseconds: number) => Promise + maxQrRefreshes?: number + deadlineMs?: number +} + +/** + * 默认二维码渲染器:把服务端返回的内容编码成二维码 PNG。 + * 服务端返回的是二维码页面 URL,因此这里必须自己编码,而不是当作图片直传。 + */ +const defaultQrEncoder: QrEncoder = async (content) => { + if (/^data:image\//i.test(content)) return content + const { toDataURL } = await import('qrcode') + return toDataURL(content, { errorCorrectionLevel: 'L', margin: 1, width: 320 }) +} + +interface LoginState { + host: string + verifyCode?: string + pendingVerifyCode?: boolean +} + +/** 等待用户输入配对码;超时视为放弃本次尝试,改为刷新二维码而不是无限等待。 */ +function awaitVerifyCode( + provider: () => Promise, + waitMs: number +): Promise { + return new Promise((resolve) => { + let settled = false + const finish = (value: string | undefined): void => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(value) + } + const timer = setTimeout(() => finish(undefined), waitMs) + provider().then( + (value) => finish(value), + () => finish(undefined) + ) + }) +} + +function isCancelled(signal: AbortSignal | undefined): boolean { + return signal?.aborted === true +} + +interface QrStatusPollResult { + outcome: 'continue' | 'refresh' | 'done' + credentials?: ILinkCredentials +} + +async function pollOnce( + client: ILinkClient, + qrcode: string, + state: LoginState, + deps: Required> & QrLoginDependencies +): Promise { + const query = new URLSearchParams({ qrcode }) + if (state.verifyCode) query.set('verify_code', state.verifyCode) + const url = `${state.host}${QR_STATUS_PATH}?${query.toString()}` + + let response: ILinkQrStatusResponse + try { + response = await client.getAnonymous(url, '二维码状态轮询', { + timeoutMs: ILINK_QR_STATUS_TIMEOUT_MS, + ...(deps.signal ? { signal: deps.signal } : {}) + }) + } catch (error) { + if (isCancelled(deps.signal)) throw error + // 网络超时 / 网关 524 属于长轮询正常控制流:保持当前二维码继续轮询。 + return { outcome: 'continue' } + } + + const status = String(response.status || '').trim() + switch (status) { + case 'wait': + deps.onEvent({ status: 'wait' }) + return { outcome: 'continue' } + + case 'scaned': + // 已扫码:若上一次轮询携带过配对码,说明已被接受,清除暂存值。 + state.verifyCode = undefined + deps.onEvent({ status: 'scaned' }) + return { outcome: 'continue' } + + case 'need_verifycode': { + deps.onEvent({ status: 'need_verifycode' }) + state.verifyCode = undefined + if (state.pendingVerifyCode) return { outcome: 'continue' } + state.pendingVerifyCode = true + const provider = deps.verifyCodeProvider + if (!provider) return { outcome: 'refresh' } + const code = await awaitVerifyCode(provider, VERIFY_CODE_WAIT_MS) + state.pendingVerifyCode = false + const normalized = String(code ?? '').trim() + if (!normalized) return { outcome: 'refresh' } + state.verifyCode = normalized + return { outcome: 'continue' } + } + + case 'verify_code_blocked': + // 多次输入错误被限制:清除配对码并刷新二维码,由调用方计数。 + deps.onEvent({ status: 'verify_code_blocked' }) + state.verifyCode = undefined + state.pendingVerifyCode = false + return { outcome: 'refresh' } + + case 'scaned_but_redirect': { + // 状态轮询需要切换 IDC 节点;只切换状态轮询主机,不影响已保存的 baseurl。 + const redirectHost = String(response.redirect_host || '').trim() + if (redirectHost) { + state.host = /^https?:\/\//.test(redirectHost) + ? redirectHost.replace(/\/+$/, '') + : `https://${redirectHost.replace(/\/+$/, '')}` + } + return { outcome: 'continue' } + } + + case 'binded_redirect': { + // 账号已绑定到本客户端:只有本地确实仍有可用凭据时才能视为成功。 + const existing = loadAllCredentials(deps.home) + const reusable = existing[existing.length - 1] + if (!reusable) return { outcome: 'refresh' } + deps.onEvent({ status: 'confirmed' }) + return { outcome: 'done', credentials: reusable } + } + + case 'expired': + deps.onEvent({ status: 'expired' }) + return { outcome: 'refresh' } + + case 'confirmed': { + const accountId = String(response.ilink_bot_id || '').trim() + if (!accountId) { + throw new ILinkError({ + kind: 'protocol', + message: '登录已确认,但服务端未返回 ilink_bot_id' + }) + } + const credentials: ILinkCredentials = { + bot_token: String(response.bot_token || ''), + ilink_bot_id: accountId, + baseurl: String(response.baseurl || deps.baseUrl || ILINK_DEFAULT_BASE_URL), + ilink_user_id: String(response.ilink_user_id || '') + } + if (!credentials.bot_token) { + throw new ILinkError({ kind: 'protocol', message: '登录已确认,但服务端未返回 bot_token' }) + } + deps.onEvent({ status: 'confirmed' }) + return { outcome: 'done', credentials } + } + + default: + // 未知状态按 wait 处理,避免因为服务端新增状态直接打断登录。 + return { outcome: 'continue' } + } +} + +/** + * 扫码登录。 + * + * 状态机与官方客户端对齐:wait / scaned / need_verifycode / verify_code_blocked / + * scaned_but_redirect / binded_redirect / expired / confirmed。 + * 成功后立即原子落盘,且先写新凭据再清理旧账号,失败登录不会摧毁可用凭据。 + */ +export async function runQrLogin(deps: QrLoginDependencies = {}): Promise { + const baseUrl = (deps.baseUrl || ILINK_DEFAULT_BASE_URL).replace(/\/+$/, '') + const now = deps.now ?? Date.now + const sleep = + deps.sleep ?? ((milliseconds: number) => new Promise((r) => setTimeout(r, milliseconds))) + const onEvent = deps.onEvent ?? ((): void => undefined) + const maxRefreshes = deps.maxQrRefreshes ?? MAX_QR_REFRESHES + const deadline = deps.deadlineMs ?? LOGIN_DEADLINE_MS + const qrEncoder = deps.qrEncoder ?? defaultQrEncoder + + const client = new ILinkClient({ + baseUrl, + ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}) + }) + + const startedAt = now() + let refreshes = 0 + + while (true) { + if (isCancelled(deps.signal)) { + throw new ILinkError({ kind: 'aborted', message: '登录已取消' }) + } + if (now() - startedAt > deadline) { + throw new ILinkError({ kind: 'timeout', message: '登录超时,请重新获取二维码' }) + } + + // local_token_list 只在取二维码时上报,便于服务端判断是否已绑定。 + const localTokenList = loadAllCredentials(deps.home) + .map((item) => item.bot_token) + .filter(Boolean) + .slice(-10) + + const qrResponse = await client.postAnonymous( + QR_CODE_PATH, + { local_token_list: localTokenList }, + '获取登录二维码', + { timeoutMs: 20_000, ...(deps.signal ? { signal: deps.signal } : {}) } + ) + const qrcode = String(qrResponse.qrcode || '').trim() + const qrcodeContent = String(qrResponse.qrcode_img_content || '').trim() + if (!qrcode || !qrcodeContent) { + throw new ILinkError({ kind: 'protocol', message: '服务端未返回有效的登录二维码' }) + } + + const qrCodeDataUrl = await qrEncoder(qrcodeContent) + onEvent({ status: 'qrcode', qrCodeDataUrl }) + + const state: LoginState = { host: baseUrl } + const sessionDeadline = now() + QR_SESSION_TTL_MS + let refresh = false + + while (!refresh) { + if (isCancelled(deps.signal)) { + throw new ILinkError({ kind: 'aborted', message: '登录已取消' }) + } + if (now() > sessionDeadline || now() - startedAt > deadline) { + throw new ILinkError({ kind: 'timeout', message: '二维码已过期,请重新获取' }) + } + + const result = await pollOnce(client, qrcode, state, { + ...deps, + onEvent, + now + }) + + if (result.outcome === 'done') { + const credentials = result.credentials! + saveCredentials(credentials, deps.home) + onEvent({ + status: 'active', + accountId: credentials.ilink_bot_id, + wechatUserId: credentials.ilink_user_id + }) + return credentials + } + + if (result.outcome === 'refresh') { + refreshes += 1 + if (refreshes > maxRefreshes) { + throw new ILinkError({ + kind: 'protocol', + message: '二维码多次刷新后仍未完成登录,请稍后重试' + }) + } + refresh = true + continue + } + + // 避免空转打满接口。 + await sleep(300) + } + } +} diff --git a/src/main/services/wechat-ilink/client.ts b/src/main/services/wechat-ilink/client.ts new file mode 100644 index 0000000..822943d --- /dev/null +++ b/src/main/services/wechat-ilink/client.ts @@ -0,0 +1,284 @@ +import { classifyNetworkError, describeErrorChain, excerptBody, ILinkError } from './errors' +import { + buildAuthorizedHeaders, + buildBaseInfo, + buildCommonHeaders, + buildQrStatusHeaders, + type ILinkHeaderOptions +} from './headers' +import { + ILINK_CONFIG_TIMEOUT_MS, + ILINK_DEFAULT_BASE_URL, + ILINK_SEND_TIMEOUT_MS, + type ILinkGetConfigResponse, + type ILinkGetUpdatesResponse, + type ILinkGetUploadUrlRequest, + type ILinkGetUploadUrlResponse, + type ILinkSendMessageRequest, + type ILinkSendMessageResponse, + type ILinkSendTypingResponse +} from './types' + +export type FetchLike = typeof fetch + +export interface ILinkClientOptions { + baseUrl?: string + botToken?: string + fetchImpl?: FetchLike + headers?: ILinkHeaderOptions +} + +export interface ILinkRequestOptions { + timeoutMs: number + signal?: AbortSignal + /** 是否在失败时把响应体拼进错误信息(默认只带 HTTP 状态码)。 */ + includeBody?: boolean +} + +function withTimeout( + signal: AbortSignal | undefined, + timeoutMs: number +): { signal: AbortSignal; cleanup: () => void } { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(new Error('ilink request timeout')), timeoutMs) + const onAbort = (): void => controller.abort(signal?.reason) + if (signal) { + if (signal.aborted) controller.abort(signal.reason) + else signal.addEventListener('abort', onAbort, { once: true }) + } + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + } + } +} + +async function parseJson(response: Response, context: string, includeBody: boolean): Promise { + const text = await response.text() + if (!response.ok) { + throw new ILinkError({ + kind: 'http', + message: includeBody + ? `${context}失败:HTTP ${response.status} ${excerptBody(text)}` + : `${context}失败:HTTP ${response.status}`, + httpStatus: response.status + }) + } + if (!text.trim()) return {} as T + try { + return JSON.parse(text) as T + } catch { + throw new ILinkError({ + kind: 'protocol', + message: `${context}失败:响应不是合法 JSON`, + httpStatus: response.status + }) + } +} + +/** + * iLink HTTP 客户端。 + * + * 只负责传输层:网络失败 / 超时 / 非 2xx / JSON 解析失败会抛 ILinkError。 + * 业务层 ret / errcode 的判断交给调用方(长轮询需要区分 -14)。 + */ +export class ILinkClient { + private baseUrlValue: string + private botTokenValue: string + private readonly fetchImpl: FetchLike + private readonly headerOptions: ILinkHeaderOptions + + constructor(options: ILinkClientOptions = {}) { + this.baseUrlValue = (options.baseUrl || ILINK_DEFAULT_BASE_URL).replace(/\/+$/, '') + this.botTokenValue = options.botToken || '' + this.fetchImpl = options.fetchImpl || globalThis.fetch + this.headerOptions = options.headers || {} + } + + get baseUrl(): string { + return this.baseUrlValue + } + + /** 登录返回 baseurl / redirect_host 后更新后续业务 API 节点。 */ + setBaseUrl(baseUrl: string): void { + const normalized = String(baseUrl || '').trim() + if (!normalized) return + this.baseUrlValue = /^https?:\/\//.test(normalized) + ? normalized.replace(/\/+$/, '') + : `https://${normalized.replace(/\/+$/, '')}` + } + + setBotToken(botToken: string): void { + this.botTokenValue = botToken + } + + get hasBotToken(): boolean { + return Boolean(this.botTokenValue) + } + + private async request( + url: string, + init: RequestInit, + context: string, + options: ILinkRequestOptions + ): Promise { + const { signal, cleanup } = withTimeout(options.signal, options.timeoutMs) + try { + return await this.fetchImpl(url, { ...init, signal }) + } catch (error) { + const kind = classifyNetworkError(error) + const aborted = options.signal?.aborted === true + throw new ILinkError({ + kind: aborted ? 'aborted' : kind, + // 带上完整错误链:顶层 fetch failed 没有信息量,原因在 cause 上。 + message: `${context}失败:${describeErrorChain(error)}`, + cause: error + }) + } finally { + cleanup() + } + } + + /** 未鉴权 POST(获取二维码)。 */ + async postAnonymous( + path: string, + body: unknown, + context: string, + options: ILinkRequestOptions + ): Promise { + const response = await this.request( + `${this.baseUrlValue}${path}`, + { + method: 'POST', + headers: buildCommonHeaders(), + body: JSON.stringify(body) + }, + context, + options + ) + return parseJson(response, context, options.includeBody === true) + } + + /** 鉴权 POST(登录后的全部业务接口)。 */ + async post( + path: string, + body: unknown, + context: string, + options: ILinkRequestOptions + ): Promise { + const response = await this.request( + `${this.baseUrlValue}${path}`, + { + method: 'POST', + headers: buildAuthorizedHeaders(this.botTokenValue), + body: JSON.stringify(body) + }, + context, + options + ) + return parseJson(response, context, options.includeBody === true) + } + + /** 二维码状态轮询使用裸 URL(可能指向 redirect_host)。 */ + async getAnonymous(url: string, context: string, options: ILinkRequestOptions): Promise { + const response = await this.request( + url, + { method: 'GET', headers: buildQrStatusHeaders() }, + context, + options + ) + return parseJson(response, context, options.includeBody === true) + } + + private baseInfo(): ReturnType { + return buildBaseInfo(this.headerOptions) + } + + async getUpdates( + getUpdatesBuf: string, + options: ILinkRequestOptions + ): Promise { + return this.post( + '/ilink/bot/getupdates', + { get_updates_buf: getUpdatesBuf, base_info: this.baseInfo() }, + 'getupdates', + options + ) + } + + async sendMessage( + msg: ILinkSendMessageRequest['msg'], + signal?: AbortSignal + ): Promise { + return this.post( + '/ilink/bot/sendmessage', + { msg, base_info: this.baseInfo() }, + 'sendmessage', + { timeoutMs: ILINK_SEND_TIMEOUT_MS, signal, includeBody: true } + ) + } + + async getUploadUrl( + request: Omit, + signal?: AbortSignal + ): Promise { + return this.post( + '/ilink/bot/getuploadurl', + { ...request, base_info: this.baseInfo() }, + 'getuploadurl', + { timeoutMs: ILINK_SEND_TIMEOUT_MS, signal, includeBody: true } + ) + } + + async getConfig( + ilinkUserId: string, + contextToken: string, + signal?: AbortSignal + ): Promise { + return this.post( + '/ilink/bot/getconfig', + { + ilink_user_id: ilinkUserId, + ...(contextToken ? { context_token: contextToken } : {}), + base_info: this.baseInfo() + }, + 'getconfig', + { timeoutMs: ILINK_CONFIG_TIMEOUT_MS, signal } + ) + } + + /** + * sendtyping:status=1 开始输入、status=2 取消。 + * 只用于输入状态,**不是** sendmessage 的鉴权凭据。 + */ + async sendTyping( + ilinkUserId: string, + typingTicket: string, + status: number, + signal?: AbortSignal + ): Promise { + return this.post( + '/ilink/bot/sendtyping', + { + ilink_user_id: ilinkUserId, + typing_ticket: typingTicket, + status, + base_info: this.baseInfo() + }, + 'sendtyping', + { timeoutMs: ILINK_CONFIG_TIMEOUT_MS, signal } + ) + } + + /** 生命周期通知:notifystart / notifystop。失败只告警,不阻断消息循环。 */ + async notifyLifecycle(action: 'start' | 'stop', signal?: AbortSignal): Promise { + await this.post<{ ret?: number; errmsg?: string }>( + `/ilink/bot/msg/notify${action}`, + { base_info: this.baseInfo() }, + `notify${action}`, + { timeoutMs: ILINK_CONFIG_TIMEOUT_MS, signal } + ) + } +} diff --git a/src/main/services/wechat-ilink/errors.ts b/src/main/services/wechat-ilink/errors.ts new file mode 100644 index 0000000..142a0f2 --- /dev/null +++ b/src/main/services/wechat-ilink/errors.ts @@ -0,0 +1,166 @@ +import { ILINK_STALE_TOKEN_CODE } from './types' + +export type ILinkErrorKind = + /** DNS / TCP / TLS 等网络层失败 */ + | 'network' + /** 客户端主动超时(长轮询正常控制流) */ + | 'timeout' + /** 非 2xx HTTP 响应 */ + | 'http' + /** 响应体不是合法 JSON,或业务 ret/errcode 非 0 */ + | 'protocol' + /** bot token 失效(ret/errcode = -14),必须重新登录 */ + | 'stale_token' + /** 调用方主动取消 */ + | 'aborted' + +const MAX_BODY_EXCERPT = 400 + +export interface ILinkErrorOptions { + kind: ILinkErrorKind + message: string + httpStatus?: number + ret?: number + errcode?: number + errmsg?: string + cause?: unknown +} + +/** + * iLink 统一错误类型。 + * + * 刻意不携带请求头 / token:错误对象可能被上层原样写入日志, + * 因此只暴露可安全打印的字段。 + */ +export class ILinkError extends Error { + readonly kind: ILinkErrorKind + readonly httpStatus?: number + readonly ret?: number + readonly errcode?: number + readonly errmsg?: string + override readonly cause?: unknown + + constructor(options: ILinkErrorOptions) { + super(options.message) + this.name = 'ILinkError' + this.kind = options.kind + if (options.httpStatus !== undefined) this.httpStatus = options.httpStatus + if (options.ret !== undefined) this.ret = options.ret + if (options.errcode !== undefined) this.errcode = options.errcode + if (options.errmsg !== undefined) this.errmsg = options.errmsg + if (options.cause !== undefined) this.cause = options.cause + } + + get isStaleToken(): boolean { + return ( + this.kind === 'stale_token' || + this.ret === ILINK_STALE_TOKEN_CODE || + this.errcode === ILINK_STALE_TOKEN_CODE + ) + } +} + +export function isILinkError(error: unknown): error is ILinkError { + return error instanceof ILinkError +} + +/** + * 把 undici / Node 的错误链压平成可诊断文本。 + * + * fetch 层失败时顶层只有一句没有信息量的 `fetch failed`,真正的原因 + * (ENOTFOUND / ECONNREFUSED / TLS 握手失败 / socket 超时)在 `error.cause` 链上。 + * 日志里必须能看到它,否则线上只能看到"连不上"却不知道为什么。 + */ +export function describeErrorChain(error: unknown, maxDepth = 4): string { + const parts: string[] = [] + let current: unknown = error + for (let depth = 0; depth < maxDepth && current; depth += 1) { + const candidate = current as { + name?: unknown + code?: unknown + message?: unknown + cause?: unknown + } + const fragments = [candidate.name, candidate.code, candidate.message] + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter(Boolean) + parts.push(fragments.join(' ') || String(current)) + current = candidate.cause + } + return parts.join(' <- ') +} + +/** 沿错误链取第一个有值的 errno 风格 code。 */ +function firstErrorCode(error: unknown, maxDepth = 4): string { + let current: unknown = error + for (let depth = 0; depth < maxDepth && current; depth += 1) { + const code = (current as { code?: unknown } | null)?.code + if (typeof code === 'string' && code.trim()) return code.trim() + current = (current as { cause?: unknown } | null)?.cause + } + return '' +} + +/** 网络层错误分类;用于决定退避策略。 */ +export function classifyNetworkError(error: unknown): ILinkErrorKind { + const code = firstErrorCode(error) + const combined = `${code} ${describeErrorChain(error)}`.toUpperCase() + + if (/ABORT|CANCEL/.test(combined)) return 'aborted' + if ( + /TIMEOUT|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT/.test( + combined + ) + ) { + return 'timeout' + } + return 'network' +} + +/** 截断响应体,避免把大段 HTML / 二进制写进日志。 */ +export function excerptBody(body: string): string { + const normalized = String(body ?? '') + .replace(/\s+/g, ' ') + .trim() + return normalized.length > MAX_BODY_EXCERPT + ? `${normalized.slice(0, MAX_BODY_EXCERPT)}…` + : normalized +} + +/** 把任意 error 压成可安全记录的字符串。 */ +export function describeError(error: unknown): string { + if (!isILinkError(error) && error instanceof Error) return describeErrorChain(error) + if (!isILinkError(error) && !(error instanceof Error)) return String(error) + if (isILinkError(error)) { + const parts = [error.message] + if (error.httpStatus !== undefined) parts.push(`http=${error.httpStatus}`) + if (error.ret !== undefined) parts.push(`ret=${error.ret}`) + if (error.errcode !== undefined) parts.push(`errcode=${error.errcode}`) + if (error.errmsg) parts.push(`errmsg=${error.errmsg}`) + return parts.join(' | ') + } + return error instanceof Error ? error.message : String(error) +} + +/** + * 协议层业务错误断言:ret / errcode 非 0 即失败。 + * HTTP 200 不能单独证明调用成功。 + */ +export function assertBusinessOk( + response: { ret?: number; errcode?: number; errmsg?: string }, + context: string +): void { + const ret = Number(response.ret ?? 0) + const errcode = Number(response.errcode ?? 0) + if (ret === 0 && errcode === 0) return + const stale = ret === ILINK_STALE_TOKEN_CODE || errcode === ILINK_STALE_TOKEN_CODE + throw new ILinkError({ + kind: stale ? 'stale_token' : 'protocol', + message: stale + ? `${context}失败:微信登录凭证已失效` + : `${context}失败:ret=${ret} errcode=${errcode}${response.errmsg ? ` errmsg=${response.errmsg}` : ''}`, + ret, + errcode, + ...(response.errmsg ? { errmsg: response.errmsg } : {}) + }) +} diff --git a/src/main/services/wechat-ilink/headers.ts b/src/main/services/wechat-ilink/headers.ts new file mode 100644 index 0000000..9ea7457 --- /dev/null +++ b/src/main/services/wechat-ilink/headers.ts @@ -0,0 +1,84 @@ +import { randomBytes } from 'node:crypto' +import { + ILINK_APP_CLIENT_VERSION, + ILINK_APP_ID, + ILINK_CHANNEL_VERSION, + ILINK_DEFAULT_BOT_AGENT, + type ILinkBaseInfo +} from './types' + +/** bot_agent 的官方约束:仅 ASCII、总分不超过 256 字节、非法 token 丢弃。 */ +const BOT_AGENT_MAX_BYTES = 256 +const BOT_AGENT_FALLBACK = 'OpenClaw' +const BOT_AGENT_TOKEN = /^[!-~]+(?:\/[!-~]+)?(?:\([!-~ ]*\))?$/ + +export interface ILinkHeaderOptions { + /** 覆盖 bot_agent,用于多产品共用同一实现时做归因。仅进入 base_info,不额外造头。 */ + botAgent?: string +} + +/** + * 清洗 bot_agent。 + * 该字段只用于服务端观测聚合,不参与鉴权与路由,因此宁可回退也不抛错。 + */ +export function sanitizeBotAgent(value: string | undefined): string { + const tokens = String(value ?? '') + .split(/\s+/) + .map((token) => token.trim()) + .filter((token) => token.length > 0 && BOT_AGENT_TOKEN.test(token)) + let result = tokens.join(' ') + if (!result) result = BOT_AGENT_FALLBACK + while (Buffer.byteLength(result, 'utf8') > BOT_AGENT_MAX_BYTES && result.includes(' ')) { + result = result.slice(0, result.lastIndexOf(' ')) + } + if (Buffer.byteLength(result, 'utf8') > BOT_AGENT_MAX_BYTES) result = BOT_AGENT_FALLBACK + return result +} + +/** + * X-WECHAT-UIN:随机 uint32 的十进制字符串再做 base64。 + * 官方要求**每次请求重新生成**以防重放,一个客户端只生成一次是不合规的。 + */ +export function generateWechatUin(): string { + const value = randomBytes(4).readUInt32LE(0) + return Buffer.from(String(value), 'utf8').toString('base64') +} + +/** 所有 POST 请求都会带的公共头(含登录前的二维码接口)。 */ +export function buildCommonHeaders(): Record { + return { + 'Content-Type': 'application/json', + 'iLink-App-Id': ILINK_APP_ID, + 'iLink-App-ClientVersion': ILINK_APP_CLIENT_VERSION + } +} + +/** + * 二维码状态轮询使用未鉴权的最小头集合(官方 2.4.6 不再带 Content-Type)。 + * 注意:不自造 SKRouteTag,那是官方内部的路由/调试开关。 + */ +export function buildQrStatusHeaders(): Record { + return { + 'iLink-App-Id': ILINK_APP_ID, + 'iLink-App-ClientVersion': ILINK_APP_CLIENT_VERSION + } +} + +/** 登录后的业务请求头;未登录时不得携带 Authorization。 */ +export function buildAuthorizedHeaders(botToken: string): Record { + if (!botToken) throw new Error('buildAuthorizedHeaders 需要有效的 bot token') + return { + ...buildCommonHeaders(), + AuthorizationType: 'ilink_bot_token', + Authorization: `Bearer ${botToken}`, + 'X-WECHAT-UIN': generateWechatUin() + } +} + +/** 每个业务请求体都要带 base_info。 */ +export function buildBaseInfo(options: ILinkHeaderOptions = {}): ILinkBaseInfo { + return { + channel_version: ILINK_CHANNEL_VERSION, + bot_agent: sanitizeBotAgent(options.botAgent ?? ILINK_DEFAULT_BOT_AGENT) + } +} diff --git a/src/main/services/wechat-ilink/index.ts b/src/main/services/wechat-ilink/index.ts new file mode 100644 index 0000000..0f0e4ea --- /dev/null +++ b/src/main/services/wechat-ilink/index.ts @@ -0,0 +1,507 @@ +import { ILinkClient, type FetchLike } from './client' +import { ILinkPoller } from './poller' +import { normalizeInboundMessage } from './messages' +import { sendText, type SendTextResult } from './sender' +import { + sendMediaFromPath, + sendMediaFromSource, + sendMediaFromUrl, + type SendMediaOptions +} from './media' +import { runQrLogin, type QrEncoder } from './auth' +import { assertBusinessOk, ILinkError, describeError } from './errors' +import { TypingCoordinator, type TypingLease, type TypingBeginInput } from './typing' +import { + findCredentials, + loadAllCredentials, + loadContextToken, + loadCursor, + normalizeAccountId, + resolveAccountDirectory, + saveContextToken, + saveCursor, + type HomeDirectoryResolver +} from './account-store' +import type { + ILinkCredentials, + WechatConnectorAccount, + WechatConnectorPhase, + WechatInboundMessage, + WechatLoginEvent +} from './types' + +export type ConnectorLogLevel = 'info' | 'warn' | 'error' + +export interface WechatConnectorHost { + onLog?: (level: ConnectorLogLevel, message: string) => void + /** 一批入站消息;全部成功 resolve 后才推进游标。 */ + onMessages?: (messages: WechatInboundMessage[]) => Promise + onPhaseChange?: (phase: WechatConnectorPhase, error?: string) => void + onLoginEvent?: (event: WechatLoginEvent) => void +} + +export interface WechatConnectorServiceOptions { + home?: HomeDirectoryResolver + fetchImpl?: FetchLike + qrEncoder?: QrEncoder + now?: () => number + sleep?: (milliseconds: number) => Promise + botAgent?: string + /** 「正在输入」心跳间隔;默认 5 秒(测试可缩短)。 */ + typingKeepaliveMs?: number + /** typing_ticket 缓存有效期。 */ + typingTicketTtlMs?: number + /** 定时器注入点,便于测试确定性触发 keepalive。 */ + typingSchedule?: (tick: () => void, intervalMs: number) => () => void + /** 便于测试注入更短的长轮询超时/退避。 */ + pollOverrides?: { + normalFailureDelayMs?: number + repeatedFailureDelayMs?: number + defaultTimeoutMs?: number + } +} + +export interface ConnectorSendTextInput { + to: string + text: string + accountId?: string + contextToken?: string + signal?: AbortSignal +} + +/** + * TraceMemo 的微信 iLink 连接器,直接跑在 Electron main process 内。 + * + * inbound 通过 onMessages 回调交给 Agent Hub,outbound 通过 sendText / sendMedia 直接调用; + * 不依赖子进程,也不开本地 HTTP 端口。 + */ +export class WechatConnectorService { + private readonly options: WechatConnectorServiceOptions + private readonly typing: TypingCoordinator + private host: WechatConnectorHost = {} + private phase: WechatConnectorPhase = 'stopped' + private activeAccountId?: string + private client: ILinkClient | null = null + private pollerAbort: AbortController | null = null + private pollerPromise: Promise | null = null + private loginAbort: AbortController | null = null + private loginPromise: Promise | null = null + private pendingVerifyCode: ((code: string | undefined) => void) | null = null + + constructor(options: WechatConnectorServiceOptions = {}) { + this.options = options + this.typing = new TypingCoordinator({ + // 取票与下发都绑定"当前账号"的 client,切账号后自然走新 client。 + fetchTicket: async ({ ilinkUserId, contextToken }) => { + const client = this.client + if (!client) return undefined + const response = await client.getConfig(ilinkUserId, contextToken ?? '') + assertBusinessOk(response, 'getconfig') + return response.typing_ticket + }, + sendTyping: async ({ ilinkUserId, ticket, status }) => { + const client = this.client + if (!client) return false + const response = await client.sendTyping(ilinkUserId, ticket, status) + return Number(response.ret ?? 0) === 0 && Number(response.errcode ?? 0) === 0 + }, + log: (level, message) => this.log(level, message), + ...(options.now ? { now: options.now } : {}), + ...(options.typingKeepaliveMs !== undefined + ? { keepaliveMs: options.typingKeepaliveMs } + : {}), + ...(options.typingTicketTtlMs !== undefined + ? { ticketTtlMs: options.typingTicketTtlMs } + : {}), + ...(options.typingSchedule ? { schedule: options.typingSchedule } : {}) + }) + } + + /** + * 开始「正在输入」。永不抛异常:拿不到 ticket 或服务端失败时返回空实现, + * 调用方照常执行业务,但**仍然要**在 finally 里 stop(幂等且安全)。 + */ + beginTyping(input: TypingBeginInput): Promise { + return this.typing.begin({ + ...input, + ...(input.accountId ? {} : this.activeAccountId ? { accountId: this.activeAccountId } : {}) + }) + } + + setHost(host: WechatConnectorHost): void { + this.host = host + } + + getPhase(): WechatConnectorPhase { + return this.phase + } + + getActiveAccountId(): string | undefined { + return this.activeAccountId + } + + isRunning(): boolean { + return this.phase === 'polling' + } + + listAccounts(): WechatConnectorAccount[] { + return loadAllCredentials(this.options.home).map((credentials) => ({ + accountId: credentials.ilink_bot_id, + wechatUserId: credentials.ilink_user_id + })) + } + + /** 主动发送时取该会话最近一次有效的 context_token。 */ + resolveContextToken(accountId: string | undefined, toUserId: string): string | undefined { + const normalized = normalizeAccountId(accountId || this.activeAccountId || '') + if (!normalized) return undefined + return loadContextToken(normalized, toUserId, this.options.home) + } + + /** 当前账号凭据所在目录(凭据 / 游标 / 会话令牌同目录)。 */ + getAccountDirectory(accountId: string): { directory: string; legacy: boolean } { + return resolveAccountDirectory(normalizeAccountId(accountId), this.options.home) + } + + /* ------------------------------ 登录 ------------------------------ */ + + /** + * 启动扫码登录。立即返回,进度通过 onLoginEvent 上报。 + * 重复调用会先取消进行中的登录。 + */ + startLogin(): void { + if (this.loginPromise) return + const controller = new AbortController() + this.loginAbort = controller + this.loginPromise = this.runLogin(controller).finally(() => { + if (this.loginAbort === controller) this.loginAbort = null + this.loginPromise = null + }) + } + + isLoginInProgress(): boolean { + return this.loginPromise !== null + } + + cancelLogin(): void { + this.pendingVerifyCode?.(undefined) + this.pendingVerifyCode = null + this.loginAbort?.abort() + this.loginAbort = null + } + + /** 手机端要求数字配对码时由宿主回填。 */ + submitVerifyCode(code: string): void { + const resolver = this.pendingVerifyCode + this.pendingVerifyCode = null + resolver?.(code) + } + + private async runLogin(controller: AbortController): Promise { + this.setPhase('starting') + try { + await runQrLogin({ + ...(this.options.home ? { home: this.options.home } : {}), + ...(this.options.fetchImpl ? { fetchImpl: this.options.fetchImpl } : {}), + ...(this.options.qrEncoder ? { qrEncoder: this.options.qrEncoder } : {}), + ...(this.options.now ? { now: this.options.now } : {}), + ...(this.options.sleep ? { sleep: this.options.sleep } : {}), + signal: controller.signal, + onEvent: (event) => this.host.onLoginEvent?.(event), + verifyCodeProvider: () => + new Promise((resolve) => { + this.pendingVerifyCode = resolve + }) + }) + this.log('info', '扫码登录成功') + // 登录成功后的连接由宿主在收到 active 事件后调用 start()。 + this.setPhase('stopped') + } catch (error) { + if (error instanceof ILinkError && error.kind === 'aborted') { + this.log('info', '扫码登录已取消') + this.setPhase('stopped') + return + } + const message = describeError(error) + this.log('error', `扫码登录失败:${message}`) + this.setPhase('error', message) + } + } + + /* --------------------------- 连接与轮询 --------------------------- */ + + /** 启动某个账号的长轮询。重复调用会先停止当前账号。 */ + async start(accountId: string): Promise { + await this.stop() + const credentials = findCredentials(normalizeAccountId(accountId), this.options.home) + if (!credentials) { + throw new ILinkError({ kind: 'protocol', message: `未找到账号 ${accountId} 的登录凭据` }) + } + + const normalizedId = normalizeAccountId(credentials.ilink_bot_id) + const client = new ILinkClient({ + baseUrl: credentials.baseurl, + botToken: credentials.bot_token, + ...(this.options.fetchImpl ? { fetchImpl: this.options.fetchImpl } : {}), + ...(this.options.botAgent ? { headers: { botAgent: this.options.botAgent } } : {}) + }) + this.client = client + this.activeAccountId = normalizedId + // 账号或 token 变了:旧 ticket 一律作废。 + this.typing.invalidateTickets() + this.setPhase('starting') + + // 生命周期通知是尽力而为:失败只告警,绝不阻断消息循环。 + await this.notifyLifecycle('start') + + const controller = new AbortController() + this.pollerAbort = controller + const poller = new ILinkPoller({ + fetchUpdates: (getUpdatesBuf, timeoutMs) => + client.getUpdates(getUpdatesBuf, { timeoutMs, signal: controller.signal }), + onMessages: async (messages) => { + this.rememberContextTokens(normalizedId, messages) + await this.host.onMessages?.(messages) + }, + normalize: (raw) => normalizeInboundMessage(normalizedId, raw), + loadCursor: () => loadCursor(normalizedId, this.options.home), + saveCursor: (getUpdatesBuf) => saveCursor(normalizedId, getUpdatesBuf, this.options.home), + signal: controller.signal, + log: (level, message) => this.log(level, message), + onStaleToken: (message) => { + // 凭证已失效:缓存的 typing_ticket 不再可用。 + this.typing.invalidateTickets() + this.setPhase('stale_token', message) + }, + ...(this.options.now ? { now: this.options.now } : {}), + ...(this.options.sleep ? { sleep: this.options.sleep } : {}), + ...(this.options.pollOverrides?.normalFailureDelayMs !== undefined + ? { normalFailureDelayMs: this.options.pollOverrides.normalFailureDelayMs } + : {}), + ...(this.options.pollOverrides?.repeatedFailureDelayMs !== undefined + ? { repeatedFailureDelayMs: this.options.pollOverrides.repeatedFailureDelayMs } + : {}), + ...(this.options.pollOverrides?.defaultTimeoutMs !== undefined + ? { defaultTimeoutMs: this.options.pollOverrides.defaultTimeoutMs } + : {}) + }) + + const pollPromise = poller + .run() + .catch((error) => { + if (controller.signal.aborted) return + const message = describeError(error) + this.log('error', `长轮询异常退出:${message}`) + this.setPhase('error', message) + }) + .finally(() => { + if (this.pollerAbort === controller) { + this.pollerAbort = null + this.pollerPromise = null + } + }) + this.pollerPromise = pollPromise + + if (this.phase !== 'stale_token' && this.phase !== 'error') this.setPhase('polling') + this.log('info', `微信连接器已启动(账号 ${normalizedId})`) + } + + async stop(): Promise { + // 先收掉输入状态,避免连接器停了微信端还显示"对方正在输入"。 + this.typing.clear() + const controller = this.pollerAbort + const running = this.pollerPromise + this.pollerAbort = null + if (controller && !controller.signal.aborted) controller.abort() + if (running) await running.catch(() => undefined) + if (this.client) { + // 用独立短超时发送停止通知,避免被长轮询的取消信号一起取消。 + await this.notifyLifecycle('stop') + } + this.client = null + this.activeAccountId = undefined + if (this.phase !== 'error') this.setPhase('stopped') + } + + private async notifyLifecycle(action: 'start' | 'stop'): Promise { + const client = this.client + if (!client || !client.hasBotToken) return + try { + await client.notifyLifecycle(action) + } catch (error) { + this.log('warn', `notify${action} 未成功(不影响消息循环):${describeError(error)}`) + } + } + + private rememberContextTokens(accountId: string, messages: WechatInboundMessage[]): void { + for (const message of messages) { + if (!message.contextToken) continue + try { + saveContextToken( + accountId, + message.fromUserId, + message.contextToken, + this.options.home, + this.options.now + ) + } catch (error) { + this.log('warn', `会话上下文令牌保存失败:${describeError(error)}`) + } + } + } + + /* ------------------------------ 发送 ------------------------------ */ + + /** + * 主动发送时的 context_token 解析: + * 显式传入优先,其次按「账号 + 用户」取最近一次有效值。 + */ + resolveOutgoingContextToken(input: { + accountId?: string + to: string + contextToken?: string + }): string | undefined { + const explicit = String(input.contextToken ?? '').trim() + if (explicit) return explicit + return this.resolveContextToken(input.accountId, input.to) + } + + private requireClient(accountId?: string): ILinkClient { + if (this.phase === 'stale_token') { + throw new ILinkError({ + kind: 'stale_token', + message: '微信登录凭证已失效,请重新扫码登录' + }) + } + if (!this.client) { + throw new ILinkError({ kind: 'protocol', message: '微信连接器尚未启动' }) + } + if (accountId) { + const normalized = normalizeAccountId(accountId) + if (this.activeAccountId && normalized !== this.activeAccountId) { + throw new ILinkError({ + kind: 'protocol', + message: `账号 ${accountId} 当前未连接` + }) + } + } + if (!this.client.hasBotToken) { + throw new ILinkError({ kind: 'stale_token', message: '微信登录凭证不可用' }) + } + return this.client + } + + async sendText(input: ConnectorSendTextInput): Promise { + const client = this.requireClient(input.accountId) + const contextToken = this.resolveOutgoingContextToken({ + ...(input.accountId ? { accountId: input.accountId } : {}), + to: input.to, + ...(input.contextToken ? { contextToken: input.contextToken } : {}) + }) + return sendText(client, { + to: input.to, + text: input.text, + ...(contextToken ? { contextToken } : {}), + ...(input.signal ? { signal: input.signal } : {}) + }) + } + + async sendMedia( + input: Omit & { accountId?: string } + ): Promise<{ clientId: string; itemType: number }> { + const client = this.requireClient(input.accountId) + const contextToken = this.resolveOutgoingContextToken({ + ...(input.accountId ? { accountId: input.accountId } : {}), + to: input.to, + ...(input.contextToken ? { contextToken: input.contextToken } : {}) + }) + return sendMediaFromSource(client, { + to: input.to, + source: input.source, + mediaKind: 'file', + ...(contextToken ? { contextToken } : {}), + ...(this.options.fetchImpl ? { fetchImpl: this.options.fetchImpl } : {}), + ...(input.signal ? { signal: input.signal } : {}) + }) + } + + async sendMediaPath(input: { + accountId?: string + to: string + filePath: string + contextToken?: string + signal?: AbortSignal + }): Promise<{ clientId: string; itemType: number }> { + const client = this.requireClient(input.accountId) + const contextToken = this.resolveOutgoingContextToken({ + ...(input.accountId ? { accountId: input.accountId } : {}), + to: input.to, + ...(input.contextToken ? { contextToken: input.contextToken } : {}) + }) + return sendMediaFromPath(client, { + to: input.to, + filePath: input.filePath, + ...(contextToken ? { contextToken } : {}), + ...(this.options.fetchImpl ? { fetchImpl: this.options.fetchImpl } : {}), + ...(input.signal ? { signal: input.signal } : {}) + }) + } + + async sendMediaUrl(input: { + accountId?: string + to: string + mediaUrl: string + contextToken?: string + signal?: AbortSignal + }): Promise<{ clientId: string; itemType: number }> { + const client = this.requireClient(input.accountId) + const contextToken = this.resolveOutgoingContextToken({ + ...(input.accountId ? { accountId: input.accountId } : {}), + to: input.to, + ...(input.contextToken ? { contextToken: input.contextToken } : {}) + }) + return sendMediaFromUrl(client, { + to: input.to, + mediaUrl: input.mediaUrl, + ...(contextToken ? { contextToken } : {}), + ...(this.options.fetchImpl ? { fetchImpl: this.options.fetchImpl } : {}), + ...(input.signal ? { signal: input.signal } : {}) + }) + } + + /** 会话凭据(含 bot_token)只读暴露给需要的内部调用方;不用于日志。 */ + getCredentials(accountId?: string): ILinkCredentials | undefined { + const id = normalizeAccountId(accountId || this.activeAccountId || '') + if (!id) return undefined + return findCredentials(id, this.options.home) + } + + private setPhase(phase: WechatConnectorPhase, error?: string): void { + this.phase = phase + this.host.onPhaseChange?.(phase, error) + } + + private log(level: ConnectorLogLevel, message: string): void { + this.host.onLog?.(level, message) + } +} + +export { ILinkClient } from './client' +export { ILinkPoller } from './poller' +export * from './types' +export { normalizeInboundMessage, extractInboundText } from './messages' +export { markdownToPlainText, extractMarkdownImageUrls } from './markdown' +export { ILinkError, describeError, isILinkError } from './errors' +export { + loadAllCredentials, + normalizeAccountId, + saveCredentials, + accountsDirectory, + legacyAccountsDirectory, + loadCursor, + saveCursor, + loadContextToken, + saveContextToken +} from './account-store' +export { sendText, createClientId } from './sender' +export { runQrLogin } from './auth' diff --git a/src/main/services/wechat-ilink/markdown.ts b/src/main/services/wechat-ilink/markdown.ts new file mode 100644 index 0000000..e31e4c0 --- /dev/null +++ b/src/main/services/wechat-ilink/markdown.ts @@ -0,0 +1,58 @@ +/** + * 微信纯文本渲染。 + * + * 微信气泡不渲染 Markdown,所以外发前把常见 Markdown 语法降级成可读纯文本。 + * 刻意不处理斜体(`*text*`):微信里 `*` 常作为普通字符出现,转换会误伤正文。 + */ + +const RE_CODE_BLOCK = /```[^\n]*\n?([\s\S]*?)```/g +const RE_INLINE_CODE = /`([^`]+)`/g +const RE_IMAGE = /!\[[^\]]*\]\([^)]*\)/g +const RE_LINK = /\[([^\]]+)\]\([^)]*\)/g +const RE_TABLE_SEPARATOR = /^\|[\s:|-]+\|$/gm +const RE_TABLE_ROW = /^\|(.+)\|$/gm +const RE_HEADER = /^#{1,6}\s+/gm +const RE_BOLD = /\*\*(.+?)\*\*|__(.+?)__/g +const RE_STRIKE = /~~(.+?)~~/g +const RE_BLOCKQUOTE = /^>\s?/gm +const RE_HORIZONTAL_RULE = /^[-*_]{3,}\s*$/gm +const RE_UNORDERED_LIST = /^(\s*)[-*+]\s+/gm +const RE_EXCESS_BLANK_LINES = /\n{3,}/g +const RE_MARKDOWN_IMAGE_URL = /!\[[^\]]*\]\(([^)]+)\)/g + +export function markdownToPlainText(text: string): string { + let result = String(text ?? '') + + result = result.replace(RE_CODE_BLOCK, (_match, body: string) => String(body ?? '').trim()) + result = result.replace(RE_IMAGE, '') + result = result.replace(RE_LINK, '$1') + result = result.replace(RE_TABLE_SEPARATOR, '') + result = result.replace(RE_TABLE_ROW, (_match, body: string) => + String(body ?? '') + .split('|') + .map((cell) => cell.trim()) + .join(' ') + ) + result = result.replace(RE_HEADER, '') + result = result.replace(RE_BOLD, (_match, strong: string, alternative: string) => + strong !== undefined && strong !== '' ? strong : String(alternative ?? '') + ) + result = result.replace(RE_STRIKE, '$1') + result = result.replace(RE_BLOCKQUOTE, '') + result = result.replace(RE_HORIZONTAL_RULE, '') + result = result.replace(RE_UNORDERED_LIST, '$1• ') + result = result.replace(RE_INLINE_CODE, '$1') + result = result.replace(RE_EXCESS_BLANK_LINES, '\n\n') + + return result.trim() +} + +/** 提取 Markdown 中内嵌的 http(s) 图片地址,用于"文字 + 随后补发图片"。 */ +export function extractMarkdownImageUrls(text: string): string[] { + const urls: string[] = [] + for (const match of String(text ?? '').matchAll(RE_MARKDOWN_IMAGE_URL)) { + const url = String(match[1] ?? '').trim() + if (url.startsWith('http://') || url.startsWith('https://')) urls.push(url) + } + return urls +} diff --git a/src/main/services/wechat-ilink/media.ts b/src/main/services/wechat-ilink/media.ts new file mode 100644 index 0000000..b114958 --- /dev/null +++ b/src/main/services/wechat-ilink/media.ts @@ -0,0 +1,391 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { basename, extname } from 'node:path' +import type { FetchLike, ILinkClient } from './client' +import { assertBusinessOk, excerptBody, ILinkError } from './errors' +import { buildSendMessageBody, createClientId } from './sender' +import { + ILINK_CDN_BASE_URL, + ILINK_CDN_MEDIA_TYPE_FILE, + ILINK_CDN_MEDIA_TYPE_IMAGE, + ILINK_CDN_MEDIA_TYPE_VIDEO, + ILINK_ITEM_TYPE_FILE, + ILINK_ITEM_TYPE_IMAGE, + ILINK_ITEM_TYPE_VIDEO, + type ILinkMessageItem +} from './types' + +/** + * 媒体消息(图片 / 视频 / 文件)。 + * + * 协议要求端到端加密:随机 16 字节 AES-128-ECB 密钥 → 加密后 PUT 到 CDN → + * sendmessage 里带 CDN 引用与 base64(hex key)。 + * + * 本项目不生成缩略图:官方文档提到 IMAGE/VIDEO 可携带缩略图字段, + * 这里显式声明 `no_need_thumb: true`,避免服务端等待一个永远不会上传的缩略图。 + */ + +const CDN_REQUEST_TIMEOUT_MS = 60_000 +const AES_BLOCK_SIZE = 16 + +const MIME_BY_EXTENSION: Readonly> = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.webm': 'video/webm', + '.mkv': 'video/x-matroska', + '.avi': 'video/x-msvideo', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.zip': 'application/zip' +} + +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']) +const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.webm', '.mkv', '.avi']) + +export function aesEcbPaddedSize(plaintextSize: number): number { + return (Math.floor(plaintextSize / AES_BLOCK_SIZE) + 1) * AES_BLOCK_SIZE +} + +export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer { + const cipher = createCipheriv('aes-128-ecb', key, null) + return Buffer.concat([cipher.update(plaintext), cipher.final()]) +} + +export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer { + if (ciphertext.length === 0 || ciphertext.length % AES_BLOCK_SIZE !== 0) { + throw new ILinkError({ kind: 'protocol', message: '密文长度不是 AES 块大小的整数倍' }) + } + const decipher = createDecipheriv('aes-128-ecb', key, null) + return Buffer.concat([decipher.update(ciphertext), decipher.final()]) +} + +/** 协议要求把 hex 字符串再做 base64 放进 item.media.aes_key。 */ +export function aesKeyToBase64(hexKey: string): string { + return Buffer.from(hexKey, 'utf8').toString('base64') +} + +/** 反向解析 item.media.aes_key:base64 → hex 字符串 → 原始密钥。 */ +export function aesKeyFromBase64(base64Key: string): Buffer { + return Buffer.from(Buffer.from(base64Key, 'base64').toString('utf8'), 'hex') +} + +export function stripQuery(rawUrl: string): string { + const index = rawUrl.indexOf('?') + return index >= 0 ? rawUrl.slice(0, index) : rawUrl +} + +export function inferContentType(source: string): string { + return MIME_BY_EXTENSION[extname(stripQuery(source)).toLowerCase()] || 'application/octet-stream' +} + +export function classifyMedia( + contentType: string, + source: string +): { cdnMediaType: number; itemType: number } { + const normalized = String(contentType || '').toLowerCase() + const extension = extname(stripQuery(source)).toLowerCase() + if (normalized.startsWith('image/') || IMAGE_EXTENSIONS.has(extension)) { + return { cdnMediaType: ILINK_CDN_MEDIA_TYPE_IMAGE, itemType: ILINK_ITEM_TYPE_IMAGE } + } + if (normalized.startsWith('video/') || VIDEO_EXTENSIONS.has(extension)) { + return { cdnMediaType: ILINK_CDN_MEDIA_TYPE_VIDEO, itemType: ILINK_ITEM_TYPE_VIDEO } + } + return { cdnMediaType: ILINK_CDN_MEDIA_TYPE_FILE, itemType: ILINK_ITEM_TYPE_FILE } +} + +export interface UploadedMedia { + downloadParam: string + aesKeyHex: string + fileSize: number + cipherSize: number +} + +async function fetchWithTimeout( + fetchImpl: FetchLike, + url: string, + init: RequestInit, + context: string +): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(new Error('cdn timeout')), CDN_REQUEST_TIMEOUT_MS) + try { + const response = await fetchImpl(url, { ...init, signal: controller.signal }) + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new ILinkError({ + kind: 'http', + message: `${context}失败:HTTP ${response.status} ${excerptBody(body)}`, + httpStatus: response.status + }) + } + return response + } catch (error) { + if (error instanceof ILinkError) throw error + throw new ILinkError({ + kind: 'network', + message: `${context}失败:${error instanceof Error ? error.message : String(error)}`, + cause: error + }) + } finally { + clearTimeout(timer) + } +} + +/** 加密并上传到微信 CDN,返回 sendmessage 需要的媒体引用。 */ +export async function uploadMediaToCdn( + client: ILinkClient, + input: { + data: Buffer + toUserId: string + mediaType: number + fetchImpl?: FetchLike + signal?: AbortSignal + } +): Promise { + const fetchImpl = input.fetchImpl ?? globalThis.fetch + const fileKey = randomBytes(16) + const aesKey = randomBytes(16) + const fileKeyHex = fileKey.toString('hex') + const aesKeyHex = aesKey.toString('hex') + const rawFileMd5 = createHash('md5').update(input.data).digest('hex') + const cipherSize = aesEcbPaddedSize(input.data.length) + + const uploadResponse = await client.getUploadUrl( + { + filekey: fileKeyHex, + media_type: input.mediaType, + to_user_id: input.toUserId, + rawsize: input.data.length, + rawfilemd5: rawFileMd5, + filesize: cipherSize, + no_need_thumb: true, + aeskey: aesKeyHex + }, + input.signal + ) + assertBusinessOk(uploadResponse, '获取 CDN 上传地址') + + const encrypted = encryptAesEcb(input.data, aesKey) + const explicitUrl = String(uploadResponse.upload_full_url ?? '').trim() + const uploadParam = String(uploadResponse.upload_param ?? '').trim() + if (!explicitUrl && !uploadParam) { + throw new ILinkError({ + kind: 'protocol', + message: '服务端未返回可用的 CDN 上传地址' + }) + } + const cdnUrl = + explicitUrl || + `${ILINK_CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(fileKeyHex)}` + + const uploadResult = await fetchWithTimeout( + fetchImpl, + cdnUrl, + { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: new Uint8Array(encrypted) + }, + 'CDN 上传' + ) + + const downloadParam = uploadResult.headers.get('X-Encrypted-Param') ?? '' + if (!downloadParam) { + throw new ILinkError({ + kind: 'protocol', + message: 'CDN 上传成功但缺少 X-Encrypted-Param 响应头' + }) + } + return { downloadParam, aesKeyHex, fileSize: input.data.length, cipherSize } +} + +async function downloadFromCdn( + fetchImpl: FetchLike, + encryptQueryParam: string, + aesKeyBase64: string +): Promise { + const url = `${ILINK_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(encryptQueryParam)}` + const response = await fetchWithTimeout(fetchImpl, url, { method: 'GET' }, 'CDN 下载') + const ciphertext = Buffer.from(await response.arrayBuffer()) + return decryptAesEcb(ciphertext, aesKeyFromBase64(aesKeyBase64)) +} + +function buildMediaItem( + itemType: number, + media: { encrypt_query_param: string; aes_key: string; encrypt_type: number }, + uploaded: UploadedMedia, + fileName: string +): ILinkMessageItem { + if (itemType === ILINK_ITEM_TYPE_IMAGE) { + return { type: ILINK_ITEM_TYPE_IMAGE, image_item: { media, mid_size: uploaded.cipherSize } } + } + if (itemType === ILINK_ITEM_TYPE_VIDEO) { + return { type: ILINK_ITEM_TYPE_VIDEO, video_item: { media, video_size: uploaded.cipherSize } } + } + return { + type: ILINK_ITEM_TYPE_FILE, + file_item: { + media, + file_name: fileName || 'file', + len: String(uploaded.fileSize) + } + } +} + +export interface SendMediaOptions { + to: string + data: Buffer + fileName: string + contentType: string + source: string + contextToken?: string + fetchImpl?: FetchLike + signal?: AbortSignal +} + +/** 上传并发送一条媒体消息。 */ +export async function sendMedia( + client: ILinkClient, + options: SendMediaOptions +): Promise<{ clientId: string; itemType: number }> { + const to = String(options.to ?? '').trim() + if (!to) throw new ILinkError({ kind: 'protocol', message: '发送媒体需要有效的接收者' }) + + const { cdnMediaType, itemType } = classifyMedia(options.contentType, options.source) + const uploaded = await uploadMediaToCdn(client, { + data: options.data, + toUserId: to, + mediaType: cdnMediaType, + ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), + ...(options.signal ? { signal: options.signal } : {}) + }) + + const clientId = createClientId() + const response = await client.sendMessage( + buildSendMessageBody({ + item: buildMediaItem( + itemType, + { + encrypt_query_param: uploaded.downloadParam, + aes_key: aesKeyToBase64(uploaded.aesKeyHex), + encrypt_type: 1 + }, + uploaded, + options.fileName + ), + to, + clientId, + ...(options.contextToken ? { contextToken: options.contextToken } : {}) + }), + options.signal + ) + assertBusinessOk(response, '发送媒体') + return { clientId, itemType } +} + +/** 发送本地文件。 */ +export async function sendMediaFromPath( + client: ILinkClient, + input: { + to: string + filePath: string + contextToken?: string + fetchImpl?: FetchLike + signal?: AbortSignal + } +): Promise<{ clientId: string; itemType: number }> { + let data: Buffer + try { + data = readFileSync(input.filePath) + } catch (error) { + throw new ILinkError({ + kind: 'protocol', + message: `读取媒体文件失败:${error instanceof Error ? error.message : String(error)}` + }) + } + return sendMedia(client, { + to: input.to, + data, + fileName: basename(input.filePath), + contentType: inferContentType(input.filePath), + source: input.filePath, + ...(input.contextToken ? { contextToken: input.contextToken } : {}), + ...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}), + ...(input.signal ? { signal: input.signal } : {}) + }) +} + +/** 发送远程媒体:下载后走同一条加密上传链路。 */ +export async function sendMediaFromUrl( + client: ILinkClient, + input: { + to: string + mediaUrl: string + contextToken?: string + fetchImpl?: FetchLike + signal?: AbortSignal + } +): Promise<{ clientId: string; itemType: number }> { + if (!/^https?:\/\//i.test(input.mediaUrl)) { + throw new ILinkError({ kind: 'protocol', message: '远程媒体必须是 http(s) 地址' }) + } + const fetchImpl = input.fetchImpl ?? globalThis.fetch + const response = await fetchWithTimeout( + fetchImpl, + input.mediaUrl, + { method: 'GET' }, + '下载远程媒体' + ) + const data = Buffer.from(await response.arrayBuffer()) + const headerType = response.headers.get('content-type') ?? '' + const name = basename(stripQuery(input.mediaUrl)) || 'file' + return sendMedia(client, { + to: input.to, + data, + fileName: name, + contentType: headerType || inferContentType(input.mediaUrl), + source: input.mediaUrl, + ...(input.contextToken ? { contextToken: input.contextToken } : {}), + ...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}), + ...(input.signal ? { signal: input.signal } : {}) + }) +} + +/** 发送媒体来源(本地路径优先,其次 http(s) URL)。供统一发送入口复用。 */ +export async function sendMediaFromSource( + client: ILinkClient, + input: { + to: string + source: string + mediaKind: 'image' | 'file' + contextToken?: string + fetchImpl?: FetchLike + signal?: AbortSignal + } +): Promise<{ clientId: string; itemType: number }> { + const isLocal = !/^https?:\/\//i.test(input.source) + if (isLocal) { + return sendMediaFromPath(client, { + to: input.to, + filePath: input.source, + ...(input.contextToken ? { contextToken: input.contextToken } : {}), + ...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}), + ...(input.signal ? { signal: input.signal } : {}) + }) + } + return sendMediaFromUrl(client, { + to: input.to, + mediaUrl: input.source, + ...(input.contextToken ? { contextToken: input.contextToken } : {}), + ...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}), + ...(input.signal ? { signal: input.signal } : {}) + }) +} + +export { downloadFromCdn } diff --git a/src/main/services/wechat-ilink/messages.ts b/src/main/services/wechat-ilink/messages.ts new file mode 100644 index 0000000..b1fd110 --- /dev/null +++ b/src/main/services/wechat-ilink/messages.ts @@ -0,0 +1,61 @@ +import { + ILINK_ITEM_TYPE_TEXT, + type ILinkMessageItem, + type ILinkWeixinMessage, + type WechatInboundItem, + type WechatInboundMessage +} from './types' + +/** + * 把协议原始消息归一化成 Agent Hub 消费的形状。 + * + * 关键点是 `context_token` **必须完整透传**:它属于会话上下文, + * 回复同一个会话时要原样回传,缺失会导致回复落到错误的会话线程。 + */ +export function normalizeInboundMessage( + accountId: string, + raw: ILinkWeixinMessage +): WechatInboundMessage | undefined { + const fromUserId = String(raw.from_user_id ?? '').trim() + if (!fromUserId) return undefined + + const items: WechatInboundItem[] = (raw.item_list ?? []).map((item) => { + const normalized: WechatInboundItem = { type: Number(item.type ?? 0) } + const text = itemText(item) + if (text) normalized.text = text + return normalized + }) + + const message: WechatInboundMessage = { + accountId, + fromUserId, + messageId: + raw.message_id !== undefined && raw.message_id !== null ? String(raw.message_id) : '', + messageType: Number(raw.message_type ?? 0), + items, + receivedAt: Date.now() + } + if (raw.seq !== undefined && raw.seq !== null) message.seq = Number(raw.seq) + const sessionId = String(raw.session_id ?? '').trim() + if (sessionId) message.sessionId = sessionId + const groupId = String(raw.group_id ?? '').trim() + if (groupId) message.groupId = groupId + const contextToken = String(raw.context_token ?? '').trim() + if (contextToken) message.contextToken = contextToken + return message +} + +function itemText(item: ILinkMessageItem): string { + const direct = String(item.text_item?.text ?? '').trim() + if (direct) return direct + // 语音条目的 text 是微信侧语音转文字结果,保留以便上层按需使用。 + return String(item.voice_item?.text ?? '').trim() +} + +/** 拼接入站消息中的文本片段:只有类型为「文本」的条目参与拼接。 */ +export function extractInboundText(items: WechatInboundItem[] | undefined): string { + return (items ?? []) + .filter((item) => item.type === ILINK_ITEM_TYPE_TEXT && item.text?.trim()) + .map((item) => item.text!.trim()) + .join(' ') +} diff --git a/src/main/services/wechat-ilink/poller.ts b/src/main/services/wechat-ilink/poller.ts new file mode 100644 index 0000000..31c9511 --- /dev/null +++ b/src/main/services/wechat-ilink/poller.ts @@ -0,0 +1,187 @@ +import { describeError, ILinkError } from './errors' +import { + ILINK_LONG_POLL_MAX_TIMEOUT_MS, + ILINK_LONG_POLL_TIMEOUT_MS, + type ILinkGetUpdatesResponse, + type ILinkWeixinMessage, + type WechatInboundMessage +} from './types' + +export type PollLogLevel = 'info' | 'warn' | 'error' +export type PollLog = (level: PollLogLevel, message: string) => void + +export interface ILinkPollerOptions { + /** 拉取一批消息;timeoutMs 来自服务端上一次响应里的建议值。 */ + fetchUpdates: (getUpdatesBuf: string, timeoutMs: number) => Promise + /** + * 处理一批入站消息。 + * **只有全部消息被成功接收后才会推进游标**,因此这里抛错会让整批重投。 + */ + onMessages: (messages: WechatInboundMessage[]) => Promise + normalize: (raw: ILinkWeixinMessage) => WechatInboundMessage | undefined + loadCursor: () => string + saveCursor: (getUpdatesBuf: string) => void + signal: AbortSignal + log: PollLog + /** bot token 失效(-14):停止轮询,交由上层通知用户重新登录。 */ + onStaleToken?: (message: string) => void + now?: () => number + sleep?: (milliseconds: number) => Promise + normalFailureDelayMs?: number + repeatedFailureDelayMs?: number + repeatedFailureThreshold?: number + defaultTimeoutMs?: number + maxTimeoutMs?: number +} + +const DEFAULT_NORMAL_DELAY_MS = 2_000 +const DEFAULT_REPEATED_DELAY_MS = 30_000 +const DEFAULT_REPEATED_THRESHOLD = 3 + +/** + * 长轮询循环。 + * + * 1. **先 dispatch 成功,再持久化新游标**。先推进游标再投递的话,进程在两步之间 + * 崩溃就会静默丢消息;这里换取 at-least-once(允许重复,不允许丢失)。 + * 2. **客户端自身超时不算失败**:保留游标直接进入下一轮,不计退避。 + * 3. **ret/errcode = -14 表示 bot token 已失效**,必须停止业务请求并让用户重新登录; + * 重置游标后紧密重试只会打满接口。 + * 4. 采用服务端 `longpolling_timeout_ms` 建议值,而不是写死超时。 + */ +export class ILinkPoller { + private readonly options: ILinkPollerOptions + private cursor: string + private consecutiveFailures = 0 + /** 是否正处于"连不上"的状态;用于在恢复时补一条日志,否则故障窗口在日志里看不出边界。 */ + private degraded = false + private nextTimeoutMs: number + + constructor(options: ILinkPollerOptions) { + this.options = options + this.cursor = options.loadCursor() + this.nextTimeoutMs = options.defaultTimeoutMs ?? ILINK_LONG_POLL_TIMEOUT_MS + } + + get getUpdatesBuf(): string { + return this.cursor + } + + async run(): Promise { + const { + signal, + log, + sleep = (milliseconds: number) => new Promise((r) => setTimeout(r, milliseconds)) + } = this.options + const normalDelay = this.options.normalFailureDelayMs ?? DEFAULT_NORMAL_DELAY_MS + const repeatedDelay = this.options.repeatedFailureDelayMs ?? DEFAULT_REPEATED_DELAY_MS + const threshold = this.options.repeatedFailureThreshold ?? DEFAULT_REPEATED_THRESHOLD + const maxTimeout = this.options.maxTimeoutMs ?? ILINK_LONG_POLL_MAX_TIMEOUT_MS + + log('info', this.cursor ? '长轮询已启动(恢复上次游标)' : '长轮询已启动') + + while (!signal.aborted) { + let response: ILinkGetUpdatesResponse + try { + response = await this.options.fetchUpdates(this.cursor, this.nextTimeoutMs) + } catch (error) { + if (signal.aborted) return + const kind = error instanceof ILinkError ? error.kind : 'network' + // 长轮询客户端超时属于正常控制流:保留游标,立即进入下一轮。 + if (kind === 'timeout') continue + // 调用方主动取消(例如停止连接器) + if (kind === 'aborted') return + this.consecutiveFailures += 1 + this.degraded = true + const delay = this.failureDelay(normalDelay, repeatedDelay, threshold) + log('warn', `获取更新失败,${Math.round(delay / 1000)} 秒后重试:${describeError(error)}`) + await sleep(delay) + continue + } + + if (signal.aborted) return + + const ret = Number(response.ret ?? 0) + const errcode = Number(response.errcode ?? 0) + + if (ret === -14 || errcode === -14) { + const message = '当前微信机器人登录凭证已失效,需要重新扫码登录' + log('error', message) + // 刻意不清理游标:重新登录后若账号相同,仍可续上原有进度。 + this.options.onStaleToken?.(message) + return + } + + if (ret !== 0 || errcode !== 0) { + this.consecutiveFailures += 1 + this.degraded = true + const delay = this.failureDelay(normalDelay, repeatedDelay, threshold) + log( + 'warn', + `服务端返回错误(ret=${ret} errcode=${errcode}${ + response.errmsg ? ` errmsg=${response.errmsg}` : '' + }),${Math.round(delay / 1000)} 秒后重试` + ) + await sleep(delay) + continue + } + + this.consecutiveFailures = 0 + if (this.degraded) { + this.degraded = false + log('info', '与微信服务器的连接已恢复') + } + + const messages = this.collectMessages(response.msgs) + if (messages.length > 0) { + try { + await this.options.onMessages(messages) + } catch (error) { + // 接收失败:绝不推进游标,让服务端重新投递这一批。 + this.consecutiveFailures += 1 + this.degraded = true + const delay = this.failureDelay(normalDelay, repeatedDelay, threshold) + log('error', `入站消息处理失败,游标保持不变以便重投:${describeError(error)}`) + await sleep(delay) + continue + } + } + + const nextBuf = String(response.get_updates_buf ?? '') + if (nextBuf && nextBuf !== this.cursor) { + this.cursor = nextBuf + try { + this.options.saveCursor(this.cursor) + } catch (error) { + log('warn', `游标持久化失败,下次启动可能重复投递:${describeError(error)}`) + } + } + + const suggested = Number(response.longpolling_timeout_ms ?? 0) + if (Number.isFinite(suggested) && suggested > 0) { + this.nextTimeoutMs = Math.min(suggested, maxTimeout) + } + } + } + + private collectMessages(raw: ILinkWeixinMessage[] | undefined): WechatInboundMessage[] { + if (!Array.isArray(raw) || raw.length === 0) return [] + const result: WechatInboundMessage[] = [] + for (const item of raw) { + const normalized = this.options.normalize(item) + if (!normalized) { + this.options.log('warn', '收到一条无法识别的入站消息,已跳过') + continue + } + result.push(normalized) + } + return result + } + + private failureDelay(normalDelay: number, repeatedDelay: number, threshold: number): number { + if (this.consecutiveFailures >= threshold) { + this.consecutiveFailures = 0 + return repeatedDelay + } + return normalDelay + } +} diff --git a/src/main/services/wechat-ilink/sender.ts b/src/main/services/wechat-ilink/sender.ts new file mode 100644 index 0000000..9bd9f3c --- /dev/null +++ b/src/main/services/wechat-ilink/sender.ts @@ -0,0 +1,85 @@ +import { randomBytes } from 'node:crypto' +import { assertBusinessOk, ILinkError } from './errors' +import { markdownToPlainText } from './markdown' +import type { ILinkClient } from './client' +import { + ILINK_ITEM_TYPE_TEXT, + ILINK_MESSAGE_STATE_FINISH, + ILINK_MESSAGE_TYPE_BOT, + type ILinkMessageItem, + type ILinkSendMessageRequest +} from './types' + +/** + * Bot 外发消息的 `from_user_id`。 + * + * 官方客户端(2.4.6)对 Bot 主动外发固定传空字符串;传 bot id 也能被服务端接受。 + * 这里遵循官方实现。 + */ +const BOT_OUTBOUND_FROM_USER_ID = '' + +export function createClientId(): string { + return `tracememo-${randomBytes(16).toString('hex')}` +} + +/** 组装 sendmessage 请求体;文本与媒体共用,每个请求只放一个 item。 */ +export function buildSendMessageBody(input: { + item: ILinkMessageItem + to: string + contextToken?: string + clientId: string + runId?: string +}): ILinkSendMessageRequest['msg'] { + return { + from_user_id: BOT_OUTBOUND_FROM_USER_ID, + to_user_id: input.to, + client_id: input.clientId, + message_type: ILINK_MESSAGE_TYPE_BOT, + message_state: ILINK_MESSAGE_STATE_FINISH, + item_list: [input.item], + // 缺失时传空字符串而不是省略字段:服务端按字段存在性判断会话。 + context_token: String(input.contextToken ?? ''), + ...(input.runId ? { run_id: input.runId } : {}) + } +} + +export interface SendTextOptions { + to: string + text: string + /** 会话上下文令牌:回复当前会话时必须原样回传。 */ + contextToken?: string + clientId?: string + runId?: string + signal?: AbortSignal +} + +export interface SendTextResult { + clientId: string + /** 实际投递的纯文本内容(Markdown 已降级)。 */ + plainText: string +} + +export async function sendText( + client: ILinkClient, + options: SendTextOptions +): Promise { + const to = String(options.to ?? '').trim() + if (!to) throw new ILinkError({ kind: 'protocol', message: '发送文本需要有效的接收者' }) + + const plainText = markdownToPlainText(options.text) + if (!plainText) throw new ILinkError({ kind: 'protocol', message: '发送文本内容为空' }) + + const clientId = options.clientId || createClientId() + const response = await client.sendMessage( + buildSendMessageBody({ + item: { type: ILINK_ITEM_TYPE_TEXT, text_item: { text: plainText } }, + to, + clientId, + ...(options.contextToken ? { contextToken: options.contextToken } : {}), + ...(options.runId ? { runId: options.runId } : {}) + }), + options.signal + ) + assertBusinessOk(response, '发送文本') + return { clientId, plainText } +} diff --git a/src/main/services/wechat-ilink/types.ts b/src/main/services/wechat-ilink/types.ts new file mode 100644 index 0000000..4242e39 --- /dev/null +++ b/src/main/services/wechat-ilink/types.ts @@ -0,0 +1,279 @@ +/** + * WeChat iLink 协议类型与常量。 + * + * 与官方客户端(channel_version 2.4.6 基线)对齐,字段名以官方协议为准。 + * 这里只描述协议本身,不引入任何 Electron 概念,便于将来被 Tauri 或其他宿主复用。 + */ + +/** 业务入口固定地址;登录与业务 API 都从这里开始。 */ +export const ILINK_DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com' + +/** CDN 上传/下载基地址。 */ +export const ILINK_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c' + +/** base_info.channel_version 上报值。 */ +export const ILINK_CHANNEL_VERSION = '2.4.6' + +/** iLink-App-Id 固定值。 */ +export const ILINK_APP_ID = 'bot' + +/** iLink-App-ClientVersion 编码为 (major << 16) | (minor << 8) | patch,2.4.6 => 132102。 */ +export const ILINK_APP_CLIENT_VERSION = String((2 << 16) | (4 << 8) | 6) + +/** base_info.bot_agent 默认值;仅用于服务端观测聚合,不参与鉴权。 */ +export const ILINK_DEFAULT_BOT_AGENT = 'TraceMemo/1.0.0' + +/** 长轮询默认与上限。 */ +export const ILINK_LONG_POLL_TIMEOUT_MS = 35_000 +export const ILINK_LONG_POLL_MAX_TIMEOUT_MS = 120_000 +export const ILINK_QR_STATUS_TIMEOUT_MS = 35_000 +export const ILINK_SEND_TIMEOUT_MS = 15_000 +export const ILINK_CONFIG_TIMEOUT_MS = 10_000 + +/** bot token 失效(官方 2.4.5 起把内部命名从 session expired 改为 stale token)。 */ +export const ILINK_STALE_TOKEN_CODE = -14 + +/** message_type */ +export const ILINK_MESSAGE_TYPE_BOT = 2 + +/** message_state */ +export const ILINK_MESSAGE_STATE_FINISH = 2 + +/** item type */ +export const ILINK_ITEM_TYPE_TEXT = 1 +export const ILINK_ITEM_TYPE_IMAGE = 2 +export const ILINK_ITEM_TYPE_VOICE = 3 +export const ILINK_ITEM_TYPE_FILE = 4 +export const ILINK_ITEM_TYPE_VIDEO = 5 + +/** CDN media_type */ +export const ILINK_CDN_MEDIA_TYPE_IMAGE = 1 +export const ILINK_CDN_MEDIA_TYPE_VIDEO = 2 +export const ILINK_CDN_MEDIA_TYPE_FILE = 3 + +/** sendtyping 的输入状态值。 */ +export const ILINK_TYPING_STATUS_TYPING = 1 +export const ILINK_TYPING_STATUS_CANCEL = 2 + +/** + * 长任务期间维持"正在输入"心跳的间隔。 + * + * 协议文档只定义了 status=1/2 两个状态、没有规定间隔;但微信客户端的输入指示 + * 会自行消失,所以长任务必须周期性重发 status=1 才能一直亮着。 + * 官方客户端按约 5 秒维持,这里沿用同一节奏。 + */ +export const ILINK_TYPING_KEEPALIVE_MS = 5_000 + +/** + * typing_ticket 的缓存有效期。 + * 官方按「账号 + 对端用户」缓存、首次取一次;这里用 24 小时兜底, + * 遇到服务端报错会立即失效并在下次需要时重取。 + */ +export const ILINK_TYPING_TICKET_TTL_MS = 24 * 60 * 60 * 1000 + +/** 二维码登录状态机。 */ +export type ILinkQrStatus = + | 'wait' + | 'scaned' + | 'confirmed' + | 'expired' + | 'need_verifycode' + | 'verify_code_blocked' + | 'scaned_but_redirect' + | 'binded_redirect' + | (string & {}) + +export interface ILinkBaseInfo { + channel_version: string + bot_agent: string +} + +export interface ILinkQrCodeResponse { + qrcode: string + qrcode_img_content: string + ret?: number + errmsg?: string +} + +export interface ILinkQrStatusResponse { + status: ILinkQrStatus + bot_token?: string + ilink_bot_id?: string + ilink_user_id?: string + baseurl?: string + redirect_host?: string + ret?: number + errcode?: number + errmsg?: string +} + +/** 持久化到 ~/.tracememo/wechat-connector/accounts/.json 的凭据。 */ +export interface ILinkCredentials { + bot_token: string + ilink_bot_id: string + baseurl: string + ilink_user_id: string +} + +export interface ILinkTextItem { + text: string +} + +export interface ILinkMediaInfo { + encrypt_query_param: string + aes_key: string + encrypt_type: number +} + +export interface ILinkImageItem { + url?: string + media?: ILinkMediaInfo + mid_size?: number +} + +export interface ILinkVideoItem { + media?: ILinkMediaInfo + video_size?: number +} + +export interface ILinkFileItem { + media?: ILinkMediaInfo + file_name?: string + len?: string +} + +export interface ILinkVoiceItem { + media?: ILinkMediaInfo + voice_size?: number + encode_type?: number + playtime?: number + text?: string +} + +export interface ILinkMessageItem { + type: number + text_item?: ILinkTextItem + image_item?: ILinkImageItem + voice_item?: ILinkVoiceItem + video_item?: ILinkVideoItem + file_item?: ILinkFileItem +} + +export interface ILinkWeixinMessage { + seq?: number + message_id?: number + from_user_id?: string + to_user_id?: string + message_type?: number + message_state?: number + item_list?: ILinkMessageItem[] + context_token?: string + session_id?: string + group_id?: string +} + +export interface ILinkGetUpdatesResponse { + ret?: number + errcode?: number + errmsg?: string + msgs?: ILinkWeixinMessage[] + get_updates_buf?: string + longpolling_timeout_ms?: number +} + +export interface ILinkSendMessageRequest { + msg: { + from_user_id: string + to_user_id: string + client_id: string + message_type: number + message_state: number + item_list: ILinkMessageItem[] + context_token: string + run_id?: string + } + base_info: ILinkBaseInfo +} + +export interface ILinkSendMessageResponse { + ret?: number + errmsg?: string +} + +export interface ILinkGetUploadUrlRequest { + filekey: string + media_type: number + to_user_id: string + rawsize: number + rawfilemd5: string + filesize: number + no_need_thumb: boolean + aeskey: string + base_info: ILinkBaseInfo +} + +export interface ILinkGetUploadUrlResponse { + ret?: number + errmsg?: string + upload_param?: string + upload_full_url?: string +} + +export interface ILinkGetConfigResponse { + ret?: number + errcode?: number + errmsg?: string + typing_ticket?: string +} + +export interface ILinkSendTypingRequest { + ilink_user_id: string + typing_ticket: string + status: number + base_info: ILinkBaseInfo +} + +export interface ILinkSendTypingResponse { + ret?: number + errcode?: number + errmsg?: string +} + +/** + * 归一化后的入站消息。Agent Hub 只消费这个形状, + * 不直接依赖协议原始字段,方便将来替换 transport。 + */ +export interface WechatInboundItem { + type: number + text?: string +} + +export interface WechatInboundMessage { + accountId: string + fromUserId: string + messageId: string + seq?: number + sessionId?: string + groupId?: string + messageType: number + /** 会话上下文令牌:回复必须原样回传,不得用于其他会话,也不得写入普通日志。 */ + contextToken?: string + items: WechatInboundItem[] + receivedAt: number +} + +/** 账号摘要(供 UI 账号列表使用)。 */ +export interface WechatConnectorAccount { + accountId: string + wechatUserId: string +} + +/** 登录过程中向宿主上报的事件。 */ +export type WechatLoginEvent = + | { status: 'qrcode'; qrCodeDataUrl: string } + | { status: 'wait' | 'scaned' | 'need_verifycode' | 'verify_code_blocked' | 'expired' } + | { status: 'confirmed' } + | { status: 'active'; accountId: string; wechatUserId: string } + +/** 连接器对外暴露的运行态。 */ +export type WechatConnectorPhase = 'stopped' | 'starting' | 'polling' | 'stale_token' | 'error' diff --git a/src/main/services/wechat-ilink/typing.ts b/src/main/services/wechat-ilink/typing.ts new file mode 100644 index 0000000..e8d386c --- /dev/null +++ b/src/main/services/wechat-ilink/typing.ts @@ -0,0 +1,338 @@ +import { secretFingerprint } from '../log-redaction' +import { + ILINK_TYPING_KEEPALIVE_MS, + ILINK_TYPING_STATUS_CANCEL, + ILINK_TYPING_STATUS_TYPING, + ILINK_TYPING_TICKET_TTL_MS +} from './types' + +/** + * 微信原生「正在输入」状态。 + * + * 协议:`getconfig` 取 `typing_ticket`,再用 `sendtyping` 下发 status=1 / status=2。 + * `typing_ticket` 只用于输入状态,**不是** sendmessage 的鉴权凭据。 + * + * 这个模块要解决四件事: + * 1. **非致命**:所有失败都只记日志,绝不影响 Query Agent / 日报 / 最终消息发送。 + * 2. **必须收尾**:调用方在 finally 里 stop,任何异常路径都不会留下"对方正在输入"。 + * 3. **引用计数**:同一用户并发两个任务时,先结束的那个不能把另一个的 typing 一起取消。 + * 4. **少打接口**:typing_ticket 按「账号 + 对端用户」缓存,并发取票去重。 + */ + +export interface TypingLease { + /** 幂等;可安全地在 finally 里调用。永不抛异常。 */ + stop(): Promise +} + +export interface TypingBeginInput { + accountId?: string + to: string + contextToken?: string +} + +export interface TypingCoordinatorDependencies { + /** 取 typing_ticket;失败/无票返回 undefined 即可(上层已 catch)。 */ + fetchTicket: (input: { + ilinkUserId: string + contextToken?: string + }) => Promise + /** 下发 status=1/2;返回是否成功。 */ + sendTyping: (input: { ilinkUserId: string; ticket: string; status: number }) => Promise + log: (level: 'info' | 'warn' | 'error', message: string) => void + now?: () => number + keepaliveMs?: number + ticketTtlMs?: number + maxPeers?: number + /** + * 定时器注入点:返回取消函数。 + * 测试用可控实现,生产用 setInterval。 + */ + schedule?: (tick: () => void, intervalMs: number) => () => void +} + +interface PeerTypingState { + key: string + accountId: string + to: string + sessionId: string + refCount: number + /** 当前微信端是否处于「正在输入」。 */ + active: boolean + startedAt: number + cancelKeepalive: (() => void) | null + ticket?: string + ticketFetchedAt: number + /** 并发取票去重。 */ + ticketFetch: Promise | null + contextToken?: string + /** 串行化 activate / deactivate,避免本次 TYPING 被上一次的 CANCEL 吃掉。 */ + queue: Promise + keepaliveFailureLogged: boolean + lastUseAt: number +} + +const DEFAULT_MAX_PEERS = 50 +const NOOP_LEASE: TypingLease = { stop: async () => undefined } + +export class TypingCoordinator { + private readonly deps: TypingCoordinatorDependencies + private readonly keepaliveMs: number + private readonly ticketTtlMs: number + private readonly maxPeers: number + private readonly states = new Map() + + constructor(dependencies: TypingCoordinatorDependencies) { + this.deps = dependencies + this.keepaliveMs = dependencies.keepaliveMs ?? ILINK_TYPING_KEEPALIVE_MS + this.ticketTtlMs = dependencies.ticketTtlMs ?? ILINK_TYPING_TICKET_TTL_MS + this.maxPeers = dependencies.maxPeers ?? DEFAULT_MAX_PEERS + } + + /** 当前处于「正在输入」的对端数量;用于测试与观测。 */ + get activePeerCount(): number { + let count = 0 + for (const state of this.states.values()) if (state.active) count += 1 + return count + } + + /** + * 开始一次输入状态。**永不抛异常**:拿不到 ticket 或服务端失败时返回一个空实现, + * 业务侧照常执行。 + */ + async begin(input: TypingBeginInput): Promise { + const to = String(input.to ?? '').trim() + if (!to) return NOOP_LEASE + const accountId = String(input.accountId ?? '').trim() + const key = `${accountId}::${to}` + + try { + this.pruneIfNeeded() + const state = this.stateFor(key, accountId, to) + state.refCount += 1 + state.lastUseAt = this.now() + if (input.contextToken) state.contextToken = input.contextToken + + const shouldActivate = state.refCount === 1 + if (shouldActivate) { + // 串行化:等上一次 deactivate 落地,避免 TYPING 紧接着被 CANCEL 掉。 + state.queue = state.queue.then(() => this.activate(state)) + await state.queue + } + return this.createLease(state) + } catch (error) { + // 兜底:任何意外都退化为空实现,绝不让 typing 影响业务。 + this.deps.log('warn', `typing.begin.failed error=${describeTypingError(error)}`) + return NOOP_LEASE + } + } + + /** 账号切换 / 重新登录 / bot token 失效后调用:清空 ticket 缓存。 */ + invalidateTickets(): void { + for (const state of this.states.values()) { + state.ticket = undefined + state.ticketFetchedAt = 0 + state.ticketFetch = null + } + } + + /** 连接器停止时调用:清掉全部状态与定时器,避免留下悬挂的 keepalive。 */ + clear(): void { + for (const state of this.states.values()) { + if (state.cancelKeepalive) { + state.cancelKeepalive() + state.cancelKeepalive = null + } + state.refCount = 0 + state.active = false + } + this.states.clear() + } + + private createLease(state: PeerTypingState): TypingLease { + let stopped = false + return { + stop: async (): Promise => { + if (stopped) return + stopped = true + try { + state.refCount = Math.max(0, state.refCount - 1) + if (state.refCount > 0) return + state.queue = state.queue.then(() => this.deactivate(state)) + await state.queue + } catch (error) { + this.deps.log( + 'warn', + `typing.stop.leaked session=${state.sessionId} error=${describeTypingError(error)}` + ) + } + } + } + } + + private stateFor(key: string, accountId: string, to: string): PeerTypingState { + const existing = this.states.get(key) + if (existing) return existing + const state: PeerTypingState = { + key, + accountId, + to, + // 日志里只出现不可逆短指纹,不出现 openid 原文。 + sessionId: secretFingerprint(key), + refCount: 0, + active: false, + startedAt: 0, + cancelKeepalive: null, + ticketFetchedAt: 0, + ticketFetch: null, + queue: Promise.resolve(), + keepaliveFailureLogged: false, + lastUseAt: this.now() + } + this.states.set(key, state) + return state + } + + private async activate(state: PeerTypingState): Promise { + if (state.active) return + + const ticket = await this.ensureTicket(state) + if (!ticket) { + // 无票(getconfig 失败 / 服务端没给)时静默降级,业务照常。 + return + } + + const ok = await this.trySendTyping(state, ticket, ILINK_TYPING_STATUS_TYPING) + if (!ok) { + // ticket 可能已失效:丢掉缓存,下次需要时重取。 + state.ticket = undefined + state.ticketFetchedAt = 0 + this.deps.log('warn', `typing.start.failed session=${state.sessionId} ticketPresent=true`) + return + } + + state.active = true + state.startedAt = this.now() + state.keepaliveFailureLogged = false + const schedule = this.deps.schedule ?? defaultSchedule + state.cancelKeepalive = schedule(() => { + void this.keepalive(state) + }, this.keepaliveMs) + this.deps.log('info', `typing.start session=${state.sessionId} ticketPresent=true`) + } + + private async deactivate(state: PeerTypingState): Promise { + if (state.cancelKeepalive) { + state.cancelKeepalive() + state.cancelKeepalive = null + } + if (!state.active) return + state.active = false + + const durationMs = Math.max(0, this.now() - state.startedAt) + const ticket = state.ticket + if (!ticket) return + + const ok = await this.trySendTyping(state, ticket, ILINK_TYPING_STATUS_CANCEL) + if (ok) { + this.deps.log( + 'info', + `typing.stop session=${state.sessionId} duration=${durationMs}ms success=true` + ) + } else { + this.deps.log( + 'warn', + `typing.stop.failed session=${state.sessionId} duration=${durationMs}ms success=false` + ) + } + } + + /** + * 长任务的输入状态会自己消失,必须周期性重发 status=1。 + * 失败只在同一会话里记一次日志,避免每 5 秒刷屏。 + */ + private async keepalive(state: PeerTypingState): Promise { + if (!state.active || !state.ticket) return + const ok = await this.trySendTyping(state, state.ticket, ILINK_TYPING_STATUS_TYPING) + if (ok) return + if (state.keepaliveFailureLogged) return + state.keepaliveFailureLogged = true + this.deps.log('warn', `typing.keepalive.failed session=${state.sessionId}`) + } + + private async ensureTicket(state: PeerTypingState): Promise { + const cached = String(state.ticket ?? '') + if (cached && this.now() - state.ticketFetchedAt < this.ticketTtlMs) return cached + if (state.ticketFetch) return state.ticketFetch + + const pending = (async (): Promise => { + try { + const ticket = await this.deps.fetchTicket({ + ilinkUserId: state.to, + ...(state.contextToken ? { contextToken: state.contextToken } : {}) + }) + const normalized = String(ticket ?? '').trim() + if (!normalized) { + this.deps.log( + 'warn', + `typing.ticket.missing session=${state.sessionId} ticketPresent=false` + ) + return undefined + } + state.ticket = normalized + state.ticketFetchedAt = this.now() + return normalized + } catch (error) { + this.deps.log( + 'warn', + `typing.ticket.failed session=${state.sessionId} ticketPresent=false error=${describeTypingError(error)}` + ) + return undefined + } finally { + state.ticketFetch = null + } + })() + + state.ticketFetch = pending + return pending + } + + private async trySendTyping( + state: PeerTypingState, + ticket: string, + status: number + ): Promise { + try { + return await this.deps.sendTyping({ ilinkUserId: state.to, ticket, status }) + } catch (error) { + // 由调用方决定记哪条日志,这里只吞掉异常保证非致命。 + void error + return false + } + } + + private pruneIfNeeded(): void { + if (this.states.size <= this.maxPeers) return + const idle = [...this.states.values()] + .filter((state) => state.refCount === 0 && !state.active) + .sort((left, right) => left.lastUseAt - right.lastUseAt) + for (const state of idle) { + if (this.states.size <= this.maxPeers) break + this.states.delete(state.key) + } + } + + private now(): number { + return this.deps.now?.() ?? Date.now() + } +} + +function defaultSchedule(tick: () => void, intervalMs: number): () => void { + const timer = setInterval(tick, intervalMs) + // keepalive 不该阻止进程退出。 + if (typeof timer.unref === 'function') timer.unref() + return () => clearInterval(timer) +} + +function describeTypingError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/services/wechat-inbound-inbox.ts b/src/main/services/wechat-inbound-inbox.ts new file mode 100644 index 0000000..33f54a9 --- /dev/null +++ b/src/main/services/wechat-inbound-inbox.ts @@ -0,0 +1,189 @@ +import { chmodSync, mkdirSync, renameSync, rmSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import type { WechatInboundItem, WechatInboundMessage } from './wechat-ilink/types' + +/** + * Agent Hub 入站收件箱。 + * + * 存在意义只有一个:**拿到消息就立刻落盘,然后才允许长轮询推进游标**。 + * 先推进游标再异步投递的话,进程在两步之间崩溃就会静默丢消息; + * 因此这里的语义是 at-least-once:允许重复,不允许丢失。 + * + * 代价:极端情况下(处理完但删除前崩溃)会重复处理同一条消息。 + * Agent Hub 有 message_id 去重 + 业务侧幂等,重复可以接受,丢消息不行。 + * + * 文件含聊天文本与 context_token,因此固定 0600 权限、处理完即删除,且从不写入日志。 + */ + +export interface WechatInboundInboxEntry { + key: string + accountId: string + fromUserId: string + messageId: string + contextToken?: string + items: WechatInboundItem[] + receivedAt: number + attempts: number +} + +interface InboxFile { + entries: WechatInboundInboxEntry[] +} + +export interface WechatInboundInboxOptions { + filePath: () => string + maxAttempts?: number + maxEntries?: number + now?: () => number +} + +const DEFAULT_MAX_ATTEMPTS = 3 +const DEFAULT_MAX_ENTRIES = 200 + +export function inboxKeyFor( + message: WechatInboundMessage, + fallbackIndex: number, + now: number +): string { + if (message.messageId) return `${message.accountId}::${message.messageId}` + return `${message.accountId}::${message.fromUserId}::${now}::${fallbackIndex}` +} + +export class WechatInboundInbox { + private readonly options: WechatInboundInboxOptions + private readonly maxAttempts: number + private readonly maxEntries: number + private entries: WechatInboundInboxEntry[] | null = null + + constructor(options: WechatInboundInboxOptions) { + this.options = options + this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS + this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES + } + + /** + * 持久化接收一批消息,返回**本次新增**的条目。 + * 已存在(重复投递)的条目不会重复返回,也不会重复处理。 + */ + accept(messages: WechatInboundMessage[]): WechatInboundInboxEntry[] { + const current = this.load() + const known = new Set(current.map((entry) => entry.key)) + const accepted: WechatInboundInboxEntry[] = [] + const now = this.options.now?.() ?? Date.now() + + messages.forEach((message, index) => { + const key = inboxKeyFor(message, index, now) + if (known.has(key)) return + known.add(key) + accepted.push({ + key, + accountId: message.accountId, + fromUserId: message.fromUserId, + messageId: message.messageId, + ...(message.contextToken ? { contextToken: message.contextToken } : {}), + items: message.items.map((item) => ({ ...item })), + receivedAt: message.receivedAt || now, + attempts: 0 + }) + }) + + if (accepted.length === 0) return [] + const next = [...current, ...accepted].slice(-this.maxEntries) + this.persist(next) + this.entries = next + return accepted + } + + /** 尚未处理完成的条目,按接收顺序返回。 */ + pending(): WechatInboundInboxEntry[] { + return this.load().map((entry) => ({ ...entry })) + } + + contains(key: string): boolean { + return this.load().some((entry) => entry.key === key) + } + + size(): number { + return this.load().length + } + + /** 处理成功,从收件箱移除。 */ + complete(key: string): void { + const current = this.load() + const next = current.filter((entry) => entry.key !== key) + if (next.length === current.length) return + this.persist(next) + this.entries = next + } + + /** + * 处理失败:累加尝试次数。 + * 达到上限后放弃,返回 abandoned=true,由调用方明确记一条 error 日志—— + * 静默丢弃是不允许的,但无限重试同一条毒消息同样不允许。 + */ + recordFailure(key: string): { attempts: number; abandoned: boolean } { + const current = this.load() + let attempts = 0 + let abandoned = false + const next = current + .map((entry) => { + if (entry.key !== key) return entry + attempts = entry.attempts + 1 + abandoned = attempts >= this.maxAttempts + return { ...entry, attempts } + }) + .filter((entry) => !(entry.key === key && abandoned)) + this.persist(next) + this.entries = next + return { attempts, abandoned } + } + + clear(): void { + this.persist([]) + this.entries = [] + } + + private load(): WechatInboundInboxEntry[] { + if (this.entries) return this.entries + let parsed: InboxFile | null = null + try { + parsed = JSON.parse(readFileSync(this.options.filePath(), 'utf8')) as InboxFile + } catch { + parsed = null + } + const entries = Array.isArray(parsed?.entries) + ? parsed!.entries.filter((entry): entry is WechatInboundInboxEntry => { + if (!entry || typeof entry !== 'object') return false + const candidate = entry as Partial + return Boolean( + String(candidate.key || '').trim() && String(candidate.fromUserId || '').trim() + ) + }) + : [] + this.entries = entries + return entries + } + + private persist(entries: WechatInboundInboxEntry[]): void { + const path = this.options.filePath() + try { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + const tempPath = `${path}.tmp-${process.pid}-${Date.now()}` + writeFileSync(tempPath, JSON.stringify({ entries } satisfies InboxFile, null, 2), { + encoding: 'utf8', + mode: 0o600 + }) + chmodSync(tempPath, 0o600) + renameSync(tempPath, path) + chmodSync(path, 0o600) + } catch (error) { + // 落盘失败必须向上抛:调用方要放弃推进游标,让服务端重新投递。 + try { + rmSync(`${path}.tmp-${process.pid}`, { force: true }) + } catch { + // 忽略临时文件清理失败。 + } + throw error + } + } +} diff --git a/src/main/services/wechat-send-gateway.ts b/src/main/services/wechat-send-gateway.ts new file mode 100644 index 0000000..d5a50f1 --- /dev/null +++ b/src/main/services/wechat-send-gateway.ts @@ -0,0 +1,357 @@ +import { randomUUID } from 'node:crypto' +import type { + PersonalWechatSendRequest, + PersonalWechatSendResult +} from '../../shared/personal-wechat' +import { + normalizeWechatSendRequest, + resolveSendTransport, + type WechatSendErrorCode, + type WechatSendLogEntry, + type WechatSendRequest, + type WechatSendResult, + type WechatSendTransport +} from '../../shared/wechat-send' +import { isILinkError } from './wechat-ilink/errors' +import { wechatSendLogService, type WechatSendLogService } from './wechat-send-log-service' + +/** iLink 发送适配器的注入点;由主进程在启动时接到 WechatConnectorService 上。 */ +export type IlinkSendAdapter = (request: WechatSendRequest) => Promise + +export interface WechatSendGatewayDependencies { + now?: () => number + createRequestId?: () => string + /** 个人微信(注入式发送)适配器。 */ + sendPersonal?: (request: PersonalWechatSendRequest) => Promise + /** iLink 适配器;未注入时 iLink 发送记为 TRANSPORT_UNAVAILABLE。 */ + sendIlink?: IlinkSendAdapter + log?: WechatSendLogService +} + +class UnsupportedSendTypeError extends Error {} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function classifyIlinkError(error: unknown): WechatSendErrorCode { + if (isILinkError(error) && error.isStaleToken) return 'STALE_TOKEN' + return 'SEND_FAILED' +} + +/** + * 统一微信发送入口。 + * + * ```text + * 业务层 + * │ + * ▼ + * WechatSendGateway + * ├── Send Log + * ├── iLink adapter → WechatConnectorService + * └── Personal adapter → PersonalWechatSendService(Windows / macOS 注入式) + * ``` + * + * 统一的是 TM 上层发送模型,不强行统一底层协议: + * 个人微信仍然是 `{toWxid,type,msg}` 心智模型,iLink 仍然是 sendmessage。 + */ +export class WechatSendGateway { + private readonly deps: Required> & + WechatSendGatewayDependencies + private ilinkAdapter: IlinkSendAdapter | null + + constructor(dependencies: WechatSendGatewayDependencies = {}) { + this.deps = { + now: dependencies.now ?? (() => Date.now()), + createRequestId: dependencies.createRequestId ?? (() => randomUUID()), + log: dependencies.log ?? wechatSendLogService, + ...(dependencies.sendPersonal ? { sendPersonal: dependencies.sendPersonal } : {}), + ...(dependencies.sendIlink ? { sendIlink: dependencies.sendIlink } : {}) + } + this.ilinkAdapter = dependencies.sendIlink ?? null + } + + /** 主进程启动时注入 iLink 通道(WechatConnectorService)。 */ + configureIlinkSender(adapter: IlinkSendAdapter): void { + this.ilinkAdapter = adapter + } + + hasIlinkSender(): boolean { + return this.ilinkAdapter !== null + } + + listSendLog(): WechatSendLogEntry[] { + return this.deps.log?.list() ?? [] + } + + /** + * 统一发送入口。 + * 刻意不抛异常:调用方永远拿到结构化结果,失败也会留下 Send Log。 + */ + async send(input: unknown): Promise { + const startedAt = this.deps.now() + const fallbackRequestId = + input && + typeof input === 'object' && + typeof (input as { request_id?: unknown }).request_id === 'string' + ? String((input as { request_id: string }).request_id).trim() + : '' + const requestId = fallbackRequestId || this.deps.createRequestId() + + const request = normalizeWechatSendRequest(input, { createRequestId: () => requestId }) + + if (!request) { + const raw = (input ?? {}) as Partial + const transport = resolveSendTransport({ + ...(raw.transport === 'ilink' || raw.transport === 'personal' + ? { transport: raw.transport } + : {}), + ...(typeof raw.context_token === 'string' ? { context_token: raw.context_token } : {}) + }) + return this.finish({ + requestId, + transport, + type: typeof raw.type === 'string' ? (raw.type as WechatSendRequest['type']) : 'text', + to: typeof raw.to === 'string' ? raw.to : '', + msg: typeof raw.msg === 'string' ? raw.msg : '', + startedAt, + errorCode: 'INVALID_REQUEST', + error: '发送请求不合法:缺少接收者、类型或内容' + }) + } + + const transport = resolveSendTransport(request) + try { + if (transport === 'ilink') { + if (!this.ilinkAdapter) { + return this.finish({ + requestId: request.request_id, + transport, + type: request.type, + to: request.to, + msg: request.msg, + startedAt, + ...(request.account_id ? { accountId: request.account_id } : {}), + errorCode: 'TRANSPORT_UNAVAILABLE', + error: 'iLink 发送通道尚未初始化' + }) + } + await this.ilinkAdapter(request) + } else { + const personalResult = await this.sendPersonalRequest(unifiedToPersonalRequest(request)) + if (!personalResult.success) { + return this.finish({ + requestId: request.request_id, + transport, + type: request.type, + to: request.to, + msg: request.msg, + startedAt, + ...(request.account_id ? { accountId: request.account_id } : {}), + errorCode: 'SEND_FAILED', + error: personalResult.error || '个人微信发送失败' + }) + } + } + return this.finish({ + requestId: request.request_id, + transport, + type: request.type, + to: request.to, + msg: request.msg, + startedAt, + ...(request.account_id ? { accountId: request.account_id } : {}), + status: 'sent' + }) + } catch (error) { + const errorCode: WechatSendErrorCode = + error instanceof UnsupportedSendTypeError + ? 'UNSUPPORTED_TYPE' + : transport === 'ilink' + ? classifyIlinkError(error) + : 'SEND_FAILED' + return this.finish({ + requestId: request.request_id, + transport, + type: request.type, + to: request.to, + msg: request.msg, + startedAt, + ...(request.account_id ? { accountId: request.account_id } : {}), + errorCode, + error: errorMessage(error) + }) + } + } + + /** + * 兼容入口:既有个人微信调用方直接给 `PersonalWechatSendRequest`。 + * 走同一条 Send Log,但保持原有返回类型,避免打断现有业务与测试。 + */ + async sendPersonal(request: PersonalWechatSendRequest): Promise { + const requestId = this.deps.createRequestId() + const startedAt = this.deps.now() + const preview = personalToPreview(request) + try { + const result = await this.sendPersonalRequest(request) + this.record({ + requestId, + transport: 'personal', + type: preview.type, + to: preview.to, + msg: preview.msg, + startedAt, + status: result.success ? 'sent' : 'failed', + ...(result.success ? {} : { errorCode: 'SEND_FAILED' as WechatSendErrorCode }) + }) + return result + } catch (error) { + this.record({ + requestId, + transport: 'personal', + type: preview.type, + to: preview.to, + msg: preview.msg, + startedAt, + status: 'failed', + errorCode: 'SEND_FAILED' + }) + throw error + } + } + + private async sendPersonalRequest( + request: PersonalWechatSendRequest + ): Promise { + const sender = this.deps.sendPersonal ?? (await defaultPersonalSender()) + return sender(request) + } + + private finish(input: { + requestId: string + transport: WechatSendTransport + type: WechatSendRequest['type'] + to: string + msg: string + startedAt: number + accountId?: string + status?: 'sent' | 'failed' | 'blocked' + errorCode?: WechatSendErrorCode + error?: string + }): WechatSendResult { + const durationMs = Math.max(0, this.deps.now() - input.startedAt) + const status = input.status ?? 'failed' + this.record({ + requestId: input.requestId, + transport: input.transport, + type: input.type, + to: input.to, + msg: input.msg, + startedAt: input.startedAt, + status, + durationMs, + ...(input.accountId ? { accountId: input.accountId } : {}), + ...(input.errorCode ? { errorCode: input.errorCode } : {}) + }) + return { + request_id: input.requestId, + success: status === 'sent', + status, + transport: input.transport, + duration_ms: durationMs, + ...(input.errorCode ? { error_code: input.errorCode } : {}), + ...(input.error ? { error: input.error } : {}) + } + } + + private record(input: { + requestId: string + transport: WechatSendTransport + type: WechatSendRequest['type'] + to: string + msg: string + startedAt: number + status: 'sent' | 'failed' | 'blocked' + durationMs?: number + accountId?: string + errorCode?: WechatSendErrorCode + }): void { + const log = this.deps.log + if (!log) return + try { + log.record( + log.buildEntry({ + request_id: input.requestId, + transport: input.transport, + to: input.to, + type: input.type, + msg: input.msg, + status: input.status, + timestamp: this.deps.now(), + ...(input.durationMs !== undefined ? { duration_ms: input.durationMs } : {}), + ...(input.accountId ? { account_id: input.accountId } : {}), + ...(input.errorCode ? { error_code: input.errorCode } : {}) + }) + ) + } catch (error) { + console.warn('[WechatSendGateway] 发送日志记录失败:', error) + } + } +} + +/** 统一模型 → 个人微信模型。文件类型个人通道不支持,显式报错而不是静默降级。 */ +export function unifiedToPersonalRequest(request: WechatSendRequest): PersonalWechatSendRequest { + const base = { to: request.to, isGroup: request.is_group === true } + if (request.type === 'text') { + return { ...base, type: 'text', text: request.msg } + } + if (request.type === 'image') { + return { ...base, type: 'image', filePath: request.msg } + } + if (request.type === 'voice') { + const metadata = request.metadata ?? {} + const fromId = typeof metadata.fromId === 'string' ? metadata.fromId.trim() : '' + const durationMs = + typeof metadata.durationMs === 'number' && Number.isFinite(metadata.durationMs) + ? metadata.durationMs + : undefined + return { + ...base, + type: 'voice', + filePath: request.msg, + ...(fromId ? { fromId } : {}), + ...(durationMs !== undefined ? { durationMs } : {}) + } + } + throw new UnsupportedSendTypeError('个人微信通道暂不支持发送文件') +} + +function personalToPreview(request: PersonalWechatSendRequest): { + type: WechatSendRequest['type'] + to: string + msg: string +} { + if (request.type === 'text') return { type: 'text', to: request.to, msg: request.text } + return { + type: request.type === 'image' ? 'image' : 'voice', + to: request.to, + msg: request.filePath + } +} + +let cachedPersonalSender: + | ((request: PersonalWechatSendRequest) => Promise) + | null = null + +async function defaultPersonalSender(): Promise< + (request: PersonalWechatSendRequest) => Promise +> { + if (!cachedPersonalSender) { + const module = await import('./personal-wechat-send-service') + cachedPersonalSender = (request) => module.personalWechatSendService.send(request) + } + return cachedPersonalSender +} + +/** 兼容性名称。 */ +export const wechatSendGateway = new WechatSendGateway() diff --git a/src/main/services/wechat-send-log-service.ts b/src/main/services/wechat-send-log-service.ts new file mode 100644 index 0000000..bf66285 --- /dev/null +++ b/src/main/services/wechat-send-log-service.ts @@ -0,0 +1,147 @@ +import { app } from 'electron' +import { createHash } from 'node:crypto' +import fs from 'fs-extra' +import path from 'path' +import { + buildSendPreview, + type WechatSendLogEntry, + type WechatSendStatus, + type WechatSendTransport, + type WechatSendType +} from '../../shared/wechat-send' + +const MAX_LOG_ENTRIES = 500 + +/** + * 只允许白名单字段落盘。 + * 即使调用方不小心把 context_token / bot_token 之类的字段混进条目, + * 也绝不会被写进 Send Log——隐私边界放在这里兜底,而不是依赖调用方自觉。 + */ +function sanitizeEntry(entry: WechatSendLogEntry): WechatSendLogEntry { + return { + request_id: String(entry.request_id ?? ''), + timestamp: Number(entry.timestamp) || 0, + transport: entry.transport, + to: String(entry.to ?? ''), + type: entry.type, + status: entry.status, + ...(entry.msg_preview ? { msg_preview: entry.msg_preview } : {}), + ...(entry.msg_hash ? { msg_hash: entry.msg_hash } : {}), + ...(entry.account_id ? { account_id: entry.account_id } : {}), + ...(entry.duration_ms !== undefined ? { duration_ms: entry.duration_ms } : {}), + ...(entry.error_code ? { error_code: entry.error_code } : {}) + } +} + +export interface WechatSendLogServiceOptions { + getUserDataPath?: () => string + maxEntries?: number +} + +/** + * 发送日志(Send Log)。 + * + * 与 WechatActionGateway 的 Action 审计刻意分成两层: + * - Send Log:**每一次**经过 WechatSendGateway 的发送都记录,包括普通 Agent 问答回复; + * - Action 审计:只记录需要业务审计的高层动作(定时日报、退群通知、用户主动 TTS)。 + * + * 记录内容:request_id / 时间 / transport / 目标 / 类型 / 截断预览 / sha256 / 状态 / 耗时。 + * **不记录**:context_token、bot_token、完整聊天文本、完整本地路径、Authorization 头。 + */ +export class WechatSendLogService { + private readonly getUserDataPath: () => string + private readonly maxEntries: number + + constructor(options: WechatSendLogServiceOptions = {}) { + this.getUserDataPath = options.getUserDataPath || (() => app.getPath('userData')) + this.maxEntries = options.maxEntries ?? MAX_LOG_ENTRIES + } + + /** 对消息原文计算稳定哈希;不做任何截断,用于后续比对是否同一条内容。 */ + hashMessage(msg: string): string { + return `sha256:${createHash('sha256') + .update(String(msg ?? '')) + .digest('hex')}` + } + + /** 组装一条日志条目;预览会自动截断,媒体只保留文件名。 */ + buildEntry(input: { + request_id: string + transport: WechatSendTransport + to: string + type: WechatSendType + msg: string + status: WechatSendStatus + timestamp: number + duration_ms?: number + account_id?: string + error_code?: WechatSendLogEntry['error_code'] + }): WechatSendLogEntry { + const preview = buildSendPreview(input.msg, input.type) + return { + request_id: input.request_id, + timestamp: input.timestamp, + transport: input.transport, + to: input.to, + type: input.type, + status: input.status, + ...(preview ? { msg_preview: preview } : {}), + msg_hash: this.hashMessage(input.msg), + ...(input.account_id ? { account_id: input.account_id } : {}), + ...(input.duration_ms !== undefined ? { duration_ms: input.duration_ms } : {}), + ...(input.error_code ? { error_code: input.error_code } : {}) + } + } + + list(): WechatSendLogEntry[] { + return this.readAll().map((entry) => ({ ...entry })) + } + + record(entry: WechatSendLogEntry): void { + const records = this.readAll() + const sanitized = sanitizeEntry(entry) + const withoutSameRequest = records.filter((item) => item.request_id !== sanitized.request_id) + const next = [sanitized, ...withoutSameRequest].slice(0, this.maxEntries) + try { + fs.ensureDirSync(path.dirname(this.filePath())) + fs.writeJsonSync(this.filePath(), next, { spaces: 2 }) + } catch (error) { + // 发送日志写失败不能影响真实发送结果。 + console.warn('[WechatSendLog] 发送日志写入失败:', error) + } + } + + clear(): void { + try { + fs.writeJsonSync(this.filePath(), [], { spaces: 2 }) + } catch { + // 忽略:清空失败不影响后续发送。 + } + } + + private filePath(): string { + return path.join(this.getUserDataPath(), 'actions', 'wechat-send-log.json') + } + + private readAll(): WechatSendLogEntry[] { + try { + const value = fs.readJsonSync(this.filePath()) as unknown + if (!Array.isArray(value)) return [] + return value.filter((item): item is WechatSendLogEntry => { + if (!item || typeof item !== 'object') return false + const record = item as Partial + // `to` 允许为空:非法请求同样要留痕,此时没有可用的接收者。 + return Boolean( + String(record.request_id || '').trim() && + String(record.type || '').trim() && + String(record.status || '').trim() && + Number.isFinite(Number(record.timestamp)) + ) + }) + } catch { + return [] + } + } +} + +export const wechatSendLogService = new WechatSendLogService() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 02cc581..e9acd83 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -5,7 +5,10 @@ import { GroupReportExportResult, GroupReportRenderSnapshotExportRequest } from '../shared/group-report' -import type { InstalledReportTemplate, ReportTemplateOperationResult } from '../shared/report-template-package' +import type { + InstalledReportTemplate, + ReportTemplateOperationResult +} from '../shared/report-template-package' import type { ReportTemplateCatalogInstallResult, ReportTemplateCatalogResult @@ -65,6 +68,11 @@ import type { ImageTextIndexStatus } from '../shared/image-text-index' import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub' +import type { + AgentHubConversation, + AgentHubConversationMessage, + AgentHubConversationSummary +} from '../shared/agent-hub-conversation' import type { PersonalWechatGeneratedTtsVoiceRequest, PersonalWechatGeneratedTtsVoiceResult, @@ -672,9 +680,13 @@ declare global { recognizeLocalImageText: (request: SystemOcrRequest) => Promise getImageTextIndexStatus: () => Promise countImageMessages: (sinceMs?: number) => Promise - startImageTextIndex: (options?: ImageTextIndexStartOptions) => Promise<{ started: boolean; state: string }> + startImageTextIndex: ( + options?: ImageTextIndexStartOptions + ) => Promise<{ started: boolean; state: string }> pauseImageTextIndex: () => Promise<{ paused: boolean; state: string }> - resumeImageTextIndex: (options?: ImageTextIndexStartOptions) => Promise<{ started: boolean; state: string }> + resumeImageTextIndex: ( + options?: ImageTextIndexStartOptions + ) => Promise<{ started: boolean; state: string }> cancelImageTextIndex: () => Promise<{ cancellable: boolean; cancelled: boolean }> clearImageTextIndex: () => Promise<{ removed: boolean; removedBytes: number }> resetImageTextIndexFailures: () => Promise<{ reset: number }> @@ -742,6 +754,16 @@ declare global { reconnectAgentHub: () => Promise disconnectAgentHub: () => Promise selectAgentHubTestImage: () => Promise<{ canceled: boolean; path?: string }> + getAgentHubConversations: () => Promise + getAgentHubConversation: (userId: string) => Promise + clearAgentHubConversations: () => Promise<{ success: boolean }> + onAgentHubConversation: ( + callback: (payload: { + summary: AgentHubConversationSummary + message: AgentHubConversationMessage + }) => void + ) => () => void + onAgentHubConversationsCleared: (callback: () => void) => () => void onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => () => void onAgentHubLog: (callback: (entry: AgentHubLogEntry) => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index 77a0afa..0b7e73a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -38,6 +38,10 @@ import type { ImageTextIndexStatus } from '../shared/image-text-index' import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub' +import type { + AgentHubConversationMessage, + AgentHubConversationSummary +} from '../shared/agent-hub-conversation' import type { PersonalWechatGeneratedTtsVoiceRequest, PersonalWechatGeneratedTtsVoiceResult, @@ -213,9 +217,7 @@ const api = { * * 事件带 requestId:UI 必须只认自己那一次请求,否则用户连问两次时阶段文案会串台。 */ - onAskWechatProgress: ( - callback: (requestId: string, event: QueryAgentProgressEvent) => void - ) => { + onAskWechatProgress: (callback: (requestId: string, event: QueryAgentProgressEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, requestId: string, @@ -481,11 +483,15 @@ const api = { /** 点击索引前的快速统计(SQL COUNT,不解密图片)。 */ countImageMessages: (sinceMs?: number): Promise => ipcRenderer.invoke('image-text-index:count', sinceMs), - startImageTextIndex: (options?: ImageTextIndexStartOptions): Promise<{ started: boolean; state: string }> => + startImageTextIndex: ( + options?: ImageTextIndexStartOptions + ): Promise<{ started: boolean; state: string }> => ipcRenderer.invoke('image-text-index:start', options), pauseImageTextIndex: (): Promise<{ paused: boolean; state: string }> => ipcRenderer.invoke('image-text-index:pause'), - resumeImageTextIndex: (options?: ImageTextIndexStartOptions): Promise<{ started: boolean; state: string }> => + resumeImageTextIndex: ( + options?: ImageTextIndexStartOptions + ): Promise<{ started: boolean; state: string }> => ipcRenderer.invoke('image-text-index:resume', options), cancelImageTextIndex: (): Promise<{ cancellable: boolean; cancelled: boolean }> => ipcRenderer.invoke('image-text-index:cancel'), @@ -604,6 +610,28 @@ const api = { reconnectAgentHub: () => ipcRenderer.invoke('agent-hub:reconnect'), disconnectAgentHub: () => ipcRenderer.invoke('agent-hub:disconnect'), selectAgentHubTestImage: () => ipcRenderer.invoke('agent-hub:selectTestImage'), + getAgentHubConversations: () => ipcRenderer.invoke('agent-hub:getConversations'), + getAgentHubConversation: (userId: string) => + ipcRenderer.invoke('agent-hub:getConversation', userId), + clearAgentHubConversations: () => ipcRenderer.invoke('agent-hub:clearConversations'), + onAgentHubConversation: ( + callback: (payload: { + summary: AgentHubConversationSummary + message: AgentHubConversationMessage + }) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { summary: AgentHubConversationSummary; message: AgentHubConversationMessage } + ): void => callback(payload) + ipcRenderer.on('agent-hub:conversation', listener) + return () => ipcRenderer.removeListener('agent-hub:conversation', listener) + }, + onAgentHubConversationsCleared: (callback: () => void) => { + const listener = (): void => callback() + ipcRenderer.on('agent-hub:conversationsCleared', listener) + return () => ipcRenderer.removeListener('agent-hub:conversationsCleared', listener) + }, onAgentHubStatus: (callback: (status: AgentHubStatus) => void) => { const listener = (_event: Electron.IpcRendererEvent, status: AgentHubStatus): void => callback(status) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e4431ef..974a9c4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2087,7 +2087,7 @@ function App(): React.ReactElement { /> ) case 'agent-hub': - return + return case 'api': return ( + + + + + + + + + + ) +} + +function pad(value: number): string { + return String(value).padStart(2, '0') +} + +function formatClock(timestamp: number): string { + const date = new Date(timestamp) + return `${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +function formatConversationTime(timestamp: number): string { + if (!Number.isFinite(timestamp) || timestamp <= 0) return '' + const date = new Date(timestamp) + const now = new Date() + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() + if (timestamp >= startOfToday) return `今天 ${formatClock(timestamp)}` + if (timestamp >= startOfToday - 24 * 60 * 60 * 1000) return `昨天 ${formatClock(timestamp)}` + return `${date.getMonth() + 1}月${date.getDate()}日 ${formatClock(timestamp)}` +} + +/** 去掉 @im.wechat 之类的后缀并截断,避免把完整 openid 铺在界面上。 */ +function shortenUserId(userId: string): string { + const bare = userId.split('@')[0] + if (bare.length <= 10) return bare + return `${bare.slice(0, 4)}…${bare.slice(-4)}` +} + +interface ConversationIdentity { + name: string + avatar?: string + initial: string + isSelf: boolean +} + +function resolveIdentity( + userId: string, + selfInfo: AgentHubConversationPanelSelfInfo | null, + selfUserId?: string +): ConversationIdentity { + // 扫码登录的那个人 == 跟机器人聊天的这个人:直接复用左下角账号的头像与昵称。 + if (selfUserId && userId === selfUserId) { + const name = selfInfo?.nickname?.trim() || '我自己' + return { + name, + ...(selfInfo?.avatar ? { avatar: selfInfo.avatar } : {}), + initial: name.charAt(0), + isSelf: true + } + } + return { name: `微信用户 ${shortenUserId(userId)}`, initial: '微', isSelf: false } +} + +function ConversationAvatar({ + identity, + size = 36 +}: { + identity: ConversationIdentity + size?: number +}): React.ReactElement { + if (identity.avatar) { + return ( + + ) + } + return ( + + {identity.initial} + + ) +} + +function messageBody(message: AgentHubConversationMessage): string { + return message.text.trim() || agentHubKindPlaceholder(message.kind) +} + +/** + * Agent Hub 对话记录。 + * + * 左侧是会话列表(默认就是本机账号自己),右侧是仿微信的收发气泡: + * 对方在左、机器人在右。数据来自本机 `conversations.json`,不经过任何外部服务。 + */ +export function AgentHubConversationPanel({ + selfInfo, + selfUserId +}: AgentHubConversationPanelProps): React.ReactElement { + const [summaries, setSummaries] = React.useState([]) + const [selectedUserId, setSelectedUserId] = React.useState(null) + const [messages, setMessages] = React.useState([]) + const [confirmingClear, setConfirmingClear] = React.useState(false) + const bodyRef = React.useRef(null) + // 用 ref 跟踪当前选中会话:推送回调里不能依赖闭包里的 selectedUserId, + // 也不能在 setState 的 updater 里塞副作用(StrictMode 下会执行两次)。 + const selectedRef = React.useRef(null) + + React.useEffect(() => { + let mounted = true + void window.api.getAgentHubConversations().then((items) => { + if (!mounted) return + setSummaries(items) + setSelectedUserId((current) => current ?? items[0]?.userId ?? null) + }) + const unsubscribe = window.api.onAgentHubConversation(({ summary, message }) => { + if (!mounted) return + setSummaries((current) => { + const withoutThis = current.filter((item) => item.userId !== summary.userId) + return [summary, ...withoutThis].sort((left, right) => right.lastAt - left.lastAt) + }) + if (selectedRef.current === null) setSelectedUserId(summary.userId) + else if (selectedRef.current === summary.userId) setMessages((list) => [...list, message]) + }) + const unsubscribeCleared = window.api.onAgentHubConversationsCleared(() => { + if (!mounted) return + setSummaries([]) + setMessages([]) + setSelectedUserId(null) + }) + return () => { + mounted = false + unsubscribe() + unsubscribeCleared() + } + }, []) + + React.useEffect(() => { + selectedRef.current = selectedUserId + if (!selectedUserId) { + setMessages([]) + return + } + let mounted = true + void window.api.getAgentHubConversation(selectedUserId).then((conversation) => { + if (mounted) setMessages(conversation?.messages ?? []) + }) + return () => { + mounted = false + } + }, [selectedUserId]) + + React.useEffect(() => { + const body = bodyRef.current + if (body) body.scrollTop = body.scrollHeight + }, [messages.length, selectedUserId]) + + const totalCount = summaries.reduce((sum, item) => sum + item.messageCount, 0) + const selectedIdentity = selectedUserId + ? resolveIdentity(selectedUserId, selfInfo, selfUserId) + : null + + const clearConversations = async (): Promise => { + await window.api.clearAgentHubConversations() + setSummaries([]) + setMessages([]) + setSelectedUserId(null) + } + + return ( +
+
+
+ 收发记录 +

对话记录

+
+
+ + {summaries.length} 个会话 · {totalCount} 条 + + +
+
+ +
+
+ {summaries.length === 0 ? ( +
+ 还没有收发记录。机器人收到或发出消息后,这里会显示完整内容。 +
+ ) : ( + summaries.map((summary) => { + const identity = resolveIdentity(summary.userId, selfInfo, selfUserId) + return ( + + ) + }) + )} +
+ +
+ {!selectedIdentity || messages.length === 0 ? ( +
+ {summaries.length === 0 + ? '左侧出现会话后,这里会显示完整对话。' + : '这个会话还没有消息。'} +
+ ) : ( + messages.map((message) => ( +
+ {message.direction === 'out' ? ( + + ) : ( + + )} +
+
{messageBody(message)}
+
+ {formatConversationTime(message.createdAt)} + {message.direction === 'out' && ( + + {message.status === 'failed' + ? `发送失败${message.errorCode ? `(${message.errorCode})` : ''}` + : '已发送'} + + )} +
+
+
+ )) + )} +
+
+ + + + + 清空对话记录? + + 将删除本机保存的全部机器人收发记录。这不影响微信里的原始消息,也不影响运行日志。 + + + + 取消 + void clearConversations()} + > + 确认清空 + + + + +
+ ) +} diff --git a/src/renderer/src/features/agent-hub/AgentHubWorkspace.tsx b/src/renderer/src/features/agent-hub/AgentHubWorkspace.tsx index 3712f9d..ed8a5a5 100644 --- a/src/renderer/src/features/agent-hub/AgentHubWorkspace.tsx +++ b/src/renderer/src/features/agent-hub/AgentHubWorkspace.tsx @@ -13,6 +13,7 @@ import { SelectTrigger, SelectValue } from '../../components/ui' +import { AgentHubConversationPanel } from './AgentHubConversationPanel' const STATUS_LABELS: Record = { checking: '正在检查', @@ -30,7 +31,14 @@ const LOG_SOURCE_LABELS: Record = { 'wechat-connector': '微信连接器' } -export function AgentHubWorkspace(): React.ReactElement { +export interface AgentHubWorkspaceProps { + /** 软件左下角那个账号:用于在对话记录左侧显示"对方是谁"。 */ + selfInfo?: { wxid: string; nickname: string; avatar?: string } | null +} + +export function AgentHubWorkspace({ + selfInfo = null +}: AgentHubWorkspaceProps = {}): React.ReactElement { const [status, setStatus] = React.useState({ hub: 'offline', connector: 'checking', @@ -283,6 +291,8 @@ export function AgentHubWorkspace(): React.ReactElement {

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

+ + ) } diff --git a/src/renderer/src/styles/reports-core.scss b/src/renderer/src/styles/reports-core.scss index 62648a2..742e945 100644 --- a/src/renderer/src/styles/reports-core.scss +++ b/src/renderer/src/styles/reports-core.scss @@ -1849,3 +1849,206 @@ overflow-y: auto; padding: 0 10px 12px; } + +/* ------------------------------------------------------------------ */ +/* Agent Hub 对话记录(收发回看) */ +/* 与运行日志明确分工:日志只记流程与故障,这里保存完整收发内容。 */ +/* 全部使用主题 token,因此浅色 / 深色自动适配,不需要单独覆写。 */ +/* ------------------------------------------------------------------ */ +.agent-hub-conversation-card { + max-width: 1080px; + margin: 16px auto 0; + padding: 20px 22px; +} +.agent-hub-conversation-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} +.agent-hub-conversation-heading h2 { + margin-top: 3px; +} +.agent-hub-conversation-actions { + display: flex; + align-items: center; + gap: 8px; +} +.agent-hub-conversation-count { + color: var(--wxex-text-muted); + font: 500 12px/18px var(--wxex-font); +} +.agent-hub-conversation-body { + display: grid; + grid-template-columns: minmax(0, 220px) minmax(0, 1fr); + min-height: 340px; + border: 1px solid hsl(var(--tm-border-subtle)); + border-radius: var(--wxex-radius-md); + overflow: hidden; +} +.agent-hub-conversation-list { + min-width: 0; + max-height: 420px; + overflow: auto; + padding: 8px; + border-right: 1px solid hsl(var(--tm-border-subtle)); +} +.agent-hub-conversation-list-empty { + padding: 12px 8px; + color: var(--wxex-text-muted); + font: 13px/20px var(--wxex-font); +} +.agent-hub-conversation-item { + display: grid; + grid-template-columns: 36px minmax(0, 1fr); + grid-template-areas: 'avatar copy' 'avatar time'; + gap: 2px 10px; + width: 100%; + padding: 8px; + border: 0; + border-radius: var(--wxex-radius-md); + background: transparent; + text-align: left; + cursor: pointer; + font-family: var(--wxex-font); +} +.agent-hub-conversation-item:hover { + background: var(--wxex-bg-sidebar); +} +.agent-hub-conversation-item.is-active { + background: var(--wxex-brand-soft); +} +.agent-hub-conversation-avatar { + grid-area: avatar; + align-self: center; + border-radius: 50%; + object-fit: cover; + flex: none; +} +.agent-hub-conversation-avatar.is-fallback { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--wxex-bg-sidebar); + color: var(--wxex-text-secondary); + font: 600 13px/1 var(--wxex-font); +} +.agent-hub-conversation-avatar.is-fallback.is-self { + background: var(--wxex-brand-soft); + color: var(--wxex-brand); +} +.agent-hub-conversation-item-copy { + grid-area: copy; + min-width: 0; + display: flex; + flex-direction: column; +} +.agent-hub-conversation-item-name { + min-width: 0; + overflow: hidden; + color: var(--wxex-text-primary); + font: 700 13px/18px var(--wxex-font); + text-overflow: ellipsis; + white-space: nowrap; +} +.agent-hub-conversation-item-preview { + min-width: 0; + overflow: hidden; + color: var(--wxex-text-muted); + font: 12px/17px var(--wxex-font); + text-overflow: ellipsis; + white-space: nowrap; +} +.agent-hub-conversation-item-time { + grid-area: time; + min-width: 0; + overflow: hidden; + color: var(--wxex-text-muted); + font: 11px/16px var(--wxex-font); + text-overflow: ellipsis; + white-space: nowrap; +} +.agent-hub-conversation-note { + margin: 10px 4px 0; + color: var(--wxex-text-muted); + font: 11px/16px var(--wxex-font); +} +.agent-hub-conversation-thread { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + max-height: 420px; + overflow: auto; + padding: 14px 16px; + background: var(--wxex-bg-main); +} +.agent-hub-conversation-thread-empty { + margin: auto; + color: var(--wxex-text-muted); + font: 13px/20px var(--wxex-font); + text-align: center; +} +.agent-hub-message { + display: flex; + gap: 8px; + min-width: 0; +} +.agent-hub-message.is-out { + flex-direction: row-reverse; +} +.agent-hub-message-main { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + max-width: min(78%, 560px); +} +.agent-hub-message.is-out .agent-hub-message-main { + align-items: flex-end; +} +.agent-hub-message-bubble { + width: fit-content; + max-width: 100%; + padding: 8px 12px; + border: 1px solid hsl(var(--tm-border-subtle)); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + color: var(--wxex-text-primary); + font: 13px/20px var(--wxex-font); + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.agent-hub-message.is-out .agent-hub-message-bubble { + border-color: transparent; + background: var(--wxex-brand-soft); +} +.agent-hub-message-meta { + display: flex; + gap: 6px; + color: var(--wxex-text-muted); + font: 11px/16px var(--wxex-font); +} +.agent-hub-message-meta .is-failed { + color: var(--wxex-danger); +} +.agent-hub-bot-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + border-radius: 50%; + background: var(--wxex-ai-soft); + color: var(--wxex-ai); +} +@media (max-width: 900px) { + .agent-hub-conversation-body { + grid-template-columns: minmax(0, 1fr); + } + .agent-hub-conversation-list { + max-height: 220px; + border-right: 0; + border-bottom: 1px solid hsl(var(--tm-border-subtle)); + } +} diff --git a/src/shared/agent-hub-conversation.ts b/src/shared/agent-hub-conversation.ts new file mode 100644 index 0000000..f59d0ac --- /dev/null +++ b/src/shared/agent-hub-conversation.ts @@ -0,0 +1,66 @@ +/** + * Agent Hub 对话记录(收发记录)。 + * + * 这是**产品数据**,与「运行日志」明确分工: + * - 运行日志(`agent-hub.log`)只记流程与故障,会脱敏,不保留完整正文; + * - 对话记录保存完整收发内容,用于在应用内回看"机器人到底和谁说了什么"。 + * + * 隐私边界:仅本机保存(`/agent-hub/conversations.json`,权限 0600), + * 不上传任何服务商,也不写入日志。 + */ + +export type AgentHubMessageDirection = 'in' | 'out' + +export type AgentHubMessageKind = 'text' | 'image' | 'voice' | 'file' | 'video' | 'system' + +export type AgentHubMessageStatus = 'sent' | 'failed' + +export interface AgentHubConversationMessage { + id: string + direction: AgentHubMessageDirection + kind: AgentHubMessageKind + /** 文本内容;媒体消息记录文件名或 URL,不记录二进制。 */ + text: string + createdAt: number + /** 仅出站消息有发送状态。 */ + status?: AgentHubMessageStatus + /** 出站失败时的错误码(如 STALE_TOKEN / SEND_FAILED)。 */ + errorCode?: string + /** 服务端 message_id:仅入站消息有,用于和日志对照。 */ + messageId?: string +} + +export interface AgentHubConversation { + /** 会话标识:入站为 from_user_id,出站为接收者 id。 */ + userId: string + accountId?: string + firstAt: number + lastAt: number + messages: AgentHubConversationMessage[] +} + +export interface AgentHubConversationSummary { + userId: string + accountId?: string + firstAt: number + lastAt: number + messageCount: number + /** 列表里显示的最后一句话(已按 kind 处理,媒体显示成中文占位)。 */ + lastPreview: string + lastDirection: AgentHubMessageDirection +} + +export const AGENT_HUB_KIND_LABELS: Record = { + text: '文字', + image: '图片', + voice: '语音', + file: '文件', + video: '视频', + system: '系统消息' +} + +/** 媒体类消息在列表 / 气泡里的占位文案。 */ +export function agentHubKindPlaceholder(kind: AgentHubMessageKind): string { + if (kind === 'text' || kind === 'system') return '' + return `[${AGENT_HUB_KIND_LABELS[kind]}]` +} diff --git a/src/shared/wechat-send.ts b/src/shared/wechat-send.ts new file mode 100644 index 0000000..cfc82cf --- /dev/null +++ b/src/shared/wechat-send.ts @@ -0,0 +1,162 @@ +/** + * TraceMemo 统一微信发送模型。 + * + * 上层业务只说"发什么给谁",不关心底下是 iLink 还是注入式个人微信。 + * 这个文件刻意不引入任何 Electron / Node 专属对象,将来换宿主(例如 Tauri) + * 只需要替换 transport 实现,业务层与协议类型都不用动。 + */ + +export type WechatSendTransport = 'personal' | 'ilink' + +export type WechatSendType = 'text' | 'image' | 'voice' | 'file' + +/** + * 统一发送请求。 + * + * - `msg` 是消息主体:text 时就是文本;image / voice / file 时是本地路径或 URL。 + * - `context_token` 是 iLink 会话上下文,只在回复对应会话时传递。 + * - `transport` 缺省时按 `context_token` 推断(有 token 即 iLink 会话)。 + */ +export interface WechatSendRequest { + request_id: string + account_id?: string + to: string + type: WechatSendType + msg: string + context_token?: string + transport?: WechatSendTransport + /** 个人微信需要区分群聊 / 联系人。 */ + is_group?: boolean + /** 传输层附加元数据(例如语音的 fromId / durationMs),不参与审计摘要。 */ + metadata?: Record +} + +export type WechatSendStatus = 'sent' | 'failed' | 'blocked' + +export type WechatSendErrorCode = + | 'INVALID_REQUEST' + | 'UNSUPPORTED_TYPE' + | 'TRANSPORT_UNAVAILABLE' + | 'STALE_TOKEN' + | 'SEND_FAILED' + | 'UNKNOWN' + +export interface WechatSendResult { + request_id: string + success: boolean + status: WechatSendStatus + transport: WechatSendTransport + duration_ms: number + error_code?: WechatSendErrorCode + error?: string +} + +/** Send Log 条目:可追溯,但不复制完整聊天内容。 */ +export interface WechatSendLogEntry { + request_id: string + timestamp: number + transport: WechatSendTransport + account_id?: string + to: string + type: WechatSendType + /** 截断后的内容预览(本地文件只记录文件名)。 */ + msg_preview?: string + /** 对 msg 原文计算的 sha256。 */ + msg_hash?: string + status: WechatSendStatus + duration_ms?: number + error_code?: WechatSendErrorCode +} + +export const MAX_SEND_PREVIEW_LENGTH = 200 + +const SUPPORTED_TYPES: ReadonlySet = new Set(['text', 'image', 'voice', 'file']) + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +/** + * 生成 Send Log 用的内容预览。 + * 本地路径只保留文件名,避免把用户目录结构写进日志。 + */ +export function buildSendPreview(msg: string, type: WechatSendType): string { + const raw = String(msg ?? '') + let preview = raw + if (type !== 'text') { + preview = filenameOf(raw) + } + const normalized = preview.replace(/\s+/g, ' ').trim() + return normalized.length > MAX_SEND_PREVIEW_LENGTH + ? normalized.slice(0, MAX_SEND_PREVIEW_LENGTH) + : normalized +} + +function filenameOf(value: string): string { + const withoutQuery = value.split('?')[0].split('#')[0] + const segments = withoutQuery.split(/[/\\]/) + return segments[segments.length - 1] || withoutQuery +} + +export interface NormalizeSendRequestOptions { + /** 缺省 request_id 生成器;便于测试注入确定性值。 */ + createRequestId?: () => string +} + +/** + * 校验并规范化发送请求。 + * 返回 null 表示请求不合法,调用方应当以 INVALID_REQUEST 记账,而不是静默丢弃。 + */ +export function normalizeWechatSendRequest( + value: unknown, + options: NormalizeSendRequestOptions = {} +): WechatSendRequest | null { + if (!value || typeof value !== 'object') return null + const input = value as Partial & Record + + const to = asTrimmedString(input.to) + if (!to) return null + + const type = asTrimmedString(input.type) as WechatSendType + if (!SUPPORTED_TYPES.has(type)) return null + + const msg = typeof input.msg === 'string' ? input.msg.trim() : '' + // 文本必须非空;媒体允许空 msg 但会被适配器拒绝,这里提前挡掉以免产生空发送。 + if (!msg) return null + + const requestId = asTrimmedString(input.request_id) || options.createRequestId?.() || '' + if (!requestId) return null + + const transport = asTrimmedString(input.transport) + const contextToken = asTrimmedString(input.context_token) + const accountId = asTrimmedString(input.account_id) + + const normalized: WechatSendRequest = { + request_id: requestId, + to, + type, + msg, + ...(accountId ? { account_id: accountId } : {}), + ...(contextToken ? { context_token: contextToken } : {}), + ...(transport === 'ilink' || transport === 'personal' ? { transport } : {}), + ...(typeof input.is_group === 'boolean' ? { is_group: input.is_group } : {}), + ...(input.metadata && typeof input.metadata === 'object' + ? { metadata: input.metadata as Record } + : {}) + } + return normalized +} + +/** + * 传输通道解析规则: + * 1. 显式声明优先; + * 2. 带 context_token 说明是 iLink 会话,走 iLink; + * 3. 其余走个人微信注入通道。 + */ +export function resolveSendTransport(request: { + transport?: WechatSendTransport + context_token?: string +}): WechatSendTransport { + if (request.transport) return request.transport + return request.context_token ? 'ilink' : 'personal' +} diff --git a/tests/component/agent-hub-controls.test.tsx b/tests/component/agent-hub-controls.test.tsx index 35eff0b..e3ad445 100644 --- a/tests/component/agent-hub-controls.test.tsx +++ b/tests/component/agent-hub-controls.test.tsx @@ -3,6 +3,12 @@ import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHubWorkspace } from '../../src/renderer/src/features/agent-hub/AgentHubWorkspace' import type { AgentHubStatus } from '../../src/shared/agent-hub' +import type { + AgentHubConversation, + AgentHubConversationSummary +} from '../../src/shared/agent-hub-conversation' + +const SELF_USER_ID = 'fixture-self-openid@im.wechat' const onlineStatus: AgentHubStatus = { hub: 'online', @@ -10,7 +16,43 @@ const onlineStatus: AgentHubStatus = { updatedAt: 1, dataApi: 'online', databaseReady: true, - accountId: 'fixture-agent' + accountId: 'fixture-bot-account', + wechatUserId: SELF_USER_ID +} + +const conversationSummary: AgentHubConversationSummary = { + userId: SELF_USER_ID, + accountId: 'fixture-bot-account', + firstAt: 1_700_000_000_000, + lastAt: 1_700_000_060_000, + messageCount: 2, + lastPreview: '这是机器人的回答', + lastDirection: 'out' +} + +const conversation: AgentHubConversation = { + userId: SELF_USER_ID, + accountId: 'fixture-bot-account', + firstAt: 1_700_000_000_000, + lastAt: 1_700_000_060_000, + messages: [ + { + id: 'm1', + direction: 'in', + kind: 'text', + text: '微信里最近发生了什么', + createdAt: 1_700_000_000_000, + messageId: '7506247944749661000' + }, + { + id: 'm2', + direction: 'out', + kind: 'text', + text: '这是机器人的回答', + createdAt: 1_700_000_060_000, + status: 'sent' + } + ] } describe('Agent Hub controls', () => { @@ -35,8 +77,13 @@ describe('Agent Hub controls', () => { cancelAgentHubLogin: vi.fn().mockResolvedValue({ status: onlineStatus }), disconnectAgentHub: vi.fn().mockResolvedValue({ status: { ...onlineStatus, connector: 'disconnected' } - }) - } as typeof window.api + }), + getAgentHubConversations: vi.fn().mockResolvedValue([]), + getAgentHubConversation: vi.fn().mockResolvedValue(null), + clearAgentHubConversations: vi.fn().mockResolvedValue({ success: true }), + onAgentHubConversation: vi.fn(() => () => undefined), + onAgentHubConversationsCleared: vi.fn(() => () => undefined) + } as unknown as typeof window.api }) it('filters, copies, and clears logs through shared controls', async () => { @@ -45,7 +92,7 @@ describe('Agent Hub controls', () => { await screen.findByText('系统就绪') await user.click(screen.getByRole('combobox', { name: '筛选日志来源' })) - await user.click(screen.getByRole('option', { name: '系统', exact: true })) + await user.click(screen.getByRole('option', { name: '系统' })) expect(screen.getByText('系统就绪')).toBeInTheDocument() expect(screen.queryByText('等待连接')).not.toBeInTheDocument() @@ -92,4 +139,56 @@ describe('Agent Hub controls', () => { }) expect(window.api.disconnectAgentHub).toHaveBeenCalledOnce() }) + + it('把收发记录渲染成仿微信对话,左侧用软件内账号的身份', async () => { + window.api.getAgentHubConversations = vi.fn().mockResolvedValue([conversationSummary]) + window.api.getAgentHubConversation = vi.fn().mockResolvedValue(conversation) + + render() + + expect(await screen.findByText('对话记录')).toBeInTheDocument() + // 会话标识与扫码登录账号一致时,左侧展示软件内账号的昵称。 + expect(await screen.findByText('测试用户')).toBeInTheDocument() + expect(screen.getByText('1 个会话 · 2 条')).toBeInTheDocument() + // 消息线程是第二次拉取,必须 await,否则在并行跑测试时会抢跑。 + expect(await screen.findByText('微信里最近发生了什么')).toBeInTheDocument() + expect(screen.getByText('这是机器人的回答')).toBeInTheDocument() + expect(screen.getByText('已发送')).toBeInTheDocument() + }) + + it('对方不是本机账号时退化为占位身份,并渲染机器人头像', async () => { + window.api.getAgentHubConversations = vi + .fn() + .mockResolvedValue([{ ...conversationSummary, userId: 'other-user-openid@im.wechat' }]) + window.api.getAgentHubConversation = vi.fn().mockResolvedValue({ + ...conversation, + userId: 'other-user-openid@im.wechat' + }) + + render() + + expect(await screen.findByText(/微信用户 othe…enid/)).toBeInTheDocument() + await screen.findByText('微信里最近发生了什么') + // 右侧气泡是机器人:用固定矢量头像,不依赖真实头像资源。 + expect(document.querySelectorAll('.agent-hub-bot-avatar').length).toBeGreaterThan(0) + }) + + it('清空对话记录需要二次确认,且不影响运行日志', async () => { + const user = userEvent.setup() + window.api.getAgentHubConversations = vi.fn().mockResolvedValue([conversationSummary]) + window.api.getAgentHubConversation = vi.fn().mockResolvedValue(conversation) + + render() + await screen.findByText('对话记录') + + await user.click(screen.getByRole('button', { name: '清空记录' })) + expect(window.api.clearAgentHubConversations).not.toHaveBeenCalled() + + await user.click(await screen.findByRole('button', { name: '确认清空' })) + await act(async () => { + await Promise.resolve() + }) + expect(window.api.clearAgentHubConversations).toHaveBeenCalledOnce() + expect(screen.getByText(/还没有收发记录/)).toBeInTheDocument() + }) }) diff --git a/tests/integration/agent-hub-inbound-flow.test.ts b/tests/integration/agent-hub-inbound-flow.test.ts new file mode 100644 index 0000000..0abfdbe --- /dev/null +++ b/tests/integration/agent-hub-inbound-flow.test.ts @@ -0,0 +1,482 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + userData: '', + ask: vi.fn(), + generateAgentGroupReport: vi.fn(), + isReady: vi.fn(), + listRecentChat: vi.fn(), + listContacts: vi.fn(), + listMessages: vi.fn(), + getGroupSnapshot: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => mocks.userData }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../../src/main/services/ask-wechat-service', () => ({ + AskWechatService: class { + ask(input: unknown, conversationKey?: string): Promise { + return mocks.ask(input, conversationKey) + } + } +})) +vi.mock('../../src/main/services/query-agent-service', () => ({ QueryAgentService: class {} })) +vi.mock('../../src/main/services/agent-group-report-service', () => ({ + generateAgentGroupReport: (input: unknown) => mocks.generateAgentGroupReport(input) +})) +vi.mock('../../src/main/services/ai-provider-service', () => ({ + AIProviderService: class { + async chat(): Promise<{ success: boolean; data: string }> { + return { success: true, data: 'AI 总结' } + } + } +})) +vi.mock('../../src/main/services/chat-service', () => ({ + isReady: () => mocks.isReady(), + listRecentChat: (limit: number) => mocks.listRecentChat(limit), + listContacts: () => mocks.listContacts(), + listMessages: (...args: unknown[]) => mocks.listMessages(...args), + getGroupSnapshot: (md5: string) => mocks.getGroupSnapshot(md5), + resolveMd5: vi.fn() +})) + +import { + AgentHubService, + type AgentHubWechatConnectorPort +} from '../../src/main/services/agent-hub-service' +import { WechatInboundInbox } from '../../src/main/services/wechat-inbound-inbox' +import { WechatSendGateway } from '../../src/main/services/wechat-send-gateway' +import { WechatSendLogService } from '../../src/main/services/wechat-send-log-service' +import type { AppSettings } from '../../src/main/services/settings-store' +import type { WechatInboundMessage } from '../../src/main/services/wechat-ilink/types' + +const ACCOUNT_ID = 'bot-1' +const USER_ID = 'user@im.wechat' + +function inbound(overrides: Partial = {}): WechatInboundMessage { + return { + accountId: ACCOUNT_ID, + fromUserId: USER_ID, + messageId: '1001', + messageType: 1, + contextToken: 'ctx-inbound', + items: [{ type: 1, text: '微信里最近发生了什么' }], + receivedAt: 1_700_000_000_000, + ...overrides + } +} + +function createFakeConnector( + contextTokens: Map, + typingEvents: string[], + typingShouldFail: () => boolean +): AgentHubWechatConnectorPort { + return { + setHost: () => undefined, + listAccounts: () => [{ accountId: ACCOUNT_ID, wechatUserId: 'user_42' }], + startLogin: () => undefined, + cancelLogin: () => undefined, + submitVerifyCode: () => undefined, + isLoginInProgress: () => false, + start: async () => undefined, + stop: async () => undefined, + getPhase: () => 'polling', + getActiveAccountId: () => ACCOUNT_ID, + resolveContextToken: (_accountId, toUserId) => contextTokens.get(toUserId), + beginTyping: async () => { + if (typingShouldFail()) throw new Error('typing 不可用') + typingEvents.push('typing-on') + return { + stop: async () => { + typingEvents.push('typing-off') + } + } + }, + sendText: async () => undefined, + sendMediaPath: async () => undefined, + sendMediaUrl: async () => undefined + } +} + +describe('Agent Hub 入站 → 出站闭环', () => { + const roots: string[] = [] + let service: AgentHubService + let inbox: WechatInboundInbox + let gateway: WechatSendGateway + let sent: unknown[] + let ilinkShouldFail: boolean + let contextTokens: Map + let userDataRoot: string + /** 与发送交织在一起的事件序列,用来断言 typing 的生命周期边界。 */ + let typingEvents: string[] + let typingShouldFail: boolean + /** 还要失败几次发送;0 表示不再失败。用于确定性地构造"先失败后成功"。 */ + let ilinkFailuresRemaining: number + + beforeEach(() => { + const root = mkdtempSync(join(tmpdir(), 'tracememo-agent-hub-')) + roots.push(root) + userDataRoot = root + mocks.userData = root + mocks.ask + .mockReset() + .mockResolvedValue({ status: 'answered', answer: '这是 Query Agent 的回答' }) + mocks.generateAgentGroupReport.mockReset() + mocks.isReady.mockReset().mockReturnValue(true) + mocks.listRecentChat.mockReset().mockReturnValue([]) + mocks.listContacts.mockReset().mockReturnValue([]) + mocks.listMessages.mockReset().mockReturnValue([]) + mocks.getGroupSnapshot.mockReset().mockReturnValue(null) + + sent = [] + ilinkShouldFail = false + typingEvents = [] + typingShouldFail = false + ilinkFailuresRemaining = 0 + contextTokens = new Map() + + inbox = new WechatInboundInbox({ + filePath: () => join(root, 'agent-hub', 'inbound-inbox.json') + }) + gateway = new WechatSendGateway({ + now: () => 1_700_000_000_000, + createRequestId: (() => { + let sequence = 0 + return () => `req-${(sequence += 1)}` + })(), + log: new WechatSendLogService({ getUserDataPath: () => root }), + sendIlink: async (request) => { + // 先把这次发送记进事件序列,失败与否都算一次尝试。 + sent.push(request) + typingEvents.push(`send:${String((request as { type: string }).type)}`) + if (ilinkFailuresRemaining > 0) { + ilinkFailuresRemaining -= 1 + throw new Error('iLink 发送失败') + } + if (ilinkShouldFail) throw new Error('iLink 发送失败') + } + }) + + service = new AgentHubService({ inbox, inboundRetryDelayMs: 5 }) + service.setSendGateway(gateway) + service.setWechatConnector( + createFakeConnector(contextTokens, typingEvents, () => typingShouldFail) + ) + service.setQueryAgentService({} as never) + }) + + afterEach(() => { + service.stop() + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) + }) + + it('入站 context_token 一路带到出站发送,不丢失', async () => { + await service.start({} as AppSettings) + await service.handleInboundMessages([inbound()]) + + await vi.waitFor(() => expect(sent).toHaveLength(1)) + + expect(sent[0]).toMatchObject({ + to: USER_ID, + type: 'text', + msg: '这是 Query Agent 的回答', + context_token: 'ctx-inbound', + transport: 'ilink' + }) + expect(mocks.ask).toHaveBeenCalledWith( + expect.objectContaining({ text: '微信里最近发生了什么' }), + `${ACCOUNT_ID}::${USER_ID}` + ) + }) + + it('处理成功后收件箱清空,重复 message_id 不会二次回复', async () => { + await service.start({} as AppSettings) + await service.handleInboundMessages([inbound()]) + await vi.waitFor(() => expect(sent).toHaveLength(1)) + + await service.handleInboundMessages([inbound()]) + await vi.waitFor(() => expect(inbox.size()).toBe(0)) + expect(sent).toHaveLength(1) + }) + + it('发送失败时消息保留在收件箱,恢复后自动补发', async () => { + await service.start({} as AppSettings) + // 用"前 1 次发送必定失败"代替布尔标志,避免测试与后台处理抢时序。 + ilinkFailuresRemaining = 1 + await service.handleInboundMessages([inbound({ messageId: '2002' })]) + + // 失败后条目必须仍留在收件箱,不能被静默丢弃。 + const key = `${ACCOUNT_ID}::2002` + await vi.waitFor(() => expect(inbox.contains(key)).toBe(true)) + + await vi.waitFor(() => expect(inbox.contains(key)).toBe(false)) + expect( + sent.filter((item) => (item as { type: string }).type === 'text').length + ).toBeGreaterThanOrEqual(2) + }) + + it('群日报请求先回执确认语,再触发 Report Action', async () => { + mocks.generateAgentGroupReport.mockResolvedValue({ + success: true, + pngPath: '/tmp/report.png', + groupName: '产品交流群', + messageCount: 12 + }) + await service.start({} as AppSettings) + await service.handleInboundMessages([ + inbound({ messageId: '3003', items: [{ type: 1, text: '生成产品交流群今天的群聊总结图片' }] }) + ]) + + await vi.waitFor(() => expect(mocks.generateAgentGroupReport).toHaveBeenCalled()) + await vi.waitFor(() => + expect(sent.some((item) => (item as { type: string }).type === 'image')).toBe(true) + ) + + expect(sent[0]).toMatchObject({ type: 'text', context_token: 'ctx-inbound' }) + expect(String((sent[0] as { msg: string }).msg)).toContain('正在生成群聊总结') + expect(mocks.generateAgentGroupReport).toHaveBeenCalledWith( + expect.objectContaining({ range: 'today' }) + ) + // 群日报图片仍走同一条发送通道,并保留会话上下文。 + expect(sent.find((item) => (item as { type: string }).type === 'image')).toMatchObject({ + msg: '/tmp/report.png', + context_token: 'ctx-inbound' + }) + }) + + it('会话列表请求走确定性能力,不调用 Query Agent', async () => { + mocks.listRecentChat.mockReturnValue([ + { m_nsNickName: '张三', m_nsUsrName: 'wxid_a', type: 'contact' } + ]) + await service.start({} as AppSettings) + await service.handleInboundMessages([ + inbound({ messageId: '4004', items: [{ type: 1, text: '最近 5 个会话' }] }) + ]) + + await vi.waitFor(() => expect(sent).toHaveLength(1)) + expect(String((sent[0] as { msg: string }).msg)).toContain('张三') + expect(mocks.ask).not.toHaveBeenCalled() + }) + + it('主动通知在没有入站 token 时回退到会话最近一次有效 token', async () => { + contextTokens.set(USER_ID, 'ctx-cached') + await service.start({} as AppSettings) + + const result = await service.sendNotification({ to: USER_ID, text: '定时日报已生成' }) + + expect(result).toMatchObject({ success: true, status: 'sent' }) + expect(sent[0]).toMatchObject({ context_token: 'ctx-cached', msg: '定时日报已生成' }) + }) + + it('登录失效时把 STALE_TOKEN 映射成 token_expired', async () => { + await service.start({} as AppSettings) + gateway.configureIlinkSender(async () => { + const { ILinkError } = await import('../../src/main/services/wechat-ilink/errors') + throw new ILinkError({ kind: 'stale_token', message: '登录凭证已失效', ret: -14 }) + }) + + const result = await service.sendNotification({ to: USER_ID, text: '测试' }) + expect(result).toMatchObject({ success: false, status: 'token_expired' }) + }) + + it('未注入 iLink 通道时映射成 connector_offline', async () => { + await service.start({} as AppSettings) + const bare = new WechatSendGateway({ + log: new WechatSendLogService({ getUserDataPath: () => mocks.userData }) + }) + service.setSendGateway(bare) + + const result = await service.sendNotification({ to: USER_ID, text: '测试' }) + expect(result).toMatchObject({ success: false, status: 'connector_offline' }) + }) + + it('对话记录保存完整收发内容,且不落 context_token', async () => { + await service.start({} as AppSettings) + await service.handleInboundMessages([inbound()]) + // 出站记录发生在发送返回之后,所以等对话记录本身而不是等 sent。 + await vi.waitFor(() => expect(service.getConversation(USER_ID)?.messages).toHaveLength(2)) + + const conversation = service.getConversation(USER_ID) + expect(conversation?.messages.map((message) => [message.direction, message.text])).toEqual([ + ['in', '微信里最近发生了什么'], + ['out', '这是 Query Agent 的回答'] + ]) + expect(service.listConversations()).toHaveLength(1) + + const raw = readFileSync(join(userDataRoot, 'agent-hub', 'conversations.json'), 'utf8') + // 会话上下文令牌属于运行时凭据,绝不能跟着对话记录落盘。 + expect(raw).not.toContain('ctx-inbound') + expect(raw).not.toContain('context_token') + }) + + it('发送失败也会记进对话记录,并带上错误码', async () => { + await service.start({} as AppSettings) + ilinkShouldFail = true + await service.handleInboundMessages([inbound({ messageId: '5005' })]) + + await vi.waitFor(() => { + const messages = service.getConversation(USER_ID)?.messages ?? [] + expect(messages.some((message) => message.status === 'failed')).toBe(true) + }) + + const failed = service + .getConversation(USER_ID) + ?.messages.find((message) => message.status === 'failed') + expect(failed).toMatchObject({ direction: 'out', errorCode: 'SEND_FAILED' }) + }) + + it('群日报图片在对话记录里记为图片消息', async () => { + mocks.generateAgentGroupReport.mockResolvedValue({ + success: true, + pngPath: '/tmp/report.png', + groupName: '产品交流群', + messageCount: 12 + }) + await service.start({} as AppSettings) + await service.handleInboundMessages([ + inbound({ messageId: '6006', items: [{ type: 1, text: '生成产品交流群今天的群聊总结图片' }] }) + ]) + + await vi.waitFor(() => { + const kinds = service.getConversation(USER_ID)?.messages.map((message) => message.kind) ?? [] + expect(kinds).toContain('image') + }) + + const image = service + .getConversation(USER_ID) + ?.messages.find((message) => message.kind === 'image') + // 媒体只记录文件名,不记录完整本地路径。 + expect(image?.text).toBe('report.png') + }) + + it('普通查询:typing ON → AI → 回复 → typing OFF,且不额外发"收到"', async () => { + await service.start({} as AppSettings) + await service.handleInboundMessages([inbound({ messageId: '8101' })]) + + await vi.waitFor(() => expect(typingEvents).toContain('typing-off')) + expect(typingEvents).toEqual(['typing-on', 'send:text', 'typing-off']) + // 普通查询只发最终回答,没有"收到,正在查询"这类确认语。 + expect(sent).toHaveLength(1) + expect(String((sent[0] as { msg: string }).msg)).toBe('这是 Query Agent 的回答') + }) + + it('群日报:确认语 → typing ON → 生成 → 文字+图片 → typing OFF', async () => { + mocks.generateAgentGroupReport.mockResolvedValue({ + success: true, + pngPath: '/tmp/report.png', + groupName: '产品交流群', + messageCount: 12 + }) + await service.start({} as AppSettings) + await service.handleInboundMessages([ + inbound({ messageId: '8102', items: [{ type: 1, text: '生成产品交流群今天的群聊总结图片' }] }) + ]) + + await vi.waitFor(() => + expect(typingEvents.filter((event) => event === 'typing-off')).toHaveLength(1) + ) + expect(typingEvents[0]).toBe('send:text') + expect(typingEvents).toContain('typing-on') + expect(typingEvents).toContain('send:image') + // typing 必须在图片发完之后才收掉。 + expect(typingEvents.indexOf('typing-off')).toBeGreaterThan(typingEvents.indexOf('send:image')) + }) + + it('Query Agent 抛异常时仍然收掉 typing', async () => { + mocks.ask.mockRejectedValueOnce(new Error('provider 挂了')) + await service.start({} as AppSettings) + await service.handleInboundMessages([inbound({ messageId: '8103' })]) + + await vi.waitFor(() => expect(typingEvents).toContain('typing-off')) + expect(typingEvents[0]).toBe('typing-on') + expect(typingEvents.indexOf('typing-off')).toBeGreaterThan(typingEvents.indexOf('send:text')) + }) + + it('最终消息发送失败时仍然收掉 typing', async () => { + await service.start({} as AppSettings) + ilinkShouldFail = true + await service.handleInboundMessages([inbound({ messageId: '8104' })]) + + await vi.waitFor(() => expect(typingEvents).toContain('typing-off')) + expect(typingEvents[0]).toBe('typing-on') + expect(typingEvents[typingEvents.length - 1]).toBe('typing-off') + }) + + it('typing 不可用时业务照常执行并回复', async () => { + typingShouldFail = true + await service.start({} as AppSettings) + await service.handleInboundMessages([inbound({ messageId: '8105' })]) + + await vi.waitFor(() => expect(sent).toHaveLength(1)) + expect(String((sent[0] as { msg: string }).msg)).toBe('这是 Query Agent 的回答') + expect(typingEvents).toEqual(['send:text']) + }) + + it('recent_list 这类快速确定性回复不触发 typing', async () => { + mocks.listRecentChat.mockReturnValue([ + { m_nsNickName: '张三', m_nsUsrName: 'wxid_a', type: 'contact' } + ]) + await service.start({} as AppSettings) + await service.handleInboundMessages([ + inbound({ messageId: '8106', items: [{ type: 1, text: '最近 5 个会话' }] }) + ]) + + await vi.waitFor(() => expect(sent).toHaveLength(1)) + expect(typingEvents).toEqual(['send:text']) + }) + + it('成员分析:确认语之后才 typing ON,结果发完才 OFF', async () => { + mocks.listContacts.mockReturnValue([ + { type: 'group', m_nsNickName: '产品交流群', m_nsUsrName: 'room@chatroom', md5: 'md5-room' } + ]) + mocks.getGroupSnapshot.mockReturnValue({ + members: [ + { + wxid: 'wxid_zhang', + groupNickname: '张三', + wechatNickname: '张三', + remark: '', + nickname: '张三' + } + ] + }) + mocks.listMessages.mockReturnValue([ + { + datetime: '2026-09-17 10:00', + content: '今天聊了新版本', + type: '普通文本', + senderId: 'wxid_zhang', + name: '张三' + } + ]) + await service.start({} as AppSettings) + await service.handleInboundMessages([ + inbound({ + messageId: '8107', + items: [{ type: 1, text: '看看产品交流群里张三今天说了什么' }] + }) + ]) + + await vi.waitFor(() => expect(typingEvents).toContain('typing-off')) + // 确认语在前,typing 在后。 + expect(typingEvents[0]).toBe('send:text') + expect(typingEvents.indexOf('typing-on')).toBeGreaterThan(0) + expect(typingEvents[typingEvents.length - 1]).toBe('typing-off') + }) + + it('清空对话记录后不再保留任何会话', async () => { + await service.start({} as AppSettings) + await service.handleInboundMessages([inbound()]) + await vi.waitFor(() => expect(service.listConversations()).toHaveLength(1)) + + service.clearConversations() + + expect(service.listConversations()).toEqual([]) + expect(service.getConversation(USER_ID)).toBeNull() + }) +}) diff --git a/tests/typecheck-baseline.json b/tests/typecheck-baseline.json new file mode 100644 index 0000000..133965f --- /dev/null +++ b/tests/typecheck-baseline.json @@ -0,0 +1,59 @@ +{ + "note": "测试文件类型检查基线:只允许下降,不允许上升。用 node scripts/typecheck-tests.cjs --update 下调。", + "total": 258, + "files": { + "tests/component/ai-model-settings.test.tsx": 2, + "tests/component/ai-search-cache-consent.test.tsx": 3, + "tests/component/ai-search-knowledge-status-hook.test.tsx": 1, + "tests/component/ai-search-query-agent.test.tsx": 18, + "tests/component/ai-search-run-hook.test.tsx": 1, + "tests/component/ai-search-workspace-regression.test.tsx": 4, + "tests/component/database-connection.test.tsx": 2, + "tests/component/export-voice-transcript.test.tsx": 1, + "tests/component/export-workspace.test.tsx": 8, + "tests/component/group-exit-monitor-workspace.test.tsx": 11, + "tests/component/image-bubble.test.tsx": 1, + "tests/component/image-text-index-card.test.tsx": 5, + "tests/component/local-image-text-recognition.test.tsx": 1, + "tests/component/personal-wechat-send-dialog.test.tsx": 1, + "tests/component/report-features.test.tsx": 4, + "tests/component/support/image-text-index-api.ts": 2, + "tests/component/text-to-speech-page.test.tsx": 1, + "tests/component/voice-player.test.tsx": 1, + "tests/component/voice-recognition-settings.test.tsx": 1, + "tests/e2e/app.spec.ts": 3, + "tests/e2e/report-template-market.spec.ts": 1, + "tests/e2e/report-template-render.spec.ts": 1, + "tests/integration/export-media-flow.test.ts": 3, + "tests/integration/image-text-index-checkpoint-and-coverage.test.ts": 3, + "tests/integration/image-text-index-concurrency.test.ts": 4, + "tests/integration/image-text-index-coverage-honesty.test.ts": 1, + "tests/integration/image-text-index-image-query.test.ts": 1, + "tests/integration/image-text-index-incremental-repair.test.ts": 4, + "tests/integration/image-text-index-knowledge-invalidation.test.ts": 3, + "tests/integration/image-text-index-progress-notify.test.ts": 1, + "tests/integration/image-text-index-store-cache.test.ts": 1, + "tests/integration/image-text-index-synthetic-e2e.test.ts": 22, + "tests/integration/query-agent-image-ocr-text.test.ts": 17, + "tests/integration/query-agent-self-direction.test.ts": 7, + "tests/integration/scheduled-report-api.test.ts": 3, + "tests/unit/agent-hub-routing.test.ts": 1, + "tests/unit/ai-search-pipeline-service.test.ts": 1, + "tests/unit/ai-search-provider-consent.test.ts": 5, + "tests/unit/ai-search-workspace-pure-functions.test.ts": 13, + "tests/unit/ask-wechat-service.test.ts": 8, + "tests/unit/group-exit-monitor-service.test.ts": 1, + "tests/unit/group-report.test.ts": 1, + "tests/unit/http-media-service.test.ts": 2, + "tests/unit/knowledge-freshness.test.ts": 1, + "tests/unit/local-query-api-service.test.ts": 51, + "tests/unit/message-state.test.ts": 1, + "tests/unit/query-agent-image-ocr-coverage.test.ts": 1, + "tests/unit/query-agent-service.test.ts": 4, + "tests/unit/report-template-market.test.ts": 1, + "tests/unit/scheduled-report-api-service.test.ts": 2, + "tests/unit/scheduled-report-service.test.ts": 18, + "tests/unit/system-ocr-service.test.ts": 3, + "tests/unit/voice-pipeline.test.ts": 1 + } +} diff --git a/tests/unit/agent-hub-conversation-store.test.ts b/tests/unit/agent-hub-conversation-store.test.ts new file mode 100644 index 0000000..1d9cfc4 --- /dev/null +++ b/tests/unit/agent-hub-conversation-store.test.ts @@ -0,0 +1,178 @@ +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { AgentHubConversationStore } from '../../src/main/services/agent-hub-conversation-store' + +describe('AgentHubConversationStore', () => { + const roots: string[] = [] + + afterEach(() => { + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) + }) + + function createStore(options: { maxMessages?: number; maxConversations?: number } = {}): { + store: AgentHubConversationStore + filePath: string + } { + const root = mkdtempSync(join(tmpdir(), 'tracememo-conv-')) + roots.push(root) + const filePath = join(root, 'agent-hub', 'conversations.json') + let sequence = 0 + const store = new AgentHubConversationStore({ + filePath: () => filePath, + createId: () => `m-${(sequence += 1)}`, + now: () => 1_700_000_000_000, + ...(options.maxMessages !== undefined + ? { maxMessagesPerConversation: options.maxMessages } + : {}), + ...(options.maxConversations !== undefined + ? { maxConversations: options.maxConversations } + : {}) + }) + return { store, filePath } + } + + it('记录一次收发并给出会话摘要', () => { + const { store, filePath } = createStore() + + store.append({ userId: 'user-a', direction: 'in', kind: 'text', text: '你好' }) + const result = store.append({ + userId: 'user-a', + direction: 'out', + kind: 'text', + text: '这是回答', + status: 'sent' + }) + + expect(result?.summary).toMatchObject({ + userId: 'user-a', + messageCount: 2, + lastPreview: '这是回答', + lastDirection: 'out' + }) + expect(result?.message).toMatchObject({ direction: 'out', status: 'sent', text: '这是回答' }) + + const conversation = store.get('user-a') + expect(conversation?.messages.map((item) => item.direction)).toEqual(['in', 'out']) + expect(statSync(filePath).mode & 0o777).toBe(0o600) + }) + + it('保留发送失败的记录与错误码,便于在界面上对账', () => { + const { store } = createStore() + store.append({ + userId: 'user-a', + direction: 'out', + kind: 'text', + text: '发不出去的内容', + status: 'failed', + errorCode: 'STALE_TOKEN' + }) + + const message = store.get('user-a')?.messages[0] + expect(message).toMatchObject({ status: 'failed', errorCode: 'STALE_TOKEN' }) + }) + + it('媒体消息只保留类型占位,不写入路径细节以外的内容', () => { + const { store } = createStore() + const result = store.append({ + userId: 'user-a', + direction: 'out', + kind: 'image', + text: '群日报.png', + status: 'sent' + }) + + expect(result?.summary.lastPreview).toBe('群日报.png') + // 空文本的媒体消息在列表里退化为中文占位。 + store.append({ userId: 'user-a', direction: 'in', kind: 'image', text: '' }) + expect(store.listSummaries()[0].lastPreview).toBe('[图片]') + }) + + it('单个会话超出上限时丢弃最旧的消息', () => { + const { store } = createStore({ maxMessages: 3 }) + for (let index = 0; index < 5; index += 1) { + store.append({ userId: 'user-a', direction: 'in', kind: 'text', text: `第 ${index} 条` }) + } + + const messages = store.get('user-a')?.messages ?? [] + expect(messages).toHaveLength(3) + expect(messages.map((item) => item.text)).toEqual(['第 2 条', '第 3 条', '第 4 条']) + }) + + it('会话数超出上限时只保留最近活跃的', () => { + const { store } = createStore({ maxConversations: 2 }) + store.append({ + userId: 'old', + direction: 'in', + kind: 'text', + text: '旧会话', + createdAt: 1_000 + }) + store.append({ + userId: 'mid', + direction: 'in', + kind: 'text', + text: '中间会话', + createdAt: 2_000 + }) + store.append({ + userId: 'new', + direction: 'in', + kind: 'text', + text: '新会话', + createdAt: 3_000 + }) + + const summaries = store.listSummaries() + expect(summaries.map((item) => item.userId)).toEqual(['new', 'mid']) + expect(store.get('old')).toBeNull() + }) + + it('会话列表按最近活跃排序,并带上条数', () => { + const { store } = createStore() + store.append({ userId: 'a', direction: 'in', kind: 'text', text: '1', createdAt: 1_000 }) + store.append({ userId: 'b', direction: 'in', kind: 'text', text: '2', createdAt: 2_000 }) + store.append({ userId: 'a', direction: 'in', kind: 'text', text: '3', createdAt: 3_000 }) + + expect(store.listSummaries().map((item) => [item.userId, item.messageCount])).toEqual([ + ['a', 2], + ['b', 1] + ]) + }) + + it('get 返回副本,外部改动不会污染缓存', () => { + const { store } = createStore() + store.append({ userId: 'a', direction: 'in', kind: 'text', text: '原始' }) + + const first = store.get('a') + first!.messages[0].text = '被改过' + expect(store.get('a')?.messages[0].text).toBe('原始') + }) + + it('清空后文件与内存都为空', () => { + const { store, filePath } = createStore() + store.append({ userId: 'a', direction: 'in', kind: 'text', text: 'x' }) + + store.clear() + + expect(store.listSummaries()).toEqual([]) + expect(store.get('a')).toBeNull() + expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual({ version: 1, conversations: [] }) + }) + + it('忽略空会话标识,不产生垃圾条目', () => { + const { store } = createStore() + expect(store.append({ userId: ' ', direction: 'in', kind: 'text', text: 'x' })).toBeNull() + expect(store.listSummaries()).toEqual([]) + }) + + it('文件损坏时按空库处理,不抛错', () => { + const { store, filePath } = createStore() + store.append({ userId: 'a', direction: 'in', kind: 'text', text: 'x' }) + rmSync(filePath) + // 重新实例化,读一个不存在的文件 + const fresh = new AgentHubConversationStore({ filePath: () => filePath }) + expect(fresh.listSummaries()).toEqual([]) + }) +}) diff --git a/tests/unit/runtime-packaging.test.ts b/tests/unit/runtime-packaging.test.ts index 043fe63..e4d0cee 100644 --- a/tests/unit/runtime-packaging.test.ts +++ b/tests/unit/runtime-packaging.test.ts @@ -105,7 +105,6 @@ describe('production runtime packaging', () => { it('uses a Windows x64-only resource set', () => { const config = readFileSync(resolve(__dirname, '../../electron-builder.win.yml'), 'utf8') - expect(config).toContain('connectors/wechat/win32-x64/**') expect(config).toContain('key/win32/x64/**') expect(config).toContain('wcdb/win32/x64/**') expect(config).toContain('electronLanguages:') diff --git a/tests/unit/wechat-ilink-account-store.test.ts b/tests/unit/wechat-ilink-account-store.test.ts new file mode 100644 index 0000000..8e95c74 --- /dev/null +++ b/tests/unit/wechat-ilink-account-store.test.ts @@ -0,0 +1,176 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + accountsDirectory, + clearCursor, + legacyAccountsDirectory, + loadAllCredentials, + loadContextToken, + loadCursor, + normalizeAccountId, + resolveAccountDirectory, + saveContextToken, + saveCredentials, + saveCursor +} from '../../src/main/services/wechat-ilink/account-store' +import type { ILinkCredentials } from '../../src/main/services/wechat-ilink/types' + +const credentials: ILinkCredentials = { + bot_token: 'bot-token-value', + ilink_bot_id: 'bot_abc@im.bot', + baseurl: 'https://ilinkai.weixin.qq.com', + ilink_user_id: 'user_42' +} + +describe('wechat-ilink account store', () => { + const homes: string[] = [] + + afterEach(() => { + while (homes.length) rmSync(homes.pop()!, { recursive: true, force: true }) + }) + + function createHome(): string { + const home = mkdtempSync(join(tmpdir(), 'tracememo-ilink-home-')) + homes.push(home) + return home + } + + it('把账号 ID 规范成文件安全的形式', () => { + expect(normalizeAccountId('bot_abc@im.bot')).toBe('bot_abc-im-bot') + expect(normalizeAccountId('a:b.c@d')).toBe('a-b-c-d') + expect(normalizeAccountId('')).toBe('') + }) + + it('凭据写入 ~/.tracememo 且文件权限为 0600', () => { + const home = createHome() + const path = saveCredentials(credentials, () => home) + + expect(path).toBe( + join(home, '.tracememo', 'wechat-connector', 'accounts', 'bot_abc-im-bot.json') + ) + expect(statSync(path).mode & 0o777).toBe(0o600) + expect(loadAllCredentials(() => home)).toEqual([credentials]) + }) + + it('当前目录为空时回退读取 ~/.wechatexplorer 历史凭据,且不删除旧文件', () => { + const home = createHome() + const legacyDirectory = legacyAccountsDirectory(() => home) + mkdirSync(legacyDirectory, { recursive: true }) + writeFileSync(join(legacyDirectory, 'legacy-bot.json'), JSON.stringify(credentials), 'utf8') + + expect(loadAllCredentials(() => home)).toEqual([credentials]) + expect(resolveAccountDirectory('legacy-bot', () => home)).toEqual({ + directory: legacyDirectory, + legacy: true + }) + expect(existsSync(join(legacyDirectory, 'legacy-bot.json'))).toBe(true) + }) + + it('游标写到 loadCursor 实际读取的目录,历史目录不会让游标失效', () => { + const home = createHome() + const legacyDirectory = legacyAccountsDirectory(() => home) + mkdirSync(legacyDirectory, { recursive: true }) + writeFileSync(join(legacyDirectory, 'legacy-bot.json'), JSON.stringify(credentials), 'utf8') + + saveCursor('legacy-bot', 'buf-1', () => home) + + expect(existsSync(join(legacyDirectory, 'legacy-bot.sync.json'))).toBe(true) + expect( + existsSync( + join( + accountsDirectory(() => home), + 'legacy-bot.sync.json' + ) + ) + ).toBe(false) + expect(loadCursor('legacy-bot', () => home)).toBe('buf-1') + }) + + it('保存新凭据时清理其它账号,但保留同账号的游标与上下文文件', () => { + const home = createHome() + saveCredentials(credentials, () => home) + const accountId = normalizeAccountId(credentials.ilink_bot_id) + saveCursor(accountId, 'buf-keep', () => home) + saveContextToken(accountId, 'user@im.wechat', 'ctx-keep', () => home) + + const otherDirectory = accountsDirectory(() => home) + writeFileSync(join(otherDirectory, 'stale-bot.json'), JSON.stringify(credentials), 'utf8') + + saveCredentials(credentials, () => home) + + expect(existsSync(join(otherDirectory, 'stale-bot.json'))).toBe(false) + expect(existsSync(join(otherDirectory, `${accountId}.sync.json`))).toBe(true) + expect(existsSync(join(otherDirectory, `${accountId}.context.json`))).toBe(true) + expect(loadCursor(accountId, () => home)).toBe('buf-keep') + }) + + it('游标按账号隔离', () => { + const home = createHome() + saveCredentials(credentials, () => home) + const accountA = normalizeAccountId(credentials.ilink_bot_id) + saveCredentials({ ...credentials, ilink_bot_id: 'bot_other' }, () => home) + + // saveCredentials 只会保留最后一个账号,这里直接验证读取隔离。 + saveCursor(accountA, 'buf-a', () => home) + saveCursor('bot_other', 'buf-b', () => home) + + expect(loadCursor(accountA, () => home)).toBe('buf-a') + expect(loadCursor('bot_other', () => home)).toBe('buf-b') + clearCursor(accountA, () => home) + expect(loadCursor(accountA, () => home)).toBe('') + expect(loadCursor('bot_other', () => home)).toBe('buf-b') + }) + + it('按账号 + 用户保存 context_token,文件权限 0600,只保留最近 500 条', () => { + const home = createHome() + saveCredentials(credentials, () => home) + const accountId = normalizeAccountId(credentials.ilink_bot_id) + + for (let index = 0; index < 520; index += 1) { + saveContextToken( + accountId, + `user-${index}`, + `ctx-${index}`, + () => home, + () => index + ) + } + + const path = join( + accountsDirectory(() => home), + `${accountId}.context.json` + ) + expect(statSync(path).mode & 0o777).toBe(0o600) + + const stored = JSON.parse(readFileSync(path, 'utf8')) as { + tokens: Record + } + expect(Object.keys(stored.tokens)).toHaveLength(500) + // 最新的保留,最旧的被裁掉。 + expect(stored.tokens['user-519'].context_token).toBe('ctx-519') + expect(stored.tokens['user-0']).toBeUndefined() + + expect(loadContextToken(accountId, 'user-519', () => home)).toBe('ctx-519') + expect(loadContextToken(accountId, 'user-missing', () => home)).toBeUndefined() + }) + + it('忽略缺少 bot_token 的损坏凭据文件', () => { + const home = createHome() + const directory = accountsDirectory(() => home) + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'broken.json'), '{ not json', 'utf8') + writeFileSync(join(directory, 'empty.json'), JSON.stringify({ ilink_bot_id: 'x' }), 'utf8') + + expect(loadAllCredentials(() => home)).toEqual([]) + }) +}) diff --git a/tests/unit/wechat-ilink-auth.test.ts b/tests/unit/wechat-ilink-auth.test.ts new file mode 100644 index 0000000..dae06e6 --- /dev/null +++ b/tests/unit/wechat-ilink-auth.test.ts @@ -0,0 +1,333 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runQrLogin } from '../../src/main/services/wechat-ilink/auth' +import { accountsDirectory } from '../../src/main/services/wechat-ilink/account-store' +import type { ILinkCredentials, WechatLoginEvent } from '../../src/main/services/wechat-ilink/types' + +interface RecordedCall { + url: string + method: string + headers: Record + body?: string +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' } + }) +} + +const qrResponse = { + qrcode: 'qr-secret-token', + qrcode_img_content: 'https://liteapp.weixin.qq.com/q/demo' +} + +const credentialsBody = { + status: 'confirmed', + bot_token: 'bot-token-value', + ilink_bot_id: 'bot_abc@im.bot', + ilink_user_id: 'user_42', + baseurl: 'https://ilinkai.weixin.qq.com' +} + +/** 防止用例写错时无限轮询:超过硬上限直接中止登录,让断言失败而不是挂死进程。 */ +const HARD_CALL_LIMIT = 40 + +function createScriptedFetch( + statusQueue: unknown[], + onExhausted?: () => void +): { fetchImpl: typeof fetch; calls: RecordedCall[] } { + const calls: RecordedCall[] = [] + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const headers: Record = {} + for (const [key, value] of Object.entries((init?.headers ?? {}) as Record)) { + headers[key.toLowerCase()] = String(value) + } + calls.push({ + url, + method: String(init?.method ?? 'GET'), + headers, + ...(typeof init?.body === 'string' ? { body: init.body } : {}) + }) + if (calls.length > HARD_CALL_LIMIT) { + onExhausted?.() + throw new Error('scripted fetch hard call limit reached') + } + + if (url.includes('/ilink/bot/get_bot_qrcode')) return jsonResponse(qrResponse) + if (url.includes('/ilink/bot/get_qrcode_status')) { + const next = statusQueue.shift() ?? { status: 'wait' } + if (next instanceof Error) throw next + return jsonResponse(next) + } + return jsonResponse({}, 404) + }) as unknown as typeof fetch + return { fetchImpl, calls } +} + +describe('wechat-ilink QR 登录', () => { + const homes: string[] = [] + + afterEach(() => { + while (homes.length) rmSync(homes.pop()!, { recursive: true, force: true }) + }) + + function createHome(): string { + const home = mkdtempSync(join(tmpdir(), 'tracememo-ilink-login-')) + homes.push(home) + return home + } + + function createLogin(options: { + home: string + statusQueue: unknown[] + maxQrRefreshes?: number + verifyCodeProvider?: () => Promise + }): { + calls: RecordedCall[] + events: WechatLoginEvent[] + run: () => Promise + } { + const controller = new AbortController() + const { fetchImpl, calls } = createScriptedFetch(options.statusQueue, () => controller.abort()) + const events: WechatLoginEvent[] = [] + return { + calls, + events, + run: () => + runQrLogin({ + home: () => options.home, + fetchImpl, + signal: controller.signal, + sleep: async () => undefined, + qrEncoder: async (content) => + `data:image/png;base64,${Buffer.from(content).toString('base64')}`, + ...(options.maxQrRefreshes !== undefined + ? { maxQrRefreshes: options.maxQrRefreshes } + : {}), + ...(options.verifyCodeProvider ? { verifyCodeProvider: options.verifyCodeProvider } : {}), + onEvent: (event) => events.push(event) + }) + } + } + + it('完成 wait → scaned → confirmed 流程并落盘凭据', async () => { + const home = createHome() + const harness = createLogin({ + home, + statusQueue: [{ status: 'wait' }, { status: 'scaned' }, credentialsBody] + }) + + const credentials = await harness.run() + + expect(credentials).toMatchObject({ + bot_token: 'bot-token-value', + ilink_bot_id: 'bot_abc@im.bot', + ilink_user_id: 'user_42' + }) + expect(harness.events.map((event) => event.status)).toEqual([ + 'qrcode', + 'wait', + 'scaned', + 'confirmed', + 'active' + ]) + const qrEvent = harness.events[0] + expect( + qrEvent.status === 'qrcode' && qrEvent.qrCodeDataUrl.startsWith('data:image/png;base64,') + ).toBe(true) + + const stored = JSON.parse( + readFileSync( + join( + accountsDirectory(() => home), + 'bot_abc-im-bot.json' + ), + 'utf8' + ) + ) as { bot_token: string } + expect(stored.bot_token).toBe('bot-token-value') + }) + + it('用 POST + local_token_list 获取二维码,且登录前不发送 Authorization', async () => { + const home = createHome() + const harness = createLogin({ home, statusQueue: [credentialsBody] }) + + await harness.run() + + const qrCall = harness.calls[0] + expect(qrCall.url).toBe('https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3') + expect(qrCall.method).toBe('POST') + expect(JSON.parse(qrCall.body!)).toEqual({ local_token_list: [] }) + expect(qrCall.headers['ilink-app-id']).toBe('bot') + expect(qrCall.headers['ilink-app-clientversion']).toBe('132102') + expect(qrCall.headers.authorization).toBeUndefined() + + const statusCall = harness.calls.find((call) => call.url.includes('get_qrcode_status'))! + expect(statusCall.headers.authorization).toBeUndefined() + expect(statusCall.headers['x-wechat-uin']).toBeUndefined() + }) + + it('已登录账号的 token 会进入 local_token_list', async () => { + const home = createHome() + const directory = accountsDirectory(() => home) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(directory, 'existing.json'), + JSON.stringify({ + bot_token: 'existing-token', + ilink_bot_id: 'bot_existing', + baseurl: 'https://ilinkai.weixin.qq.com', + ilink_user_id: 'user_1' + }), + 'utf8' + ) + const harness = createLogin({ home, statusQueue: [credentialsBody] }) + + await harness.run() + + expect(JSON.parse(harness.calls[0].body!)).toEqual({ local_token_list: ['existing-token'] }) + }) + + it('scaned_but_redirect 后改用 redirect_host 继续轮询状态', async () => { + const home = createHome() + const harness = createLogin({ + home, + statusQueue: [ + { status: 'scaned_but_redirect', redirect_host: 'idc2.weixin.qq.com' }, + credentialsBody + ] + }) + + await harness.run() + + const statusCalls = harness.calls.filter((call) => call.url.includes('get_qrcode_status')) + expect(statusCalls[0].url.startsWith('https://ilinkai.weixin.qq.com/')).toBe(true) + expect(statusCalls[1].url.startsWith('https://idc2.weixin.qq.com/')).toBe(true) + expect(statusCalls[1].url).toContain('qrcode=qr-secret-token') + }) + + it('need_verifycode 时把配对码填进下一次状态请求', async () => { + const home = createHome() + const harness = createLogin({ + home, + statusQueue: [{ status: 'need_verifycode' }, credentialsBody], + verifyCodeProvider: async () => ' 246810 ' + }) + + await harness.run() + + const statusCalls = harness.calls.filter((call) => call.url.includes('get_qrcode_status')) + expect(statusCalls[0].url).not.toContain('verify_code') + expect(statusCalls[1].url).toContain('verify_code=246810') + expect(harness.events.some((event) => event.status === 'need_verifycode')).toBe(true) + }) + + it('没有配对码输入通道时刷新二维码,超过上限后明确失败', async () => { + const home = createHome() + const harness = createLogin({ + home, + statusQueue: [{ status: 'need_verifycode' }, { status: 'need_verifycode' }], + maxQrRefreshes: 1 + }) + + await expect(harness.run()).rejects.toThrow(/二维码多次刷新后仍未完成登录/) + expect(harness.calls.filter((call) => call.url.includes('get_bot_qrcode'))).toHaveLength(2) + }) + + it('verify_code_blocked 清除配对码并刷新二维码', async () => { + const home = createHome() + const harness = createLogin({ + home, + statusQueue: [ + { status: 'need_verifycode' }, + { status: 'verify_code_blocked' }, + { status: 'need_verifycode' }, + { status: 'verify_code_blocked' } + ], + maxQrRefreshes: 1, + verifyCodeProvider: async () => '123456' + }) + + await expect(harness.run()).rejects.toThrow(/二维码多次刷新后仍未完成登录/) + + const statusCalls = harness.calls.filter((call) => call.url.includes('get_qrcode_status')) + expect(statusCalls[1].url).toContain('verify_code=123456') + // 被限制后重新扫码,第二次请求不再携带旧配对码。 + expect(statusCalls[2].url).not.toContain('verify_code=') + expect(harness.events.some((event) => event.status === 'verify_code_blocked')).toBe(true) + }) + + it('二维码过期时自动刷新', async () => { + const home = createHome() + const harness = createLogin({ + home, + statusQueue: [{ status: 'expired' }, credentialsBody], + maxQrRefreshes: 2 + }) + + await harness.run() + + expect(harness.calls.filter((call) => call.url.includes('get_bot_qrcode'))).toHaveLength(2) + expect(harness.events.filter((event) => event.status === 'qrcode')).toHaveLength(2) + expect(harness.events.some((event) => event.status === 'expired')).toBe(true) + }) + + it('binded_redirect 在本地没有可用凭据时不能当作登录成功', async () => { + const home = createHome() + const harness = createLogin({ + home, + statusQueue: [{ status: 'binded_redirect' }], + maxQrRefreshes: 0 + }) + + await expect(harness.run()).rejects.toThrow(/二维码多次刷新后仍未完成登录/) + }) + + it('binded_redirect 在本地已有凭据时直接复用,不重新扫码', async () => { + const home = createHome() + const directory = accountsDirectory(() => home) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(directory, 'bot_existing.json'), + JSON.stringify({ + bot_token: 'existing-token', + ilink_bot_id: 'bot_existing', + baseurl: 'https://ilinkai.weixin.qq.com', + ilink_user_id: 'user_1' + }), + 'utf8' + ) + + const harness = createLogin({ home, statusQueue: [{ status: 'binded_redirect' }] }) + const credentials = await harness.run() + + expect(credentials.bot_token).toBe('existing-token') + expect(harness.events.map((event) => event.status)).toEqual(['qrcode', 'confirmed', 'active']) + }) + + it('confirmed 但缺少 ilink_bot_id 时明确报错而不是写入半截凭据', async () => { + const home = createHome() + const harness = createLogin({ + home, + statusQueue: [{ status: 'confirmed', bot_token: 'x' }] + }) + + await expect(harness.run()).rejects.toThrow(/ilink_bot_id/) + }) + + it('状态轮询网络错误按 wait 处理并继续轮询', async () => { + const home = createHome() + const harness = createLogin({ + home, + statusQueue: [new Error('ETIMEDOUT'), { status: 'scaned' }, credentialsBody] + }) + + const credentials = await harness.run() + expect(credentials.ilink_bot_id).toBe('bot_abc@im.bot') + }) +}) diff --git a/tests/unit/wechat-ilink-context-token.test.ts b/tests/unit/wechat-ilink-context-token.test.ts new file mode 100644 index 0000000..a9f831e --- /dev/null +++ b/tests/unit/wechat-ilink-context-token.test.ts @@ -0,0 +1,252 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WechatConnectorService } from '../../src/main/services/wechat-ilink' +import { + accountsDirectory, + normalizeAccountId, + saveCredentials +} from '../../src/main/services/wechat-ilink/account-store' +import type { WechatInboundMessage } from '../../src/main/services/wechat-ilink/types' + +const ACCOUNT_ID = 'bot_ctx@im.bot' +const NORMALIZED_ACCOUNT_ID = normalizeAccountId(ACCOUNT_ID) + +interface RecordedRequest { + path: string + body: Record +} + +describe('wechat-ilink 连接器:context_token 全链路', () => { + const homes: string[] = [] + + afterEach(() => { + while (homes.length) rmSync(homes.pop()!, { recursive: true, force: true }) + }) + + function createHome(): string { + const home = mkdtempSync(join(tmpdir(), 'tracememo-ilink-ctx-')) + homes.push(home) + saveCredentials( + { + bot_token: 'bot-token-value', + ilink_bot_id: ACCOUNT_ID, + baseurl: 'https://ilinkai.weixin.qq.com', + ilink_user_id: 'user_42' + }, + () => home + ) + return home + } + + function createHarness(options: { inboundContextToken?: string; sendRet?: number } = {}): { + home: string + service: WechatConnectorService + requests: RecordedRequest[] + received: WechatInboundMessage[] + fetchImpl: typeof fetch + } { + const home = createHome() + const requests: RecordedRequest[] = [] + const received: WechatInboundMessage[] = [] + let getUpdatesCalls = 0 + + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)) + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : {} + requests.push({ path: url.pathname, body }) + + const json = (payload: unknown): Response => + new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + + if (url.pathname.endsWith('/getupdates')) { + getUpdatesCalls += 1 + if (getUpdatesCalls === 1) { + return json({ + ret: 0, + errcode: 0, + msgs: [ + { + message_id: 9001, + from_user_id: 'user@im.wechat', + message_type: 1, + item_list: [{ type: 1, text_item: { text: '最近聊了什么' } }], + context_token: options.inboundContextToken ?? 'ctx-from-inbound' + } + ], + get_updates_buf: 'buf-after-first-batch', + longpolling_timeout_ms: 35_000 + }) + } + // 让出宏任务,避免测试期间的紧密轮询饿死定时器。 + await new Promise((resolve) => setTimeout(resolve, 2)) + return json({ ret: 0, msgs: [], get_updates_buf: 'buf-after-first-batch' }) + } + + if (url.pathname.endsWith('/sendmessage')) { + return json({ ret: options.sendRet ?? 0, errmsg: options.sendRet ? 'send failed' : '' }) + } + return json({ ret: 0 }) + }) as unknown as typeof fetch + + const service = new WechatConnectorService({ + home: () => home, + fetchImpl, + sleep: async () => undefined + }) + service.setHost({ + onMessages: async (messages) => { + received.push(...messages) + } + }) + return { home, service, requests, received, fetchImpl } + } + + it('入站 context_token 原样出现在回复的 sendmessage 请求里', async () => { + const harness = createHarness({ inboundContextToken: 'ctx-from-inbound' }) + + await harness.service.start(ACCOUNT_ID) + await vi.waitFor(() => expect(harness.received).toHaveLength(1)) + expect(harness.received[0].contextToken).toBe('ctx-from-inbound') + + await harness.service.sendText({ + to: 'user@im.wechat', + text: '这是回答', + contextToken: harness.received[0].contextToken + }) + await harness.service.stop() + + const send = harness.requests.filter((request) => request.path.endsWith('/sendmessage')) + expect(send).toHaveLength(1) + const msg = send[0].body.msg as Record + expect(msg.context_token).toBe('ctx-from-inbound') + expect(msg.to_user_id).toBe('user@im.wechat') + expect(msg.message_type).toBe(2) + expect(msg.message_state).toBe(2) + // 官方实现:Bot 外发 from_user_id 传空字符串。 + expect(msg.from_user_id).toBe('') + expect((send[0].body.base_info as Record).channel_version).toBe('2.4.6') + expect((msg.item_list as Array>)[0]).toEqual({ + type: 1, + text_item: { text: '这是回答' } + }) + }) + + it('主动发送时回退到该会话最近一次有效 context_token', async () => { + const harness = createHarness({ inboundContextToken: 'ctx-stored' }) + + await harness.service.start(ACCOUNT_ID) + await vi.waitFor(() => expect(harness.received).toHaveLength(1)) + + // 定时日报这类主动发送不会携带入站 token。 + await harness.service.sendText({ to: 'user@im.wechat', text: '日报已生成' }) + await harness.service.stop() + + const send = harness.requests.filter((request) => request.path.endsWith('/sendmessage')) + expect(send[0].body.msg).toMatchObject({ context_token: 'ctx-stored' }) + }) + + it('没有入站记录的会话主动发送时 context_token 为空字符串而不是错误 token', async () => { + const harness = createHarness() + + await harness.service.start(ACCOUNT_ID) + await vi.waitFor(() => expect(harness.received).toHaveLength(1)) + await harness.service.sendText({ to: 'someone-else@im.wechat', text: 'hi' }) + await harness.service.stop() + + const send = harness.requests.filter((request) => request.path.endsWith('/sendmessage')) + expect(send[0].body.msg).toMatchObject({ context_token: '' }) + }) + + it('getupdates 携带持久化游标,并在消息被接收后推进游标', async () => { + const harness = createHarness() + + await harness.service.start(ACCOUNT_ID) + await vi.waitFor(() => expect(harness.received).toHaveLength(1)) + await harness.service.stop() + + const getUpdates = harness.requests.filter((request) => request.path.endsWith('/getupdates')) + expect(getUpdates[0].body.get_updates_buf).toBe('') + + const cursorFile = join( + accountsDirectory(() => harness.home), + `${NORMALIZED_ACCOUNT_ID}.sync.json` + ) + const stored = JSON.parse(readFileSync(cursorFile, 'utf8')) as { get_updates_buf: string } + expect(stored.get_updates_buf).toBe('buf-after-first-batch') + }) + + it('session 失效时进入 stale_token 状态并停止轮询', async () => { + const home = createHome() + const phases: string[] = [] + const fetchImpl = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)) + if (url.pathname.endsWith('/getupdates')) { + return new Response( + JSON.stringify({ ret: 0, errcode: -14, msgs: [], get_updates_buf: '' }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ) + } + return new Response(JSON.stringify({ ret: 0 }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + }) as unknown as typeof fetch + + const service = new WechatConnectorService({ home: () => home, fetchImpl }) + service.setHost({ + onMessages: async () => undefined, + onPhaseChange: (phase) => phases.push(phase) + }) + await service.start(ACCOUNT_ID) + await vi.waitFor(() => expect(service.getPhase()).toBe('stale_token')) + + expect(phases).toContain('stale_token') + // 会话失效后发送必须被明确拒绝,而不是静默失败。 + await expect(service.sendText({ to: 'user@im.wechat', text: 'hi' })).rejects.toThrow( + /登录凭证已失效/ + ) + }) + + it('sendmessage ret 非 0 时抛错,绝不当成发送成功', async () => { + const harness = createHarness({ sendRet: 40001 }) + await harness.service.start(ACCOUNT_ID) + await vi.waitFor(() => expect(harness.received).toHaveLength(1)) + + await expect(harness.service.sendText({ to: 'user@im.wechat', text: 'hi' })).rejects.toThrow( + /ret=40001/ + ) + + await harness.service.stop() + }) + + it('账号列表来自持久化凭据,且未启动时发送会被拒绝', async () => { + const home = createHome() + const directory = accountsDirectory(() => home) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(directory, 'another.json'), + JSON.stringify({ + bot_token: 'other', + ilink_bot_id: 'bot_other', + baseurl: 'https://ilinkai.weixin.qq.com', + ilink_user_id: 'user_9' + }), + 'utf8' + ) + const service = new WechatConnectorService({ home: () => home }) + + const accounts = service.listAccounts().map((item) => item.accountId) + expect(accounts).toContain(ACCOUNT_ID) + expect(accounts).toContain('bot_other') + + await expect(service.sendText({ to: 'user@im.wechat', text: 'hi' })).rejects.toThrow(/尚未启动/) + }) +}) diff --git a/tests/unit/wechat-ilink-poller.test.ts b/tests/unit/wechat-ilink-poller.test.ts new file mode 100644 index 0000000..f211b50 --- /dev/null +++ b/tests/unit/wechat-ilink-poller.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest' +import { ILinkPoller } from '../../src/main/services/wechat-ilink/poller' +import { ILinkError } from '../../src/main/services/wechat-ilink/errors' +import type { + ILinkGetUpdatesResponse, + ILinkWeixinMessage, + WechatInboundMessage +} from '../../src/main/services/wechat-ilink/types' + +interface HarnessOptions { + initialCursor?: string + onMessages?: (messages: WechatInboundMessage[]) => Promise + onStaleToken?: (message: string) => void +} + +function message(id: number): ILinkWeixinMessage { + return { + message_id: id, + from_user_id: 'user@im.wechat', + message_type: 1, + item_list: [{ type: 1, text_item: { text: `msg-${id}` } }], + context_token: `ctx-${id}` + } +} + +function createHarness( + steps: Array, + options: HarnessOptions = {} +): { + poller: ILinkPoller + savedCursors: string[] + dispatched: WechatInboundMessage[][] + logs: string[] + timeline: string[] + fetchTimeouts: number[] + fetchCount: () => number +} { + const controller = new AbortController() + const savedCursors: string[] = [] + const dispatched: WechatInboundMessage[][] = [] + const logs: string[] = [] + const timeline: string[] = [] + const fetchTimeouts: number[] = [] + let index = 0 + + const poller = new ILinkPoller({ + fetchUpdates: async (getUpdatesBuf, timeoutMs) => { + fetchTimeouts.push(timeoutMs) + if (index >= steps.length) { + // 队列耗尽后结束循环,避免无限轮询。 + controller.abort() + return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf } + } + const step = steps[index] + index += 1 + if (step instanceof Error) throw step + return step + }, + onMessages: async (messages) => { + dispatched.push(messages) + timeline.push('dispatch') + await options.onMessages?.(messages) + }, + normalize: (raw): WechatInboundMessage | undefined => { + if (!raw.from_user_id) return undefined + return { + accountId: 'acc', + fromUserId: raw.from_user_id, + messageId: String(raw.message_id ?? ''), + messageType: 1, + items: [{ type: 1, text: raw.item_list?.[0]?.text_item?.text ?? '' }], + receivedAt: 1 + } + }, + loadCursor: () => options.initialCursor ?? '', + saveCursor: (getUpdatesBuf) => { + savedCursors.push(getUpdatesBuf) + timeline.push('saveCursor') + }, + signal: controller.signal, + log: (level, text) => logs.push(`${level}:${text}`), + ...(options.onStaleToken ? { onStaleToken: options.onStaleToken } : {}), + sleep: async () => undefined, + now: () => 0 + }) + + return { + poller, + savedCursors, + dispatched, + logs, + timeline, + fetchTimeouts, + fetchCount: () => index + } +} + +describe('ILinkPoller', () => { + it('只有 dispatch 成功之后才持久化新游标', async () => { + const harness = createHarness([{ ret: 0, msgs: [message(1)], get_updates_buf: 'buf-1' }]) + + await harness.poller.run() + + expect(harness.dispatched).toHaveLength(1) + expect(harness.savedCursors).toEqual(['buf-1']) + // 顺序固定:先投递,再推进游标。反过来就会出现"游标已推进、消息还没处理"的静默丢消息。 + expect(harness.timeline).toEqual(['dispatch', 'saveCursor']) + }) + + it('dispatch 失败时不推进游标,让服务端重投', async () => { + let attempts = 0 + const harness = createHarness( + [ + { ret: 0, msgs: [message(7)], get_updates_buf: 'buf-1' }, + { ret: 0, msgs: [message(7)], get_updates_buf: 'buf-1' } + ], + { + onMessages: async () => { + attempts += 1 + if (attempts === 1) throw new Error('AgentHub 暂时不可用') + } + } + ) + + await harness.poller.run() + + expect(attempts).toBe(2) + // 第一次失败不写游标;第二次成功后写一次。 + expect(harness.savedCursors).toEqual(['buf-1']) + expect(harness.logs.some((line) => line.startsWith('error:'))).toBe(true) + }) + + it('同一批消息重投时允许重复,但不能丢弃', async () => { + const harness = createHarness([ + { ret: 0, msgs: [message(1), message(2)], get_updates_buf: 'buf-9' } + ]) + + await harness.poller.run() + + expect(harness.dispatched[0].map((item) => item.messageId)).toEqual(['1', '2']) + expect(harness.savedCursors).toEqual(['buf-9']) + }) + + it('errcode=-14 视为 bot token 失效:停止轮询、不重置游标、不立即重试', async () => { + const staleMessages: string[] = [] + const harness = createHarness( + [ + { ret: 0, errcode: -14, errmsg: 'stale token', msgs: [], get_updates_buf: 'buf-ignored' }, + { ret: 0, msgs: [message(1)], get_updates_buf: 'buf-later' } + ], + { onStaleToken: (text) => staleMessages.push(text) } + ) + + await harness.poller.run() + + expect(staleMessages).toHaveLength(1) + // 第二条响应永远不会被消费:-14 之后不再无间隔重试。 + expect(harness.fetchCount()).toBe(1) + expect(harness.dispatched).toHaveLength(0) + // 游标保持不变,重新登录后仍可续上原有进度。 + expect(harness.savedCursors).toEqual([]) + expect(harness.logs.some((line) => line.includes('重新扫码登录'))).toBe(true) + }) + + it('客户端长轮询超时属于正常控制流,不推进游标也不计入失败', async () => { + const harness = createHarness([ + new ILinkError({ kind: 'timeout', message: 'request timeout' }), + { ret: 0, msgs: [], get_updates_buf: '' } + ]) + + await harness.poller.run() + + expect(harness.fetchTimeouts.length).toBeGreaterThanOrEqual(2) + expect(harness.savedCursors).toEqual([]) + expect(harness.logs.some((line) => line.includes('获取更新失败'))).toBe(false) + }) + + it('ret 非 0 时按退避重试且不推进游标', async () => { + const harness = createHarness([ + { ret: 500, errcode: 500, errmsg: 'server busy', msgs: [], get_updates_buf: 'buf-x' }, + { ret: 0, msgs: [], get_updates_buf: 'buf-y' } + ]) + + await harness.poller.run() + + expect(harness.savedCursors).toEqual(['buf-y']) + expect(harness.logs.some((line) => line.includes('服务端返回错误'))).toBe(true) + }) + + it('采用服务端建议的 longpolling_timeout_ms', async () => { + const harness = createHarness([ + { ret: 0, msgs: [], get_updates_buf: 'a', longpolling_timeout_ms: 5_000 }, + { ret: 0, msgs: [], get_updates_buf: 'b', longpolling_timeout_ms: 7_000 } + ]) + + await harness.poller.run() + + expect(harness.fetchTimeouts[0]).toBe(35_000) + expect(harness.fetchTimeouts[1]).toBe(5_000) + expect(harness.fetchTimeouts[2]).toBe(7_000) + }) + + it('从失败中恢复时补一条 info,让故障窗口在日志里有边界', async () => { + const harness = createHarness([ + new Error('ECONNRESET'), + { ret: 0, msgs: [], get_updates_buf: 'ok' } + ]) + + await harness.poller.run() + + expect(harness.logs.some((line) => line.startsWith('warn:'))).toBe(true) + expect(harness.logs.filter((line) => line.includes('已恢复'))).toHaveLength(1) + }) + + it('一直正常时不打恢复日志', async () => { + const harness = createHarness([{ ret: 0, msgs: [], get_updates_buf: 'a' }]) + + await harness.poller.run() + + expect(harness.logs.some((line) => line.includes('已恢复'))).toBe(false) + }) + + it('从持久化游标恢复,且不沿用空游标覆盖', async () => { + const harness = createHarness([{ ret: 0, msgs: [], get_updates_buf: 'buf-next' }], { + initialCursor: 'buf-restored' + }) + + expect(harness.poller.getUpdatesBuf).toBe('buf-restored') + await harness.poller.run() + expect(harness.savedCursors).toEqual(['buf-next']) + }) +}) diff --git a/tests/unit/wechat-ilink-protocol.test.ts b/tests/unit/wechat-ilink-protocol.test.ts new file mode 100644 index 0000000..1fbbf36 --- /dev/null +++ b/tests/unit/wechat-ilink-protocol.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' +import { + buildAuthorizedHeaders, + buildBaseInfo, + buildCommonHeaders, + buildQrStatusHeaders, + generateWechatUin, + sanitizeBotAgent +} from '../../src/main/services/wechat-ilink/headers' +import { + extractMarkdownImageUrls, + markdownToPlainText +} from '../../src/main/services/wechat-ilink/markdown' +import { + extractInboundText, + normalizeInboundMessage +} from '../../src/main/services/wechat-ilink/messages' + +describe('wechat-ilink headers', () => { + it('X-WECHAT-UIN 是随机 uint32 的 base64,并且每次请求都重新生成', () => { + const decoded = Buffer.from(generateWechatUin(), 'base64').toString('utf8') + expect(decoded).toMatch(/^\d+$/) + expect(Number(decoded)).toBeGreaterThanOrEqual(0) + expect(Number(decoded)).toBeLessThanOrEqual(0xffffffff) + + const first = buildAuthorizedHeaders('token')['X-WECHAT-UIN'] + const second = buildAuthorizedHeaders('token')['X-WECHAT-UIN'] + expect(first).toBeTruthy() + // 官方要求每次请求重新生成以防重放:连续两次不应相同。 + expect(second).not.toBe(first) + }) + + it('登录后的业务头包含鉴权字段与固定应用头', () => { + const headers = buildAuthorizedHeaders('bot-token') + expect(headers.AuthorizationType).toBe('ilink_bot_token') + expect(headers.Authorization).toBe('Bearer bot-token') + expect(headers['iLink-App-Id']).toBe('bot') + expect(headers['iLink-App-ClientVersion']).toBe('132102') + }) + + it('没有 token 时拒绝构造鉴权头', () => { + expect(() => buildAuthorizedHeaders('')).toThrow(/bot token/) + }) + + it('登录前后的公共头集合不同:未鉴权请求不携带 Authorization / X-WECHAT-UIN', () => { + const anonymous = { ...buildCommonHeaders(), ...buildQrStatusHeaders() } + expect(anonymous.Authorization).toBeUndefined() + expect(anonymous.AuthorizationType).toBeUndefined() + expect(anonymous['X-WECHAT-UIN']).toBeUndefined() + expect(anonymous['iLink-App-Id']).toBe('bot') + }) + + it('base_info 携带当前协议版本与合法 bot_agent', () => { + const baseInfo = buildBaseInfo() + expect(baseInfo.channel_version).toBe('2.4.6') + expect(baseInfo.bot_agent).toMatch(/^[\x21-\x7e]+$/) + }) + + it('bot_agent 清洗非法 token 并在为空时回退', () => { + expect(sanitizeBotAgent('TraceMemo/2.4.0')).toBe('TraceMemo/2.4.0') + expect(sanitizeBotAgent('TraceMemo/2.4.0 (mac)')).toBe('TraceMemo/2.4.0 (mac)') + expect(sanitizeBotAgent('中文非法/1.0')).toBe('OpenClaw') + expect(sanitizeBotAgent('')).toBe('OpenClaw') + expect(sanitizeBotAgent(undefined)).toBe('OpenClaw') + // 混合时丢弃非法 token,保留合法部分。 + expect(sanitizeBotAgent('好坏的 Agent/1.0')).toBe('Agent/1.0') + }) + + it('bot_agent 总长度受 256 字节限制', () => { + const agent = Array.from({ length: 200 }, (_, index) => `Agent${index}/1.0`).join(' ') + expect(Buffer.byteLength(sanitizeBotAgent(agent), 'utf8')).toBeLessThanOrEqual(256) + }) +}) + +describe('wechat-ilink markdown 降级', () => { + it('把常见 Markdown 降级成微信可读纯文本', () => { + const input = [ + '# 群聊总结', + '', + '**重点**:`code` 与 ~~删除线~~', + '', + '- 第一项', + '- 第二项', + '', + '> 引用', + '', + '| A | B |', + '| --- | --- |', + '| 1 | 2 |', + '', + '链接:[官网](https://example.com)', + '图片:![图](https://example.com/a.png)', + '', + '```', + 'const a = 1', + '```' + ].join('\n') + + const output = markdownToPlainText(input) + + expect(output).toContain('群聊总结') + expect(output).not.toContain('#') + expect(output).toContain('重点') + expect(output).not.toContain('**') + expect(output).toContain('code') + expect(output).not.toContain('`') + expect(output).toContain('删除线') + expect(output).not.toContain('~~') + expect(output).toContain('• 第一项') + expect(output).not.toContain('> 引用') + expect(output).toContain('A B') + expect(output).not.toContain('|') + expect(output).toContain('官网') + expect(output).not.toContain('https://example.com/a.png') + expect(output).toContain('const a = 1') + expect(output).not.toContain('```') + }) + + it('折叠多余空行并去掉首尾空白', () => { + expect(markdownToPlainText('\n\n\n内容\n\n\n')).toBe('内容') + }) + + it('提取文本中内嵌的 http(s) 图片地址', () => { + const text = '看图 ![a](https://cdn.example.com/1.png) 和 ![b](/local/2.png)' + expect(extractMarkdownImageUrls(text)).toEqual(['https://cdn.example.com/1.png']) + }) +}) + +describe('wechat-ilink 入站消息归一化', () => { + it('完整透传 context_token 与会话标识', () => { + const message = normalizeInboundMessage('bot-1', { + message_id: 12345, + seq: 3, + from_user_id: 'user@im.wechat', + message_type: 1, + item_list: [{ type: 1, text_item: { text: '你好' } }], + context_token: 'ctx-token', + session_id: 'session-1', + group_id: 'group-1' + }) + + expect(message).toMatchObject({ + accountId: 'bot-1', + fromUserId: 'user@im.wechat', + messageId: '12345', + seq: 3, + sessionId: 'session-1', + groupId: 'group-1', + contextToken: 'ctx-token' + }) + expect(message?.items).toEqual([{ type: 1, text: '你好' }]) + }) + + it('缺少 from_user_id 的消息无法投递', () => { + expect(normalizeInboundMessage('bot-1', { message_id: 1 })).toBeUndefined() + expect(normalizeInboundMessage('bot-1', { from_user_id: ' ' })).toBeUndefined() + }) + + it('语音条目保留微信侧转写文本,但不算作文本条目', () => { + const message = normalizeInboundMessage('bot-1', { + from_user_id: 'user@im.wechat', + item_list: [{ type: 3, voice_item: { text: '语音转写内容' } }] + }) + + expect(message?.items).toEqual([{ type: 3, text: '语音转写内容' }]) + // 与旧 webhook 行为一致:只有 type=1 参与文本提取。 + expect(extractInboundText(message?.items)).toBe('') + }) + + it('多条文本条目按空格拼接', () => { + expect( + extractInboundText([{ type: 1, text: ' 第一段 ' }, { type: 2 }, { type: 1, text: '第二段' }]) + ).toBe('第一段 第二段') + }) +}) diff --git a/tests/unit/wechat-ilink-typing-protocol.test.ts b/tests/unit/wechat-ilink-typing-protocol.test.ts new file mode 100644 index 0000000..0f76199 --- /dev/null +++ b/tests/unit/wechat-ilink-typing-protocol.test.ts @@ -0,0 +1,195 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { WechatConnectorService } from '../../src/main/services/wechat-ilink' +import { saveCredentials } from '../../src/main/services/wechat-ilink/account-store' +import { + ILINK_TYPING_STATUS_CANCEL, + ILINK_TYPING_STATUS_TYPING +} from '../../src/main/services/wechat-ilink/types' + +const ACCOUNT_ID = 'bot_typing@im.bot' +const PEER = 'fixture-user-openid@im.wechat' + +interface Recorded { + path: string + body: Record +} + +describe('wechat-ilink typing 协议接线', () => { + const homes: string[] = [] + + afterEach(() => { + while (homes.length) rmSync(homes.pop()!, { recursive: true, force: true }) + }) + + async function createHarness(options: { keepaliveMs?: number } = {}): Promise<{ + service: WechatConnectorService + requests: Recorded[] + logs: string[] + ticks: Array<() => void> + }> { + const home = mkdtempSync(join(tmpdir(), 'tracememo-ilink-typing-')) + homes.push(home) + saveCredentials( + { + bot_token: 'bot-token-value', + ilink_bot_id: ACCOUNT_ID, + baseurl: 'https://ilinkai.weixin.qq.com', + ilink_user_id: PEER + }, + () => home + ) + + const requests: Recorded[] = [] + const logs: string[] = [] + const ticks: Array<() => void> = [] + + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)) + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : {} + requests.push({ path: url.pathname, body }) + const json = (payload: unknown): Response => + new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + + if (url.pathname.endsWith('/getupdates')) { + await new Promise((resolve) => setTimeout(resolve, 2)) + return json({ ret: 0, msgs: [], get_updates_buf: '' }) + } + if (url.pathname.endsWith('/getconfig')) { + return json({ ret: 0, errmsg: '', typing_ticket: 'ticket-abc' }) + } + if (url.pathname.endsWith('/sendtyping')) return json({ ret: 0, errmsg: '' }) + return json({ ret: 0 }) + }) as unknown as typeof fetch + + const service = new WechatConnectorService({ + home: () => home, + fetchImpl, + sleep: async () => undefined, + ...(options.keepaliveMs !== undefined ? { typingKeepaliveMs: options.keepaliveMs } : {}), + typingSchedule: (tick) => { + ticks.push(tick) + return () => undefined + } + }) + service.setHost({ onLog: (_level, message) => logs.push(message) }) + await service.start(ACCOUNT_ID) + return { service, requests, logs, ticks } + } + + it('下发 getconfig → sendtyping(1),stop 时 sendtyping(2)', async () => { + const harness = await createHarness() + + const lease = await harness.service.beginTyping({ to: PEER, contextToken: 'ctx-1' }) + const configCalls = harness.requests.filter((r) => r.path.endsWith('/getconfig')) + expect(configCalls).toHaveLength(1) + // getconfig 需要带上对端与会话上下文。 + expect(configCalls[0].body).toMatchObject({ + ilink_user_id: PEER, + context_token: 'ctx-1' + }) + expect((configCalls[0].body.base_info as Record).channel_version).toBe('2.4.6') + + const typingCalls = harness.requests.filter((r) => r.path.endsWith('/sendtyping')) + expect(typingCalls).toHaveLength(1) + expect(typingCalls[0].body).toMatchObject({ + ilink_user_id: PEER, + typing_ticket: 'ticket-abc', + status: ILINK_TYPING_STATUS_TYPING + }) + + await lease.stop() + + const afterStop = harness.requests.filter((r) => r.path.endsWith('/sendtyping')) + expect(afterStop).toHaveLength(2) + expect(afterStop[1].body).toMatchObject({ + ilink_user_id: PEER, + typing_ticket: 'ticket-abc', + status: ILINK_TYPING_STATUS_CANCEL + }) + + await harness.service.stop() + }) + + it('typing_ticket 按对端缓存:第二次会话不再调 getconfig', async () => { + const harness = await createHarness() + + const first = await harness.service.beginTyping({ to: PEER, contextToken: 'ctx-1' }) + await first.stop() + const second = await harness.service.beginTyping({ to: PEER, contextToken: 'ctx-1' }) + await second.stop() + + expect(harness.requests.filter((r) => r.path.endsWith('/getconfig'))).toHaveLength(1) + expect(harness.requests.filter((r) => r.path.endsWith('/sendtyping'))).toHaveLength(4) + + await harness.service.stop() + }) + + it('keepalive 会周期性重发 status=1', async () => { + const harness = await createHarness() + + const lease = await harness.service.beginTyping({ to: PEER }) + expect(harness.ticks).toHaveLength(1) + + harness.ticks[0]() + harness.ticks[0]() + await new Promise((resolve) => setTimeout(resolve, 5)) + + const typingCalls = harness.requests.filter((r) => r.path.endsWith('/sendtyping')) + expect(typingCalls).toHaveLength(3) + expect(typingCalls.every((call) => call.body.status === ILINK_TYPING_STATUS_TYPING)).toBe(true) + + await lease.stop() + await harness.service.stop() + }) + + it('并发两个 lease:只有最后一个 stop 才下发 cancel', async () => { + const harness = await createHarness() + + const first = await harness.service.beginTyping({ to: PEER }) + const second = await harness.service.beginTyping({ to: PEER }) + await first.stop() + + let typingCalls = harness.requests.filter((r) => r.path.endsWith('/sendtyping')) + expect(typingCalls).toHaveLength(1) + + await second.stop() + typingCalls = harness.requests.filter((r) => r.path.endsWith('/sendtyping')) + expect(typingCalls).toHaveLength(2) + expect(typingCalls[1].body.status).toBe(ILINK_TYPING_STATUS_CANCEL) + + await harness.service.stop() + }) + + it('日志里不含 typing_ticket 与 context_token 原文', async () => { + const harness = await createHarness() + + const lease = await harness.service.beginTyping({ to: PEER, contextToken: 'ctx-SECRET' }) + await lease.stop() + await harness.service.stop() + + const joined = harness.logs.join('\n') + expect(joined).toContain('typing.start') + expect(joined).toContain('typing.stop') + expect(joined).not.toContain('ticket-abc') + expect(joined).not.toContain('ctx-SECRET') + expect(joined).not.toContain(PEER) + expect(joined).not.toContain('bot-token-value') + }) + + it('连接器 stop 后再 beginTyping 会安全降级(不发请求也不抛)', async () => { + const harness = await createHarness() + await harness.service.stop() + + const before = harness.requests.filter((r) => r.path.endsWith('/sendtyping')).length + const lease = await harness.service.beginTyping({ to: PEER }) + await lease.stop() + + expect(harness.requests.filter((r) => r.path.endsWith('/sendtyping'))).toHaveLength(before) + }) +}) diff --git a/tests/unit/wechat-ilink-typing.test.ts b/tests/unit/wechat-ilink-typing.test.ts new file mode 100644 index 0000000..0a435bb --- /dev/null +++ b/tests/unit/wechat-ilink-typing.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, it } from 'vitest' +import { TypingCoordinator } from '../../src/main/services/wechat-ilink/typing' +import { + ILINK_TYPING_KEEPALIVE_MS, + ILINK_TYPING_STATUS_CANCEL, + ILINK_TYPING_STATUS_TYPING +} from '../../src/main/services/wechat-ilink/types' + +interface HarnessOptions { + tickets?: Array + sendResults?: Array + keepaliveMs?: number +} + +function createHarness(options: HarnessOptions = {}): { + coordinator: TypingCoordinator + logs: string[] + typingCalls: Array<{ userId: string; status: number; ticket: string }> + ticks: Array<() => void> + scheduled: number[] + ticketCalls: () => number + cancelled: () => number + advance: (ms: number) => void +} { + const tickets = [...(options.tickets ?? ['ticket-1'])] + const sends = [...(options.sendResults ?? [])] + const logs: string[] = [] + const typingCalls: Array<{ userId: string; status: number; ticket: string }> = [] + let ticketCalls = 0 + const ticks: Array<() => void> = [] + const scheduled: number[] = [] + let cancelled = 0 + let now = 1_000 + + const coordinator = new TypingCoordinator({ + fetchTicket: async () => { + ticketCalls += 1 + const next = tickets.length > 1 ? tickets.shift() : tickets[0] + if (next instanceof Error) throw next + return typeof next === 'string' ? next : undefined + }, + sendTyping: async ({ ilinkUserId, ticket, status }) => { + typingCalls.push({ userId: ilinkUserId, status, ticket }) + const next = sends.length > 1 ? sends.shift() : sends[0] + if (next instanceof Error) throw next + return next ?? true + }, + log: (level, message) => logs.push(`${level}:${message}`), + now: () => now, + ...(options.keepaliveMs !== undefined ? { keepaliveMs: options.keepaliveMs } : {}), + schedule: (tick, intervalMs) => { + scheduled.push(intervalMs) + ticks.push(tick) + return () => { + cancelled += 1 + } + } + }) + + return { + coordinator, + logs, + typingCalls, + ticks, + scheduled, + ticketCalls: () => ticketCalls, + cancelled: () => cancelled, + advance: (ms: number) => { + now += ms + } + } +} + +const PEER = 'fixture-user-openid@im.wechat' + +describe('TypingCoordinator', () => { + it('正常时序:取票 → status=1 → stop 时 status=2', async () => { + const harness = createHarness() + + const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + expect(harness.ticketCalls()).toBe(1) + expect(harness.typingCalls.map((call) => call.status)).toEqual([ILINK_TYPING_STATUS_TYPING]) + expect(harness.coordinator.activePeerCount).toBe(1) + expect(harness.logs.some((line) => line.includes('typing.start'))).toBe(true) + + harness.advance(1_200) + await lease.stop() + + expect(harness.typingCalls.map((call) => call.status)).toEqual([ + ILINK_TYPING_STATUS_TYPING, + ILINK_TYPING_STATUS_CANCEL + ]) + expect(harness.coordinator.activePeerCount).toBe(0) + expect(harness.logs.some((line) => line.includes('typing.stop'))).toBe(true) + expect(harness.logs.find((line) => line.includes('typing.stop'))).toContain('duration=1200ms') + }) + + it('typing_ticket 按「账号+对端」缓存,不会每次重取', async () => { + const harness = createHarness() + + const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await first.stop() + const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await second.stop() + + expect(harness.ticketCalls()).toBe(1) + expect(harness.typingCalls).toHaveLength(4) + }) + + it('换账号或重新登录后缓存作废,会重新取票', async () => { + const harness = createHarness({ tickets: ['ticket-1', 'ticket-2'] }) + + const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await first.stop() + harness.coordinator.invalidateTickets() + const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await second.stop() + + expect(harness.ticketCalls()).toBe(2) + expect(harness.typingCalls[2].ticket).toBe('ticket-2') + }) + + it('没有 typing_ticket 时降级:不下发任何 typing,也不抛异常', async () => { + const harness = createHarness({ tickets: [undefined] }) + + const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await lease.stop() + + expect(harness.typingCalls).toHaveLength(0) + expect(harness.coordinator.activePeerCount).toBe(0) + expect(harness.logs.some((line) => line.includes('typing.ticket.missing'))).toBe(true) + }) + + it('getconfig 失败时降级:业务照常,不抛异常', async () => { + const harness = createHarness({ tickets: [new Error('getconfig 网络错误')] }) + + const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await lease.stop() + + expect(harness.typingCalls).toHaveLength(0) + expect(harness.logs.some((line) => line.includes('typing.ticket.failed'))).toBe(true) + }) + + it('sendtyping start 失败(返回 ret!=0)时不当成成功,也不抛异常', async () => { + const harness = createHarness({ sendResults: [false] }) + + const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + expect(harness.coordinator.activePeerCount).toBe(0) + expect(harness.logs.some((line) => line.includes('typing.start.failed'))).toBe(true) + + await lease.stop() + // 没进入 active 就不该下发 cancel。 + expect(harness.typingCalls.map((call) => call.status)).toEqual([ILINK_TYPING_STATUS_TYPING]) + }) + + it('sendtyping 抛异常时不冒泡', async () => { + const harness = createHarness({ sendResults: [new Error('boom')] }) + + const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await lease.stop() + + expect(harness.coordinator.activePeerCount).toBe(0) + }) + + it('start 失败后 ticket 缓存作废,下次会重取', async () => { + const harness = createHarness({ + tickets: ['stale-ticket', 'fresh-ticket'], + sendResults: [false, true] + }) + + const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await first.stop() + const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await second.stop() + + expect(harness.ticketCalls()).toBe(2) + expect(harness.typingCalls[1].ticket).toBe('fresh-ticket') + }) + + it('stop 失败时记 typing.stop.failed,但 stop 本身不抛异常', async () => { + const harness = createHarness({ sendResults: [true, false] }) + + const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await expect(lease.stop()).resolves.toBeUndefined() + + expect(harness.logs.some((line) => line.includes('typing.stop.failed'))).toBe(true) + }) + + it('长任务按 5 秒节奏维持 typing', async () => { + const harness = createHarness() + + const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + expect(harness.scheduled).toEqual([ILINK_TYPING_KEEPALIVE_MS]) + + harness.ticks[0]() + harness.ticks[0]() + await Promise.resolve() + + expect( + harness.typingCalls.filter((call) => call.status === ILINK_TYPING_STATUS_TYPING) + ).toHaveLength(3) + + await lease.stop() + expect(harness.cancelled()).toBe(1) + }) + + it('keepalive 失败只在同一会话里记一次日志', async () => { + const harness = createHarness({ sendResults: [true, false, false, false] }) + + await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + harness.ticks[0]() + await Promise.resolve() + harness.ticks[0]() + harness.ticks[0]() + await Promise.resolve() + + expect(harness.logs.filter((line) => line.includes('typing.keepalive.failed'))).toHaveLength(1) + }) + + it('stop 之后 keepalive 不再产生请求', async () => { + const harness = createHarness() + + const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await lease.stop() + const countAfterStop = harness.typingCalls.length + + harness.ticks[0]() + await Promise.resolve() + + expect(harness.typingCalls).toHaveLength(countAfterStop) + }) + + it('并发:先结束的任务不会取消仍在执行任务的 typing', async () => { + const harness = createHarness() + + const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + // 引用计数为 2 时不应重复 start。 + expect( + harness.typingCalls.filter((call) => call.status === ILINK_TYPING_STATUS_TYPING) + ).toHaveLength(1) + + await first.stop() + expect(harness.typingCalls.map((call) => call.status)).toEqual([ILINK_TYPING_STATUS_TYPING]) + expect(harness.coordinator.activePeerCount).toBe(1) + + await second.stop() + expect(harness.typingCalls.map((call) => call.status)).toEqual([ + ILINK_TYPING_STATUS_TYPING, + ILINK_TYPING_STATUS_CANCEL + ]) + expect(harness.coordinator.activePeerCount).toBe(0) + }) + + it('并发:最后一个任务结束后再次 begin 会重新 start', async () => { + const harness = createHarness() + + const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await first.stop() + await second.stop() + + const third = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await third.stop() + + expect(harness.typingCalls.map((call) => call.status)).toEqual([ + ILINK_TYPING_STATUS_TYPING, + ILINK_TYPING_STATUS_CANCEL, + ILINK_TYPING_STATUS_TYPING, + ILINK_TYPING_STATUS_CANCEL + ]) + }) + + it('不同对端的 typing 状态互相独立', async () => { + const other = 'other-openid@im.wechat' + const harness = createHarness() + + const a = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + const b = await harness.coordinator.begin({ accountId: 'bot-1', to: other }) + expect(harness.coordinator.activePeerCount).toBe(2) + + await a.stop() + expect(harness.coordinator.activePeerCount).toBe(1) + expect( + harness.typingCalls.some( + (call) => call.userId === other && call.status === ILINK_TYPING_STATUS_TYPING + ) + ).toBe(true) + // 只取消了自己的那一个。 + const cancels = harness.typingCalls.filter((call) => call.status === ILINK_TYPING_STATUS_CANCEL) + expect(cancels.map((call) => call.userId)).toEqual([PEER]) + + await b.stop() + expect(harness.coordinator.activePeerCount).toBe(0) + }) + + it('lease.stop 幂等:重复调用只取消一次', async () => { + const harness = createHarness() + + const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await lease.stop() + await lease.stop() + + expect( + harness.typingCalls.filter((call) => call.status === ILINK_TYPING_STATUS_CANCEL) + ).toHaveLength(1) + }) + + it('stop 超过 begin 次数时不会把引用计数压成负数', async () => { + const harness = createHarness() + + const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + await first.stop() + await first.stop() + await second.stop() + + expect(harness.coordinator.activePeerCount).toBe(0) + const third = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + expect(harness.coordinator.activePeerCount).toBe(1) + await third.stop() + }) + + it('clear 会取消 keepalive 定时器', async () => { + const harness = createHarness() + + await harness.coordinator.begin({ accountId: 'bot-1', to: PEER }) + harness.coordinator.clear() + + expect(harness.cancelled()).toBe(1) + expect(harness.coordinator.activePeerCount).toBe(0) + }) + + it('日志里不出现 typing_ticket / context_token 原文', async () => { + const secretTicket = 'TICKET-SECRET-VALUE' + const secretContext = 'CONTEXT-TOKEN-SECRET' + const harness = createHarness({ tickets: [secretTicket] }) + + const lease = await harness.coordinator.begin({ + accountId: 'bot-1', + to: PEER, + contextToken: secretContext + }) + harness.ticks[0]() + await Promise.resolve() + await lease.stop() + + const joined = harness.logs.join('\n') + expect(joined).not.toContain(secretTicket) + expect(joined).not.toContain(secretContext) + // 也不该出现 openid 原文。 + expect(joined).not.toContain(PEER) + // 但要有可诊断的信息。 + expect(joined).toContain('typing.start') + expect(joined).toContain('ticketPresent=true') + }) + + it('空 to 直接返回空实现,不打任何接口', async () => { + const harness = createHarness() + + const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: ' ' }) + await lease.stop() + + expect(harness.ticketCalls()).toBe(0) + expect(harness.typingCalls).toHaveLength(0) + }) +}) diff --git a/tests/unit/wechat-inbound-inbox.test.ts b/tests/unit/wechat-inbound-inbox.test.ts new file mode 100644 index 0000000..f2d9870 --- /dev/null +++ b/tests/unit/wechat-inbound-inbox.test.ts @@ -0,0 +1,116 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { WechatInboundInbox } from '../../src/main/services/wechat-inbound-inbox' +import type { WechatInboundMessage } from '../../src/main/services/wechat-ilink/types' + +function inbound(messageId: string, text = 'hello'): WechatInboundMessage { + return { + accountId: 'bot-1', + fromUserId: 'user@im.wechat', + messageId, + messageType: 1, + contextToken: `ctx-${messageId}`, + items: [{ type: 1, text }], + receivedAt: 1_700_000_000_000 + } +} + +describe('WechatInboundInbox', () => { + const roots: string[] = [] + + afterEach(() => { + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) + }) + + function createInbox(options: { maxAttempts?: number } = {}): { + inbox: WechatInboundInbox + filePath: string + root: string + } { + const root = mkdtempSync(join(tmpdir(), 'tracememo-inbox-')) + roots.push(root) + const filePath = join(root, 'agent-hub', 'inbound-inbox.json') + const inbox = new WechatInboundInbox({ + filePath: () => filePath, + ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}) + }) + return { inbox, filePath, root } + } + + it('接收即落盘,并保留 context_token 供后续回复使用', () => { + const { inbox, filePath } = createInbox() + const accepted = inbox.accept([inbound('1'), inbound('2')]) + + expect(accepted.map((entry) => entry.messageId)).toEqual(['1', '2']) + expect(accepted[0].contextToken).toBe('ctx-1') + expect(statSync(filePath).mode & 0o777).toBe(0o600) + expect(inbox.size()).toBe(2) + }) + + it('重复投递同一 message_id 时不会重复接收', () => { + const { inbox } = createInbox() + inbox.accept([inbound('1')]) + + expect(inbox.accept([inbound('1')])).toEqual([]) + expect(inbox.size()).toBe(1) + }) + + it('重新实例化后仍能读到未处理的消息(崩溃恢复)', () => { + const { inbox, filePath } = createInbox() + inbox.accept([inbound('7')]) + + const reopened = new WechatInboundInbox({ filePath: () => filePath }) + expect(reopened.pending().map((entry) => entry.messageId)).toEqual(['7']) + }) + + it('处理成功后从收件箱移除', () => { + const { inbox } = createInbox() + const [entry] = inbox.accept([inbound('1')]) + + inbox.complete(entry.key) + + expect(inbox.size()).toBe(0) + expect(inbox.contains(entry.key)).toBe(false) + }) + + it('失败累加尝试次数,达到上限后放弃但可被上层观测', () => { + const { inbox } = createInbox({ maxAttempts: 2 }) + const [entry] = inbox.accept([inbound('1')]) + + expect(inbox.recordFailure(entry.key)).toEqual({ attempts: 1, abandoned: false }) + expect(inbox.contains(entry.key)).toBe(true) + + expect(inbox.recordFailure(entry.key)).toEqual({ attempts: 2, abandoned: true }) + expect(inbox.contains(entry.key)).toBe(false) + }) + + it('没有 message_id 时使用回退键,仍然可追踪', () => { + const { inbox } = createInbox() + const message = { ...inbound(''), messageId: '' } + const [entry] = inbox.accept([message]) + + expect(entry.key).toContain('bot-1::user@im.wechat::') + expect(inbox.pending()).toHaveLength(1) + }) + + it('落盘失败时向上抛错,调用方据此放弃推进游标', () => { + const root = mkdtempSync(join(tmpdir(), 'tracememo-inbox-fail-')) + roots.push(root) + const asDirectory = join(root, 'blocked.json') + mkdirSync(asDirectory, { recursive: true }) + + const inbox = new WechatInboundInbox({ filePath: () => asDirectory }) + expect(() => inbox.accept([inbound('1')])).toThrow() + }) + + it('落盘内容包含聊天文本,但仅限 userData 下的 0600 文件', () => { + const { inbox, filePath } = createInbox() + inbox.accept([inbound('1', '机密内容')]) + + const raw = readFileSync(filePath, 'utf8') + expect(raw).toContain('机密内容') + expect(statSync(filePath).mode & 0o777).toBe(0o600) + }) +}) diff --git a/tests/unit/wechat-send-gateway.test.ts b/tests/unit/wechat-send-gateway.test.ts new file mode 100644 index 0000000..a8ff73e --- /dev/null +++ b/tests/unit/wechat-send-gateway.test.ts @@ -0,0 +1,270 @@ +import { mkdtempSync, readFileSync, rmSync } from 'fs-extra' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-send-gateway-default' } })) +vi.mock('../../src/main/services/personal-wechat-send-service', () => ({ + personalWechatSendService: { send: vi.fn() } +})) + +import { WechatSendGateway } from '../../src/main/services/wechat-send-gateway' +import { WechatSendLogService } from '../../src/main/services/wechat-send-log-service' +import { ILinkError } from '../../src/main/services/wechat-ilink/errors' +import type { PersonalWechatSendResult } from '../../src/shared/personal-wechat' +import { + buildSendPreview, + MAX_SEND_PREVIEW_LENGTH, + normalizeWechatSendRequest, + resolveSendTransport +} from '../../src/shared/wechat-send' + +const okResult: PersonalWechatSendResult = { success: true, status: {} as never } + +describe('统一发送模型', () => { + it('规范化合法请求并保留 context_token', () => { + const request = normalizeWechatSendRequest({ + request_id: 'req-1', + account_id: 'bot-1', + to: 'user@im.wechat', + type: 'text', + msg: ' Hello ', + context_token: 'ctx-1' + }) + + expect(request).toEqual({ + request_id: 'req-1', + account_id: 'bot-1', + to: 'user@im.wechat', + type: 'text', + msg: 'Hello', + context_token: 'ctx-1' + }) + }) + + it('拒绝缺少接收者、类型或内容的请求', () => { + const createRequestId = (): string => 'generated' + expect(normalizeWechatSendRequest(null, { createRequestId })).toBeNull() + expect(normalizeWechatSendRequest({ type: 'text', msg: 'hi' }, { createRequestId })).toBeNull() + expect( + normalizeWechatSendRequest({ to: 'user', type: 'video', msg: 'x' }, { createRequestId }) + ).toBeNull() + expect( + normalizeWechatSendRequest({ to: 'user', type: 'text', msg: ' ' }, { createRequestId }) + ).toBeNull() + // voice 在统一模型里是合法类型,但业务上由个人微信通道承载。 + expect( + normalizeWechatSendRequest( + { to: 'user', type: 'voice', msg: '/tmp/a.silk' }, + { + createRequestId + } + ) + ).toMatchObject({ type: 'voice' }) + }) + + it('按 context_token 推断传输通道', () => { + expect(resolveSendTransport({ context_token: 'ctx' })).toBe('ilink') + expect(resolveSendTransport({})).toBe('personal') + expect(resolveSendTransport({ transport: 'ilink' })).toBe('ilink') + expect(resolveSendTransport({ transport: 'personal', context_token: 'ctx' })).toBe('personal') + }) + + it('截断预览但不动原文', () => { + const text = 'x'.repeat(MAX_SEND_PREVIEW_LENGTH + 50) + expect(buildSendPreview(text, 'text')).toHaveLength(MAX_SEND_PREVIEW_LENGTH) + expect(text).toHaveLength(MAX_SEND_PREVIEW_LENGTH + 50) + }) +}) + +describe('WechatSendGateway', () => { + const directories: string[] = [] + + afterEach(() => { + while (directories.length) rmSync(directories.pop()!, { recursive: true, force: true }) + }) + + function createGateway( + options: { + sendPersonal?: (request: never) => Promise + sendIlink?: (request: never) => Promise + } = {} + ): { gateway: WechatSendGateway; root: string; log: WechatSendLogService } { + const root = mkdtempSync(join(tmpdir(), 'tracememo-send-gateway-')) + directories.push(root) + const log = new WechatSendLogService({ getUserDataPath: () => root }) + let sequence = 0 + const gateway = new WechatSendGateway({ + now: () => 1_700_000_000_000, + createRequestId: () => `generated-${(sequence += 1)}`, + log, + sendPersonal: (options.sendPersonal as never) ?? (async () => okResult), + ...(options.sendIlink ? { sendIlink: options.sendIlink as never } : {}) + }) + return { gateway, root, log } + } + + it('成功发送 iLink 文本时写入一致的 Send Log', async () => { + const received: unknown[] = [] + const { gateway, log } = createGateway({ + sendIlink: async (request: never) => { + received.push(request) + } + }) + + const result = await gateway.send({ + request_id: 'req-ilink', + account_id: 'bot-1', + to: 'user@im.wechat', + type: 'text', + msg: '你好', + context_token: 'ctx-secret' + }) + + expect(result).toMatchObject({ + request_id: 'req-ilink', + success: true, + status: 'sent', + transport: 'ilink' + }) + expect(received).toEqual([ + { + request_id: 'req-ilink', + account_id: 'bot-1', + to: 'user@im.wechat', + type: 'text', + msg: '你好', + context_token: 'ctx-secret' + } + ]) + + const [entry] = log.list() + expect(entry.request_id).toBe('req-ilink') + expect(entry.transport).toBe('ilink') + expect(entry.status).toBe('sent') + expect(entry.msg_preview).toBe('你好') + expect(entry.duration_ms).toBe(0) + }) + + it('iLink 发送失败时同样留下 Send Log 并给出错误码', async () => { + const { gateway, log } = createGateway({ + sendIlink: async () => { + throw new ILinkError({ kind: 'stale_token', message: '登录凭证已失效', ret: -14 }) + } + }) + + const result = await gateway.send({ + request_id: 'req-stale', + to: 'user@im.wechat', + type: 'text', + msg: '测试', + transport: 'ilink' + }) + + expect(result).toMatchObject({ success: false, status: 'failed', error_code: 'STALE_TOKEN' }) + expect(log.list()[0]).toMatchObject({ request_id: 'req-stale', status: 'failed' }) + expect(log.list()[0].error_code).toBe('STALE_TOKEN') + }) + + it('iLink 通道未初始化时不抛异常,而是记录 TRANSPORT_UNAVAILABLE', async () => { + const { gateway, log } = createGateway() + const result = await gateway.send({ + request_id: 'req-no-transport', + to: 'user@im.wechat', + type: 'text', + msg: '测试', + transport: 'ilink' + }) + + expect(result).toMatchObject({ success: false, error_code: 'TRANSPORT_UNAVAILABLE' }) + expect(log.list()[0].error_code).toBe('TRANSPORT_UNAVAILABLE') + }) + + it('不合法请求也会留下 INVALID_REQUEST 记录,而不是静默丢弃', async () => { + const { gateway, log } = createGateway() + const result = await gateway.send({ to: '', type: 'text', msg: '' }) + + expect(result.success).toBe(false) + expect(result.error_code).toBe('INVALID_REQUEST') + expect(log.list()).toHaveLength(1) + expect(log.list()[0].request_id).toBe('generated-1') + }) + + it('个人微信通道按 {to,isGroup,type,msg} 心智模型转发并记账', async () => { + const received: unknown[] = [] + const { gateway, log } = createGateway({ + sendPersonal: async (request: never) => { + received.push(request) + return okResult + } + }) + + const result = await gateway.send({ + request_id: 'req-personal', + to: 'room@chatroom', + type: 'text', + msg: '群成员已退群', + transport: 'personal', + is_group: true + }) + + expect(result).toMatchObject({ success: true, transport: 'personal' }) + expect(received).toEqual([ + { to: 'room@chatroom', isGroup: true, type: 'text', text: '群成员已退群' } + ]) + expect(log.list()[0]).toMatchObject({ transport: 'personal', status: 'sent' }) + }) + + it('个人微信通道不支持文件类型时明确报错', async () => { + const { gateway, log } = createGateway() + const result = await gateway.send({ + request_id: 'req-file', + to: 'wxid_demo', + type: 'file', + msg: '/tmp/report.pdf', + transport: 'personal' + }) + + expect(result).toMatchObject({ success: false, error_code: 'UNSUPPORTED_TYPE' }) + expect(log.list()[0].error_code).toBe('UNSUPPORTED_TYPE') + }) + + it('sendPersonal 保留既有返回契约,并把同一次发送记入 Send Log', async () => { + const received: unknown[] = [] + const { gateway, log } = createGateway({ + sendPersonal: async (request: never) => { + received.push(request) + return okResult + } + }) + + const request = { + to: 'room@chatroom', + isGroup: true, + type: 'text', + text: '日报已发送' + } as const + const result = await gateway.sendPersonal(request) + + expect(result).toBe(okResult) + expect(received[0]).toEqual(request) + expect(log.list()).toHaveLength(1) + expect(log.list()[0]).toMatchObject({ transport: 'personal', status: 'sent', type: 'text' }) + }) + + it('Send Log 落盘内容不包含 context_token 与 bot_token', async () => { + const { gateway, root } = createGateway({ sendIlink: async () => undefined }) + await gateway.send({ + request_id: 'req-secret', + to: 'user@im.wechat', + type: 'text', + msg: '回复内容', + context_token: 'ctx-SECRET-TOKEN', + transport: 'ilink' + }) + + const raw = readFileSync(join(root, 'actions', 'wechat-send-log.json'), 'utf8') + expect(raw).not.toContain('ctx-SECRET-TOKEN') + expect(raw).not.toContain('SECRET') + }) +}) diff --git a/tests/unit/wechat-send-log-service.test.ts b/tests/unit/wechat-send-log-service.test.ts new file mode 100644 index 0000000..523cd8f --- /dev/null +++ b/tests/unit/wechat-send-log-service.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, readFileSync, rmSync } from 'fs-extra' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-send-log-default' } })) + +import { WechatSendLogService } from '../../src/main/services/wechat-send-log-service' +import { MAX_SEND_PREVIEW_LENGTH } from '../../src/shared/wechat-send' + +describe('WechatSendLogService', () => { + const directories: string[] = [] + + afterEach(() => { + while (directories.length) rmSync(directories.pop()!, { recursive: true, force: true }) + }) + + function createService(maxEntries?: number): { service: WechatSendLogService; root: string } { + const root = mkdtempSync(join(tmpdir(), 'tracememo-send-log-')) + directories.push(root) + return { + service: new WechatSendLogService( + maxEntries === undefined + ? { getUserDataPath: () => root } + : { getUserDataPath: () => root, maxEntries } + ), + root + } + } + + it('truncates the text preview but keeps a stable full-content hash', () => { + const { service } = createService() + const text = '通'.repeat(600) + const entry = service.buildEntry({ + request_id: 'req-1', + transport: 'ilink', + to: 'user@im.wechat', + type: 'text', + msg: text, + status: 'sent', + timestamp: 1_700_000_000_000 + }) + + expect(entry.msg_preview).toHaveLength(MAX_SEND_PREVIEW_LENGTH) + expect(entry.msg_hash).toBe(service.hashMessage(text)) + expect(entry.msg_hash).toMatch(/^sha256:[0-9a-f]{64}$/) + // hash 覆盖完整内容,而不是被截断的预览。 + expect(entry.msg_hash).not.toBe(service.hashMessage(entry.msg_preview!)) + }) + + it('records only the file name for media payloads', () => { + const { service } = createService() + const entry = service.buildEntry({ + request_id: 'req-2', + transport: 'ilink', + to: 'user@im.wechat', + type: 'image', + msg: '/tmp/agent-hub-fixture/群聊总结.png', + status: 'sent', + timestamp: 1 + }) + + expect(entry.msg_preview).toBe('群聊总结.png') + expect(JSON.stringify(entry)).not.toContain('agent-hub-fixture') + }) + + it('keeps the newest entry first and replaces the same request_id', () => { + const { service } = createService() + const base = { + transport: 'ilink' as const, + to: 'user@im.wechat', + type: 'text' as const, + msg: 'hello', + timestamp: 1 + } + + service.record(service.buildEntry({ ...base, request_id: 'a', status: 'sent' })) + service.record(service.buildEntry({ ...base, request_id: 'b', status: 'failed' })) + service.record(service.buildEntry({ ...base, request_id: 'a', status: 'failed' })) + + const entries = service.list() + expect(entries.map((entry) => entry.request_id)).toEqual(['a', 'b']) + expect(entries[0].status).toBe('failed') + }) + + it('caps the persisted file at maxEntries', () => { + const { service } = createService(3) + for (let index = 0; index < 6; index += 1) { + service.record( + service.buildEntry({ + request_id: `req-${index}`, + transport: 'personal', + to: 'wxid_demo', + type: 'text', + msg: `消息 ${index}`, + status: 'sent', + timestamp: index + }) + ) + } + expect(service.list().map((entry) => entry.request_id)).toEqual(['req-5', 'req-4', 'req-3']) + }) + + it('never persists context tokens or bot tokens', () => { + const { service, root } = createService() + const contextToken = 'ctx-token-SECRET-VALUE' + service.record({ + ...service.buildEntry({ + request_id: 'req-secret', + transport: 'ilink', + to: 'user@im.wechat', + type: 'text', + msg: '日报已经生成,请查看。', + status: 'sent', + timestamp: 1 + }), + // 即使被误传进来,条目结构本身也不应该携带该字段。 + ...({ context_token: contextToken } as Record) + }) + + const raw = readFileSync(join(root, 'actions', 'wechat-send-log.json'), 'utf8') + expect(raw).not.toContain(contextToken) + expect(raw).not.toContain('context_token') + expect(service.list()[0]).not.toHaveProperty('context_token') + }) +}) diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..1ad7eac --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,27 @@ +{ + "extends": "./tsconfig.web.json", + "include": [ + "tests/**/*.ts", + "tests/**/*.tsx", + "src/**/*.d.ts", + "vitest.unit.config.ts", + "vitest.integration.config.ts", + "vitest.component.config.ts", + "vitest.knowledge-benchmark.config.ts", + "playwright.config.ts" + ], + "compilerOptions": { + "composite": false, + "noEmit": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@renderer/*": [ + "src/renderer/src/*" + ] + }, + "types": [ + "node" + ] + } +} \ No newline at end of file diff --git a/tsconfig.web.json b/tsconfig.web.json index 4d4da62..796eea6 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -3,7 +3,6 @@ "include": [ "src/renderer/src/env.d.ts", "src/renderer/src/**/*", - "src/renderer/src/**/*.tsx", "src/preload/*.d.ts", "src/shared/**/*" ],