// Regression coverage for the c2c-file-in-send_private_msg bug. // // Symptom in the wild: bot called `send_private_msg` with a // `{type:'file', file_id}` segment; the OneBot module passed it // straight to `bridge.sendPrivateMessage(userId, [fileElement])`; // `buildSendElems` used to warn and drop it; the resulting wire send had 0 elems and the chat // rendered "[空消息]". // // Fix: c2c file segments are split out at the OneBot layer in // `sendPrivateMessage` and dispatched through `bridge.sendC2cFileMessage` // (which writes `RichText.notOnlineFile`, the correct wire slot for // c2c files). Matches NapCat's `SendMsg.ts:404-415` which splits // FILE / VIDEO / ARK / PTT into their own sendMsg calls and never // mixes them with the regular elems[] batch. import { describe, expect, it, vi } from 'vitest'; import type { BridgeInterface } from '../../src/bridge/bridge-interface'; import type { OneBotInstanceContext } from '../src/instance-context'; import { sendPrivateMessage } from '../src/modules/message-actions'; function fakeBridge(overrides: Partial = {}): BridgeInterface { return new Proxy({ identity: { nickname: 'SnowLuma' }, ...overrides } as BridgeInterface, { get(target, prop) { if (prop in target) { return (target as any)[prop]; } throw new Error(`fakeBridge: '${String(prop)}' was not stubbed for this test`); }, }); } function makeCtx(bridge: BridgeInterface): OneBotInstanceContext { return { uin: '10001', selfId: 10001, bridge, messageStore: { findEvent: () => null, storeEvent: vi.fn() } as any, cacheMessageMeta: vi.fn(), mediaStore: {} as any, converterCtx: { selfId: 10001, imageUrlResolver: null, mediaUrlResolver: null, messageIdResolver: null, mediaSegmentSink: null, }, musicSignUrl: '', } as unknown as OneBotInstanceContext; } const goodReceipt = { messageId: 1, sequence: 100, clientSequence: 0, random: 1, timestamp: 1700000000, }; describe('send_private_msg with {type:"file"} segment', () => { it('file-only message routes through sendC2cFileMessage (not sendPrivateMessage)', async () => { // Pure-file case — the elems[] path would have dropped this entirely // and shipped "[空消息]". The c2c file route must take over. const sendPrivateMessage_bridge = vi.fn(); const sendC2cFileMessage = vi.fn( async (_uin: number, _uid: string, _info: any) => goodReceipt, ); const resolveUserUid = vi.fn(async () => 'u_peer'); const recallUploadedFile = vi.fn(() => undefined); const bridge = fakeBridge({ apis: { message: { sendPrivate: sendPrivateMessage_bridge, sendC2cFile: sendC2cFileMessage, }, } as any, resolveUserUid, recallUploadedFile, } as any); const ctx = makeCtx(bridge); await sendPrivateMessage( ctx, 67890, [ { type: 'file', data: { file_id: 'uuid-abc', name: 'doc.txt', size: 123, md5: '00112233445566778899aabbccddeeff', }, }, ] as any, false, ); expect(sendC2cFileMessage).toHaveBeenCalledOnce(); expect(sendPrivateMessage_bridge).not.toHaveBeenCalled(); const [uin, uid, info] = sendC2cFileMessage.mock.calls[0]!; expect(uin).toBe(67890); expect(uid).toBe('u_peer'); expect(info).toMatchObject({ fileId: 'uuid-abc', fileName: 'doc.txt', fileSize: 123, }); expect(info.fileMd5).toEqual(Buffer.from('00112233445566778899aabbccddeeff', 'hex')); }); it('file-only with just file_id hydrates fileName/size/md5 from the upload cache', async () => { // Reproduces the "0 B file" bug: a caller passes only `file_id` from // a previous upload_private_file, with no size/md5/name. Without // the cache lookup the wire packet ships fileSize=0/md5=empty and // the recipient sees a 0-byte file. With the cache we recover the // real tuple. const sendC2cFileMessage = vi.fn( async (_uin: number, _uid: string, _info: any) => goodReceipt, ); const recallUploadedFile = vi.fn((id: string) => id === 'uuid-cached' ? { fileId: 'uuid-cached', scope: 'private' as const, userId: 67890, fileName: 'real-doc.pdf', fileSize: 4096, fileMd5: Buffer.from('aabbccddeeff', 'hex'), fileSha1: new Uint8Array(20), fileHash: 'srv-hash', rememberedAt: 0, } : undefined, ); const bridge = fakeBridge({ apis: { message: { sendPrivate: vi.fn(), sendC2cFile: sendC2cFileMessage } } as any, resolveUserUid: vi.fn(async () => 'u_peer'), recallUploadedFile, } as any); const ctx = makeCtx(bridge); await sendPrivateMessage( ctx, 67890, [ { type: 'file', data: { file_id: 'uuid-cached' }, }, ] as any, false, ); expect(sendC2cFileMessage).toHaveBeenCalledOnce(); const [, , info] = sendC2cFileMessage.mock.calls[0]!; expect(info).toMatchObject({ fileId: 'uuid-cached', fileName: 'real-doc.pdf', fileSize: 4096, fileHash: 'srv-hash', }); expect(info.fileMd5).toEqual(Buffer.from('aabbccddeeff', 'hex')); }); it('inline segment fields take precedence over the upload cache', async () => { // If the caller threads the metadata inline (e.g. they tracked the // upload response themselves), honour that over the cache so a // refresh of an evicted file still works. const sendC2cFileMessage = vi.fn( async (_uin: number, _uid: string, _info: any) => goodReceipt, ); const recallUploadedFile = vi.fn(() => ({ fileId: 'uuid-x', scope: 'private' as const, userId: 67890, fileName: 'stale-cache-name.txt', fileSize: 99, fileMd5: new Uint8Array(16), fileSha1: new Uint8Array(20), fileHash: 'stale-hash', rememberedAt: 0, })); const bridge = fakeBridge({ apis: { message: { sendPrivate: vi.fn(), sendC2cFile: sendC2cFileMessage } } as any, resolveUserUid: vi.fn(async () => 'u_peer'), recallUploadedFile, } as any); const ctx = makeCtx(bridge); await sendPrivateMessage( ctx, 67890, [ { type: 'file', data: { file_id: 'uuid-x', name: 'inline.txt', size: 200, md5: '11223344556677889900aabbccddeeff', file_hash: 'inline-hash', }, }, ] as any, false, ); const [, , info] = sendC2cFileMessage.mock.calls[0]!; expect(info).toMatchObject({ fileName: 'inline.txt', fileSize: 200, fileHash: 'inline-hash', }); expect(info.fileMd5).toEqual(Buffer.from('11223344556677889900aabbccddeeff', 'hex')); }); it('mixed file + text splits across two sends (text first, file second)', async () => { const sendPrivateMessage_bridge = vi.fn( async (_uin: number, _elements: any[]) => goodReceipt, ); const sendC2cFileMessage = vi.fn( async (_uin: number, _uid: string, _info: any) => goodReceipt, ); const resolveUserUid = vi.fn(async () => 'u_peer'); const bridge = fakeBridge({ apis: { message: { sendPrivate: sendPrivateMessage_bridge, sendC2cFile: sendC2cFileMessage, }, } as any, resolveUserUid, recallUploadedFile: vi.fn(() => undefined), } as any); const ctx = makeCtx(bridge); await sendPrivateMessage( ctx, 67890, [ { type: 'text', data: { text: 'here is the file:' } }, { type: 'file', data: { file_id: 'uuid-xyz', name: 'pkg.zip' } }, ] as any, false, ); expect(sendPrivateMessage_bridge).toHaveBeenCalledOnce(); expect(sendC2cFileMessage).toHaveBeenCalledOnce(); // Text goes via the elems[] pipeline. const [textUin, textElements] = sendPrivateMessage_bridge.mock.calls[0]!; expect(textUin).toBe(67890); expect(textElements).toEqual([{ type: 'text', text: 'here is the file:' }]); // File goes via the dedicated c2c-file API. const [fileUin, fileUid, fileInfo] = sendC2cFileMessage.mock.calls[0]!; expect(fileUin).toBe(67890); expect(fileUid).toBe('u_peer'); expect(fileInfo).toMatchObject({ fileId: 'uuid-xyz', fileName: 'pkg.zip' }); }); it('rejects the whole message when a file segment has neither file_id nor url', async () => { const sendPrivateMessage_bridge = vi.fn( async (_uin: number, _elements: any[]) => goodReceipt, ); const sendC2cFileMessage = vi.fn(); const bridge = fakeBridge({ apis: { message: { sendPrivate: sendPrivateMessage_bridge, sendC2cFile: sendC2cFileMessage, }, } as any, resolveUserUid: vi.fn(), recallUploadedFile: vi.fn(() => undefined), } as any); const ctx = makeCtx(bridge); await expect( sendPrivateMessage( ctx, 67890, [ { type: 'text', data: { text: 'with bad file segment' } }, { type: 'file', data: {} }, // no file_id ] as any, false, ), ).rejects.toMatchObject({ code: 'MISSING_FIELD', elementType: 'file', }); expect(sendPrivateMessage_bridge).not.toHaveBeenCalled(); expect(sendC2cFileMessage).not.toHaveBeenCalled(); }); it('throws when uid cannot be resolved for a file-only message', async () => { // C2C file send requires the recipient's UID for the RichText. // notOnlineFile routing. If lookup fails we can't proceed — // surfacing the error is better than shipping a c2c send with // an empty uid. const sendC2cFileMessage = vi.fn(); const bridge = fakeBridge({ apis: { message: { sendPrivate: vi.fn(), sendC2cFile: sendC2cFileMessage } } as any, resolveUserUid: vi.fn(async () => ''), // intentional empty recallUploadedFile: vi.fn(() => undefined), } as any); const ctx = makeCtx(bridge); await expect( sendPrivateMessage( ctx, 67890, [{ type: 'file', data: { file_id: 'uuid-orphan', name: 'x.txt' } }] as any, false, ), ).rejects.toThrow(/could not resolve uid/); expect(sendC2cFileMessage).not.toHaveBeenCalled(); }); it('does not send preceding text when a mixed file message cannot resolve uid', async () => { const sendPrivateMessage_bridge = vi.fn(async () => goodReceipt); const sendC2cFileMessage = vi.fn(); const bridge = fakeBridge({ apis: { message: { sendPrivate: sendPrivateMessage_bridge, sendC2cFile: sendC2cFileMessage, }, } as any, resolveUserUid: vi.fn(async () => ''), recallUploadedFile: vi.fn(() => undefined), } as any); const ctx = makeCtx(bridge); await expect( sendPrivateMessage( ctx, 67890, [ { type: 'text', data: { text: 'must remain unsent' } }, { type: 'file', data: { file_id: 'uuid-orphan', name: 'x.txt' } }, ] as any, false, ), ).rejects.toThrow(/could not resolve uid/); expect(sendPrivateMessage_bridge).not.toHaveBeenCalled(); expect(sendC2cFileMessage).not.toHaveBeenCalled(); }); it('file segment with url uploads without publishing, then sends once with the cached metadata', async () => { const uploadPrivate = vi.fn( async (_uid: number, _src: string, _name: string, _doUpload: boolean) => ({ fileId: 'auto-fid', fileHash: 'auto-hash', }), ); const sendC2cFileMessage = vi.fn(async () => goodReceipt); const bridge = fakeBridge({ apis: { message: { sendPrivate: vi.fn(), sendC2cFile: sendC2cFileMessage }, groupFile: { uploadPrivate }, } as any, resolveUserUid: vi.fn(async () => 'u_peer'), recallUploadedFile: vi.fn(() => ({ fileId: 'auto-fid', scope: 'private', userId: 67890, fileName: 'audio.wav', fileSize: 3, fileMd5: new Uint8Array(16), fileSha1: new Uint8Array(20), fileHash: 'auto-hash', rememberedAt: Date.now(), })), } as any); const ctx = makeCtx(bridge); await sendPrivateMessage( ctx, 67890, [ { type: 'file', data: { file: '/tmp/audio.wav', name: 'audio.wav' }, }, ] as any, false, ); expect(uploadPrivate).toHaveBeenCalledOnce(); const [uid, src, name, doUpload] = uploadPrivate.mock.calls[0]!; expect(uid).toBe(67890); expect(src).toBe('/tmp/audio.wav'); expect(name).toBe('audio.wav'); expect(doUpload).toBe(true); expect(uploadPrivate).toHaveBeenCalledWith( 67890, '/tmp/audio.wav', 'audio.wav', true, false, ); expect(sendC2cFileMessage).toHaveBeenCalledOnce(); expect(sendC2cFileMessage).toHaveBeenCalledWith( 67890, 'u_peer', expect.objectContaining({ fileId: 'auto-fid', fileName: 'audio.wav', fileSize: 3, fileHash: 'auto-hash', }), ); }); it('surfaces a failed chat post after uploading a file source', async () => { const uploadPrivate = vi.fn(async () => ({ fileId: 'uploaded-but-unsent', fileHash: 'upload-hash', })); const sendC2cFile = vi.fn(async () => { throw new Error('chat post rejected'); }); const bridge = fakeBridge({ apis: { message: { sendPrivate: vi.fn(), sendC2cFile }, groupFile: { uploadPrivate }, } as any, resolveUserUid: vi.fn(async () => 'u_peer'), recallUploadedFile: vi.fn(() => ({ fileId: 'uploaded-but-unsent', scope: 'private', userId: 67890, fileName: 'failed.txt', fileSize: 3, fileMd5: new Uint8Array(16), fileSha1: new Uint8Array(20), fileHash: 'upload-hash', rememberedAt: Date.now(), })), } as any); const ctx = makeCtx(bridge); await expect( sendPrivateMessage( ctx, 67890, [ { type: 'file', data: { file: 'base64://AQID', name: 'failed.txt' }, }, ] as any, false, ), ).rejects.toThrow('chat post rejected'); expect(uploadPrivate).toHaveBeenCalledWith( 67890, 'base64://AQID', 'failed.txt', true, false, ); expect(sendC2cFile).toHaveBeenCalledOnce(); }); it('percent-decodes a file:// URL basename when name is omitted (#354)', async () => { const uploadPrivate = vi.fn(async () => ({ fileId: 'auto-fid', fileHash: 'auto-hash' })); const sendC2cFileMessage = vi.fn(async () => goodReceipt); const bridge = fakeBridge({ apis: { message: { sendPrivate: vi.fn(), sendC2cFile: sendC2cFileMessage }, groupFile: { uploadPrivate }, } as any, resolveUserUid: vi.fn(async () => 'u_peer'), recallUploadedFile: vi.fn(() => ({ fileId: 'auto-fid', scope: 'private', userId: 67890, fileName: '测试文件.xlsx', fileSize: 3, fileMd5: new Uint8Array(16), fileSha1: new Uint8Array(20), fileHash: 'auto-hash', rememberedAt: Date.now(), })), } as any); const ctx = makeCtx(bridge); await sendPrivateMessage( ctx, 67890, [ { type: 'file', data: { file: 'file:///D:/test/%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.xlsx' }, }, ] as any, false, ); expect(uploadPrivate).toHaveBeenCalledWith( 67890, 'file:///D:/test/%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.xlsx', '测试文件.xlsx', true, false, ); }); });