import type { SendRichText } from '@snowluma/proto-defs/action'; // Regression test for the c2c-vs-group businessType asymmetry that made // private-chat video / record sends bounce with `send private message // rejected: result=79`. The receive-side decoder // (msg-push/rich-body-decoder.ts) explicitly treats the businessType // pairs as: // // image : c2c=10, group=20 // video : c2c=11, group=21 // record : c2c=12, group=22 // // `makeImageElem` always honoured this split (`isGroup ? 20 : 10`). // `makeVideoElem` and `makePttElem` used to hardcode the group value // for both scenes, so any c2c video / private voice send arrived at the // QQ NT server with a businessType the c2c routing path did not // recognise and got rejected with result=79. import { describe, expect, it, vi } from 'vitest'; import { inflateSync } from 'zlib'; import { subscribeLogs, type LogEntry } from '@snowluma/common/logger'; vi.mock('@snowluma/protocol/highway/image-upload', () => ({ uploadImageMsgInfo: vi.fn(async () => new Uint8Array([7, 8, 9])), })); vi.mock('@snowluma/protocol/highway/ptt-upload', () => ({ uploadPtt: vi.fn(async () => ({ msgInfo: new Uint8Array([4, 5, 6]) })), })); vi.mock('@snowluma/protocol/highway/video-upload', () => ({ uploadVideoMsgInfo: vi.fn(async () => new Uint8Array([1, 2, 3])), })); import { type bytes, type pb, protobuf_decode, protobuf_encode } from '@snowluma/proton'; import type { GroupFileExtra, MsgInfo } from '@snowluma/proto-defs/element'; import { buildSendElems, encodePrivateRichText } from '@snowluma/protocol/element-builder'; import { uploadPtt } from '@snowluma/protocol/highway/ptt-upload'; import { MessageElementValidationError } from '@snowluma/protocol/element-manifest'; import { uploadImageMsgInfo } from '@snowluma/protocol/highway/image-upload'; import { uploadVideoMsgInfo } from '@snowluma/protocol/highway/video-upload'; const fakeBridge = {} as any; function commonElem(elem: any): { serviceType: number; businessType: number; pbElem: Uint8Array } { return elem.commonElem; } function inflatePrefixedPayload(data: Uint8Array): string { expect(data[0]).toBe(0x01); return inflateSync(Buffer.from(data.subarray(1))).toString('utf8'); } describe('element-builder / rich card encoding', () => { it('encodes json segments as deflated LightApp payloads and preserves non-ASCII text', async () => { const card = JSON.stringify({ app: 'com.tencent.structmsg', view: 'music', prompt: '[音乐]起风了', meta: { music: { title: '起风了', desc: '买辣椒也用券' } }, }); const [elem] = await buildSendElems([{ type: 'json', text: card } as any], { bridge: fakeBridge, groupId: 12345, }); expect(elem.lightApp).toBeDefined(); expect((elem as any).richMsg).toBeUndefined(); expect(inflatePrefixedPayload(elem.lightApp!.data!)).toBe(card); }); it('encodes xml richMsg payloads by UTF-8 bytes, not JavaScript string length', async () => { const xml = ''; const [elem] = await buildSendElems([{ type: 'xml', text: xml, subType: 35 } as any], { bridge: fakeBridge, groupId: 12345, }); expect(elem.richMsg?.serviceId).toBe(35); expect(inflatePrefixedPayload(elem.richMsg!.template1!)).toBe(xml); }); }); describe('element-builder / commonElem.businessType per scene', () => { describe('private window shake', () => { it('encodes poke type 1 as CommonElem service 2 with the verified payload', async () => { const [elem] = await buildSendElems([{ type: 'poke', subType: 1 }], { bridge: fakeBridge, userUid: 'u_peer', scene: 'direct-private', }); expect(commonElem(elem)).toMatchObject({ serviceType: 2, businessType: 1, }); expect(Array.from(commonElem(elem).pbElem)).toEqual([0x08, 0x01]); }); it('rejects mixed window-shake content before uploading an earlier media segment', async () => { vi.mocked(uploadImageMsgInfo).mockClear(); await expect( buildSendElems( [ { type: 'image', url: 'file:///tmp/must-not-upload.png' }, { type: 'poke', subType: 1 }, ], { bridge: fakeBridge, userUid: 'u_peer', scene: 'direct-private', }, ), ).rejects.toMatchObject({ code: 'UNSENDABLE_TYPE', elementType: 'poke', message: expect.stringContaining('only segment'), }); expect(uploadImageMsgInfo).not.toHaveBeenCalled(); }); }); describe('image', () => { it('c2c → businessType 10', async () => { const [elem] = await buildSendElems( [{ type: 'image', url: 'file:///tmp/a.png' } as any], { bridge: fakeBridge, userUid: 'u_peer', }, ); expect(commonElem(elem).serviceType).toBe(48); expect(commonElem(elem).businessType).toBe(10); }); it('group → businessType 20', async () => { const [elem] = await buildSendElems( [{ type: 'image', url: 'file:///tmp/a.png' } as any], { bridge: fakeBridge, groupId: 12345, }, ); expect(commonElem(elem).businessType).toBe(20); }); }); describe('video', () => { it('c2c → businessType 11 (regression: was 21, server returned result=79)', async () => { const [elem] = await buildSendElems( [{ type: 'video', url: 'file:///tmp/clip.mp4' } as any], { bridge: fakeBridge, userUid: 'u_peer', }, ); expect(commonElem(elem).serviceType).toBe(48); expect(commonElem(elem).businessType).toBe(11); }); it('group → businessType 21', async () => { const [elem] = await buildSendElems( [{ type: 'video', url: 'file:///tmp/clip.mp4' } as any], { bridge: fakeBridge, groupId: 12345, }, ); expect(commonElem(elem).businessType).toBe(21); }); }); describe('record', () => { it('c2c → businessType 12 (regression: was 22)', async () => { const [elem] = await buildSendElems( [{ type: 'record', url: 'file:///tmp/voice.amr' } as any], { bridge: fakeBridge, userUid: 'u_peer' }, ); expect(commonElem(elem).serviceType).toBe(48); expect(commonElem(elem).businessType).toBe(12); }); it('group → businessType 22', async () => { const [elem] = await buildSendElems( [{ type: 'record', url: 'file:///tmp/voice.amr' } as any], { bridge: fakeBridge, groupId: 12345 }, ); expect(commonElem(elem).businessType).toBe(22); }); }); }); describe('element-builder / all-message validation preflight', () => { it('rejects hidden siblings and duplicate videos before uploading', async () => { vi.mocked(uploadVideoMsgInfo).mockClear(); await expect( buildSendElems( [ { type: 'video', url: 'file:///tmp/clip.mp4' }, { type: 'text', text: 'must not become hidden content' }, ], { bridge: fakeBridge, groupId: 12345 }, ), ).rejects.toMatchObject({ code: 'UNSENDABLE_TYPE', elementType: 'video', message: expect.stringContaining('only segment'), }); await expect( buildSendElems( [ { type: 'video', url: 'file:///tmp/first.mp4' }, { type: 'video', url: 'file:///tmp/second.mp4' }, ], { bridge: fakeBridge, groupId: 12345 }, ), ).rejects.toMatchObject({ code: 'UNSENDABLE_TYPE', elementType: 'video', message: expect.stringContaining('only once'), }); expect(uploadVideoMsgInfo).not.toHaveBeenCalled(); }); it('keeps empty canonical text invalid instead of sending it beside a video', async () => { vi.mocked(uploadVideoMsgInfo).mockClear(); await expect( buildSendElems( [ { type: 'text', text: '' }, { type: 'video', url: 'file:///tmp/clip.mp4' }, ], { bridge: fakeBridge, groupId: 12345 }, ), ).rejects.toMatchObject({ code: 'INVALID_FIELD', elementType: 'text', }); expect(uploadVideoMsgInfo).not.toHaveBeenCalled(); }); it('applies standalone-video validation to forward nodes', async () => { vi.mocked(uploadVideoMsgInfo).mockClear(); await expect( buildSendElems( [ { type: 'video', url: 'file:///tmp/clip.mp4' }, { type: 'text', text: 'must not survive bot forwarding' }, ], { bridge: fakeBridge, groupId: 12345, forwardFake: true, scene: 'forward', }, ), ).rejects.toMatchObject({ code: 'UNSENDABLE_TYPE', elementType: 'video', }); expect(uploadVideoMsgInfo).not.toHaveBeenCalled(); }); it('does not upload an early image when a later segment is unsendable', async () => { vi.mocked(uploadImageMsgInfo).mockClear(); await expect( buildSendElems( [ { type: 'image', url: 'file:///tmp/valid-before-invalid.png' }, { type: 'poke', subType: 1 }, ] as any, { bridge: fakeBridge, groupId: 12345 }, ), ).rejects.toMatchObject({ code: 'UNSENDABLE_TYPE', elementType: 'poke', }); expect(uploadImageMsgInfo).not.toHaveBeenCalled(); }); it('emits a customFace sibling for forwardFake group images (#441)', async () => { const md5Hex = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const msgInfo = protobuf_encode({ msgInfoBody: [ { index: { fileUuid: 'EhRimg', info: { fileHash: md5Hex, fileName: `${md5Hex}.png`, fileSize: 12, width: 10, height: 10, type: { picFormat: 1001 }, }, }, }, ], }); vi.mocked(uploadImageMsgInfo).mockResolvedValueOnce(msgInfo); const elems = await buildSendElems([{ type: 'image', url: 'file:///tmp/a.png' }], { bridge: fakeBridge, groupId: 12345, forwardFake: true, scene: 'forward', }); expect(elems).toHaveLength(2); expect(elems[0]!.commonElem).toBeDefined(); expect(elems[1]!.customFace?.filePath).toBe(`${md5Hex}.png`); expect(elems[1]!.customFace?.md5).toHaveLength(16); }); it('does not emit a legacy sibling on a live group image send', async () => { vi.mocked(uploadImageMsgInfo).mockResolvedValueOnce(new Uint8Array([7, 8, 9])); const elems = await buildSendElems([{ type: 'image', url: 'file:///tmp/a.png' }], { bridge: fakeBridge, groupId: 12345, }); expect(elems).toHaveLength(1); expect(elems[0]!.commonElem).toBeDefined(); expect(elems[0]!.customFace).toBeUndefined(); }); it('accepts a received imageUrl-only element for re-send', async () => { vi.mocked(uploadImageMsgInfo).mockClear(); await buildSendElems([{ type: 'image', imageUrl: 'https://gchat.qpic.cn/received.jpg' }], { bridge: fakeBridge, groupId: 12345, }); expect(uploadImageMsgInfo).toHaveBeenCalledWith( fakeBridge, true, 12345, expect.objectContaining({ imageUrl: 'https://gchat.qpic.cn/received.jpg' }), undefined, ); }); it('does not upload an early image when a later fast-only media element lacks hashes', async () => { vi.mocked(uploadImageMsgInfo).mockClear(); await expect( buildSendElems( [ { type: 'image', url: 'file:///tmp/valid-before-invalid.png' }, { type: 'image', url: 'file:///tmp/must-not-fallback.png', noByteFallback: true, }, ] as any, { bridge: fakeBridge, groupId: 12345 }, ), ).rejects.toMatchObject({ code: 'MISSING_FIELD', elementType: 'image', field: 'md5Hex', }); expect(uploadImageMsgInfo).not.toHaveBeenCalled(); }); }); describe('element-builder / file element is no longer carried in elems[]', () => { it.each([12345, 54321])( 'preserves temporary storage only in a matching group forward (%s)', async (cachedGroupId) => { const bridge = { recallUploadedFile: vi.fn(() => ({ scope: 'group', groupId: cachedGroupId, busId: 104, fileName: 'doc.pdf', fileSize: 3, })), } as any; const [elem] = await buildSendElems([{ type: 'file', fileId: 'temp' }], { bridge, groupId: 12345, forwardFake: true, }); const extra = protobuf_decode(elem!.transElem!.elemValue!.subarray(3)); expect(extra.inner?.info?.busId).toBe(cachedGroupId === 12345 ? 104 : 102); }, ); // Regression for the `result=79` class: previously the element-builder // emitted a `transElem(elemType=24, ...)` for `{type:'file'}` segments // and the QQ-NT server rejected the outgoing PbSendMsg with that wire // shape. The fix moves group-file publishing onto a dedicated OIDB // call (`OidbSvcTrpcTcp.0x6d9_4`), driven from the OneBot layer in // `modules/message-actions.ts::sendGroupMessage` after the file // segment is split off. The element-builder therefore must NOT emit // any element for `{type:'file'}` anymore — if it does, the message // ships with a transElem(24) payload and result=79 returns. it('fails fast when a live {type:"file"} reaches the element builder', async () => { await expect( buildSendElems( [ { type: 'file', fileId: 'fid-abc', fileName: 'doc.txt', fileSize: 123, md5Hex: 'aabbccddeeff00112233445566778899', sha1Hex: '0102030405060708090a0b0c0d0e0f1011121314', } as any, ], { bridge: fakeBridge, groupId: 12345 }, ), ).rejects.toMatchObject({ name: 'MessageElementValidationError', code: 'UNSENDABLE_TYPE', elementType: 'file', }); }); }); describe('element-builder / forward preview', () => { // Flat outer forwards use the serviceID=35 RichMsg shape accepted by NTQQ. // Nested previews keep the modern LightApp JSON because their // meta.detail.uniseq must match an actionCommand piggyback in the outer // LongMsgResult. function decodeLightApp(elem: any): unknown { return JSON.parse(inflatePrefixedPayload(elem.lightApp.data)); } it('emits a serviceID=35 richMsg XML for a flat outer forward', async () => { const [elem] = await buildSendElems([{ type: 'forward', resId: 'res-XYZ' } as any], { bridge: fakeBridge, groupId: 12345, }); expect(elem.richMsg?.serviceId).toBe(35); expect(elem.lightApp).toBeUndefined(); const xml = inflatePrefixedPayload(elem.richMsg!.template1!); expect(xml).toContain('action="viewMultiMsg"'); expect(xml).toContain('m_resid="res-XYZ"'); }); it('places resid + uniseq inside meta.detail (and uniseq matches extra.filename)', async () => { const [elem] = await buildSendElems( [{ type: 'forward', resId: 'res-XYZ', forwardUuid: 'fixed-uuid-1234' } as any], { bridge: fakeBridge, groupId: 12345 }, ); expect(elem.lightApp).toBeDefined(); expect(elem.richMsg).toBeUndefined(); const json = decodeLightApp(elem) as any; expect(json.app).toBe('com.tencent.multimsg'); expect(json.meta.detail.resid).toBe('res-XYZ'); expect(json.meta.detail.uniseq).toBe('fixed-uuid-1234'); // `extra` is a JSON string holding {filename, tsum}; filename must // round-trip the same uniseq so the QQ-NT client links them. const extra = JSON.parse(json.extra); expect(extra.filename).toBe('fixed-uuid-1234'); }); it('autogenerates m_fileName for a flat outer forward', async () => { const [elem] = await buildSendElems([{ type: 'forward', resId: 'res-flat' } as any], { bridge: fakeBridge, groupId: 12345, }); const xml = inflatePrefixedPayload(elem.richMsg!.template1!); expect(xml).toContain('m_resid="res-flat"'); expect(xml).toMatch(/m_fileName="[0-9a-f-]{36}"/i); }); it('escapes dynamic XML fields in a flat outer forward', async () => { const [elem] = await buildSendElems( [ { type: 'forward', resId: 'res&<>"\'', forwardSource: 'Alice & ', forwardSummary: 'summary "quoted" & more', forwardPrompt: '[A & B]', forwardNews: [{ text: 'Alice: ' }, { text: 'Bob: it\'s "ok"' }], forwardTSum: 2, } as any, ], { bridge: fakeBridge, groupId: 12345 }, ); const xml = inflatePrefixedPayload(elem.richMsg!.template1!); expect(xml).toContain('m_resid="res&<>"'"'); expect(xml).toContain('brief="[A & B]"'); expect(xml).toContain('Alice & <Bob>'); expect(xml).toContain('summary "quoted" & more'); expect(xml).toContain('Alice: <hello>'); expect(xml).toContain('Bob: it's "ok"'); expect(xml).toContain('tSum="2"'); }); it('normalizes only unsupported preview characters and records the affected fields', async () => { const logs: LogEntry[] = []; const unsubscribe = subscribeLogs((entry) => logs.push(entry)); const element = { type: 'forward' as const, resId: 'preview-controls', forwardSource: 'source\u0000', forwardSummary: 'summary\uFFFE', forwardPrompt: 'prompt\uFFFF', forwardNews: [{ text: 'a\u0001b\u000Bc\u001Fd\uD800e\uDC00f😀中文\t\n\r' }], }; const original = structuredClone(element); try { const [elem] = await buildSendElems([element], { bridge: fakeBridge, groupId: 12345 }); const xml = inflatePrefixedPayload(elem.richMsg!.template1!); expect(xml).toContain('brief="prompt�"'); expect(xml).toContain('source�'); expect(xml).toContain('summary�'); expect(xml).toContain('a�b�c�d�e�f😀中文 '); expect(element).toEqual(original); expect( logs .filter((entry) => entry.message.startsWith('forward preview characters normalized:'), ) .map((entry) => entry.message), ).toEqual([ 'forward preview characters normalized: field=source', 'forward preview characters normalized: field=news[0]', 'forward preview characters normalized: field=prompt', 'forward preview characters normalized: field=summary', ]); } finally { unsubscribe(); } }); it('rejects an unrepresentable resource reference instead of rewriting its identity', async () => { await expect( buildSendElems([{ type: 'forward', resId: 'res\u0001id' }], { bridge: fakeBridge, groupId: 12345, }), ).rejects.toMatchObject({ code: 'INVALID_FIELD', elementType: 'forward', field: 'resId' }); }); it('preserves valid Unicode boundaries and whitespace in preview text', async () => { const text = '\u0009\u000A\u000D\u0020\uD7FF\uE000\uFFFD\u{10000}\u{10FFFF}'; const [elem] = await buildSendElems( [{ type: 'forward', resId: 'valid-unicode', forwardNews: [{ text }] }], { bridge: fakeBridge, groupId: 12345, }, ); expect(inflatePrefixedPayload(elem.richMsg!.template1!)).toContain( ' \uD7FF\uE000\uFFFD\u{10000}\u{10FFFF}', ); }); it('threads forwardSource / forwardSummary / forwardPrompt / forwardNews / forwardTSum verbatim', async () => { const [elem] = await buildSendElems( [ { type: 'forward', resId: 'r1', forwardUuid: 'u1', forwardSource: 'alice和bob的聊天记录', forwardSummary: '查看3条转发消息', forwardPrompt: '[聊天记录]', forwardNews: [{ text: 'alice: hi' }, { text: 'bob: hey' }], forwardTSum: 3, } as any, ], { bridge: fakeBridge, groupId: 12345 }, ); const json = decodeLightApp(elem) as any; expect(json.meta.detail.source).toBe('alice和bob的聊天记录'); expect(json.meta.detail.summary).toBe('查看3条转发消息'); expect(json.meta.detail.news).toEqual([{ text: 'alice: hi' }, { text: 'bob: hey' }]); expect(json.desc).toBe('[聊天记录]'); expect(json.prompt).toBe('[聊天记录]'); expect(JSON.parse(json.extra).tsum).toBe(3); }); it('rejects a forward element when resId is missing', async () => { await expect( buildSendElems([{ type: 'forward' } as any], { bridge: fakeBridge, groupId: 12345 }), ).rejects.toBeInstanceOf(MessageElementValidationError); }); }); describe('private voice compatibility', () => { const context = { bridge: fakeBridge, userUid: 'u_peer', scene: 'direct-private' } as const; const record = { type: 'record', url: 'voice.amr' } as const; const modern = Uint8Array.of(4, 5, 6); it('keeps all modern clips when one legacy voice slot cannot represent the message', async () => { const compat = protobuf_encode<{ ptt?: pb<4, bytes> }>({ ptt: Uint8Array.of(8, 4) }); vi.mocked(uploadPtt) .mockResolvedValueOnce({ msgInfo: modern, compatQMsg: compat }) .mockResolvedValueOnce({ msgInfo: modern, compatQMsg: compat }); const rich = await encodePrivateRichText([record, record], context); expect(protobuf_decode<{ ptt?: pb<4, bytes> }>(rich).ptt).toBeNull(); expect(protobuf_decode(rich).elems).toHaveLength(2); }); it('sends modern voice when no compatibility data is available and rejects malformed data', async () => { vi.mocked(uploadPtt).mockResolvedValueOnce({ msgInfo: modern }); expect( protobuf_decode(await encodePrivateRichText([record], context)).elems, ).toHaveLength(1); vi.mocked(uploadPtt).mockResolvedValueOnce({ msgInfo: modern, compatQMsg: Uint8Array.of(0x12, 0), }); await expect(encodePrivateRichText([record], context)).rejects.toThrow( 'incomplete playback information', ); }); });