mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-08 07:21:19 +08:00
feat: 重写agent hub连接器,新增对话记录和微信输入状态
- 新增 Agent Hub 对话记录面板,按会话回看机器人与微信用户的完整收发内容 - 新增微信原生正在输入状态,长任务维持 typing,异常路径强制收尾
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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、微信数据路径或聊天内容。
|
||||
|
||||
@@ -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 测试;需要对应平台环境。
|
||||
|
||||
## 代码变更对应文档
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: 
|
||||
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  and "
|
||||
urls := ExtractImageURLs(text)
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("expected 2 urls, got %d", len(urls))
|
||||
}
|
||||
if urls[0] != "https://example.com/a.png" {
|
||||
t.Errorf("urls[0] = %q", urls[0])
|
||||
}
|
||||
if urls[1] != "https://example.com/b.jpg" {
|
||||
t.Errorf("urls[1] = %q", urls[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractImageURLs_NoImages(t *testing.T) {
|
||||
urls := ExtractImageURLs("just plain text")
|
||||
if len(urls) != 0 {
|
||||
t.Errorf("expected 0 urls, got %d", len(urls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractImageURLs_RelativeURL(t *testing.T) {
|
||||
text := ""
|
||||
urls := ExtractImageURLs(text)
|
||||
if len(urls) != 0 {
|
||||
t.Errorf("expected 0 urls for relative path, got %d", len(urls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilenameFromURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"https://example.com/photo.png", "photo.png"},
|
||||
{"https://example.com/path/to/report.pdf", "report.pdf"},
|
||||
{"https://example.com/file", "file"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := filenameFromURL(tt.url)
|
||||
if got != tt.want {
|
||||
t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilenameFromURL_WithQuery(t *testing.T) {
|
||||
got := filenameFromURL("https://example.com/photo.png?token=abc")
|
||||
if got != "photo.png" {
|
||||
t.Errorf("got %q, want %q", got, "photo.png")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"https://example.com/a?b=c", "https://example.com/a"},
|
||||
{"https://example.com/a", "https://example.com/a"},
|
||||
{"https://example.com/?x=1&y=2", "https://example.com/"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := stripQuery(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripQuery(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(' ')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Vendored
+25
-3
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]}]`
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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(/尚未启动/)
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)',
|
||||
'图片:',
|
||||
'',
|
||||
'```',
|
||||
'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 = '看图  和 '
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
"include": [
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/preload/*.d.ts",
|
||||
"src/shared/**/*"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user