mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 13:54:10 +08:00
feat: 重写agent hub连接器,新增对话记录和微信输入状态
- 新增 Agent Hub 对话记录面板,按会话回看机器人与微信用户的完整收发内容 - 新增微信原生正在输入状态,长任务维持 typing,异常路径强制收尾
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user