import fs from 'fs'; import os from 'os'; import path from 'path'; import { describe, it, expect, vi } from 'vitest'; import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; import { exclusiveForwardNodeList, MessageElementValidationError, parseMessage, } from '../src/message-parser'; import { buildSendElems } from '@snowluma/protocol/element-builder'; import type { MentionExtraSend } from '@snowluma/proto-defs/action'; import type { MarketFacePbReserve } from '@snowluma/proto-defs/element'; import type { OidbBase } from '@snowluma/proto-defs/oidb'; import type { NTV2UploadRichMediaReq, NTV2UploadRichMediaResp } from '@snowluma/proto-defs/highway'; describe('parseMessage', () => { describe('plain text', () => { it('parses simple text', async () => { const result = await parseMessage('hello world', false); expect(result).toEqual([{ type: 'text', text: 'hello world' }]); }); it('rejects an empty string', async () => { await expect(parseMessage('', false)).rejects.toMatchObject({ name: 'MessageElementValidationError', code: 'MISSING_FIELD', field: 'message', }); }); it('autoEscape treats CQ codes as text', async () => { const result = await parseMessage('[CQ:face,id=123]', true); expect(result).toEqual([{ type: 'text', text: '[CQ:face,id=123]' }]); }); }); describe('CQ code parsing', () => { it.each(['0', '2', 0, 2])('preserves an animation result %s', async (resultId) => { expect( await parseMessage([{ type: 'face', data: { id: 114, resultId } }], false), ).toEqual([{ type: 'face', faceId: 114, resultId: String(resultId) }]); }); it.each([null, false, {}, [], '', ' ', -1, 1.5, Number.MAX_SAFE_INTEGER + 1])( 'rejects an invalid animation result %j', async (resultId) => { await expect( parseMessage([{ type: 'face', data: { id: 114, resultId } }], false), ).rejects.toMatchObject({ code: 'INVALID_FIELD', field: 'resultId' }); }, ); it('rejects a selected animation on a small face', async () => { await expect( parseMessage('[CQ:face,id=114,large=false,resultId=0]', false), ).rejects.toMatchObject({ code: 'INVALID_FIELD', field: 'resultId' }); }); it('parses face CQ code', async () => { const result = await parseMessage('[CQ:face,id=123]', false); expect(result).toHaveLength(1); expect(result[0].type).toBe('face'); expect(result[0].faceId).toBe(123); }); it.each([true, false, 'true', 'false', 1, 0, '1', '0'])( 'preserves face animation choice %s', async (large) => { const result = await parseMessage( [{ type: 'face', data: { id: '424', large } }], false, ); expect(result).toEqual([ { type: 'face', faceId: 424, large: [true, 'true', 1, '1'].includes(large) }, ]); }, ); it('accepts the CQ animation switch and rejects invalid booleans', async () => { expect(await parseMessage('[CQ:face,id=424,large=false]', false)).toEqual([ { type: 'face', faceId: 424, large: false }, ]); await expect(parseMessage('[CQ:face,id=424,large=maybe]', false)).rejects.toMatchObject( { code: 'INVALID_FIELD', }, ); }); it('parses at CQ code', async () => { const result = await parseMessage('[CQ:at,qq=12345]', false); expect(result).toHaveLength(1); expect(result[0].type).toBe('at'); expect(result[0].targetUin).toBe(12345); }); it('parses at all', async () => { const result = await parseMessage('[CQ:at,qq=all]', false); expect(result).toHaveLength(1); expect(result[0].type).toBe('at'); expect(result[0].targetUin).toBe(0); }); it('parses mixed text and CQ codes', async () => { const result = await parseMessage('Hello [CQ:face,id=1] World', false); expect(result).toHaveLength(3); expect(result[0]).toEqual({ type: 'text', text: 'Hello ' }); expect(result[1].type).toBe('face'); expect(result[2]).toEqual({ type: 'text', text: ' World' }); }); it('unescapes CQ special chars', async () => { const result = await parseMessage('a&b[c]d', false); expect(result).toEqual([{ type: 'text', text: 'a&b[c]d' }]); }); it('recognizes underscored CQ types and rejects receive-only flash_file', async () => { await expect( parseMessage('[CQ:flash_file,file_set_id=x]', false), ).rejects.toMatchObject({ code: 'UNSENDABLE_TYPE', elementType: 'flash_file', message: expect.stringContaining('send_flash_msg'), }); }); it('does not send an unknown underscored CQ code as literal text', async () => { await expect(parseMessage('[CQ:unknown_segment]', false)).rejects.toMatchObject({ code: 'UNKNOWN_TYPE', elementType: 'unknown_segment', }); }); }); describe('JSON segment array', () => { it('parses text segment', async () => { const result = await parseMessage( [{ type: 'text', data: { text: 'hello' } }] as any, false, ); expect(result).toEqual([{ type: 'text', text: 'hello' }]); }); it('rejects a node mixed into a normal message', async () => { await expect( parseMessage( [ { type: 'text', data: { text: 'hi' } }, { type: 'node', data: { user_id: 1, content: [{ type: 'text', data: { text: 'x' } }] }, }, ] as any, false, ), ).rejects.toMatchObject({ code: 'UNSENDABLE_TYPE', elementType: 'node', message: 'message segment "node" is only valid inside a forward node list', }); }); it('ignores explicit empty text placeholders between sendable segments (issue #273)', async () => { const result = await parseMessage( [ { type: 'image', data: { file: 'https://example.com/first.png' } }, { type: 'text', data: { text: '' } }, { type: 'image', data: { file: 'https://example.com/second.png' } }, ] as any, false, ); expect(result).toHaveLength(2); expect(result).toMatchObject([ { type: 'image', url: 'https://example.com/first.png' }, { type: 'image', url: 'https://example.com/second.png' }, ]); }); it('rejects a segment array containing only empty text placeholders', async () => { await expect( parseMessage( [ { type: 'text', data: { text: '' } }, { type: 'text', data: { text: '' } }, ] as any, false, ), ).rejects.toMatchObject({ code: 'MISSING_FIELD', field: 'message', }); }); it('keeps a single empty text segment invalid', async () => { await expect( parseMessage({ type: 'text', data: { text: '' } } as any, false), ).rejects.toMatchObject({ code: 'MISSING_FIELD', elementType: 'text', }); }); it('does not treat missing or null text fields as empty placeholders', async () => { await expect( parseMessage([{ type: 'text', data: {} }] as any, false), ).rejects.toMatchObject({ code: 'MISSING_FIELD', elementType: 'text', }); await expect( parseMessage([{ type: 'text', data: { text: null } }] as any, false), ).rejects.toMatchObject({ code: 'MISSING_FIELD', elementType: 'text', }); }); it('does not treat whitespace-padded segment types as empty text placeholders', async () => { await expect( parseMessage( [ { type: 'image', data: { file: 'https://example.com/image.png' } }, { type: ' text ', data: { text: '' } }, ] as any, false, ), ).rejects.toMatchObject({ code: 'UNKNOWN_TYPE', elementType: ' text ', }); }); it('preserves whitespace-only text segments', async () => { await expect( parseMessage([{ type: 'text', data: { text: ' ' } }] as any, false), ).resolves.toEqual([{ type: 'text', text: ' ' }]); }); it('rejects object-valued fields for scalar-only segments', async () => { await expect( parseMessage([{ type: 'text', data: { text: { bad: true } } }] as any, false), ).rejects.toMatchObject({ code: 'INVALID_FIELD', elementType: 'text', field: 'text', }); }); it('parses face segment', async () => { const result = await parseMessage([{ type: 'face', data: { id: 123 } }] as any, false); expect(result).toHaveLength(1); expect(result[0].type).toBe('face'); expect(result[0].faceId).toBe(123); }); it('rejects numeric fields with suffix garbage or fractional values', async () => { await expect( parseMessage([{ type: 'at', data: { qq: '123abc' } }] as any, false), ).rejects.toMatchObject({ code: 'INVALID_FIELD' }); await expect( parseMessage([{ type: 'face', data: { id: 1.9 } }] as any, false), ).rejects.toMatchObject({ code: 'INVALID_FIELD' }); }); it('skips an unusable reply segment while preserving sendable content', async () => { const result = await parseMessage( [ { type: 'reply', data: { id: 'notice-event-id' } }, { type: 'text', data: { text: 'still sent' } }, ] as any, false, ); expect(result).toEqual([{ type: 'text', text: 'still sent' }]); }); it('parses image segment', async () => { const result = await parseMessage( [{ type: 'image', data: { file: 'https://example.com/img.png' } }] as any, false, ); expect(result).toHaveLength(1); expect(result[0].type).toBe('image'); expect(result[0].url).toBe('https://example.com/img.png'); }); it('prefers a real url when file is a QQ-internal id (issue #155)', async () => { // Yunzai et al. echo a received image by resending the original // `file=.ext` together with the download `url`. `file` is not a // local path, so picking it would statSync → ENOENT on send. const result = await parseMessage( [ { type: 'image', data: { file: '35246A5B5C287F680C90839829FD7620.png', url: 'https://multimedia.nt.qq.com.cn/download?fileid=abc&rkey=xyz', sub_type: 1, summary: '[动画表情]', }, }, ] as any, true, ); expect(result[0].type).toBe('image'); expect(result[0].url).toBe( 'https://multimedia.nt.qq.com.cn/download?fileid=abc&rkey=xyz', ); expect(result[0].subType).toBe(1); }); it('keeps file when it is itself loadable even if a url is also present', async () => { // A path (has a separator) or inline bytes are the actual content and // must win over any sibling url. const viaPath = await parseMessage( [ { type: 'image', data: { file: '/tmp/local.png', url: 'https://example.com/other.png' }, }, ] as any, false, ); expect(viaPath[0].url).toBe('/tmp/local.png'); const viaBase64 = await parseMessage( [ { type: 'image', data: { file: 'base64://AAAA', url: 'https://example.com/other.png' }, }, ] as any, false, ); expect(viaBase64[0].url).toBe('base64://AAAA'); }); it('falls back a QQ-internal id record/video to the sibling url (issue #155)', async () => { const rec = await parseMessage( [ { type: 'record', data: { file: 'ABCD1234.amr', url: 'https://example.com/v.amr' }, }, ] as any, false, ); expect(rec).toEqual([{ type: 'record', url: 'https://example.com/v.amr' }]); const vid = await parseMessage( [ { type: 'video', data: { file: 'EF567890.mp4', url: 'https://example.com/v.mp4' }, }, ] as any, false, ); expect(vid).toEqual([ { type: 'video', url: 'https://example.com/v.mp4', thumbUrl: undefined }, ]); }); it('parses record segment from data.file', async () => { const result = await parseMessage( [{ type: 'record', data: { file: 'file:///tmp/voice.amr' } }] as any, false, ); expect(result).toEqual([{ type: 'record', url: 'file:///tmp/voice.amr' }]); }); it('parses video segment from data.path and preserves thumb', async () => { const result = await parseMessage( [{ type: 'video', data: { path: '/tmp/clip.mp4', thumb: '/tmp/clip.png' } }] as any, false, ); expect(result).toEqual([ { type: 'video', url: '/tmp/clip.mp4', thumbUrl: '/tmp/clip.png' }, ]); }); it('parses AstrBot-style Video segment with top-level media', async () => { const result = await parseMessage( [{ type: 'Video', media: 'file:////AstrBot/data/cache/BV-test.mp4' }] as any, false, ); expect(result).toEqual([ { type: 'video', url: 'file:////AstrBot/data/cache/BV-test.mp4', thumbUrl: undefined, }, ]); }); it('rejects a video segment with an empty source', async () => { await expect( parseMessage([{ type: 'video', data: {} }] as any, false), ).rejects.toMatchObject({ name: 'MessageElementValidationError', code: 'MISSING_FIELD', elementType: 'video', }); }); it('parses record segment from data.path (NapCat parity)', async () => { const result = await parseMessage( [{ type: 'record', data: { path: 'C:\\voices\\hi.silk' } }] as any, false, ); expect(result).toEqual([{ type: 'record', url: 'C:\\voices\\hi.silk' }]); }); it('rejects a record segment with an empty source', async () => { await expect( parseMessage([{ type: 'record', data: {} }] as any, false), ).rejects.toMatchObject({ name: 'MessageElementValidationError', code: 'MISSING_FIELD', elementType: 'record', }); }); it('parses at segment', async () => { const result = await parseMessage([{ type: 'at', data: { qq: 12345 } }] as any, false); expect(result).toHaveLength(1); expect(result[0].type).toBe('at'); expect(result[0].targetUin).toBe(12345); }); it('uses at segment name for display text and preserves uid', async () => { const result = await parseMessage( [{ type: 'at', data: { qq: '123456', name: 'User', uid: 'u_test_uid' } }] as any, false, ); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ type: 'at', targetUin: 123456, uid: 'u_test_uid', text: '@User ', }); }); it('resolves missing at uid through parse options', async () => { const result = await parseMessage( [{ type: 'at', data: { qq: '123456', name: 'User' } }] as any, false, { resolveMentionUid: async (targetUin) => targetUin === 123456 ? 'u_resolved_uid' : null, }, ); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ type: 'at', targetUin: 123456, uid: 'u_resolved_uid', text: '@User ', }); }); it('encodes mention extra with resolved uid for QQ notification', async () => { const elements = await parseMessage( [{ type: 'at', data: { qq: '123456', name: 'User' } }] as any, false, { resolveMentionUid: () => 'u_resolved_uid' }, ); const protoElems = await buildSendElems(elements); const reserve = protoElems[0].text?.pbReserve; expect(reserve).toBeInstanceOf(Uint8Array); const extra = protobuf_decode(reserve as Uint8Array); expect(extra).toMatchObject({ type: 2, uin: 123456, uid: 'u_resolved_uid', }); expect(protoElems[0].text?.str).toBe('@User '); }); it('rejects the whole message when record SendContext is absent', async () => { const elements = await parseMessage( [ { type: 'text', data: { text: 'hi ' } }, { type: 'record', data: { file: '/tmp/x.silk' } }, ] as any, false, ); await expect(buildSendElems(elements)).rejects.toBeInstanceOf( MessageElementValidationError, ); }); it('rejects the whole message when video SendContext is absent', async () => { const elements = await parseMessage( [ { type: 'text', data: { text: 'hi ' } }, { type: 'video', data: { file: '/tmp/x.mp4' } }, ] as any, false, ); await expect(buildSendElems(elements)).rejects.toBeInstanceOf( MessageElementValidationError, ); }); it('builds video commonElem through NTV2 fast-upload response', async () => { const videoPath = path.join( os.tmpdir(), `snowluma-video-test-${process.pid}-${Date.now()}.mp4`, ); fs.writeFileSync(videoPath, Buffer.from([0, 1, 2, 3])); const responseData = protobuf_encode>({ command: 0x11ea, subCommand: 100, errorCode: 0, body: { respHead: { retCode: 0, message: '' }, upload: { uKey: '', msgInfo: { msgInfoBody: [ { index: { fileUuid: 'video-uuid' }, fileExist: true }, { index: { fileUuid: 'thumb-uuid' }, fileExist: true }, ], extBizInfo: { video: { bytesPbReserve: new Uint8Array([0x80, 0x01, 0x00]) }, }, }, subFileInfos: [{ uKey: '' }], }, }, errorMsg: '', reserved: 1, }); const bridge = { identity: { uin: '10000' }, sendRawPacket: vi.fn(async () => ({ success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData, })), } as any; try { const protoElems = await buildSendElems( [ { type: 'video', url: videoPath, thumbUrl: 'base64://iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', }, ], { bridge, groupId: 123456 }, ); // The video main file carries real bytes but the server fast-paths it // (fileExist:true, no uKey). The send trusts that single fast-path // response — the old forceFullOnFastPath retry was removed (proven // ineffective, see #145), so exactly ONE OIDB request goes out. expect(bridge.sendRawPacket).toHaveBeenCalledTimes(1); expect(bridge.sendRawPacket).toHaveBeenNthCalledWith( 1, 'OidbSvcTrpcTcp.0x11ea_100', expect.any(Uint8Array), ); const firstReq = protobuf_decode>( bridge.sendRawPacket.mock.calls[0]![1] as Uint8Array, ); expect(firstReq.body.upload.tryFastUploadCompleted).toBe(true); expect(protoElems).toHaveLength(1); expect(protoElems[0].commonElem?.serviceType).toBe(48); expect(protoElems[0].commonElem?.businessType).toBe(21); expect(protoElems[0].commonElem?.pbElem).toBeInstanceOf(Uint8Array); } finally { try { fs.unlinkSync(videoPath); } catch { /* ignore */ } } }); it('parses multiple segments', async () => { const result = await parseMessage( [ { type: 'text', data: { text: 'hi ' } }, { type: 'at', data: { qq: 999 } }, { type: 'text', data: { text: ' there' } }, ] as any, false, ); expect(result).toHaveLength(3); expect(result[0].type).toBe('text'); expect(result[1].type).toBe('at'); expect(result[2].type).toBe('text'); }); it('rejects the whole message when one segment type is unknown', async () => { await expect( parseMessage( [ { type: 'text', data: { text: 'ok' } }, { type: 'unknown_type_xyz', data: {} }, ] as any, false, ), ).rejects.toMatchObject({ name: 'MessageElementValidationError', code: 'UNKNOWN_TYPE', elementType: 'unknown_type_xyz', }); }); }); describe('market face (mface)', () => { const EMOJI_ID = '235a82d9c0acd2e2db6e0b94e1a1c4f3'; it('parses an mface segment into a market-face element', async () => { const result = await parseMessage( [ { type: 'mface', data: { emoji_id: EMOJI_ID, emoji_package_id: 12, key: 'abc', summary: '可爱', }, }, ] as any, false, ); expect(result).toEqual([ { type: 'mface', text: '可爱', emojiId: EMOJI_ID, emojiPackageId: 12, emojiKey: 'abc', }, ]); }); it('round-trips an image-with-emoji_id back into a market face (not a re-uploaded picture)', async () => { const result = await parseMessage( [ { type: 'image', data: { file: `23-${EMOJI_ID}.gif`, url: `https://gxh.vip.qq.com/club/item/parcel/item/23/${EMOJI_ID}/raw300.gif`, emoji_id: EMOJI_ID, emoji_package_id: 12, key: 'abc', summary: '可爱', }, }, ] as any, false, ); expect(result).toEqual([ { type: 'mface', text: '可爱', emojiId: EMOJI_ID, emojiPackageId: 12, emojiKey: 'abc', }, ]); }); it('leaves a plain image (no emoji_id) as an image element', async () => { const result = await parseMessage( [{ type: 'image', data: { file: 'https://example.com/a.png' } }] as any, false, ); expect(result[0]!.type).toBe('image'); expect((result[0] as any).emojiId).toBeUndefined(); }); it('rejects an mface segment without emoji_id', async () => { await expect( parseMessage( [{ type: 'mface', data: { emoji_package_id: 12, summary: 'x' } }] as any, false, ), ).rejects.toMatchObject({ code: 'MISSING_FIELD', elementType: 'mface' }); }); it('rejects a non-hex market-face GUID before wire encoding', async () => { await expect( parseMessage( [{ type: 'mface', data: { emoji_id: 'zz', summary: 'broken' } }] as any, false, ), ).rejects.toMatchObject({ code: 'INVALID_FIELD', elementType: 'mface', field: 'emojiId', }); }); it('builds the wire marketFace element (faceId = hex(emoji_id), NapCat constants)', async () => { const elements = await parseMessage( [ { type: 'mface', data: { emoji_id: EMOJI_ID, emoji_package_id: 12, key: 'abc', summary: '可爱', }, }, ] as any, false, ); const protoElems = await buildSendElems(elements); expect(protoElems).toHaveLength(1); const mf = protoElems[0]!.marketFace; expect(mf).toBeDefined(); expect(Buffer.from(mf!.faceId as Uint8Array).toString('hex')).toBe(EMOJI_ID); expect(mf).toMatchObject({ faceName: '可爱', itemType: 6, faceInfo: 1, tabId: 12, subType: 3, key: 'abc', imageWidth: 300, imageHeight: 300, }); const reserve = protobuf_decode(mf!.pbReserve as Uint8Array); expect(reserve).toMatchObject({ field8: 1 }); }); }); describe('special segments', () => { it('parses json segment', async () => { const result = await parseMessage( [{ type: 'json', data: { data: '{"app":"test"}' } }] as any, false, ); expect(result).toHaveLength(1); expect(result[0].type).toBe('json'); expect(result[0].text).toBe('{"app":"test"}'); }); it('accepts an object JSON payload produced by AstrBot-compatible clients', async () => { const result = await parseMessage( [ { type: 'json', data: { data: { app: 'com.tencent.music.lua', meta: { music: { title: '晴天' } }, }, config: { token: 'signed-token' }, }, }, ] as any, false, ); expect(result).toEqual([ { type: 'json', text: '{"app":"com.tencent.music.lua","meta":{"music":{"title":"晴天"}}}', }, ]); }); it('accepts the JSON raw-string segment shorthand used by compatible clients', async () => { await expect( parseMessage( [{ type: 'json', data: '{"app":"com.tencent.music.lua"}' }] as any, false, ), ).resolves.toEqual([ { type: 'json', text: '{"app":"com.tencent.music.lua"}', }, ]); }); it.each([ { label: 'array', value: [] }, { label: 'null', value: null }, { label: 'number', value: 1 }, ])( 'rejects a $label JSON payload instead of sending a non-object card', async ({ value }) => { await expect( parseMessage([{ type: 'json', data: { data: value } }] as any, false), ).rejects.toMatchObject({ code: 'INVALID_FIELD', elementType: 'json', field: 'data', }); }, ); it('reads canonical xml resid and file_size fields', async () => { const xml = await parseMessage( [{ type: 'xml', data: { data: '', resid: '35' } }] as any, false, ); expect(xml).toEqual([{ type: 'xml', text: '', subType: 35 }]); const file = await parseMessage( [{ type: 'file', data: { file_id: 'fid', file_size: '4096' } }] as any, false, ); expect(file).toEqual([{ type: 'file', fileId: 'fid', fileSize: 4096 }]); await expect( parseMessage( [ { type: 'file', data: { file: 'report.pdf', url: 'https://example.com/report.pdf' }, }, ] as any, false, ), ).resolves.toEqual([ { type: 'file', url: 'https://example.com/report.pdf', }, ]); await expect(parseMessage('[CQ:file,id=fid-from-raw]', false)).resolves.toEqual([ { type: 'file', fileId: 'fid-from-raw' }, ]); }); it('parses reply segment with resolveReplySequence', async () => { const result = await parseMessage([{ type: 'reply', data: { id: 42 } }] as any, false, { resolveReplySequence: (id) => (id === 42 ? 100 : null), }); expect(result).toHaveLength(1); expect(result[0].type).toBe('reply'); expect(result[0].replySeq).toBe(100); }); it('resolves a signed-int32 message_id to the stored reply sequence (#371)', async () => { const result = await parseMessage( [ { type: 'reply', data: { id: -188861750 } }, { type: 'text', data: { text: '测试成功' } }, ] as any, false, { resolveReplySequence: (id) => (id === -188861750 ? 4242 : null) }, ); expect(result).toEqual([ { type: 'reply', replySeq: 4242, replyMessageId: -188861750 }, { type: 'text', text: '测试成功' }, ]); }); it('does not treat an unresolved negative message_id as a QQ sequence (#371)', async () => { const result = await parseMessage( [ { type: 'reply', data: { id: -188861750 } }, { type: 'text', data: { text: '测试成功' } }, ] as any, false, { resolveReplySequence: () => null }, ); expect(result).toEqual([{ type: 'text', text: '测试成功' }]); }); it('parses share as json card', async () => { const result = await parseMessage( [{ type: 'share', data: { url: 'https://example.com', title: 'Test' } }] as any, false, ); expect(result).toHaveLength(1); expect(result[0].type).toBe('json'); expect(result[0].text).toBeDefined(); const parsed = JSON.parse(result[0].text!); expect(parsed.app).toBe('com.tencent.structmsg'); expect(parsed.meta.news.title).toBe('Test'); }); it('rejects malformed rich input sugar before resolver or signer side effects', async () => { const resolveContactArk = vi.fn(); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); try { await expect( parseMessage([{ type: 'share', data: {} }] as any, false), ).rejects.toMatchObject({ code: 'INVALID_FIELD', elementType: 'share' }); await expect( parseMessage( [{ type: 'location', data: { lat: '', lon: '181' } }] as any, false, ), ).rejects.toMatchObject({ code: 'INVALID_FIELD', elementType: 'location' }); await expect( parseMessage( [{ type: 'contact', data: { type: 'channel', id: '123' } }] as any, false, { resolveContactArk, }, ), ).rejects.toMatchObject({ code: 'INVALID_FIELD', elementType: 'contact' }); await expect( parseMessage([{ type: 'music', data: { type: '163' } }] as any, false), ).rejects.toMatchObject({ code: 'MISSING_FIELD', elementType: 'music' }); } finally { vi.unstubAllGlobals(); } expect(resolveContactArk).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled(); }); it('parses rps as face', async () => { const result = await parseMessage([{ type: 'rps', data: {} }] as any, false); expect(result).toHaveLength(1); expect(result[0].type).toBe('face'); expect(result[0].faceId).toBe(359); }); it('parses dice as face', async () => { const result = await parseMessage([{ type: 'dice', data: {} }] as any, false); expect(result).toHaveLength(1); expect(result[0].type).toBe('face'); expect(result[0].faceId).toBe(358); }); it('parses poke and the legacy shake alias as window-shake elements', async () => { await expect( parseMessage([{ type: 'poke', data: { type: 1 } }] as any, false), ).resolves.toEqual([{ type: 'poke', subType: 1 }]); await expect( parseMessage([{ type: 'shake', data: {} }] as any, false), ).resolves.toEqual([{ type: 'poke', subType: 1 }]); }); it('rejects anonymous because it has no executable send semantics', async () => { await expect( parseMessage( [ { type: 'anonymous', data: {} }, { type: 'text', data: { text: 'hi' } }, ] as any, false, ), ).rejects.toMatchObject({ code: 'UNSENDABLE_TYPE', elementType: 'anonymous', }); }); it('parses location as json card', async () => { const result = await parseMessage( [ { type: 'location', data: { lat: '39.9', lon: '116.3', title: 'Beijing' } }, ] as any, false, ); expect(result).toHaveLength(1); expect(result[0].type).toBe('json'); expect(result[0].text).toBeDefined(); const parsed = JSON.parse(result[0].text!); expect(parsed.app).toBe('com.tencent.map'); expect(parsed.meta.Location.lat).toBe('39.9'); }); it('parses contact as json card', async () => { const result = await parseMessage( [{ type: 'contact', data: { type: 'group', id: '12345' } }] as any, false, ); expect(result).toHaveLength(1); expect(result[0].type).toBe('json'); expect(result[0].text).toBeDefined(); const parsed = JSON.parse(result[0].text!); expect(parsed.meta.contact.type).toBe('group'); }); it('accepts id-less custom music data with a platform type for NapCat compatibility', async () => { const card = JSON.stringify({ app: 'com.tencent.structmsg', view: 'music', prompt: '[音乐]光', }); const fetchMock = vi.fn( async (_input: string | URL | Request, _init?: RequestInit) => ({ ok: true, text: async () => card, }), ); vi.stubGlobal('fetch', fetchMock); try { const result = await parseMessage( [ { type: 'music', data: { type: '163', url: 'https://music.example/song/3402687142', audio: 'https://cdn.example/3402687142.mp3', title: '光', image: 'https://cdn.example/3402687142.jpg', content: '陈粒', }, }, ] as any, false, ); expect(result).toEqual([{ type: 'json', text: card }]); expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ type: '163', url: 'https://music.example/song/3402687142', audio: 'https://cdn.example/3402687142.mp3', title: '光', image: 'https://cdn.example/3402687142.jpg', singer: '陈粒', }); } finally { vi.unstubAllGlobals(); } }); it('parses signed music cards and buildSendElems preserves non-ASCII JSON', async () => { const card = JSON.stringify({ app: 'com.tencent.structmsg', view: 'music', prompt: '[音乐]风中有朵雨做的云', meta: { music: { title: '风中有朵雨做的云', desc: '孟庭苇' } }, }); vi.stubGlobal( 'fetch', vi.fn(async () => ({ ok: true, text: async () => card, })), ); try { const result = await parseMessage( [{ type: 'music', data: { type: '163', id: '123456' } }] as any, false, ); expect(result).toEqual([{ type: 'json', text: card }]); const protoElems = await buildSendElems(result); expect(protoElems).toHaveLength(1); expect(protoElems[0].lightApp?.data?.[0]).toBe(0x01); } finally { vi.unstubAllGlobals(); } }); }); describe('exclusiveForwardNodeList', () => { const node = { type: 'node', data: { user_id: 1, content: [{ type: 'text', data: { text: 'x' } }] }, }; it('accepts a node array and a single node object', () => { expect(exclusiveForwardNodeList([node], false)).toEqual([node]); expect(exclusiveForwardNodeList(node, false)).toEqual([node]); }); it('strips explicit empty text placeholders around nodes', () => { expect( exclusiveForwardNodeList( [ { type: 'text', data: { text: '' } }, node, { type: 'text', data: { text: '' } }, ], false, ), ).toEqual([node]); }); it('rejects mixed node+text and auto-escaped strings', () => { expect( exclusiveForwardNodeList([node, { type: 'text', data: { text: 'hi' } }], false), ).toBeNull(); expect(exclusiveForwardNodeList('hello', true)).toBeNull(); }); }); describe('single segment object', () => { it('parses single segment object', async () => { const result = await parseMessage( { type: 'text', data: { text: 'single' } } as any, false, ); expect(result).toEqual([{ type: 'text', text: 'single' }]); }); }); describe('edge cases', () => { it('rejects null', async () => { await expect(parseMessage(null as any, false)).rejects.toMatchObject({ code: 'INVALID_FIELD', }); }); it('rejects a number', async () => { await expect(parseMessage(42 as any, false)).rejects.toMatchObject({ code: 'INVALID_FIELD', }); }); it('rejects an empty segment array and an object without type', async () => { await expect(parseMessage([] as any, false)).rejects.toMatchObject({ code: 'MISSING_FIELD', }); await expect(parseMessage({ data: { text: 'x' } } as any, false)).rejects.toMatchObject( { code: 'MISSING_FIELD', field: 'type', }, ); }); }); describe('file segment', () => { // Closes the parser-side of the "upload says ok but message is // empty" bug. Before this, `{type:'file', file_id:'…'}` segments // fell through to the default warn-and-drop branch — which made // `send_msg([{type:'file', file_id:''}])` produce either // a "message is empty" error or, when mixed with text, a message // with the file segment quietly missing. it('parses {type:"file", file_id:"x"} into a file MessageElement', async () => { const result = await parseMessage( [{ type: 'file', data: { file_id: 'fid-1', name: 'a.zip', size: 123 } } as any], false, ); expect(result).toEqual([ { type: 'file', fileId: 'fid-1', fileName: 'a.zip', fileSize: 123 }, ]); }); it('parses {type:"file", file:"/path"} (no file_id) into a file element with url', async () => { // Inline file path: the parser now accepts file/url/path and stores // it in `url`. The send layer (sendGroupMessage / sendPrivateMessage) // handles the actual upload so the parser stays side-effect-free. const result = await parseMessage( [{ type: 'file', data: { file: '/local/path/a.bin' } } as any], false, ); expect(result).toEqual([{ type: 'file', url: '/local/path/a.bin' }]); }); it('does not let an empty file field mask a valid url', async () => { const result = await parseMessage( [{ type: 'file', data: { file: '', url: 'https://example.com/a.bin' } } as any], false, ); expect(result).toEqual([{ type: 'file', url: 'https://example.com/a.bin' }]); }); it('rejects a file segment with neither file_id nor file/url', async () => { await expect( parseMessage([{ type: 'file', data: {} } as any], false), ).rejects.toMatchObject({ code: 'MISSING_FIELD', elementType: 'file', }); }); it('accepts fileId / filename / fileName / md5 / sha1 aliases', async () => { const result = await parseMessage( [ { type: 'file', data: { fileId: 'fid-2', filename: 'b.zip', fileSize: 456, md5: 'ab'.repeat(16), sha1: 'cd'.repeat(20), }, } as any, ], false, ); expect(result).toEqual([ { type: 'file', fileId: 'fid-2', fileName: 'b.zip', fileSize: 456, md5Hex: 'ab'.repeat(16), sha1Hex: 'cd'.repeat(20), }, ]); }); }); });