import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import test from 'node:test'; import { buildWechatTextReply, createWechatMpService, loadWechatMpConfig, splitWechatText, verifyWechatMpSignature, WECHAT_CUSTOMER_TEXT_MAX_BYTES, } from './wechat-mp.mjs'; function signatureFor(token, timestamp, nonce) { return crypto .createHash('sha1') .update([token, timestamp, nonce].sort().join('')) .digest('hex'); } function inboundXml({ toUserName = 'gh_test', fromUserName = 'openid-1', msgType = 'text', content = 'hello', event = '', } = {}) { return [ '', ``, ``, '1710000000', ``, content ? `` : '', event ? `` : '', '10001', '', ].join(''); } test('splitWechatText respects WeChat 2048-byte customer service limit', () => { const longChinese = '中'.repeat(900); const chunks = splitWechatText(longChinese); assert.ok(chunks.length > 1); for (const chunk of chunks) { assert.ok(Buffer.byteLength(chunk, 'utf8') <= WECHAT_CUSTOMER_TEXT_MAX_BYTES); } assert.equal(chunks.join(''), longChinese); }); test('wechat mp service splits long agent replies into multiple customer messages', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; const longReply = '答'.repeat(900); const wechatCalls = []; const service = createWechatMpService({ config: { enabled: true, appId: 'wx123', appSecret: 'secret', token, publicBaseUrl: 'https://example.com', bindPath: '/auth/wechat/authorize?intent=login', ackText: 'ack', progressDelayMs: 0, unsupportedText: 'unsupported', unboundTextPrefix: '请先绑定', }, userAuth: { async findWechatUserByOpenid() { return { userId: 'user-1', status: 'active', nickname: '毕升' }; }, async getWechatAgentRoute() { return { agentSessionId: 'session-1' }; }, async clearWechatAgentRoute() {}, async canUseChat() { return { ok: true }; }, async resolveWorkingDir() { return '/tmp/user-1'; }, async getAgentSessionPolicy() { return { enableContextMemory: false, extensionOverrides: [], unrestricted: true }; }, async getUserPublishLayout() { return { displayName: 'John', username: 'john', slug: 'john', constraints: null }; }, async registerAgentSession() {}, async upsertWechatAgentRoute() {}, async billSessionUsage() {}, }, sessionApiFetch: async (sessionId, pathname, init = {}) => { assert.equal(sessionId, 'session-1'); if (pathname === '/sessions/session-1/events') { return new Response( [ `data: {"type":"Message","request_id":"req-long","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":${JSON.stringify(longReply)}}]}}\n\n`, 'data: {"type":"Finish","request_id":"req-long","token_state":{"inputTokens":1,"outputTokens":2}}\n\n', ].join(''), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, ); } if (pathname === '/sessions/session-1/reply') { return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); } throw new Error(`unexpected api path: ${pathname}`); }, wechatFetch: async (url, init = {}) => { wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]); if (String(url).includes('/cgi-bin/stable_token')) { return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (String(url).includes('/cgi-bin/message/custom/send')) { const payload = JSON.parse(init.body); assert.ok(Buffer.byteLength(payload.text.content, 'utf8') <= WECHAT_CUSTOMER_TEXT_MAX_BYTES); return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } throw new Error(`unexpected wechat url: ${url}`); }, }); const originalRandomUuid = crypto.randomUUID; crypto.randomUUID = () => 'req-long'; try { const result = await service.handleInboundMessage(inboundXml({ content: '给我长回复' }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce), }); assert.equal(result.status, 200); await result.task; } finally { crypto.randomUUID = originalRandomUuid; } const sendCalls = wechatCalls.filter(([url]) => String(url).includes('/cgi-bin/message/custom/send')); assert.ok(sendCalls.length > 1); const combined = sendCalls .map(([, , body]) => JSON.parse(body).text.content) .join(''); assert.equal(combined, longReply); }); test('loadWechatMpConfig requires full config and enable flag', () => { const config = loadWechatMpConfig({ H5_WECHAT_MP_ENABLED: '1', H5_WECHAT_MP_APP_ID: 'wx123', H5_WECHAT_MP_APP_SECRET: 'secret', H5_WECHAT_MP_TOKEN: 'token', H5_PUBLIC_BASE_URL: 'https://example.com', }); assert.equal(config.enabled, true); assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login'); }); test('verifyWechatMpSignature accepts valid signature', () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; assert.equal( verifyWechatMpSignature({ token, timestamp, nonce, signature: signatureFor(token, timestamp, nonce), }), true, ); }); test('buildWechatTextReply includes both parties and content', () => { const xml = buildWechatTextReply({ toUserName: 'openid-1', fromUserName: 'gh_test', content: '收到', createTime: 123, }); assert.match(xml, /<\/ToUserName>/); assert.match(xml, /<\/Content>/); }); test('wechat mp service prompts binding for unknown openid', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; const service = createWechatMpService({ config: { enabled: true, appId: 'wx123', appSecret: 'secret', token, publicBaseUrl: 'https://example.com', bindPath: '/auth/wechat/authorize?intent=login', ackText: 'ack', unsupportedText: 'unsupported', unboundTextPrefix: '请先绑定', }, userAuth: { async findWechatUserByOpenid() { return null; }, }, apiFetch: async () => new Response('{}', { status: 200 }), }); const result = await service.handleInboundMessage(inboundXml(), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce), }); assert.equal(result.status, 200); assert.match(result.body, /请先绑定/); assert.match(result.body, /https:\/\/example.com\/auth\/wechat\/authorize\?intent=login/); }); test('wechat mp service routes text to dedicated session and sends customer service reply', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; const apiCalls = []; const wechatCalls = []; const service = createWechatMpService({ config: { enabled: true, appId: 'wx123', appSecret: 'secret', token, publicBaseUrl: 'https://example.com', bindPath: '/auth/wechat/authorize?intent=login', ackText: 'ack', unsupportedText: 'unsupported', unboundTextPrefix: '请先绑定', }, userAuth: { async findWechatUserByOpenid() { return { userId: 'user-1', status: 'active', nickname: '毕升' }; }, async getWechatAgentRoute() { return { agentSessionId: 'session-1' }; }, async clearWechatAgentRoute() {}, async canUseChat() { return { ok: true }; }, async resolveWorkingDir() { return '/tmp/user-1'; }, async getAgentSessionPolicy() { return { enableContextMemory: false, extensionOverrides: [], unrestricted: true }; }, async getUserPublishLayout() { return { displayName: 'John', username: 'john', slug: 'john', constraints: null }; }, async registerAgentSession() {}, async upsertWechatAgentRoute() {}, async billSessionUsage(userId, sessionId, tokenState, requestId) { apiCalls.push(['bill', userId, sessionId, tokenState, requestId]); }, }, apiFetch: async (pathname, init = {}) => { apiCalls.push(['fallback', pathname, init.method ?? 'GET']); return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); }, sessionApiFetch: async (sessionId, pathname, init = {}) => { assert.equal(sessionId, 'session-1'); apiCalls.push([pathname, init.method ?? 'GET']); if (pathname === '/sessions/session-1/events') { return new Response( [ 'data: {"type":"Message","request_id":"req-1","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"wx_abcd1234,页面已创建完毕!\\n[标题](https://example.com/page.html)\\n\\n| 项 | 值 |\\n| --- | --- |\\n| A | B |"}]}}\n\n', 'data: {"type":"Finish","request_id":"req-1","token_state":{"inputTokens":1,"outputTokens":2}}\n\n', ].join(''), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, ); } if (pathname === '/sessions/session-1/reply') { const body = JSON.parse(init.body); assert.equal(body.user_message.content[0].text, '【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。\n若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。\n\n用户消息:帮我做个页面'); body.request_id && (apiCalls[apiCalls.length - 1][2] = body.request_id); return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname === '/agent/harness_remember') { const body = JSON.parse(init.body); assert.equal(body.sessionId, 'session-1'); assert.match(body.content, /当前服务号用户名称/); return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname === '/agent/harness_bootstrap') { const body = JSON.parse(init.body); assert.equal(body.sessionId, 'session-1'); assert.equal(body.force, true); return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); } throw new Error(`unexpected api path: ${pathname}`); }, wechatFetch: async (url, init = {}) => { wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]); if (String(url).includes('/cgi-bin/stable_token')) { return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (String(url).includes('/cgi-bin/message/custom/send')) { const payload = JSON.parse(init.body); assert.equal(payload.touser, 'openid-1'); assert.match(payload.text.content, /页面已创建完毕/); assert.doesNotMatch(payload.text.content, /^wx_/); assert.doesNotMatch(payload.text.content, /\[标题\]\(/); assert.match(payload.text.content, /标题\nhttps:\/\/example.com\/page.html/); assert.match(payload.text.content, /项:A;值:B/); return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } throw new Error(`unexpected wechat url: ${url}`); }, }); const originalRandomUuid = crypto.randomUUID; let uuidCalls = 0; crypto.randomUUID = () => { uuidCalls += 1; return uuidCalls === 1 ? 'req-1' : `uuid-${uuidCalls}`; }; try { const result = await service.handleInboundMessage(inboundXml({ content: '帮我做个页面' }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce), }); assert.equal(result.status, 200); assert.match(result.body, /ack/); await result.task; } finally { crypto.randomUUID = originalRandomUuid; } assert.equal( apiCalls.some(([pathname]) => pathname === '/sessions/session-1/reply'), true, ); assert.equal( apiCalls.some(([kind]) => kind === 'fallback'), false, ); assert.equal( wechatCalls.some(([url]) => String(url).includes('/cgi-bin/message/custom/send')), true, ); }); test('wechat mp service creates daily todo digest without routing to agent', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; const apiCalls = []; const service = createWechatMpService({ config: { enabled: true, appId: 'wx123', appSecret: 'secret', token, publicBaseUrl: 'https://example.com', bindPath: '/auth/wechat/authorize?intent=login', ackText: 'ack', unsupportedText: 'unsupported', unboundTextPrefix: '请先绑定', }, userAuth: { async findWechatUserByOpenid() { return { userId: 'user-1', status: 'active', nickname: '毕升' }; }, async recordWechatMpMessage() { return { inserted: true }; }, async finishWechatMpMessage(input) { apiCalls.push(['finish', input.status, input.agentSessionId]); }, }, scheduleService: { async createDailyTodoDigest(input) { apiCalls.push(['digest', input.userId, input.hour, input.minute, input.channel]); return { id: 'sub-1', userId: input.userId, hour: input.hour, minute: input.minute, timezone: input.timezone, channel: input.channel, }; }, }, apiFetch: async (pathname) => { apiCalls.push(['agent', pathname]); return new Response('{}', { status: 200 }); }, }); const result = await service.handleInboundMessage( inboundXml({ content: '每天早上 7 点给我发一天的待办记录' }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce), }, ); assert.equal(result.status, 200); assert.match(result.body, /每天早上 7点/); assert.deepEqual(apiCalls, [ ['digest', 'user-1', 7, 0, 'wechat'], ['finish', 'done', null], ]); }); test('wechat mp service returns status text for question mark probes', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; const service = createWechatMpService({ config: { enabled: true, appId: 'wx123', appSecret: 'secret', token, publicBaseUrl: 'https://example.com', bindPath: '/auth/wechat/authorize?intent=login', ackText: 'ack', statusText: '我在这边', unsupportedText: 'unsupported', unboundTextPrefix: '请先绑定', }, userAuth: { async findWechatUserByOpenid() { return { userId: 'user-1', status: 'active', nickname: '毕升' }; }, }, apiFetch: async () => new Response('{}', { status: 200 }), }); const result = await service.handleInboundMessage(inboundXml({ content: '?' }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce), }); assert.equal(result.status, 200); assert.match(result.body, /我在这边/); assert.match(result.body, /毕升/); }); test('wechat mp service greets bound users with stored wechat nickname', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; let replyCalled = false; const service = createWechatMpService({ config: { enabled: true, appId: 'wx123', appSecret: 'secret', token, publicBaseUrl: 'https://example.com', bindPath: '/auth/wechat/authorize?intent=login', ackText: 'ack', unsupportedText: 'unsupported', unboundTextPrefix: '请先绑定', }, userAuth: { async findWechatUserByOpenid() { return { userId: 'user-1', status: 'active', nickname: '毕升' }; }, }, apiFetch: async (pathname) => { if (pathname === '/sessions/session-1/reply') replyCalled = true; return new Response('{}', { status: 200 }); }, }); const result = await service.handleInboundMessage(inboundXml({ content: '你好' }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce), }); assert.equal(result.status, 200); assert.match(result.body, /你好,毕升/); assert.equal(replyCalled, false); }); test('wechat mp service replies to connectivity test without agent', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; let replyCalled = false; const service = createWechatMpService({ config: { enabled: true, appId: 'wx123', appSecret: 'secret', token, publicBaseUrl: 'https://example.com', bindPath: '/auth/wechat/authorize?intent=login', ackText: 'ack', unsupportedText: 'unsupported', unboundTextPrefix: '请先绑定', }, userAuth: { async findWechatUserByOpenid() { return { userId: 'user-1', status: 'active', nickname: '毕升' }; }, }, apiFetch: async (pathname) => { if (pathname.includes('/reply')) replyCalled = true; return new Response('{}', { status: 200 }); }, }); const result = await service.handleInboundMessage(inboundXml({ content: '测试999' }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce), }); assert.equal(result.status, 200); assert.match(result.body, /公众号消息通道正常/); assert.equal(replyCalled, false); }); test('wechat mp service ignores duplicated message ids', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; let replyCalled = false; const service = createWechatMpService({ config: { enabled: true, appId: 'wx123', appSecret: 'secret', token, publicBaseUrl: 'https://example.com', bindPath: '/auth/wechat/authorize?intent=login', ackText: 'ack', unsupportedText: 'unsupported', unboundTextPrefix: '请先绑定', }, userAuth: { async findWechatUserByOpenid() { return { userId: 'user-1', status: 'active' }; }, async recordWechatMpMessage() { return { inserted: false }; }, }, apiFetch: async (pathname) => { if (pathname === '/sessions/session-1/reply') replyCalled = true; return new Response('{}', { status: 200 }); }, }); const result = await service.handleInboundMessage(inboundXml({ content: '帮我继续处理' }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce), }); assert.equal(result.status, 200); assert.match(result.body, /上一条如果还没完成/); assert.equal(replyCalled, false); }); test('wechat mp service exposes route status and can recreate route for bound user', async () => { const calls = []; let routeCleared = false; let routeUpserted = false; const service = createWechatMpService({ config: { enabled: true, appId: 'wx123', appSecret: 'secret', token: 'token', publicBaseUrl: 'https://example.com', bindPath: '/auth/wechat/authorize?intent=login', ackText: 'ack', unsupportedText: 'unsupported', unboundTextPrefix: '请先绑定', }, userAuth: { async getWechatOpenidForUser() { return 'openid-1'; }, async getWechatAgentRoute(_appId, _openid) { if (routeCleared && !routeUpserted) return null; return { agentSessionId: routeUpserted ? 'session-new' : 'session-old', status: 'active', updatedAt: 1710000000000, }; }, async clearWechatAgentRoute() { routeCleared = true; calls.push('cleared'); }, async canUseChat() { return { ok: true }; }, async resolveWorkingDir() { return '/tmp/user-1'; }, async getAgentSessionPolicy() { return { enableContextMemory: false, extensionOverrides: [], unrestricted: true }; }, async getUserPublishLayout() { return { displayName: 'John', username: 'john', slug: 'john', constraints: null }; }, async registerAgentSession(_userId, sessionId) { calls.push(`register:${sessionId}`); }, async upsertWechatAgentRoute() { routeUpserted = true; calls.push('upserted'); }, }, apiFetch: async (pathname) => { if (pathname === '/agent/start') { return new Response(JSON.stringify({ id: 'session-new' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } throw new Error(`unexpected fallback api path: ${pathname}`); }, sessionApiFetch: async (_sessionId, pathname) => { if (pathname === '/sessions/session-new') { return new Response(JSON.stringify({ working_dir: '/tmp/user-1' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (pathname === '/sessions/session-new/extensions') { return new Response(JSON.stringify({ extensions: [] }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if ( pathname === '/agent/restart' ) { return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' }, }); } throw new Error(`unexpected api path: ${pathname}`); }, }); const status = await service.getRouteStatusForUser('user-1'); assert.deepEqual(status, { enabled: true, bound: true, appId: 'wx123', openid: 'openid-1', agentSessionId: 'session-old', routeStatus: 'active', updatedAt: 1710000000000, }); const recreated = await service.recreateRouteForUser('user-1'); assert.equal(recreated.ok, true); assert.equal(recreated.route.agentSessionId, 'session-new'); assert.equal(calls.includes('cleared'), true); assert.equal(calls.includes('upserted'), true); });