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

- 新增 Agent Hub 对话记录面板,按会话回看机器人与微信用户的完整收发内容
- 新增微信原生正在输入状态,长任务维持 typing,异常路径强制收尾
This commit is contained in:
Wxw-Gu
2026-09-17 17:32:37 +08:00
parent 1337bcb7de
commit 4b2395a8d2
73 changed files with 8855 additions and 2890 deletions
@@ -0,0 +1,178 @@
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { AgentHubConversationStore } from '../../src/main/services/agent-hub-conversation-store'
describe('AgentHubConversationStore', () => {
const roots: string[] = []
afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
})
function createStore(options: { maxMessages?: number; maxConversations?: number } = {}): {
store: AgentHubConversationStore
filePath: string
} {
const root = mkdtempSync(join(tmpdir(), 'tracememo-conv-'))
roots.push(root)
const filePath = join(root, 'agent-hub', 'conversations.json')
let sequence = 0
const store = new AgentHubConversationStore({
filePath: () => filePath,
createId: () => `m-${(sequence += 1)}`,
now: () => 1_700_000_000_000,
...(options.maxMessages !== undefined
? { maxMessagesPerConversation: options.maxMessages }
: {}),
...(options.maxConversations !== undefined
? { maxConversations: options.maxConversations }
: {})
})
return { store, filePath }
}
it('记录一次收发并给出会话摘要', () => {
const { store, filePath } = createStore()
store.append({ userId: 'user-a', direction: 'in', kind: 'text', text: '你好' })
const result = store.append({
userId: 'user-a',
direction: 'out',
kind: 'text',
text: '这是回答',
status: 'sent'
})
expect(result?.summary).toMatchObject({
userId: 'user-a',
messageCount: 2,
lastPreview: '这是回答',
lastDirection: 'out'
})
expect(result?.message).toMatchObject({ direction: 'out', status: 'sent', text: '这是回答' })
const conversation = store.get('user-a')
expect(conversation?.messages.map((item) => item.direction)).toEqual(['in', 'out'])
expect(statSync(filePath).mode & 0o777).toBe(0o600)
})
it('保留发送失败的记录与错误码,便于在界面上对账', () => {
const { store } = createStore()
store.append({
userId: 'user-a',
direction: 'out',
kind: 'text',
text: '发不出去的内容',
status: 'failed',
errorCode: 'STALE_TOKEN'
})
const message = store.get('user-a')?.messages[0]
expect(message).toMatchObject({ status: 'failed', errorCode: 'STALE_TOKEN' })
})
it('媒体消息只保留类型占位,不写入路径细节以外的内容', () => {
const { store } = createStore()
const result = store.append({
userId: 'user-a',
direction: 'out',
kind: 'image',
text: '群日报.png',
status: 'sent'
})
expect(result?.summary.lastPreview).toBe('群日报.png')
// 空文本的媒体消息在列表里退化为中文占位。
store.append({ userId: 'user-a', direction: 'in', kind: 'image', text: '' })
expect(store.listSummaries()[0].lastPreview).toBe('[图片]')
})
it('单个会话超出上限时丢弃最旧的消息', () => {
const { store } = createStore({ maxMessages: 3 })
for (let index = 0; index < 5; index += 1) {
store.append({ userId: 'user-a', direction: 'in', kind: 'text', text: `第 ${index} 条` })
}
const messages = store.get('user-a')?.messages ?? []
expect(messages).toHaveLength(3)
expect(messages.map((item) => item.text)).toEqual(['第 2 条', '第 3 条', '第 4 条'])
})
it('会话数超出上限时只保留最近活跃的', () => {
const { store } = createStore({ maxConversations: 2 })
store.append({
userId: 'old',
direction: 'in',
kind: 'text',
text: '旧会话',
createdAt: 1_000
})
store.append({
userId: 'mid',
direction: 'in',
kind: 'text',
text: '中间会话',
createdAt: 2_000
})
store.append({
userId: 'new',
direction: 'in',
kind: 'text',
text: '新会话',
createdAt: 3_000
})
const summaries = store.listSummaries()
expect(summaries.map((item) => item.userId)).toEqual(['new', 'mid'])
expect(store.get('old')).toBeNull()
})
it('会话列表按最近活跃排序,并带上条数', () => {
const { store } = createStore()
store.append({ userId: 'a', direction: 'in', kind: 'text', text: '1', createdAt: 1_000 })
store.append({ userId: 'b', direction: 'in', kind: 'text', text: '2', createdAt: 2_000 })
store.append({ userId: 'a', direction: 'in', kind: 'text', text: '3', createdAt: 3_000 })
expect(store.listSummaries().map((item) => [item.userId, item.messageCount])).toEqual([
['a', 2],
['b', 1]
])
})
it('get 返回副本,外部改动不会污染缓存', () => {
const { store } = createStore()
store.append({ userId: 'a', direction: 'in', kind: 'text', text: '原始' })
const first = store.get('a')
first!.messages[0].text = '被改过'
expect(store.get('a')?.messages[0].text).toBe('原始')
})
it('清空后文件与内存都为空', () => {
const { store, filePath } = createStore()
store.append({ userId: 'a', direction: 'in', kind: 'text', text: 'x' })
store.clear()
expect(store.listSummaries()).toEqual([])
expect(store.get('a')).toBeNull()
expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual({ version: 1, conversations: [] })
})
it('忽略空会话标识,不产生垃圾条目', () => {
const { store } = createStore()
expect(store.append({ userId: ' ', direction: 'in', kind: 'text', text: 'x' })).toBeNull()
expect(store.listSummaries()).toEqual([])
})
it('文件损坏时按空库处理,不抛错', () => {
const { store, filePath } = createStore()
store.append({ userId: 'a', direction: 'in', kind: 'text', text: 'x' })
rmSync(filePath)
// 重新实例化,读一个不存在的文件
const fresh = new AgentHubConversationStore({ filePath: () => filePath })
expect(fresh.listSummaries()).toEqual([])
})
})
-1
View File
@@ -105,7 +105,6 @@ describe('production runtime packaging', () => {
it('uses a Windows x64-only resource set', () => {
const config = readFileSync(resolve(__dirname, '../../electron-builder.win.yml'), 'utf8')
expect(config).toContain('connectors/wechat/win32-x64/**')
expect(config).toContain('key/win32/x64/**')
expect(config).toContain('wcdb/win32/x64/**')
expect(config).toContain('electronLanguages:')
@@ -0,0 +1,176 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
accountsDirectory,
clearCursor,
legacyAccountsDirectory,
loadAllCredentials,
loadContextToken,
loadCursor,
normalizeAccountId,
resolveAccountDirectory,
saveContextToken,
saveCredentials,
saveCursor
} from '../../src/main/services/wechat-ilink/account-store'
import type { ILinkCredentials } from '../../src/main/services/wechat-ilink/types'
const credentials: ILinkCredentials = {
bot_token: 'bot-token-value',
ilink_bot_id: 'bot_abc@im.bot',
baseurl: 'https://ilinkai.weixin.qq.com',
ilink_user_id: 'user_42'
}
describe('wechat-ilink account store', () => {
const homes: string[] = []
afterEach(() => {
while (homes.length) rmSync(homes.pop()!, { recursive: true, force: true })
})
function createHome(): string {
const home = mkdtempSync(join(tmpdir(), 'tracememo-ilink-home-'))
homes.push(home)
return home
}
it('把账号 ID 规范成文件安全的形式', () => {
expect(normalizeAccountId('bot_abc@im.bot')).toBe('bot_abc-im-bot')
expect(normalizeAccountId('a:b.c@d')).toBe('a-b-c-d')
expect(normalizeAccountId('')).toBe('')
})
it('凭据写入 ~/.tracememo 且文件权限为 0600', () => {
const home = createHome()
const path = saveCredentials(credentials, () => home)
expect(path).toBe(
join(home, '.tracememo', 'wechat-connector', 'accounts', 'bot_abc-im-bot.json')
)
expect(statSync(path).mode & 0o777).toBe(0o600)
expect(loadAllCredentials(() => home)).toEqual([credentials])
})
it('当前目录为空时回退读取 ~/.wechatexplorer 历史凭据,且不删除旧文件', () => {
const home = createHome()
const legacyDirectory = legacyAccountsDirectory(() => home)
mkdirSync(legacyDirectory, { recursive: true })
writeFileSync(join(legacyDirectory, 'legacy-bot.json'), JSON.stringify(credentials), 'utf8')
expect(loadAllCredentials(() => home)).toEqual([credentials])
expect(resolveAccountDirectory('legacy-bot', () => home)).toEqual({
directory: legacyDirectory,
legacy: true
})
expect(existsSync(join(legacyDirectory, 'legacy-bot.json'))).toBe(true)
})
it('游标写到 loadCursor 实际读取的目录,历史目录不会让游标失效', () => {
const home = createHome()
const legacyDirectory = legacyAccountsDirectory(() => home)
mkdirSync(legacyDirectory, { recursive: true })
writeFileSync(join(legacyDirectory, 'legacy-bot.json'), JSON.stringify(credentials), 'utf8')
saveCursor('legacy-bot', 'buf-1', () => home)
expect(existsSync(join(legacyDirectory, 'legacy-bot.sync.json'))).toBe(true)
expect(
existsSync(
join(
accountsDirectory(() => home),
'legacy-bot.sync.json'
)
)
).toBe(false)
expect(loadCursor('legacy-bot', () => home)).toBe('buf-1')
})
it('保存新凭据时清理其它账号,但保留同账号的游标与上下文文件', () => {
const home = createHome()
saveCredentials(credentials, () => home)
const accountId = normalizeAccountId(credentials.ilink_bot_id)
saveCursor(accountId, 'buf-keep', () => home)
saveContextToken(accountId, 'user@im.wechat', 'ctx-keep', () => home)
const otherDirectory = accountsDirectory(() => home)
writeFileSync(join(otherDirectory, 'stale-bot.json'), JSON.stringify(credentials), 'utf8')
saveCredentials(credentials, () => home)
expect(existsSync(join(otherDirectory, 'stale-bot.json'))).toBe(false)
expect(existsSync(join(otherDirectory, `${accountId}.sync.json`))).toBe(true)
expect(existsSync(join(otherDirectory, `${accountId}.context.json`))).toBe(true)
expect(loadCursor(accountId, () => home)).toBe('buf-keep')
})
it('游标按账号隔离', () => {
const home = createHome()
saveCredentials(credentials, () => home)
const accountA = normalizeAccountId(credentials.ilink_bot_id)
saveCredentials({ ...credentials, ilink_bot_id: 'bot_other' }, () => home)
// saveCredentials 只会保留最后一个账号,这里直接验证读取隔离。
saveCursor(accountA, 'buf-a', () => home)
saveCursor('bot_other', 'buf-b', () => home)
expect(loadCursor(accountA, () => home)).toBe('buf-a')
expect(loadCursor('bot_other', () => home)).toBe('buf-b')
clearCursor(accountA, () => home)
expect(loadCursor(accountA, () => home)).toBe('')
expect(loadCursor('bot_other', () => home)).toBe('buf-b')
})
it('按账号 + 用户保存 context_token,文件权限 0600,只保留最近 500 条', () => {
const home = createHome()
saveCredentials(credentials, () => home)
const accountId = normalizeAccountId(credentials.ilink_bot_id)
for (let index = 0; index < 520; index += 1) {
saveContextToken(
accountId,
`user-${index}`,
`ctx-${index}`,
() => home,
() => index
)
}
const path = join(
accountsDirectory(() => home),
`${accountId}.context.json`
)
expect(statSync(path).mode & 0o777).toBe(0o600)
const stored = JSON.parse(readFileSync(path, 'utf8')) as {
tokens: Record<string, { context_token: string }>
}
expect(Object.keys(stored.tokens)).toHaveLength(500)
// 最新的保留,最旧的被裁掉。
expect(stored.tokens['user-519'].context_token).toBe('ctx-519')
expect(stored.tokens['user-0']).toBeUndefined()
expect(loadContextToken(accountId, 'user-519', () => home)).toBe('ctx-519')
expect(loadContextToken(accountId, 'user-missing', () => home)).toBeUndefined()
})
it('忽略缺少 bot_token 的损坏凭据文件', () => {
const home = createHome()
const directory = accountsDirectory(() => home)
mkdirSync(directory, { recursive: true })
writeFileSync(join(directory, 'broken.json'), '{ not json', 'utf8')
writeFileSync(join(directory, 'empty.json'), JSON.stringify({ ilink_bot_id: 'x' }), 'utf8')
expect(loadAllCredentials(() => home)).toEqual([])
})
})
+333
View File
@@ -0,0 +1,333 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { runQrLogin } from '../../src/main/services/wechat-ilink/auth'
import { accountsDirectory } from '../../src/main/services/wechat-ilink/account-store'
import type { ILinkCredentials, WechatLoginEvent } from '../../src/main/services/wechat-ilink/types'
interface RecordedCall {
url: string
method: string
headers: Record<string, string>
body?: string
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
})
}
const qrResponse = {
qrcode: 'qr-secret-token',
qrcode_img_content: 'https://liteapp.weixin.qq.com/q/demo'
}
const credentialsBody = {
status: 'confirmed',
bot_token: 'bot-token-value',
ilink_bot_id: 'bot_abc@im.bot',
ilink_user_id: 'user_42',
baseurl: 'https://ilinkai.weixin.qq.com'
}
/** 防止用例写错时无限轮询:超过硬上限直接中止登录,让断言失败而不是挂死进程。 */
const HARD_CALL_LIMIT = 40
function createScriptedFetch(
statusQueue: unknown[],
onExhausted?: () => void
): { fetchImpl: typeof fetch; calls: RecordedCall[] } {
const calls: RecordedCall[] = []
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
const headers: Record<string, string> = {}
for (const [key, value] of Object.entries((init?.headers ?? {}) as Record<string, string>)) {
headers[key.toLowerCase()] = String(value)
}
calls.push({
url,
method: String(init?.method ?? 'GET'),
headers,
...(typeof init?.body === 'string' ? { body: init.body } : {})
})
if (calls.length > HARD_CALL_LIMIT) {
onExhausted?.()
throw new Error('scripted fetch hard call limit reached')
}
if (url.includes('/ilink/bot/get_bot_qrcode')) return jsonResponse(qrResponse)
if (url.includes('/ilink/bot/get_qrcode_status')) {
const next = statusQueue.shift() ?? { status: 'wait' }
if (next instanceof Error) throw next
return jsonResponse(next)
}
return jsonResponse({}, 404)
}) as unknown as typeof fetch
return { fetchImpl, calls }
}
describe('wechat-ilink QR 登录', () => {
const homes: string[] = []
afterEach(() => {
while (homes.length) rmSync(homes.pop()!, { recursive: true, force: true })
})
function createHome(): string {
const home = mkdtempSync(join(tmpdir(), 'tracememo-ilink-login-'))
homes.push(home)
return home
}
function createLogin(options: {
home: string
statusQueue: unknown[]
maxQrRefreshes?: number
verifyCodeProvider?: () => Promise<string | undefined>
}): {
calls: RecordedCall[]
events: WechatLoginEvent[]
run: () => Promise<ILinkCredentials>
} {
const controller = new AbortController()
const { fetchImpl, calls } = createScriptedFetch(options.statusQueue, () => controller.abort())
const events: WechatLoginEvent[] = []
return {
calls,
events,
run: () =>
runQrLogin({
home: () => options.home,
fetchImpl,
signal: controller.signal,
sleep: async () => undefined,
qrEncoder: async (content) =>
`data:image/png;base64,${Buffer.from(content).toString('base64')}`,
...(options.maxQrRefreshes !== undefined
? { maxQrRefreshes: options.maxQrRefreshes }
: {}),
...(options.verifyCodeProvider ? { verifyCodeProvider: options.verifyCodeProvider } : {}),
onEvent: (event) => events.push(event)
})
}
}
it('完成 wait → scaned → confirmed 流程并落盘凭据', async () => {
const home = createHome()
const harness = createLogin({
home,
statusQueue: [{ status: 'wait' }, { status: 'scaned' }, credentialsBody]
})
const credentials = await harness.run()
expect(credentials).toMatchObject({
bot_token: 'bot-token-value',
ilink_bot_id: 'bot_abc@im.bot',
ilink_user_id: 'user_42'
})
expect(harness.events.map((event) => event.status)).toEqual([
'qrcode',
'wait',
'scaned',
'confirmed',
'active'
])
const qrEvent = harness.events[0]
expect(
qrEvent.status === 'qrcode' && qrEvent.qrCodeDataUrl.startsWith('data:image/png;base64,')
).toBe(true)
const stored = JSON.parse(
readFileSync(
join(
accountsDirectory(() => home),
'bot_abc-im-bot.json'
),
'utf8'
)
) as { bot_token: string }
expect(stored.bot_token).toBe('bot-token-value')
})
it('用 POST + local_token_list 获取二维码,且登录前不发送 Authorization', async () => {
const home = createHome()
const harness = createLogin({ home, statusQueue: [credentialsBody] })
await harness.run()
const qrCall = harness.calls[0]
expect(qrCall.url).toBe('https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3')
expect(qrCall.method).toBe('POST')
expect(JSON.parse(qrCall.body!)).toEqual({ local_token_list: [] })
expect(qrCall.headers['ilink-app-id']).toBe('bot')
expect(qrCall.headers['ilink-app-clientversion']).toBe('132102')
expect(qrCall.headers.authorization).toBeUndefined()
const statusCall = harness.calls.find((call) => call.url.includes('get_qrcode_status'))!
expect(statusCall.headers.authorization).toBeUndefined()
expect(statusCall.headers['x-wechat-uin']).toBeUndefined()
})
it('已登录账号的 token 会进入 local_token_list', async () => {
const home = createHome()
const directory = accountsDirectory(() => home)
mkdirSync(directory, { recursive: true })
writeFileSync(
join(directory, 'existing.json'),
JSON.stringify({
bot_token: 'existing-token',
ilink_bot_id: 'bot_existing',
baseurl: 'https://ilinkai.weixin.qq.com',
ilink_user_id: 'user_1'
}),
'utf8'
)
const harness = createLogin({ home, statusQueue: [credentialsBody] })
await harness.run()
expect(JSON.parse(harness.calls[0].body!)).toEqual({ local_token_list: ['existing-token'] })
})
it('scaned_but_redirect 后改用 redirect_host 继续轮询状态', async () => {
const home = createHome()
const harness = createLogin({
home,
statusQueue: [
{ status: 'scaned_but_redirect', redirect_host: 'idc2.weixin.qq.com' },
credentialsBody
]
})
await harness.run()
const statusCalls = harness.calls.filter((call) => call.url.includes('get_qrcode_status'))
expect(statusCalls[0].url.startsWith('https://ilinkai.weixin.qq.com/')).toBe(true)
expect(statusCalls[1].url.startsWith('https://idc2.weixin.qq.com/')).toBe(true)
expect(statusCalls[1].url).toContain('qrcode=qr-secret-token')
})
it('need_verifycode 时把配对码填进下一次状态请求', async () => {
const home = createHome()
const harness = createLogin({
home,
statusQueue: [{ status: 'need_verifycode' }, credentialsBody],
verifyCodeProvider: async () => ' 246810 '
})
await harness.run()
const statusCalls = harness.calls.filter((call) => call.url.includes('get_qrcode_status'))
expect(statusCalls[0].url).not.toContain('verify_code')
expect(statusCalls[1].url).toContain('verify_code=246810')
expect(harness.events.some((event) => event.status === 'need_verifycode')).toBe(true)
})
it('没有配对码输入通道时刷新二维码,超过上限后明确失败', async () => {
const home = createHome()
const harness = createLogin({
home,
statusQueue: [{ status: 'need_verifycode' }, { status: 'need_verifycode' }],
maxQrRefreshes: 1
})
await expect(harness.run()).rejects.toThrow(/二维码多次刷新后仍未完成登录/)
expect(harness.calls.filter((call) => call.url.includes('get_bot_qrcode'))).toHaveLength(2)
})
it('verify_code_blocked 清除配对码并刷新二维码', async () => {
const home = createHome()
const harness = createLogin({
home,
statusQueue: [
{ status: 'need_verifycode' },
{ status: 'verify_code_blocked' },
{ status: 'need_verifycode' },
{ status: 'verify_code_blocked' }
],
maxQrRefreshes: 1,
verifyCodeProvider: async () => '123456'
})
await expect(harness.run()).rejects.toThrow(/二维码多次刷新后仍未完成登录/)
const statusCalls = harness.calls.filter((call) => call.url.includes('get_qrcode_status'))
expect(statusCalls[1].url).toContain('verify_code=123456')
// 被限制后重新扫码,第二次请求不再携带旧配对码。
expect(statusCalls[2].url).not.toContain('verify_code=')
expect(harness.events.some((event) => event.status === 'verify_code_blocked')).toBe(true)
})
it('二维码过期时自动刷新', async () => {
const home = createHome()
const harness = createLogin({
home,
statusQueue: [{ status: 'expired' }, credentialsBody],
maxQrRefreshes: 2
})
await harness.run()
expect(harness.calls.filter((call) => call.url.includes('get_bot_qrcode'))).toHaveLength(2)
expect(harness.events.filter((event) => event.status === 'qrcode')).toHaveLength(2)
expect(harness.events.some((event) => event.status === 'expired')).toBe(true)
})
it('binded_redirect 在本地没有可用凭据时不能当作登录成功', async () => {
const home = createHome()
const harness = createLogin({
home,
statusQueue: [{ status: 'binded_redirect' }],
maxQrRefreshes: 0
})
await expect(harness.run()).rejects.toThrow(/二维码多次刷新后仍未完成登录/)
})
it('binded_redirect 在本地已有凭据时直接复用,不重新扫码', async () => {
const home = createHome()
const directory = accountsDirectory(() => home)
mkdirSync(directory, { recursive: true })
writeFileSync(
join(directory, 'bot_existing.json'),
JSON.stringify({
bot_token: 'existing-token',
ilink_bot_id: 'bot_existing',
baseurl: 'https://ilinkai.weixin.qq.com',
ilink_user_id: 'user_1'
}),
'utf8'
)
const harness = createLogin({ home, statusQueue: [{ status: 'binded_redirect' }] })
const credentials = await harness.run()
expect(credentials.bot_token).toBe('existing-token')
expect(harness.events.map((event) => event.status)).toEqual(['qrcode', 'confirmed', 'active'])
})
it('confirmed 但缺少 ilink_bot_id 时明确报错而不是写入半截凭据', async () => {
const home = createHome()
const harness = createLogin({
home,
statusQueue: [{ status: 'confirmed', bot_token: 'x' }]
})
await expect(harness.run()).rejects.toThrow(/ilink_bot_id/)
})
it('状态轮询网络错误按 wait 处理并继续轮询', async () => {
const home = createHome()
const harness = createLogin({
home,
statusQueue: [new Error('ETIMEDOUT'), { status: 'scaned' }, credentialsBody]
})
const credentials = await harness.run()
expect(credentials.ilink_bot_id).toBe('bot_abc@im.bot')
})
})
@@ -0,0 +1,252 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WechatConnectorService } from '../../src/main/services/wechat-ilink'
import {
accountsDirectory,
normalizeAccountId,
saveCredentials
} from '../../src/main/services/wechat-ilink/account-store'
import type { WechatInboundMessage } from '../../src/main/services/wechat-ilink/types'
const ACCOUNT_ID = 'bot_ctx@im.bot'
const NORMALIZED_ACCOUNT_ID = normalizeAccountId(ACCOUNT_ID)
interface RecordedRequest {
path: string
body: Record<string, unknown>
}
describe('wechat-ilink 连接器:context_token 全链路', () => {
const homes: string[] = []
afterEach(() => {
while (homes.length) rmSync(homes.pop()!, { recursive: true, force: true })
})
function createHome(): string {
const home = mkdtempSync(join(tmpdir(), 'tracememo-ilink-ctx-'))
homes.push(home)
saveCredentials(
{
bot_token: 'bot-token-value',
ilink_bot_id: ACCOUNT_ID,
baseurl: 'https://ilinkai.weixin.qq.com',
ilink_user_id: 'user_42'
},
() => home
)
return home
}
function createHarness(options: { inboundContextToken?: string; sendRet?: number } = {}): {
home: string
service: WechatConnectorService
requests: RecordedRequest[]
received: WechatInboundMessage[]
fetchImpl: typeof fetch
} {
const home = createHome()
const requests: RecordedRequest[] = []
const received: WechatInboundMessage[] = []
let getUpdatesCalls = 0
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input))
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {}
requests.push({ path: url.pathname, body })
const json = (payload: unknown): Response =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
if (url.pathname.endsWith('/getupdates')) {
getUpdatesCalls += 1
if (getUpdatesCalls === 1) {
return json({
ret: 0,
errcode: 0,
msgs: [
{
message_id: 9001,
from_user_id: 'user@im.wechat',
message_type: 1,
item_list: [{ type: 1, text_item: { text: '最近聊了什么' } }],
context_token: options.inboundContextToken ?? 'ctx-from-inbound'
}
],
get_updates_buf: 'buf-after-first-batch',
longpolling_timeout_ms: 35_000
})
}
// 让出宏任务,避免测试期间的紧密轮询饿死定时器。
await new Promise((resolve) => setTimeout(resolve, 2))
return json({ ret: 0, msgs: [], get_updates_buf: 'buf-after-first-batch' })
}
if (url.pathname.endsWith('/sendmessage')) {
return json({ ret: options.sendRet ?? 0, errmsg: options.sendRet ? 'send failed' : '' })
}
return json({ ret: 0 })
}) as unknown as typeof fetch
const service = new WechatConnectorService({
home: () => home,
fetchImpl,
sleep: async () => undefined
})
service.setHost({
onMessages: async (messages) => {
received.push(...messages)
}
})
return { home, service, requests, received, fetchImpl }
}
it('入站 context_token 原样出现在回复的 sendmessage 请求里', async () => {
const harness = createHarness({ inboundContextToken: 'ctx-from-inbound' })
await harness.service.start(ACCOUNT_ID)
await vi.waitFor(() => expect(harness.received).toHaveLength(1))
expect(harness.received[0].contextToken).toBe('ctx-from-inbound')
await harness.service.sendText({
to: 'user@im.wechat',
text: '这是回答',
contextToken: harness.received[0].contextToken
})
await harness.service.stop()
const send = harness.requests.filter((request) => request.path.endsWith('/sendmessage'))
expect(send).toHaveLength(1)
const msg = send[0].body.msg as Record<string, unknown>
expect(msg.context_token).toBe('ctx-from-inbound')
expect(msg.to_user_id).toBe('user@im.wechat')
expect(msg.message_type).toBe(2)
expect(msg.message_state).toBe(2)
// 官方实现:Bot 外发 from_user_id 传空字符串。
expect(msg.from_user_id).toBe('')
expect((send[0].body.base_info as Record<string, unknown>).channel_version).toBe('2.4.6')
expect((msg.item_list as Array<Record<string, unknown>>)[0]).toEqual({
type: 1,
text_item: { text: '这是回答' }
})
})
it('主动发送时回退到该会话最近一次有效 context_token', async () => {
const harness = createHarness({ inboundContextToken: 'ctx-stored' })
await harness.service.start(ACCOUNT_ID)
await vi.waitFor(() => expect(harness.received).toHaveLength(1))
// 定时日报这类主动发送不会携带入站 token。
await harness.service.sendText({ to: 'user@im.wechat', text: '日报已生成' })
await harness.service.stop()
const send = harness.requests.filter((request) => request.path.endsWith('/sendmessage'))
expect(send[0].body.msg).toMatchObject({ context_token: 'ctx-stored' })
})
it('没有入站记录的会话主动发送时 context_token 为空字符串而不是错误 token', async () => {
const harness = createHarness()
await harness.service.start(ACCOUNT_ID)
await vi.waitFor(() => expect(harness.received).toHaveLength(1))
await harness.service.sendText({ to: 'someone-else@im.wechat', text: 'hi' })
await harness.service.stop()
const send = harness.requests.filter((request) => request.path.endsWith('/sendmessage'))
expect(send[0].body.msg).toMatchObject({ context_token: '' })
})
it('getupdates 携带持久化游标,并在消息被接收后推进游标', async () => {
const harness = createHarness()
await harness.service.start(ACCOUNT_ID)
await vi.waitFor(() => expect(harness.received).toHaveLength(1))
await harness.service.stop()
const getUpdates = harness.requests.filter((request) => request.path.endsWith('/getupdates'))
expect(getUpdates[0].body.get_updates_buf).toBe('')
const cursorFile = join(
accountsDirectory(() => harness.home),
`${NORMALIZED_ACCOUNT_ID}.sync.json`
)
const stored = JSON.parse(readFileSync(cursorFile, 'utf8')) as { get_updates_buf: string }
expect(stored.get_updates_buf).toBe('buf-after-first-batch')
})
it('session 失效时进入 stale_token 状态并停止轮询', async () => {
const home = createHome()
const phases: string[] = []
const fetchImpl = (async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname.endsWith('/getupdates')) {
return new Response(
JSON.stringify({ ret: 0, errcode: -14, msgs: [], get_updates_buf: '' }),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
)
}
return new Response(JSON.stringify({ ret: 0 }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
}) as unknown as typeof fetch
const service = new WechatConnectorService({ home: () => home, fetchImpl })
service.setHost({
onMessages: async () => undefined,
onPhaseChange: (phase) => phases.push(phase)
})
await service.start(ACCOUNT_ID)
await vi.waitFor(() => expect(service.getPhase()).toBe('stale_token'))
expect(phases).toContain('stale_token')
// 会话失效后发送必须被明确拒绝,而不是静默失败。
await expect(service.sendText({ to: 'user@im.wechat', text: 'hi' })).rejects.toThrow(
/登录凭证已失效/
)
})
it('sendmessage ret 非 0 时抛错,绝不当成发送成功', async () => {
const harness = createHarness({ sendRet: 40001 })
await harness.service.start(ACCOUNT_ID)
await vi.waitFor(() => expect(harness.received).toHaveLength(1))
await expect(harness.service.sendText({ to: 'user@im.wechat', text: 'hi' })).rejects.toThrow(
/ret=40001/
)
await harness.service.stop()
})
it('账号列表来自持久化凭据,且未启动时发送会被拒绝', async () => {
const home = createHome()
const directory = accountsDirectory(() => home)
mkdirSync(directory, { recursive: true })
writeFileSync(
join(directory, 'another.json'),
JSON.stringify({
bot_token: 'other',
ilink_bot_id: 'bot_other',
baseurl: 'https://ilinkai.weixin.qq.com',
ilink_user_id: 'user_9'
}),
'utf8'
)
const service = new WechatConnectorService({ home: () => home })
const accounts = service.listAccounts().map((item) => item.accountId)
expect(accounts).toContain(ACCOUNT_ID)
expect(accounts).toContain('bot_other')
await expect(service.sendText({ to: 'user@im.wechat', text: 'hi' })).rejects.toThrow(/尚未启动/)
})
})
+232
View File
@@ -0,0 +1,232 @@
import { describe, expect, it } from 'vitest'
import { ILinkPoller } from '../../src/main/services/wechat-ilink/poller'
import { ILinkError } from '../../src/main/services/wechat-ilink/errors'
import type {
ILinkGetUpdatesResponse,
ILinkWeixinMessage,
WechatInboundMessage
} from '../../src/main/services/wechat-ilink/types'
interface HarnessOptions {
initialCursor?: string
onMessages?: (messages: WechatInboundMessage[]) => Promise<void>
onStaleToken?: (message: string) => void
}
function message(id: number): ILinkWeixinMessage {
return {
message_id: id,
from_user_id: 'user@im.wechat',
message_type: 1,
item_list: [{ type: 1, text_item: { text: `msg-${id}` } }],
context_token: `ctx-${id}`
}
}
function createHarness(
steps: Array<ILinkGetUpdatesResponse | Error>,
options: HarnessOptions = {}
): {
poller: ILinkPoller
savedCursors: string[]
dispatched: WechatInboundMessage[][]
logs: string[]
timeline: string[]
fetchTimeouts: number[]
fetchCount: () => number
} {
const controller = new AbortController()
const savedCursors: string[] = []
const dispatched: WechatInboundMessage[][] = []
const logs: string[] = []
const timeline: string[] = []
const fetchTimeouts: number[] = []
let index = 0
const poller = new ILinkPoller({
fetchUpdates: async (getUpdatesBuf, timeoutMs) => {
fetchTimeouts.push(timeoutMs)
if (index >= steps.length) {
// 队列耗尽后结束循环,避免无限轮询。
controller.abort()
return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf }
}
const step = steps[index]
index += 1
if (step instanceof Error) throw step
return step
},
onMessages: async (messages) => {
dispatched.push(messages)
timeline.push('dispatch')
await options.onMessages?.(messages)
},
normalize: (raw): WechatInboundMessage | undefined => {
if (!raw.from_user_id) return undefined
return {
accountId: 'acc',
fromUserId: raw.from_user_id,
messageId: String(raw.message_id ?? ''),
messageType: 1,
items: [{ type: 1, text: raw.item_list?.[0]?.text_item?.text ?? '' }],
receivedAt: 1
}
},
loadCursor: () => options.initialCursor ?? '',
saveCursor: (getUpdatesBuf) => {
savedCursors.push(getUpdatesBuf)
timeline.push('saveCursor')
},
signal: controller.signal,
log: (level, text) => logs.push(`${level}:${text}`),
...(options.onStaleToken ? { onStaleToken: options.onStaleToken } : {}),
sleep: async () => undefined,
now: () => 0
})
return {
poller,
savedCursors,
dispatched,
logs,
timeline,
fetchTimeouts,
fetchCount: () => index
}
}
describe('ILinkPoller', () => {
it('只有 dispatch 成功之后才持久化新游标', async () => {
const harness = createHarness([{ ret: 0, msgs: [message(1)], get_updates_buf: 'buf-1' }])
await harness.poller.run()
expect(harness.dispatched).toHaveLength(1)
expect(harness.savedCursors).toEqual(['buf-1'])
// 顺序固定:先投递,再推进游标。反过来就会出现"游标已推进、消息还没处理"的静默丢消息。
expect(harness.timeline).toEqual(['dispatch', 'saveCursor'])
})
it('dispatch 失败时不推进游标,让服务端重投', async () => {
let attempts = 0
const harness = createHarness(
[
{ ret: 0, msgs: [message(7)], get_updates_buf: 'buf-1' },
{ ret: 0, msgs: [message(7)], get_updates_buf: 'buf-1' }
],
{
onMessages: async () => {
attempts += 1
if (attempts === 1) throw new Error('AgentHub 暂时不可用')
}
}
)
await harness.poller.run()
expect(attempts).toBe(2)
// 第一次失败不写游标;第二次成功后写一次。
expect(harness.savedCursors).toEqual(['buf-1'])
expect(harness.logs.some((line) => line.startsWith('error:'))).toBe(true)
})
it('同一批消息重投时允许重复,但不能丢弃', async () => {
const harness = createHarness([
{ ret: 0, msgs: [message(1), message(2)], get_updates_buf: 'buf-9' }
])
await harness.poller.run()
expect(harness.dispatched[0].map((item) => item.messageId)).toEqual(['1', '2'])
expect(harness.savedCursors).toEqual(['buf-9'])
})
it('errcode=-14 视为 bot token 失效:停止轮询、不重置游标、不立即重试', async () => {
const staleMessages: string[] = []
const harness = createHarness(
[
{ ret: 0, errcode: -14, errmsg: 'stale token', msgs: [], get_updates_buf: 'buf-ignored' },
{ ret: 0, msgs: [message(1)], get_updates_buf: 'buf-later' }
],
{ onStaleToken: (text) => staleMessages.push(text) }
)
await harness.poller.run()
expect(staleMessages).toHaveLength(1)
// 第二条响应永远不会被消费:-14 之后不再无间隔重试。
expect(harness.fetchCount()).toBe(1)
expect(harness.dispatched).toHaveLength(0)
// 游标保持不变,重新登录后仍可续上原有进度。
expect(harness.savedCursors).toEqual([])
expect(harness.logs.some((line) => line.includes('重新扫码登录'))).toBe(true)
})
it('客户端长轮询超时属于正常控制流,不推进游标也不计入失败', async () => {
const harness = createHarness([
new ILinkError({ kind: 'timeout', message: 'request timeout' }),
{ ret: 0, msgs: [], get_updates_buf: '' }
])
await harness.poller.run()
expect(harness.fetchTimeouts.length).toBeGreaterThanOrEqual(2)
expect(harness.savedCursors).toEqual([])
expect(harness.logs.some((line) => line.includes('获取更新失败'))).toBe(false)
})
it('ret 非 0 时按退避重试且不推进游标', async () => {
const harness = createHarness([
{ ret: 500, errcode: 500, errmsg: 'server busy', msgs: [], get_updates_buf: 'buf-x' },
{ ret: 0, msgs: [], get_updates_buf: 'buf-y' }
])
await harness.poller.run()
expect(harness.savedCursors).toEqual(['buf-y'])
expect(harness.logs.some((line) => line.includes('服务端返回错误'))).toBe(true)
})
it('采用服务端建议的 longpolling_timeout_ms', async () => {
const harness = createHarness([
{ ret: 0, msgs: [], get_updates_buf: 'a', longpolling_timeout_ms: 5_000 },
{ ret: 0, msgs: [], get_updates_buf: 'b', longpolling_timeout_ms: 7_000 }
])
await harness.poller.run()
expect(harness.fetchTimeouts[0]).toBe(35_000)
expect(harness.fetchTimeouts[1]).toBe(5_000)
expect(harness.fetchTimeouts[2]).toBe(7_000)
})
it('从失败中恢复时补一条 info,让故障窗口在日志里有边界', async () => {
const harness = createHarness([
new Error('ECONNRESET'),
{ ret: 0, msgs: [], get_updates_buf: 'ok' }
])
await harness.poller.run()
expect(harness.logs.some((line) => line.startsWith('warn:'))).toBe(true)
expect(harness.logs.filter((line) => line.includes('已恢复'))).toHaveLength(1)
})
it('一直正常时不打恢复日志', async () => {
const harness = createHarness([{ ret: 0, msgs: [], get_updates_buf: 'a' }])
await harness.poller.run()
expect(harness.logs.some((line) => line.includes('已恢复'))).toBe(false)
})
it('从持久化游标恢复,且不沿用空游标覆盖', async () => {
const harness = createHarness([{ ret: 0, msgs: [], get_updates_buf: 'buf-next' }], {
initialCursor: 'buf-restored'
})
expect(harness.poller.getUpdatesBuf).toBe('buf-restored')
await harness.poller.run()
expect(harness.savedCursors).toEqual(['buf-next'])
})
})
+175
View File
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest'
import {
buildAuthorizedHeaders,
buildBaseInfo,
buildCommonHeaders,
buildQrStatusHeaders,
generateWechatUin,
sanitizeBotAgent
} from '../../src/main/services/wechat-ilink/headers'
import {
extractMarkdownImageUrls,
markdownToPlainText
} from '../../src/main/services/wechat-ilink/markdown'
import {
extractInboundText,
normalizeInboundMessage
} from '../../src/main/services/wechat-ilink/messages'
describe('wechat-ilink headers', () => {
it('X-WECHAT-UIN 是随机 uint32 的 base64,并且每次请求都重新生成', () => {
const decoded = Buffer.from(generateWechatUin(), 'base64').toString('utf8')
expect(decoded).toMatch(/^\d+$/)
expect(Number(decoded)).toBeGreaterThanOrEqual(0)
expect(Number(decoded)).toBeLessThanOrEqual(0xffffffff)
const first = buildAuthorizedHeaders('token')['X-WECHAT-UIN']
const second = buildAuthorizedHeaders('token')['X-WECHAT-UIN']
expect(first).toBeTruthy()
// 官方要求每次请求重新生成以防重放:连续两次不应相同。
expect(second).not.toBe(first)
})
it('登录后的业务头包含鉴权字段与固定应用头', () => {
const headers = buildAuthorizedHeaders('bot-token')
expect(headers.AuthorizationType).toBe('ilink_bot_token')
expect(headers.Authorization).toBe('Bearer bot-token')
expect(headers['iLink-App-Id']).toBe('bot')
expect(headers['iLink-App-ClientVersion']).toBe('132102')
})
it('没有 token 时拒绝构造鉴权头', () => {
expect(() => buildAuthorizedHeaders('')).toThrow(/bot token/)
})
it('登录前后的公共头集合不同:未鉴权请求不携带 Authorization / X-WECHAT-UIN', () => {
const anonymous = { ...buildCommonHeaders(), ...buildQrStatusHeaders() }
expect(anonymous.Authorization).toBeUndefined()
expect(anonymous.AuthorizationType).toBeUndefined()
expect(anonymous['X-WECHAT-UIN']).toBeUndefined()
expect(anonymous['iLink-App-Id']).toBe('bot')
})
it('base_info 携带当前协议版本与合法 bot_agent', () => {
const baseInfo = buildBaseInfo()
expect(baseInfo.channel_version).toBe('2.4.6')
expect(baseInfo.bot_agent).toMatch(/^[\x21-\x7e]+$/)
})
it('bot_agent 清洗非法 token 并在为空时回退', () => {
expect(sanitizeBotAgent('TraceMemo/2.4.0')).toBe('TraceMemo/2.4.0')
expect(sanitizeBotAgent('TraceMemo/2.4.0 (mac)')).toBe('TraceMemo/2.4.0 (mac)')
expect(sanitizeBotAgent('中文非法/1.0')).toBe('OpenClaw')
expect(sanitizeBotAgent('')).toBe('OpenClaw')
expect(sanitizeBotAgent(undefined)).toBe('OpenClaw')
// 混合时丢弃非法 token,保留合法部分。
expect(sanitizeBotAgent('好坏的 Agent/1.0')).toBe('Agent/1.0')
})
it('bot_agent 总长度受 256 字节限制', () => {
const agent = Array.from({ length: 200 }, (_, index) => `Agent${index}/1.0`).join(' ')
expect(Buffer.byteLength(sanitizeBotAgent(agent), 'utf8')).toBeLessThanOrEqual(256)
})
})
describe('wechat-ilink markdown 降级', () => {
it('把常见 Markdown 降级成微信可读纯文本', () => {
const input = [
'# 群聊总结',
'',
'**重点**:`code` 与 ~~删除线~~',
'',
'- 第一项',
'- 第二项',
'',
'> 引用',
'',
'| A | B |',
'| --- | --- |',
'| 1 | 2 |',
'',
'链接:[官网](https://example.com)',
'图片:![图](https://example.com/a.png)',
'',
'```',
'const a = 1',
'```'
].join('\n')
const output = markdownToPlainText(input)
expect(output).toContain('群聊总结')
expect(output).not.toContain('#')
expect(output).toContain('重点')
expect(output).not.toContain('**')
expect(output).toContain('code')
expect(output).not.toContain('`')
expect(output).toContain('删除线')
expect(output).not.toContain('~~')
expect(output).toContain('• 第一项')
expect(output).not.toContain('> 引用')
expect(output).toContain('A B')
expect(output).not.toContain('|')
expect(output).toContain('官网')
expect(output).not.toContain('https://example.com/a.png')
expect(output).toContain('const a = 1')
expect(output).not.toContain('```')
})
it('折叠多余空行并去掉首尾空白', () => {
expect(markdownToPlainText('\n\n\n内容\n\n\n')).toBe('内容')
})
it('提取文本中内嵌的 http(s) 图片地址', () => {
const text = '看图 ![a](https://cdn.example.com/1.png) 和 ![b](/local/2.png)'
expect(extractMarkdownImageUrls(text)).toEqual(['https://cdn.example.com/1.png'])
})
})
describe('wechat-ilink 入站消息归一化', () => {
it('完整透传 context_token 与会话标识', () => {
const message = normalizeInboundMessage('bot-1', {
message_id: 12345,
seq: 3,
from_user_id: 'user@im.wechat',
message_type: 1,
item_list: [{ type: 1, text_item: { text: '你好' } }],
context_token: 'ctx-token',
session_id: 'session-1',
group_id: 'group-1'
})
expect(message).toMatchObject({
accountId: 'bot-1',
fromUserId: 'user@im.wechat',
messageId: '12345',
seq: 3,
sessionId: 'session-1',
groupId: 'group-1',
contextToken: 'ctx-token'
})
expect(message?.items).toEqual([{ type: 1, text: '你好' }])
})
it('缺少 from_user_id 的消息无法投递', () => {
expect(normalizeInboundMessage('bot-1', { message_id: 1 })).toBeUndefined()
expect(normalizeInboundMessage('bot-1', { from_user_id: ' ' })).toBeUndefined()
})
it('语音条目保留微信侧转写文本,但不算作文本条目', () => {
const message = normalizeInboundMessage('bot-1', {
from_user_id: 'user@im.wechat',
item_list: [{ type: 3, voice_item: { text: '语音转写内容' } }]
})
expect(message?.items).toEqual([{ type: 3, text: '语音转写内容' }])
// 与旧 webhook 行为一致:只有 type=1 参与文本提取。
expect(extractInboundText(message?.items)).toBe('')
})
it('多条文本条目按空格拼接', () => {
expect(
extractInboundText([{ type: 1, text: ' 第一段 ' }, { type: 2 }, { type: 1, text: '第二段' }])
).toBe('第一段 第二段')
})
})
@@ -0,0 +1,195 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { WechatConnectorService } from '../../src/main/services/wechat-ilink'
import { saveCredentials } from '../../src/main/services/wechat-ilink/account-store'
import {
ILINK_TYPING_STATUS_CANCEL,
ILINK_TYPING_STATUS_TYPING
} from '../../src/main/services/wechat-ilink/types'
const ACCOUNT_ID = 'bot_typing@im.bot'
const PEER = 'fixture-user-openid@im.wechat'
interface Recorded {
path: string
body: Record<string, unknown>
}
describe('wechat-ilink typing 协议接线', () => {
const homes: string[] = []
afterEach(() => {
while (homes.length) rmSync(homes.pop()!, { recursive: true, force: true })
})
async function createHarness(options: { keepaliveMs?: number } = {}): Promise<{
service: WechatConnectorService
requests: Recorded[]
logs: string[]
ticks: Array<() => void>
}> {
const home = mkdtempSync(join(tmpdir(), 'tracememo-ilink-typing-'))
homes.push(home)
saveCredentials(
{
bot_token: 'bot-token-value',
ilink_bot_id: ACCOUNT_ID,
baseurl: 'https://ilinkai.weixin.qq.com',
ilink_user_id: PEER
},
() => home
)
const requests: Recorded[] = []
const logs: string[] = []
const ticks: Array<() => void> = []
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input))
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {}
requests.push({ path: url.pathname, body })
const json = (payload: unknown): Response =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
if (url.pathname.endsWith('/getupdates')) {
await new Promise((resolve) => setTimeout(resolve, 2))
return json({ ret: 0, msgs: [], get_updates_buf: '' })
}
if (url.pathname.endsWith('/getconfig')) {
return json({ ret: 0, errmsg: '', typing_ticket: 'ticket-abc' })
}
if (url.pathname.endsWith('/sendtyping')) return json({ ret: 0, errmsg: '' })
return json({ ret: 0 })
}) as unknown as typeof fetch
const service = new WechatConnectorService({
home: () => home,
fetchImpl,
sleep: async () => undefined,
...(options.keepaliveMs !== undefined ? { typingKeepaliveMs: options.keepaliveMs } : {}),
typingSchedule: (tick) => {
ticks.push(tick)
return () => undefined
}
})
service.setHost({ onLog: (_level, message) => logs.push(message) })
await service.start(ACCOUNT_ID)
return { service, requests, logs, ticks }
}
it('下发 getconfig → sendtyping(1),stop 时 sendtyping(2)', async () => {
const harness = await createHarness()
const lease = await harness.service.beginTyping({ to: PEER, contextToken: 'ctx-1' })
const configCalls = harness.requests.filter((r) => r.path.endsWith('/getconfig'))
expect(configCalls).toHaveLength(1)
// getconfig 需要带上对端与会话上下文。
expect(configCalls[0].body).toMatchObject({
ilink_user_id: PEER,
context_token: 'ctx-1'
})
expect((configCalls[0].body.base_info as Record<string, unknown>).channel_version).toBe('2.4.6')
const typingCalls = harness.requests.filter((r) => r.path.endsWith('/sendtyping'))
expect(typingCalls).toHaveLength(1)
expect(typingCalls[0].body).toMatchObject({
ilink_user_id: PEER,
typing_ticket: 'ticket-abc',
status: ILINK_TYPING_STATUS_TYPING
})
await lease.stop()
const afterStop = harness.requests.filter((r) => r.path.endsWith('/sendtyping'))
expect(afterStop).toHaveLength(2)
expect(afterStop[1].body).toMatchObject({
ilink_user_id: PEER,
typing_ticket: 'ticket-abc',
status: ILINK_TYPING_STATUS_CANCEL
})
await harness.service.stop()
})
it('typing_ticket 按对端缓存:第二次会话不再调 getconfig', async () => {
const harness = await createHarness()
const first = await harness.service.beginTyping({ to: PEER, contextToken: 'ctx-1' })
await first.stop()
const second = await harness.service.beginTyping({ to: PEER, contextToken: 'ctx-1' })
await second.stop()
expect(harness.requests.filter((r) => r.path.endsWith('/getconfig'))).toHaveLength(1)
expect(harness.requests.filter((r) => r.path.endsWith('/sendtyping'))).toHaveLength(4)
await harness.service.stop()
})
it('keepalive 会周期性重发 status=1', async () => {
const harness = await createHarness()
const lease = await harness.service.beginTyping({ to: PEER })
expect(harness.ticks).toHaveLength(1)
harness.ticks[0]()
harness.ticks[0]()
await new Promise((resolve) => setTimeout(resolve, 5))
const typingCalls = harness.requests.filter((r) => r.path.endsWith('/sendtyping'))
expect(typingCalls).toHaveLength(3)
expect(typingCalls.every((call) => call.body.status === ILINK_TYPING_STATUS_TYPING)).toBe(true)
await lease.stop()
await harness.service.stop()
})
it('并发两个 lease:只有最后一个 stop 才下发 cancel', async () => {
const harness = await createHarness()
const first = await harness.service.beginTyping({ to: PEER })
const second = await harness.service.beginTyping({ to: PEER })
await first.stop()
let typingCalls = harness.requests.filter((r) => r.path.endsWith('/sendtyping'))
expect(typingCalls).toHaveLength(1)
await second.stop()
typingCalls = harness.requests.filter((r) => r.path.endsWith('/sendtyping'))
expect(typingCalls).toHaveLength(2)
expect(typingCalls[1].body.status).toBe(ILINK_TYPING_STATUS_CANCEL)
await harness.service.stop()
})
it('日志里不含 typing_ticket 与 context_token 原文', async () => {
const harness = await createHarness()
const lease = await harness.service.beginTyping({ to: PEER, contextToken: 'ctx-SECRET' })
await lease.stop()
await harness.service.stop()
const joined = harness.logs.join('\n')
expect(joined).toContain('typing.start')
expect(joined).toContain('typing.stop')
expect(joined).not.toContain('ticket-abc')
expect(joined).not.toContain('ctx-SECRET')
expect(joined).not.toContain(PEER)
expect(joined).not.toContain('bot-token-value')
})
it('连接器 stop 后再 beginTyping 会安全降级(不发请求也不抛)', async () => {
const harness = await createHarness()
await harness.service.stop()
const before = harness.requests.filter((r) => r.path.endsWith('/sendtyping')).length
const lease = await harness.service.beginTyping({ to: PEER })
await lease.stop()
expect(harness.requests.filter((r) => r.path.endsWith('/sendtyping'))).toHaveLength(before)
})
})
+367
View File
@@ -0,0 +1,367 @@
import { describe, expect, it } from 'vitest'
import { TypingCoordinator } from '../../src/main/services/wechat-ilink/typing'
import {
ILINK_TYPING_KEEPALIVE_MS,
ILINK_TYPING_STATUS_CANCEL,
ILINK_TYPING_STATUS_TYPING
} from '../../src/main/services/wechat-ilink/types'
interface HarnessOptions {
tickets?: Array<string | undefined | Error>
sendResults?: Array<boolean | Error | undefined>
keepaliveMs?: number
}
function createHarness(options: HarnessOptions = {}): {
coordinator: TypingCoordinator
logs: string[]
typingCalls: Array<{ userId: string; status: number; ticket: string }>
ticks: Array<() => void>
scheduled: number[]
ticketCalls: () => number
cancelled: () => number
advance: (ms: number) => void
} {
const tickets = [...(options.tickets ?? ['ticket-1'])]
const sends = [...(options.sendResults ?? [])]
const logs: string[] = []
const typingCalls: Array<{ userId: string; status: number; ticket: string }> = []
let ticketCalls = 0
const ticks: Array<() => void> = []
const scheduled: number[] = []
let cancelled = 0
let now = 1_000
const coordinator = new TypingCoordinator({
fetchTicket: async () => {
ticketCalls += 1
const next = tickets.length > 1 ? tickets.shift() : tickets[0]
if (next instanceof Error) throw next
return typeof next === 'string' ? next : undefined
},
sendTyping: async ({ ilinkUserId, ticket, status }) => {
typingCalls.push({ userId: ilinkUserId, status, ticket })
const next = sends.length > 1 ? sends.shift() : sends[0]
if (next instanceof Error) throw next
return next ?? true
},
log: (level, message) => logs.push(`${level}:${message}`),
now: () => now,
...(options.keepaliveMs !== undefined ? { keepaliveMs: options.keepaliveMs } : {}),
schedule: (tick, intervalMs) => {
scheduled.push(intervalMs)
ticks.push(tick)
return () => {
cancelled += 1
}
}
})
return {
coordinator,
logs,
typingCalls,
ticks,
scheduled,
ticketCalls: () => ticketCalls,
cancelled: () => cancelled,
advance: (ms: number) => {
now += ms
}
}
}
const PEER = 'fixture-user-openid@im.wechat'
describe('TypingCoordinator', () => {
it('正常时序:取票 → status=1 → stop 时 status=2', async () => {
const harness = createHarness()
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
expect(harness.ticketCalls()).toBe(1)
expect(harness.typingCalls.map((call) => call.status)).toEqual([ILINK_TYPING_STATUS_TYPING])
expect(harness.coordinator.activePeerCount).toBe(1)
expect(harness.logs.some((line) => line.includes('typing.start'))).toBe(true)
harness.advance(1_200)
await lease.stop()
expect(harness.typingCalls.map((call) => call.status)).toEqual([
ILINK_TYPING_STATUS_TYPING,
ILINK_TYPING_STATUS_CANCEL
])
expect(harness.coordinator.activePeerCount).toBe(0)
expect(harness.logs.some((line) => line.includes('typing.stop'))).toBe(true)
expect(harness.logs.find((line) => line.includes('typing.stop'))).toContain('duration=1200ms')
})
it('typing_ticket 按「账号+对端」缓存,不会每次重取', async () => {
const harness = createHarness()
const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await first.stop()
const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await second.stop()
expect(harness.ticketCalls()).toBe(1)
expect(harness.typingCalls).toHaveLength(4)
})
it('换账号或重新登录后缓存作废,会重新取票', async () => {
const harness = createHarness({ tickets: ['ticket-1', 'ticket-2'] })
const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await first.stop()
harness.coordinator.invalidateTickets()
const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await second.stop()
expect(harness.ticketCalls()).toBe(2)
expect(harness.typingCalls[2].ticket).toBe('ticket-2')
})
it('没有 typing_ticket 时降级:不下发任何 typing,也不抛异常', async () => {
const harness = createHarness({ tickets: [undefined] })
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await lease.stop()
expect(harness.typingCalls).toHaveLength(0)
expect(harness.coordinator.activePeerCount).toBe(0)
expect(harness.logs.some((line) => line.includes('typing.ticket.missing'))).toBe(true)
})
it('getconfig 失败时降级:业务照常,不抛异常', async () => {
const harness = createHarness({ tickets: [new Error('getconfig 网络错误')] })
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await lease.stop()
expect(harness.typingCalls).toHaveLength(0)
expect(harness.logs.some((line) => line.includes('typing.ticket.failed'))).toBe(true)
})
it('sendtyping start 失败(返回 ret!=0)时不当成成功,也不抛异常', async () => {
const harness = createHarness({ sendResults: [false] })
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
expect(harness.coordinator.activePeerCount).toBe(0)
expect(harness.logs.some((line) => line.includes('typing.start.failed'))).toBe(true)
await lease.stop()
// 没进入 active 就不该下发 cancel。
expect(harness.typingCalls.map((call) => call.status)).toEqual([ILINK_TYPING_STATUS_TYPING])
})
it('sendtyping 抛异常时不冒泡', async () => {
const harness = createHarness({ sendResults: [new Error('boom')] })
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await lease.stop()
expect(harness.coordinator.activePeerCount).toBe(0)
})
it('start 失败后 ticket 缓存作废,下次会重取', async () => {
const harness = createHarness({
tickets: ['stale-ticket', 'fresh-ticket'],
sendResults: [false, true]
})
const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await first.stop()
const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await second.stop()
expect(harness.ticketCalls()).toBe(2)
expect(harness.typingCalls[1].ticket).toBe('fresh-ticket')
})
it('stop 失败时记 typing.stop.failed,但 stop 本身不抛异常', async () => {
const harness = createHarness({ sendResults: [true, false] })
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await expect(lease.stop()).resolves.toBeUndefined()
expect(harness.logs.some((line) => line.includes('typing.stop.failed'))).toBe(true)
})
it('长任务按 5 秒节奏维持 typing', async () => {
const harness = createHarness()
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
expect(harness.scheduled).toEqual([ILINK_TYPING_KEEPALIVE_MS])
harness.ticks[0]()
harness.ticks[0]()
await Promise.resolve()
expect(
harness.typingCalls.filter((call) => call.status === ILINK_TYPING_STATUS_TYPING)
).toHaveLength(3)
await lease.stop()
expect(harness.cancelled()).toBe(1)
})
it('keepalive 失败只在同一会话里记一次日志', async () => {
const harness = createHarness({ sendResults: [true, false, false, false] })
await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
harness.ticks[0]()
await Promise.resolve()
harness.ticks[0]()
harness.ticks[0]()
await Promise.resolve()
expect(harness.logs.filter((line) => line.includes('typing.keepalive.failed'))).toHaveLength(1)
})
it('stop 之后 keepalive 不再产生请求', async () => {
const harness = createHarness()
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await lease.stop()
const countAfterStop = harness.typingCalls.length
harness.ticks[0]()
await Promise.resolve()
expect(harness.typingCalls).toHaveLength(countAfterStop)
})
it('并发:先结束的任务不会取消仍在执行任务的 typing', async () => {
const harness = createHarness()
const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
// 引用计数为 2 时不应重复 start。
expect(
harness.typingCalls.filter((call) => call.status === ILINK_TYPING_STATUS_TYPING)
).toHaveLength(1)
await first.stop()
expect(harness.typingCalls.map((call) => call.status)).toEqual([ILINK_TYPING_STATUS_TYPING])
expect(harness.coordinator.activePeerCount).toBe(1)
await second.stop()
expect(harness.typingCalls.map((call) => call.status)).toEqual([
ILINK_TYPING_STATUS_TYPING,
ILINK_TYPING_STATUS_CANCEL
])
expect(harness.coordinator.activePeerCount).toBe(0)
})
it('并发:最后一个任务结束后再次 begin 会重新 start', async () => {
const harness = createHarness()
const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await first.stop()
await second.stop()
const third = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await third.stop()
expect(harness.typingCalls.map((call) => call.status)).toEqual([
ILINK_TYPING_STATUS_TYPING,
ILINK_TYPING_STATUS_CANCEL,
ILINK_TYPING_STATUS_TYPING,
ILINK_TYPING_STATUS_CANCEL
])
})
it('不同对端的 typing 状态互相独立', async () => {
const other = 'other-openid@im.wechat'
const harness = createHarness()
const a = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
const b = await harness.coordinator.begin({ accountId: 'bot-1', to: other })
expect(harness.coordinator.activePeerCount).toBe(2)
await a.stop()
expect(harness.coordinator.activePeerCount).toBe(1)
expect(
harness.typingCalls.some(
(call) => call.userId === other && call.status === ILINK_TYPING_STATUS_TYPING
)
).toBe(true)
// 只取消了自己的那一个。
const cancels = harness.typingCalls.filter((call) => call.status === ILINK_TYPING_STATUS_CANCEL)
expect(cancels.map((call) => call.userId)).toEqual([PEER])
await b.stop()
expect(harness.coordinator.activePeerCount).toBe(0)
})
it('lease.stop 幂等:重复调用只取消一次', async () => {
const harness = createHarness()
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await lease.stop()
await lease.stop()
expect(
harness.typingCalls.filter((call) => call.status === ILINK_TYPING_STATUS_CANCEL)
).toHaveLength(1)
})
it('stop 超过 begin 次数时不会把引用计数压成负数', async () => {
const harness = createHarness()
const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await first.stop()
await first.stop()
await second.stop()
expect(harness.coordinator.activePeerCount).toBe(0)
const third = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
expect(harness.coordinator.activePeerCount).toBe(1)
await third.stop()
})
it('clear 会取消 keepalive 定时器', async () => {
const harness = createHarness()
await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
harness.coordinator.clear()
expect(harness.cancelled()).toBe(1)
expect(harness.coordinator.activePeerCount).toBe(0)
})
it('日志里不出现 typing_ticket / context_token 原文', async () => {
const secretTicket = 'TICKET-SECRET-VALUE'
const secretContext = 'CONTEXT-TOKEN-SECRET'
const harness = createHarness({ tickets: [secretTicket] })
const lease = await harness.coordinator.begin({
accountId: 'bot-1',
to: PEER,
contextToken: secretContext
})
harness.ticks[0]()
await Promise.resolve()
await lease.stop()
const joined = harness.logs.join('\n')
expect(joined).not.toContain(secretTicket)
expect(joined).not.toContain(secretContext)
// 也不该出现 openid 原文。
expect(joined).not.toContain(PEER)
// 但要有可诊断的信息。
expect(joined).toContain('typing.start')
expect(joined).toContain('ticketPresent=true')
})
it('空 to 直接返回空实现,不打任何接口', async () => {
const harness = createHarness()
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: ' ' })
await lease.stop()
expect(harness.ticketCalls()).toBe(0)
expect(harness.typingCalls).toHaveLength(0)
})
})
+116
View File
@@ -0,0 +1,116 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { WechatInboundInbox } from '../../src/main/services/wechat-inbound-inbox'
import type { WechatInboundMessage } from '../../src/main/services/wechat-ilink/types'
function inbound(messageId: string, text = 'hello'): WechatInboundMessage {
return {
accountId: 'bot-1',
fromUserId: 'user@im.wechat',
messageId,
messageType: 1,
contextToken: `ctx-${messageId}`,
items: [{ type: 1, text }],
receivedAt: 1_700_000_000_000
}
}
describe('WechatInboundInbox', () => {
const roots: string[] = []
afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
})
function createInbox(options: { maxAttempts?: number } = {}): {
inbox: WechatInboundInbox
filePath: string
root: string
} {
const root = mkdtempSync(join(tmpdir(), 'tracememo-inbox-'))
roots.push(root)
const filePath = join(root, 'agent-hub', 'inbound-inbox.json')
const inbox = new WechatInboundInbox({
filePath: () => filePath,
...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {})
})
return { inbox, filePath, root }
}
it('接收即落盘,并保留 context_token 供后续回复使用', () => {
const { inbox, filePath } = createInbox()
const accepted = inbox.accept([inbound('1'), inbound('2')])
expect(accepted.map((entry) => entry.messageId)).toEqual(['1', '2'])
expect(accepted[0].contextToken).toBe('ctx-1')
expect(statSync(filePath).mode & 0o777).toBe(0o600)
expect(inbox.size()).toBe(2)
})
it('重复投递同一 message_id 时不会重复接收', () => {
const { inbox } = createInbox()
inbox.accept([inbound('1')])
expect(inbox.accept([inbound('1')])).toEqual([])
expect(inbox.size()).toBe(1)
})
it('重新实例化后仍能读到未处理的消息(崩溃恢复)', () => {
const { inbox, filePath } = createInbox()
inbox.accept([inbound('7')])
const reopened = new WechatInboundInbox({ filePath: () => filePath })
expect(reopened.pending().map((entry) => entry.messageId)).toEqual(['7'])
})
it('处理成功后从收件箱移除', () => {
const { inbox } = createInbox()
const [entry] = inbox.accept([inbound('1')])
inbox.complete(entry.key)
expect(inbox.size()).toBe(0)
expect(inbox.contains(entry.key)).toBe(false)
})
it('失败累加尝试次数,达到上限后放弃但可被上层观测', () => {
const { inbox } = createInbox({ maxAttempts: 2 })
const [entry] = inbox.accept([inbound('1')])
expect(inbox.recordFailure(entry.key)).toEqual({ attempts: 1, abandoned: false })
expect(inbox.contains(entry.key)).toBe(true)
expect(inbox.recordFailure(entry.key)).toEqual({ attempts: 2, abandoned: true })
expect(inbox.contains(entry.key)).toBe(false)
})
it('没有 message_id 时使用回退键,仍然可追踪', () => {
const { inbox } = createInbox()
const message = { ...inbound(''), messageId: '' }
const [entry] = inbox.accept([message])
expect(entry.key).toContain('bot-1::user@im.wechat::')
expect(inbox.pending()).toHaveLength(1)
})
it('落盘失败时向上抛错,调用方据此放弃推进游标', () => {
const root = mkdtempSync(join(tmpdir(), 'tracememo-inbox-fail-'))
roots.push(root)
const asDirectory = join(root, 'blocked.json')
mkdirSync(asDirectory, { recursive: true })
const inbox = new WechatInboundInbox({ filePath: () => asDirectory })
expect(() => inbox.accept([inbound('1')])).toThrow()
})
it('落盘内容包含聊天文本,但仅限 userData 下的 0600 文件', () => {
const { inbox, filePath } = createInbox()
inbox.accept([inbound('1', '机密内容')])
const raw = readFileSync(filePath, 'utf8')
expect(raw).toContain('机密内容')
expect(statSync(filePath).mode & 0o777).toBe(0o600)
})
})
+270
View File
@@ -0,0 +1,270 @@
import { mkdtempSync, readFileSync, rmSync } from 'fs-extra'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-send-gateway-default' } }))
vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
personalWechatSendService: { send: vi.fn() }
}))
import { WechatSendGateway } from '../../src/main/services/wechat-send-gateway'
import { WechatSendLogService } from '../../src/main/services/wechat-send-log-service'
import { ILinkError } from '../../src/main/services/wechat-ilink/errors'
import type { PersonalWechatSendResult } from '../../src/shared/personal-wechat'
import {
buildSendPreview,
MAX_SEND_PREVIEW_LENGTH,
normalizeWechatSendRequest,
resolveSendTransport
} from '../../src/shared/wechat-send'
const okResult: PersonalWechatSendResult = { success: true, status: {} as never }
describe('统一发送模型', () => {
it('规范化合法请求并保留 context_token', () => {
const request = normalizeWechatSendRequest({
request_id: 'req-1',
account_id: 'bot-1',
to: 'user@im.wechat',
type: 'text',
msg: ' Hello ',
context_token: 'ctx-1'
})
expect(request).toEqual({
request_id: 'req-1',
account_id: 'bot-1',
to: 'user@im.wechat',
type: 'text',
msg: 'Hello',
context_token: 'ctx-1'
})
})
it('拒绝缺少接收者、类型或内容的请求', () => {
const createRequestId = (): string => 'generated'
expect(normalizeWechatSendRequest(null, { createRequestId })).toBeNull()
expect(normalizeWechatSendRequest({ type: 'text', msg: 'hi' }, { createRequestId })).toBeNull()
expect(
normalizeWechatSendRequest({ to: 'user', type: 'video', msg: 'x' }, { createRequestId })
).toBeNull()
expect(
normalizeWechatSendRequest({ to: 'user', type: 'text', msg: ' ' }, { createRequestId })
).toBeNull()
// voice 在统一模型里是合法类型,但业务上由个人微信通道承载。
expect(
normalizeWechatSendRequest(
{ to: 'user', type: 'voice', msg: '/tmp/a.silk' },
{
createRequestId
}
)
).toMatchObject({ type: 'voice' })
})
it('按 context_token 推断传输通道', () => {
expect(resolveSendTransport({ context_token: 'ctx' })).toBe('ilink')
expect(resolveSendTransport({})).toBe('personal')
expect(resolveSendTransport({ transport: 'ilink' })).toBe('ilink')
expect(resolveSendTransport({ transport: 'personal', context_token: 'ctx' })).toBe('personal')
})
it('截断预览但不动原文', () => {
const text = 'x'.repeat(MAX_SEND_PREVIEW_LENGTH + 50)
expect(buildSendPreview(text, 'text')).toHaveLength(MAX_SEND_PREVIEW_LENGTH)
expect(text).toHaveLength(MAX_SEND_PREVIEW_LENGTH + 50)
})
})
describe('WechatSendGateway', () => {
const directories: string[] = []
afterEach(() => {
while (directories.length) rmSync(directories.pop()!, { recursive: true, force: true })
})
function createGateway(
options: {
sendPersonal?: (request: never) => Promise<PersonalWechatSendResult>
sendIlink?: (request: never) => Promise<void>
} = {}
): { gateway: WechatSendGateway; root: string; log: WechatSendLogService } {
const root = mkdtempSync(join(tmpdir(), 'tracememo-send-gateway-'))
directories.push(root)
const log = new WechatSendLogService({ getUserDataPath: () => root })
let sequence = 0
const gateway = new WechatSendGateway({
now: () => 1_700_000_000_000,
createRequestId: () => `generated-${(sequence += 1)}`,
log,
sendPersonal: (options.sendPersonal as never) ?? (async () => okResult),
...(options.sendIlink ? { sendIlink: options.sendIlink as never } : {})
})
return { gateway, root, log }
}
it('成功发送 iLink 文本时写入一致的 Send Log', async () => {
const received: unknown[] = []
const { gateway, log } = createGateway({
sendIlink: async (request: never) => {
received.push(request)
}
})
const result = await gateway.send({
request_id: 'req-ilink',
account_id: 'bot-1',
to: 'user@im.wechat',
type: 'text',
msg: '你好',
context_token: 'ctx-secret'
})
expect(result).toMatchObject({
request_id: 'req-ilink',
success: true,
status: 'sent',
transport: 'ilink'
})
expect(received).toEqual([
{
request_id: 'req-ilink',
account_id: 'bot-1',
to: 'user@im.wechat',
type: 'text',
msg: '你好',
context_token: 'ctx-secret'
}
])
const [entry] = log.list()
expect(entry.request_id).toBe('req-ilink')
expect(entry.transport).toBe('ilink')
expect(entry.status).toBe('sent')
expect(entry.msg_preview).toBe('你好')
expect(entry.duration_ms).toBe(0)
})
it('iLink 发送失败时同样留下 Send Log 并给出错误码', async () => {
const { gateway, log } = createGateway({
sendIlink: async () => {
throw new ILinkError({ kind: 'stale_token', message: '登录凭证已失效', ret: -14 })
}
})
const result = await gateway.send({
request_id: 'req-stale',
to: 'user@im.wechat',
type: 'text',
msg: '测试',
transport: 'ilink'
})
expect(result).toMatchObject({ success: false, status: 'failed', error_code: 'STALE_TOKEN' })
expect(log.list()[0]).toMatchObject({ request_id: 'req-stale', status: 'failed' })
expect(log.list()[0].error_code).toBe('STALE_TOKEN')
})
it('iLink 通道未初始化时不抛异常,而是记录 TRANSPORT_UNAVAILABLE', async () => {
const { gateway, log } = createGateway()
const result = await gateway.send({
request_id: 'req-no-transport',
to: 'user@im.wechat',
type: 'text',
msg: '测试',
transport: 'ilink'
})
expect(result).toMatchObject({ success: false, error_code: 'TRANSPORT_UNAVAILABLE' })
expect(log.list()[0].error_code).toBe('TRANSPORT_UNAVAILABLE')
})
it('不合法请求也会留下 INVALID_REQUEST 记录,而不是静默丢弃', async () => {
const { gateway, log } = createGateway()
const result = await gateway.send({ to: '', type: 'text', msg: '' })
expect(result.success).toBe(false)
expect(result.error_code).toBe('INVALID_REQUEST')
expect(log.list()).toHaveLength(1)
expect(log.list()[0].request_id).toBe('generated-1')
})
it('个人微信通道按 {to,isGroup,type,msg} 心智模型转发并记账', async () => {
const received: unknown[] = []
const { gateway, log } = createGateway({
sendPersonal: async (request: never) => {
received.push(request)
return okResult
}
})
const result = await gateway.send({
request_id: 'req-personal',
to: 'room@chatroom',
type: 'text',
msg: '群成员已退群',
transport: 'personal',
is_group: true
})
expect(result).toMatchObject({ success: true, transport: 'personal' })
expect(received).toEqual([
{ to: 'room@chatroom', isGroup: true, type: 'text', text: '群成员已退群' }
])
expect(log.list()[0]).toMatchObject({ transport: 'personal', status: 'sent' })
})
it('个人微信通道不支持文件类型时明确报错', async () => {
const { gateway, log } = createGateway()
const result = await gateway.send({
request_id: 'req-file',
to: 'wxid_demo',
type: 'file',
msg: '/tmp/report.pdf',
transport: 'personal'
})
expect(result).toMatchObject({ success: false, error_code: 'UNSUPPORTED_TYPE' })
expect(log.list()[0].error_code).toBe('UNSUPPORTED_TYPE')
})
it('sendPersonal 保留既有返回契约,并把同一次发送记入 Send Log', async () => {
const received: unknown[] = []
const { gateway, log } = createGateway({
sendPersonal: async (request: never) => {
received.push(request)
return okResult
}
})
const request = {
to: 'room@chatroom',
isGroup: true,
type: 'text',
text: '日报已发送'
} as const
const result = await gateway.sendPersonal(request)
expect(result).toBe(okResult)
expect(received[0]).toEqual(request)
expect(log.list()).toHaveLength(1)
expect(log.list()[0]).toMatchObject({ transport: 'personal', status: 'sent', type: 'text' })
})
it('Send Log 落盘内容不包含 context_token 与 bot_token', async () => {
const { gateway, root } = createGateway({ sendIlink: async () => undefined })
await gateway.send({
request_id: 'req-secret',
to: 'user@im.wechat',
type: 'text',
msg: '回复内容',
context_token: 'ctx-SECRET-TOKEN',
transport: 'ilink'
})
const raw = readFileSync(join(root, 'actions', 'wechat-send-log.json'), 'utf8')
expect(raw).not.toContain('ctx-SECRET-TOKEN')
expect(raw).not.toContain('SECRET')
})
})
+126
View File
@@ -0,0 +1,126 @@
import { mkdtempSync, readFileSync, rmSync } from 'fs-extra'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-send-log-default' } }))
import { WechatSendLogService } from '../../src/main/services/wechat-send-log-service'
import { MAX_SEND_PREVIEW_LENGTH } from '../../src/shared/wechat-send'
describe('WechatSendLogService', () => {
const directories: string[] = []
afterEach(() => {
while (directories.length) rmSync(directories.pop()!, { recursive: true, force: true })
})
function createService(maxEntries?: number): { service: WechatSendLogService; root: string } {
const root = mkdtempSync(join(tmpdir(), 'tracememo-send-log-'))
directories.push(root)
return {
service: new WechatSendLogService(
maxEntries === undefined
? { getUserDataPath: () => root }
: { getUserDataPath: () => root, maxEntries }
),
root
}
}
it('truncates the text preview but keeps a stable full-content hash', () => {
const { service } = createService()
const text = '通'.repeat(600)
const entry = service.buildEntry({
request_id: 'req-1',
transport: 'ilink',
to: 'user@im.wechat',
type: 'text',
msg: text,
status: 'sent',
timestamp: 1_700_000_000_000
})
expect(entry.msg_preview).toHaveLength(MAX_SEND_PREVIEW_LENGTH)
expect(entry.msg_hash).toBe(service.hashMessage(text))
expect(entry.msg_hash).toMatch(/^sha256:[0-9a-f]{64}$/)
// hash 覆盖完整内容,而不是被截断的预览。
expect(entry.msg_hash).not.toBe(service.hashMessage(entry.msg_preview!))
})
it('records only the file name for media payloads', () => {
const { service } = createService()
const entry = service.buildEntry({
request_id: 'req-2',
transport: 'ilink',
to: 'user@im.wechat',
type: 'image',
msg: '/tmp/agent-hub-fixture/群聊总结.png',
status: 'sent',
timestamp: 1
})
expect(entry.msg_preview).toBe('群聊总结.png')
expect(JSON.stringify(entry)).not.toContain('agent-hub-fixture')
})
it('keeps the newest entry first and replaces the same request_id', () => {
const { service } = createService()
const base = {
transport: 'ilink' as const,
to: 'user@im.wechat',
type: 'text' as const,
msg: 'hello',
timestamp: 1
}
service.record(service.buildEntry({ ...base, request_id: 'a', status: 'sent' }))
service.record(service.buildEntry({ ...base, request_id: 'b', status: 'failed' }))
service.record(service.buildEntry({ ...base, request_id: 'a', status: 'failed' }))
const entries = service.list()
expect(entries.map((entry) => entry.request_id)).toEqual(['a', 'b'])
expect(entries[0].status).toBe('failed')
})
it('caps the persisted file at maxEntries', () => {
const { service } = createService(3)
for (let index = 0; index < 6; index += 1) {
service.record(
service.buildEntry({
request_id: `req-${index}`,
transport: 'personal',
to: 'wxid_demo',
type: 'text',
msg: `消息 ${index}`,
status: 'sent',
timestamp: index
})
)
}
expect(service.list().map((entry) => entry.request_id)).toEqual(['req-5', 'req-4', 'req-3'])
})
it('never persists context tokens or bot tokens', () => {
const { service, root } = createService()
const contextToken = 'ctx-token-SECRET-VALUE'
service.record({
...service.buildEntry({
request_id: 'req-secret',
transport: 'ilink',
to: 'user@im.wechat',
type: 'text',
msg: '日报已经生成,请查看。',
status: 'sent',
timestamp: 1
}),
// 即使被误传进来,条目结构本身也不应该携带该字段。
...({ context_token: contextToken } as Record<string, unknown>)
})
const raw = readFileSync(join(root, 'actions', 'wechat-send-log.json'), 'utf8')
expect(raw).not.toContain(contextToken)
expect(raw).not.toContain('context_token')
expect(service.list()[0]).not.toHaveProperty('context_token')
})
})