feat: 重写agent hub连接器,新增对话记录和微信输入状态

- 新增 Agent Hub 对话记录面板,按会话回看机器人与微信用户的完整收发内容
- 新增微信原生正在输入状态,长任务维持 typing,异常路径强制收尾
This commit is contained in:
Wxw-Gu
2026-09-17 17:32:37 +08:00
parent 1337bcb7de
commit 4b2395a8d2
73 changed files with 8855 additions and 2890 deletions
-8
View File
@@ -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
-1
View File
@@ -10,7 +10,6 @@ out
coverage/
playwright-report/
test-results/
resources/connectors/wechat/
resources/connectors/wechat-personal/
.omc
.codex/
@@ -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、微信数据路径或聊天内容。
+2 -3
View File
@@ -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 测试;需要对应平台环境。
## 代码变更对应文档
-1
View File
@@ -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/**
+10 -14
View File
@@ -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"
-65
View File
@@ -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}`)
}
+140
View File
@@ -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()
-21
View File
@@ -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.
-25
View File
@@ -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 <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.
-135
View File
@@ -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
}
@@ -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")
}
}
-8
View File
@@ -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
)
-4
View File
@@ -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=
-236
View File
@@ -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()
}
@@ -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)
}
}
-218
View File
@@ -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))
}
-181
View File
@@ -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)
}
-219
View File
@@ -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"`
}
-200
View File
@@ -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
}
-232
View File
@@ -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
}
@@ -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(),
}
}
@@ -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())
}
}
@@ -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)
}
@@ -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
}
@@ -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)
}
}
}
@@ -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] + "..."
}
+39
View File
@@ -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()
@@ -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
}
}
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
import { createHash } from 'node:crypto'
/**
* 统一日志脱敏。
*
* 微信连接器与发送日志共用同一套规则:任何 secret 都不能以原值进入普通日志,
* 包括 bot_token、context_token、typing_ticket、AES key、二维码凭据与 Authorization 头。
*
* 只用于自由文本日志;结构化字段(如 sha256 摘要)不要经过这里,否则会被误伤。
*/
const REDACTION_RULES: ReadonlyArray<readonly [RegExp, string]> = [
// 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)
}
+3 -2
View File
@@ -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))
@@ -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/<account-id>.json 凭据
* ~/.tracememo/wechat-connector/accounts/<account-id>.sync.json 长轮询游标
* ~/.tracememo/wechat-connector/accounts/<account-id>.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<T>(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<ILinkCredentials> | 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 }
}
/**
* 保存新登录的凭据。
* 先写入新凭据再清理旧账号文件,因此一次失败的登录不会摧毁上一个可用账号。
* 同时保留同前缀的 `<id>.sync.json` / `<id>.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<ILinkCredentials>(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<ILinkCredentials>(
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<SyncRecord>(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<string, { context_token: string; updated_at: number }>
}
/**
* 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<ContextRecord>(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<ContextRecord>(
join(directory, `${accountId}.context.json`),
(value) => Boolean(value && typeof value.tokens === 'object' && value.tokens !== null)
)
return record?.tokens?.[toUserId]?.context_token || undefined
}
+293
View File
@@ -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<string>
export interface QrLoginDependencies {
fetchImpl?: FetchLike
home?: HomeDirectoryResolver
baseUrl?: string
signal?: AbortSignal
onEvent?: (event: WechatLoginEvent) => void
/** 把二维码内容渲染成可直接给 <img src> 的 data URL。 */
qrEncoder?: QrEncoder
/** 手机端要求数字配对码时,由宿主提供;返回 undefined 表示放弃这次登录。 */
verifyCodeProvider?: () => Promise<string | undefined>
now?: () => number
sleep?: (milliseconds: number) => Promise<void>
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<string | undefined>,
waitMs: number
): Promise<string | undefined> {
return new Promise<string | undefined>((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<Pick<QrLoginDependencies, 'onEvent' | 'now'>> & QrLoginDependencies
): Promise<QrStatusPollResult> {
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<ILinkQrStatusResponse>(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<ILinkCredentials> {
const baseUrl = (deps.baseUrl || ILINK_DEFAULT_BASE_URL).replace(/\/+$/, '')
const now = deps.now ?? Date.now
const sleep =
deps.sleep ?? ((milliseconds: number) => new Promise<void>((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<ILinkQrCodeResponse>(
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)
}
}
}
+284
View File
@@ -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<T>(response: Response, context: string, includeBody: boolean): Promise<T> {
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<Response> {
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<T>(
path: string,
body: unknown,
context: string,
options: ILinkRequestOptions
): Promise<T> {
const response = await this.request(
`${this.baseUrlValue}${path}`,
{
method: 'POST',
headers: buildCommonHeaders(),
body: JSON.stringify(body)
},
context,
options
)
return parseJson<T>(response, context, options.includeBody === true)
}
/** 鉴权 POST(登录后的全部业务接口)。 */
async post<T>(
path: string,
body: unknown,
context: string,
options: ILinkRequestOptions
): Promise<T> {
const response = await this.request(
`${this.baseUrlValue}${path}`,
{
method: 'POST',
headers: buildAuthorizedHeaders(this.botTokenValue),
body: JSON.stringify(body)
},
context,
options
)
return parseJson<T>(response, context, options.includeBody === true)
}
/** 二维码状态轮询使用裸 URL(可能指向 redirect_host)。 */
async getAnonymous<T>(url: string, context: string, options: ILinkRequestOptions): Promise<T> {
const response = await this.request(
url,
{ method: 'GET', headers: buildQrStatusHeaders() },
context,
options
)
return parseJson<T>(response, context, options.includeBody === true)
}
private baseInfo(): ReturnType<typeof buildBaseInfo> {
return buildBaseInfo(this.headerOptions)
}
async getUpdates(
getUpdatesBuf: string,
options: ILinkRequestOptions
): Promise<ILinkGetUpdatesResponse> {
return this.post<ILinkGetUpdatesResponse>(
'/ilink/bot/getupdates',
{ get_updates_buf: getUpdatesBuf, base_info: this.baseInfo() },
'getupdates',
options
)
}
async sendMessage(
msg: ILinkSendMessageRequest['msg'],
signal?: AbortSignal
): Promise<ILinkSendMessageResponse> {
return this.post<ILinkSendMessageResponse>(
'/ilink/bot/sendmessage',
{ msg, base_info: this.baseInfo() },
'sendmessage',
{ timeoutMs: ILINK_SEND_TIMEOUT_MS, signal, includeBody: true }
)
}
async getUploadUrl(
request: Omit<ILinkGetUploadUrlRequest, 'base_info'>,
signal?: AbortSignal
): Promise<ILinkGetUploadUrlResponse> {
return this.post<ILinkGetUploadUrlResponse>(
'/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<ILinkGetConfigResponse> {
return this.post<ILinkGetConfigResponse>(
'/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<ILinkSendTypingResponse> {
return this.post<ILinkSendTypingResponse>(
'/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<void> {
await this.post<{ ret?: number; errmsg?: string }>(
`/ilink/bot/msg/notify${action}`,
{ base_info: this.baseInfo() },
`notify${action}`,
{ timeoutMs: ILINK_CONFIG_TIMEOUT_MS, signal }
)
}
}
+166
View File
@@ -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 } : {})
})
}
+84
View File
@@ -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<string, string> {
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<string, string> {
return {
'iLink-App-Id': ILINK_APP_ID,
'iLink-App-ClientVersion': ILINK_APP_CLIENT_VERSION
}
}
/** 登录后的业务请求头;未登录时不得携带 Authorization。 */
export function buildAuthorizedHeaders(botToken: string): Record<string, string> {
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)
}
}
+507
View File
@@ -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<void>
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<void>
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<void> | null = null
private loginAbort: AbortController | null = null
private loginPromise: Promise<void> | 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<TypingLease> {
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<void> {
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<string | undefined>((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<void> {
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<void> {
// 先收掉输入状态,避免连接器停了微信端还显示"对方正在输入"。
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<void> {
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<SendTextResult> {
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<SendMediaOptions, 'fetchImpl'> & { 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'
@@ -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
}
+391
View File
@@ -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<Record<string, string>> = {
'.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<Response> {
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<UploadedMedia> {
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<Buffer> {
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 }
@@ -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(' ')
}
+187
View File
@@ -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<ILinkGetUpdatesResponse>
/**
* 处理一批入站消息。
* **只有全部消息被成功接收后才会推进游标**,因此这里抛错会让整批重投。
*/
onMessages: (messages: WechatInboundMessage[]) => Promise<void>
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<void>
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<void> {
const {
signal,
log,
sleep = (milliseconds: number) => new Promise<void>((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
}
}
+85
View File
@@ -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<SendTextResult> {
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 }
}
+279
View File
@@ -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/<id>.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'
+338
View File
@@ -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<void>
}
export interface TypingBeginInput {
accountId?: string
to: string
contextToken?: string
}
export interface TypingCoordinatorDependencies {
/** 取 typing_ticket;失败/无票返回 undefined 即可(上层已 catch)。 */
fetchTicket: (input: {
ilinkUserId: string
contextToken?: string
}) => Promise<string | undefined>
/** 下发 status=1/2;返回是否成功。 */
sendTyping: (input: { ilinkUserId: string; ticket: string; status: number }) => Promise<boolean>
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<string | undefined> | null
contextToken?: string
/** 串行化 activate / deactivate,避免本次 TYPING 被上一次的 CANCEL 吃掉。 */
queue: Promise<void>
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<string, PeerTypingState>()
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<TypingLease> {
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<void> => {
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<void> {
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<void> {
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<void> {
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<string | undefined> {
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<string | undefined> => {
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<boolean> {
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)
}
+189
View File
@@ -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<WechatInboundInboxEntry>
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
}
}
}
+357
View File
@@ -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<void>
export interface WechatSendGatewayDependencies {
now?: () => number
createRequestId?: () => string
/** 个人微信(注入式发送)适配器。 */
sendPersonal?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
/** 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<Pick<WechatSendGatewayDependencies, 'now' | 'createRequestId'>> &
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<WechatSendResult> {
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<WechatSendRequest>
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<PersonalWechatSendResult> {
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<PersonalWechatSendResult> {
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<PersonalWechatSendResult>)
| null = null
async function defaultPersonalSender(): Promise<
(request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
> {
if (!cachedPersonalSender) {
const module = await import('./personal-wechat-send-service')
cachedPersonalSender = (request) => module.personalWechatSendService.send(request)
}
return cachedPersonalSender
}
/** 兼容性名称。 */
export const wechatSendGateway = new WechatSendGateway()
@@ -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<WechatSendLogEntry>
// `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()
+25 -3
View File
@@ -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<SystemOcrResult>
getImageTextIndexStatus: () => Promise<ImageTextIndexStatus>
countImageMessages: (sinceMs?: number) => Promise<ImageTextIndexCountResult>
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<AgentHubActionResult>
disconnectAgentHub: () => Promise<AgentHubActionResult>
selectAgentHubTestImage: () => Promise<{ canceled: boolean; path?: string }>
getAgentHubConversations: () => Promise<AgentHubConversationSummary[]>
getAgentHubConversation: (userId: string) => Promise<AgentHubConversation | null>
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
}
+33 -5
View File
@@ -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<ImageTextIndexCountResult> =>
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)
+1 -1
View File
@@ -2087,7 +2087,7 @@ function App(): React.ReactElement {
/>
)
case 'agent-hub':
return <AgentHubWorkspace />
return <AgentHubWorkspace selfInfo={selfInfo} />
case 'api':
return (
<ApiWorkspace
@@ -0,0 +1,331 @@
import React from 'react'
import type {
AgentHubConversationMessage,
AgentHubConversationSummary
} from '../../../../shared/agent-hub-conversation'
import { agentHubKindPlaceholder } from '../../../../shared/agent-hub-conversation'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button
} from '../../components/ui'
export interface AgentHubConversationPanelSelfInfo {
wxid: string
nickname: string
avatar?: string
}
interface AgentHubConversationPanelProps {
selfInfo: AgentHubConversationPanelSelfInfo | null
/** 机器人账号对应的微信用户标识;与入站 from_user_id 相同即为"本机自己"。 */
selfUserId?: string
}
/**
* 机器人头像。
*
* iLink 只给 openid,拿不到真实头像,所以这里用固定的品牌色矢量头像,
* 让"右侧是机器人"一眼可辨,且不依赖任何外部资源。
*/
function AgentHubBotAvatar({ size = 32 }: { size?: number }): React.ReactElement {
return (
<span className="agent-hub-bot-avatar" style={{ width: size, height: size }} aria-hidden>
<svg viewBox="0 0 24 24" width={size * 0.62} height={size * 0.62} role="presentation">
<rect x="4.5" y="8" width="15" height="11" rx="3" fill="currentColor" />
<rect x="11" y="3.6" width="2" height="3.4" rx="1" fill="currentColor" />
<circle cx="12" cy="3.2" r="1.4" fill="currentColor" />
<circle cx="9.2" cy="12.6" r="1.5" fill="var(--wxex-bg-elevated)" />
<circle cx="14.8" cy="12.6" r="1.5" fill="var(--wxex-bg-elevated)" />
<rect x="9.4" y="16" width="5.2" height="1.4" rx="0.7" fill="var(--wxex-bg-elevated)" />
</svg>
</span>
)
}
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 (
<img
className="agent-hub-conversation-avatar"
style={{ width: size, height: size }}
src={identity.avatar}
alt=""
referrerPolicy="no-referrer"
/>
)
}
return (
<span
className={`agent-hub-conversation-avatar is-fallback ${identity.isSelf ? 'is-self' : ''}`}
style={{ width: size, height: size }}
aria-hidden
>
{identity.initial}
</span>
)
}
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<AgentHubConversationSummary[]>([])
const [selectedUserId, setSelectedUserId] = React.useState<string | null>(null)
const [messages, setMessages] = React.useState<AgentHubConversationMessage[]>([])
const [confirmingClear, setConfirmingClear] = React.useState(false)
const bodyRef = React.useRef<HTMLDivElement>(null)
// 用 ref 跟踪当前选中会话:推送回调里不能依赖闭包里的 selectedUserId,
// 也不能在 setState 的 updater 里塞副作用(StrictMode 下会执行两次)。
const selectedRef = React.useRef<string | null>(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<void> => {
await window.api.clearAgentHubConversations()
setSummaries([])
setMessages([])
setSelectedUserId(null)
}
return (
<section className="agent-hub-card agent-hub-conversation-card">
<div className="agent-hub-conversation-heading">
<div>
<span className="agent-hub-card-kicker">收发记录</span>
<h2>对话记录</h2>
</div>
<div className="agent-hub-conversation-actions">
<span className="agent-hub-conversation-count">
{summaries.length} 个会话 · {totalCount} 条
</span>
<Button
variant="ghost"
size="sm"
disabled={totalCount === 0}
onClick={() => setConfirmingClear(true)}
>
清空记录
</Button>
</div>
</div>
<div className="agent-hub-conversation-body">
<div className="agent-hub-conversation-list">
{summaries.length === 0 ? (
<div className="agent-hub-conversation-list-empty">
还没有收发记录。机器人收到或发出消息后,这里会显示完整内容。
</div>
) : (
summaries.map((summary) => {
const identity = resolveIdentity(summary.userId, selfInfo, selfUserId)
return (
<button
type="button"
key={summary.userId}
className={`agent-hub-conversation-item ${
summary.userId === selectedUserId ? 'is-active' : ''
}`}
onClick={() => setSelectedUserId(summary.userId)}
>
<ConversationAvatar identity={identity} />
<span className="agent-hub-conversation-item-copy">
<span className="agent-hub-conversation-item-name">{identity.name}</span>
<span className="agent-hub-conversation-item-preview">
{summary.lastDirection === 'out' ? '机器人:' : ''}
{summary.lastPreview || '(空消息)'}
</span>
</span>
<span className="agent-hub-conversation-item-time">
{formatConversationTime(summary.lastAt)}
</span>
</button>
)
})
)}
</div>
<div className="agent-hub-conversation-thread" ref={bodyRef}>
{!selectedIdentity || messages.length === 0 ? (
<div className="agent-hub-conversation-thread-empty">
{summaries.length === 0
? '左侧出现会话后,这里会显示完整对话。'
: '这个会话还没有消息。'}
</div>
) : (
messages.map((message) => (
<div
className={`agent-hub-message ${message.direction === 'out' ? 'is-out' : 'is-in'}`}
key={message.id}
>
{message.direction === 'out' ? (
<AgentHubBotAvatar />
) : (
<ConversationAvatar identity={selectedIdentity} size={32} />
)}
<div className="agent-hub-message-main">
<div className="agent-hub-message-bubble">{messageBody(message)}</div>
<div className="agent-hub-message-meta">
<span>{formatConversationTime(message.createdAt)}</span>
{message.direction === 'out' && (
<span className={message.status === 'failed' ? 'is-failed' : ''}>
{message.status === 'failed'
? `发送失败${message.errorCode ? `(${message.errorCode})` : ''}`
: '已发送'}
</span>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
<AlertDialog open={confirmingClear} onOpenChange={setConfirmingClear}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清空对话记录?</AlertDialogTitle>
<AlertDialogDescription>
将删除本机保存的全部机器人收发记录。这不影响微信里的原始消息,也不影响运行日志。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => void clearConversations()}
>
确认清空
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
)
}
@@ -13,6 +13,7 @@ import {
SelectTrigger,
SelectValue
} from '../../components/ui'
import { AgentHubConversationPanel } from './AgentHubConversationPanel'
const STATUS_LABELS: Record<WechatConnectorStatus, string> = {
checking: '正在检查',
@@ -30,7 +31,14 @@ const LOG_SOURCE_LABELS: Record<AgentHubLogSource, string> = {
'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<AgentHubStatus>({
hub: 'offline',
connector: 'checking',
@@ -283,6 +291,8 @@ export function AgentHubWorkspace(): React.ReactElement {
</div>
<p className="agent-hub-log-note">日志会隐藏 Token 和二维码数据,不记录你的微信密码。</p>
</section>
<AgentHubConversationPanel selfInfo={selfInfo} selfUserId={status.wechatUserId} />
</div>
)
}
+203
View File
@@ -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));
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Agent Hub 对话记录(收发记录)。
*
* 这是**产品数据**,与「运行日志」明确分工:
* - 运行日志(`agent-hub.log`)只记流程与故障,会脱敏,不保留完整正文;
* - 对话记录保存完整收发内容,用于在应用内回看"机器人到底和谁说了什么"。
*
* 隐私边界:仅本机保存(`<userData>/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<AgentHubMessageKind, string> = {
text: '文字',
image: '图片',
voice: '语音',
file: '文件',
video: '视频',
system: '系统消息'
}
/** 媒体类消息在列表 / 气泡里的占位文案。 */
export function agentHubKindPlaceholder(kind: AgentHubMessageKind): string {
if (kind === 'text' || kind === 'system') return ''
return `[${AGENT_HUB_KIND_LABELS[kind]}]`
}
+162
View File
@@ -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<string, unknown>
}
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<string> = 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<WechatSendRequest> & Record<string, unknown>
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<string, unknown> }
: {})
}
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'
}
+103 -4
View File
@@ -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(<AgentHubWorkspace selfInfo={{ wxid: 'wxid_me', nickname: '测试用户' }} />)
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(<AgentHubWorkspace selfInfo={{ wxid: 'wxid_me', nickname: '测试用户' }} />)
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(<AgentHubWorkspace selfInfo={null} />)
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()
})
})
@@ -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<unknown> {
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> = {}): 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<string, string>,
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<string, string>
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()
})
})
+59
View File
@@ -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
}
}
@@ -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([])
})
})
-1
View File
@@ -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:')
@@ -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<string, { context_token: string }>
}
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([])
})
})
+333
View File
@@ -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<string, string>
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<string, string> = {}
for (const [key, value] of Object.entries((init?.headers ?? {}) as Record<string, string>)) {
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<string | undefined>
}): {
calls: RecordedCall[]
events: WechatLoginEvent[]
run: () => Promise<ILinkCredentials>
} {
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')
})
})
@@ -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<string, unknown>
}
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<string, unknown>) : {}
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<string, unknown>
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<string, unknown>).channel_version).toBe('2.4.6')
expect((msg.item_list as Array<Record<string, unknown>>)[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(/尚未启动/)
})
})
+232
View File
@@ -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<void>
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<ILinkGetUpdatesResponse | Error>,
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'])
})
})
+175
View File
@@ -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('第一段 第二段')
})
})
@@ -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<string, unknown>
}
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<string, unknown>) : {}
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<string, unknown>).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)
})
})
+367
View File
@@ -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<string | undefined | Error>
sendResults?: Array<boolean | Error | undefined>
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)
})
})
+116
View File
@@ -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)
})
})
+270
View File
@@ -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<PersonalWechatSendResult>
sendIlink?: (request: never) => Promise<void>
} = {}
): { 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')
})
})
+126
View File
@@ -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<string, unknown>)
})
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')
})
})
+27
View File
@@ -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"
]
}
}
-1
View File
@@ -3,7 +3,6 @@
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts",
"src/shared/**/*"
],