mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
124 lines
3.8 KiB
TypeScript
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')
|
|
})
|
|
})
|