cc-haha/adapters/wechat/__tests__/protocol.test.ts
程序员阿江(Relakkes) 622b94011f fix: keep WeChat agent replies visible
WeChat development turns were reaching the local CLI, but the adapter treated iLink business failures as successful sends and only emitted a short-lived typing indicator. The adapter now validates sendmessage/sendtyping ret codes, retries text replies without stale context tokens, keeps typing alive during long-running tool work, and surfaces queue failures back to the chat.

Constraint: WeChat uses separate sendmessage and sendtyping APIs with business-level ret codes inside HTTP 200 responses.
Rejected: Only fixing the typing indicator | the transcript showed the agent completed the task, so the reply delivery path also needed hardening.
Confidence: high
Scope-risk: narrow
Directive: Do not treat WeChat HTTP 200 responses as successful until the iLink ret/errcode body has been checked.
Tested: bun test adapters/wechat/__tests__/protocol.test.ts adapters/wechat/__tests__/typing.test.ts
Tested: cd adapters && bunx tsc --noEmit
Tested: bun run check:adapters
Tested: real bound WeChat getconfig, typing, text send, cancel typing smoke
Not-tested: Full inbound WeChat user-message loop without a fresh user-triggered message
2026-05-05 19:58:53 +08:00

124 lines
3.8 KiB
TypeScript

import { afterEach, describe, expect, it } from 'bun:test'
import { buildClientVersion, extractWechatText, sendWechatText, sendWechatTyping } from '../protocol.js'
import { collectWechatMediaCandidates } from '../media.js'
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
describe('WeChat protocol helpers', () => {
it('encodes iLink client versions like the OpenClaw Weixin plugin', () => {
expect(buildClientVersion('2.1.7')).toBe((2 << 16) | (1 << 8) | 7)
expect(buildClientVersion('1.0.11')).toBe(65547)
})
it('extracts plain text from WeChat message items', () => {
expect(extractWechatText([
{ type: 1, text_item: { text: 'hello' } },
])).toBe('hello')
})
it('extracts voice transcription when text items are absent', () => {
expect(extractWechatText([
{ type: 3, voice_item: { text: 'voice text' } },
])).toBe('voice text')
})
it('preserves quoted text context', () => {
expect(extractWechatText([
{
type: 1,
text_item: { text: 'reply' },
ref_msg: {
title: 'quote title',
message_item: { type: 1, text_item: { text: 'quoted body' } },
},
},
])).toBe('[引用: quote title | quoted body]\nreply')
})
it('collects image and file media candidates from message items', () => {
expect(collectWechatMediaCandidates([
{
type: 2,
msg_id: 'img-1',
image_item: {
aeskey: '00112233445566778899aabbccddeeff',
media: {
full_url: 'https://cdn.example.com/image',
encrypt_query_param: 'enc=1',
},
},
},
{
type: 4,
msg_id: 'file-1',
file_item: {
file_name: 'report.pdf',
media: {
full_url: 'https://cdn.example.com/file',
aes_key: Buffer.from('00112233445566778899aabbccddeeff').toString('base64'),
},
},
},
])).toMatchObject([
{
kind: 'image',
name: 'wechat-image-img-1.jpg',
url: 'https://cdn.example.com/image',
},
{
kind: 'file',
name: 'report.pdf',
url: 'https://cdn.example.com/file',
mimeType: 'application/pdf',
},
])
})
it('throws when sendmessage returns a non-zero WeChat ret code', async () => {
globalThis.fetch = (async () => new Response(JSON.stringify({ ret: 40001, errmsg: 'bad context_token' }), { status: 200 })) as unknown as typeof fetch
await expect(sendWechatText({
baseUrl: 'https://api.example.com',
token: 'token',
to: 'user',
text: 'hello',
contextToken: 'stale-context',
})).rejects.toThrow('wechatSendMessage returned 40001: bad context_token')
})
it('allows successful sendmessage responses', async () => {
const requests: string[] = []
globalThis.fetch = (async (_url: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
requests.push(String(init?.body ?? ''))
return new Response(JSON.stringify({ ret: 0 }), { status: 200 })
}) as unknown as typeof fetch
await sendWechatText({
baseUrl: 'https://api.example.com',
token: 'token',
to: 'user',
text: 'hello',
contextToken: 'ctx',
})
expect(requests).toHaveLength(1)
expect(JSON.parse(requests[0]!).msg.context_token).toBe('ctx')
})
it('throws when sendtyping returns a non-zero WeChat ret code', async () => {
globalThis.fetch = (async () => new Response(JSON.stringify({ ret: 42001, errmsg: 'typing ticket expired' }), { status: 200 })) as unknown as typeof fetch
await expect(sendWechatTyping({
baseUrl: 'https://api.example.com',
token: 'token',
ilinkUserId: 'user',
typingTicket: 'ticket',
status: 'typing',
})).rejects.toThrow('wechatSendTyping returned 42001: typing ticket expired')
})
})