// Regression coverage for the group-file-in-send_group_msg bug. // // Symptom in the wild: bot uploaded a group file via `upload_group_file`, // then sent `{type:'file', file_id}` through `send_group_msg`; the // element-builder wrapped it as `transElem(elemType=24, ...)` inside // `richText.elems` and shipped a regular `MessageSvc.PbSendMsg`. The // QQ-NT server rejected the message with `result=79` because that wire // shape is RECEIVE-side only (rich-body-decoder unpacks it into a // FileEntity for inbound messages, but the server's intake validator // flags it on the send side). // // Fix: split file segments off at the OneBot layer (same pattern as // the c2c-file split for private messages) and dispatch through // `bridge.apis.groupFile.publish`, which calls dedicated OIDB // `OidbSvcTrpcTcp.0x6d9_4` — Lagrange.Core V2's // `GroupSendFileService.cs`. import { describe, expect, it, vi } from 'vitest'; import type { BridgeInterface } from '../../src/bridge/bridge-interface'; import type { OneBotInstanceContext } from '../src/instance-context'; import { MessageStore } from '../src/message-store'; import { sendGroupMessage } from '../src/modules/message-actions'; function fakeBridge(overrides: Partial = {}): BridgeInterface { return new Proxy( { identity: { nickname: 'SnowLuma' }, recallUploadedFile: vi.fn(), ...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, messageStore?: MessageStore): OneBotInstanceContext { const store = messageStore ?? ({ findEvent: () => null, resolveReplySequence: () => 0, storeEvent: vi.fn() } as any); return { uin: '10001', selfId: 10001, bridge, messageStore: store, cacheMessageMeta: messageStore ? async (messageId, meta) => await messageStore.storeMeta(messageId, meta) : 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_group_msg with {type:"file"} segment', () => { it('file-only message routes through apis.groupFile.publish (not apis.message.sendGroup)', async () => { // Pure-file case — the elems[] path used to ship a transElem(24) // and got result=79. The dedicated OIDB-0x6d9_4 route must take // over for group file publishing. const sendGroupMessage_bridge = vi.fn(); // Declare params on the fn so `mock.calls[0]` infers a tuple of // [groupId, fileId] instead of `[]` (which makes the destructuring // a tsc error under noUncheckedIndexedAccess). const publish = vi.fn(async (_groupId: number, _fileId: string) => undefined); const bridge = fakeBridge({ apis: { message: { sendGroup: sendGroupMessage_bridge }, groupFile: { publish }, } as any, resolveUserUid: vi.fn(), } as any); const ctx = makeCtx(bridge); await sendGroupMessage( ctx, 12345, [ { type: 'file', data: { file_id: 'gfid-abc', name: 'doc.txt', size: 123 }, }, ] as any, false, ); expect(publish).toHaveBeenCalledOnce(); expect(sendGroupMessage_bridge).not.toHaveBeenCalled(); const [groupId, fileId] = publish.mock.calls[0]!; expect(groupId).toBe(12345); expect(fileId).toBe('gfid-abc'); }); it('keeps an OIDB-only group file id separate from a QQ server sequence (#254)', async () => { const messageStore = new MessageStore(':memory:'); await messageStore.initialization; try { const publish = vi.fn(async (_groupId: number, _fileId: string) => undefined); const bridge = fakeBridge({ identity: { nickname: 'Bot' }, apis: { message: { sendGroup: vi.fn() }, groupFile: { publish }, } as any, resolveUserUid: vi.fn(), } as any); const ctx = makeCtx(bridge, messageStore); const result = await sendGroupMessage( ctx, 12345, [ { type: 'file', data: { file_id: 'gfid-abc', name: 'doc.txt', size: 123 }, }, ] as any, false, ); expect(result.messageId).not.toBe(0); expect(await messageStore.findMeta(result.messageId)).toMatchObject({ sequence: 0, sequenceAuthoritative: false, }); expect(await messageStore.findEvent(result.messageId)).toMatchObject({ message_seq: 0, }); expect(await messageStore.findLatestAuthoritativeSequence(true, 12345)).toBeNull(); } finally { await messageStore.close(); } }); it('skips an OIDB-only group-file reply and sends the remaining content (#254)', async () => { const messageStore = new MessageStore(':memory:'); await messageStore.initialization; try { const sendGroup = vi.fn(async (_groupId: number, _elements: any[]) => goodReceipt); const publish = vi.fn(async (_groupId: number, _fileId: string) => undefined); const bridge = fakeBridge({ identity: { nickname: 'Bot' }, apis: { message: { sendGroup }, groupFile: { publish }, } as any, resolveUserUid: vi.fn(), } as any); const ctx = makeCtx(bridge, messageStore); const file = await sendGroupMessage( ctx, 12345, [ { type: 'file', data: { file_id: 'gfid-abc', name: 'doc.txt', size: 123 }, }, ] as any, false, ); await expect( sendGroupMessage( ctx, 12345, [ { type: 'reply', data: { id: file.messageId } }, { type: 'text', data: { text: 'received' } }, ] as any, false, ), ).resolves.toMatchObject({ messageId: expect.any(Number) }); expect(sendGroup).toHaveBeenCalledWith(12345, [{ type: 'text', text: 'received' }]); } finally { await messageStore.close(); } }); it('skips a local-only reply id even when its full event was not cached', async () => { const messageStore = new MessageStore(':memory:'); await messageStore.initialization; try { const sendGroup = vi.fn(async (_groupId: number, _elements: any[]) => goodReceipt); const bridge = fakeBridge({ identity: { nickname: 'Bot' }, apis: { message: { sendGroup } } as any, resolveUserUid: vi.fn(), } as any); const ctx = makeCtx(bridge, messageStore); await messageStore.storeMeta(77, { isGroup: true, targetId: 12345, sequence: 0, sequenceAuthoritative: false, eventName: 'group_message', clientSequence: 0, random: 42, timestamp: 1700000000, }); await expect( sendGroupMessage( ctx, 12345, [ { type: 'reply', data: { id: 77 } }, { type: 'text', data: { text: 'received' } }, ] as any, false, ), ).resolves.toMatchObject({ messageId: expect.any(Number) }); expect(sendGroup).toHaveBeenCalledWith(12345, [{ type: 'text', text: 'received' }]); } finally { await messageStore.close(); } }); it('does not apply local-only reply metadata from another group', async () => { const messageStore = new MessageStore(':memory:'); await messageStore.initialization; try { const sendGroup = vi.fn(async (_groupId: number, _elements: any[]) => goodReceipt); const bridge = fakeBridge({ identity: { nickname: 'Bot' }, apis: { message: { sendGroup } } as any, resolveUserUid: vi.fn(), } as any); const ctx = makeCtx(bridge, messageStore); await messageStore.storeMeta(77, { isGroup: true, targetId: 99999, sequence: 0, sequenceAuthoritative: false, eventName: 'group_message', clientSequence: 0, random: 42, timestamp: 1700000000, }); await sendGroupMessage( ctx, 12345, [ { type: 'reply', data: { id: 77 } }, { type: 'text', data: { text: 'direct sequence' } }, ] as any, false, ); expect(sendGroup).toHaveBeenCalledWith(12345, [ { type: 'reply', replySeq: 77 }, { type: 'text', text: 'direct sequence' }, ]); } finally { await messageStore.close(); } }); it('mixed text + file splits across two sends (text via elems[], file via OIDB)', async () => { const sendGroupMessage_bridge = vi.fn( async (_gid: number, _elements: any[]) => goodReceipt, ); const publish = vi.fn(async (_groupId: number, _fileId: string) => undefined); const bridge = fakeBridge({ apis: { message: { sendGroup: sendGroupMessage_bridge }, groupFile: { publish }, } as any, resolveUserUid: vi.fn(async () => 'u_peer'), } as any); const ctx = makeCtx(bridge); await sendGroupMessage( ctx, 12345, [ { type: 'text', data: { text: 'here is the file:' } }, { type: 'file', data: { file_id: 'gfid-xyz', name: 'pkg.zip' } }, ] as any, false, ); expect(sendGroupMessage_bridge).toHaveBeenCalledOnce(); expect(publish).toHaveBeenCalledOnce(); const [textGid, textElements] = sendGroupMessage_bridge.mock.calls[0]!; expect(textGid).toBe(12345); expect(textElements).toEqual([{ type: 'text', text: 'here is the file:' }]); const [fileGid, fileFileId] = publish.mock.calls[0]!; expect(fileGid).toBe(12345); expect(fileFileId).toBe('gfid-xyz'); }); it('rejects the whole message when a file segment has neither file_id nor url', async () => { const sendGroupMessage_bridge = vi.fn( async (_gid: number, _elements: any[]) => goodReceipt, ); const publish = vi.fn(); const upload = vi.fn(); const bridge = fakeBridge({ apis: { message: { sendGroup: sendGroupMessage_bridge }, groupFile: { publish, upload }, } as any, resolveUserUid: vi.fn(), } as any); const ctx = makeCtx(bridge); await expect( sendGroupMessage( ctx, 12345, [ { type: 'text', data: { text: 'with bad file segment' } }, { type: 'file', data: {} }, // no file_id and no url ] as any, false, ), ).rejects.toMatchObject({ code: 'MISSING_FIELD', elementType: 'file', }); expect(sendGroupMessage_bridge).not.toHaveBeenCalled(); expect(publish).not.toHaveBeenCalled(); expect(upload).not.toHaveBeenCalled(); }); it('rejects text + image + temporary file before any chat publication', async () => { const sendGroup = vi.fn(async () => goodReceipt); const publish = vi.fn(); const upload = vi.fn(async () => ({ fileId: 'temp', busId: 104 })); const ctx = makeCtx( fakeBridge({ apis: { message: { sendGroup }, groupFile: { upload, publish } } as any, }), ); await expect( sendGroupMessage( ctx, 12345, [ { type: 'text', data: { text: 'caption' } }, { type: 'image', data: { file: 'base64://AQID' } }, { type: 'file', data: { file: '/tmp/report.pdf' } }, ], false, ), ).rejects.toThrow(/temporary group files.*only file or image/i); expect(sendGroup).not.toHaveBeenCalled(); expect(publish).not.toHaveBeenCalled(); }); it('rejects cached temporary file + text before publishing an earlier permanent file', async () => { const sendGroup = vi.fn(async () => goodReceipt); const publish = vi.fn(); const ctx = makeCtx( fakeBridge({ apis: { message: { sendGroup }, groupFile: { publish } } as any, recallUploadedFile: vi.fn((id) => id === 'temp' ? { fileId: id, scope: 'group', groupId: 12345, busId: 104, } : undefined, ), } as any), ); await expect( sendGroupMessage( ctx, 12345, [ { type: 'file', data: { file_id: 'permanent' } }, { type: 'text', data: { text: 'caption' } }, { type: 'file', data: { file_id: 'temp' } }, ], false, ), ).rejects.toThrow(/temporary group files.*only file or image/i); expect(sendGroup).not.toHaveBeenCalled(); expect(publish).not.toHaveBeenCalled(); }); it.each([false, true])( 'allows temporary files with only file/image content (image=%s)', async (withImage) => { const sendGroup = vi.fn(async () => goodReceipt); const publish = vi.fn(); const upload = vi.fn(async () => { expect(sendGroup).not.toHaveBeenCalled(); expect(publish).not.toHaveBeenCalled(); return { fileId: 'temp', busId: 104 }; }); const ctx = makeCtx( fakeBridge({ apis: { message: { sendGroup }, groupFile: { upload, publish } } as any, }), ); const message = [ { type: 'file', data: { file: '/tmp/report.pdf' } }, ...(withImage ? [{ type: 'image', data: { file: 'base64://AQID' } }] : []), ]; await sendGroupMessage(ctx, 12345, message, false); expect(upload).toHaveBeenCalledWith( 12345, '/tmp/report.pdf', 'report.pdf', '/', true, false, { allowTemporary: true }, ); expect(publish).toHaveBeenCalledExactlyOnceWith(12345, 'temp', 104); expect(sendGroup).toHaveBeenCalledTimes(withImage ? 1 : 0); }, ); it('preserves ordinary image + text without entering the group-file path', async () => { const sendGroup = vi.fn(async () => goodReceipt); const upload = vi.fn(); const publish = vi.fn(); const ctx = makeCtx( fakeBridge({ apis: { message: { sendGroup }, groupFile: { upload, publish } } as any }), ); await sendGroupMessage( ctx, 12345, [ { type: 'image', data: { file: 'base64://AQID' } }, { type: 'text', data: { text: 'caption' } }, ], false, ); expect(sendGroup).toHaveBeenCalledOnce(); expect(sendGroup.mock.calls[0]).toMatchObject([ 12345, [ { type: 'image', url: 'base64://AQID' }, { type: 'text', text: 'caption' }, ], ]); expect(upload).not.toHaveBeenCalled(); expect(publish).not.toHaveBeenCalled(); }); it('keeps permanent file + text sends, but checks uploads before sending the text', async () => { const sendGroup = vi.fn(async () => goodReceipt); const publish = vi.fn(); const upload = vi.fn(async () => { expect(sendGroup).not.toHaveBeenCalled(); return { fileId: 'permanent', busId: 102 }; }); const ctx = makeCtx( fakeBridge({ apis: { message: { sendGroup }, groupFile: { upload, publish } } as any }), ); await sendGroupMessage( ctx, 12345, [ { type: 'text', data: { text: 'caption' } }, { type: 'file', data: { file: '/tmp/report.pdf' } }, ], false, ); expect(upload).toHaveBeenCalledWith( 12345, '/tmp/report.pdf', 'report.pdf', '/', true, false, { allowTemporary: false }, ); expect(sendGroup).toHaveBeenCalledOnce(); expect(publish).toHaveBeenCalledExactlyOnceWith(12345, 'permanent', 102); }); it('does not publish the first file or text if a later upload fails', async () => { const sendGroup = vi.fn(); const publish = vi.fn(); const upload = vi .fn() .mockResolvedValueOnce({ fileId: 'first', busId: 102 }) .mockRejectedValueOnce( new Error('temporary group files require only file or image segments'), ); const ctx = makeCtx( fakeBridge({ apis: { message: { sendGroup }, groupFile: { upload, publish } } as any }), ); await expect( sendGroupMessage( ctx, 12345, [ { type: 'text', data: { text: 'caption' } }, { type: 'file', data: { file: '/tmp/one.pdf' } }, { type: 'file', data: { file: '/tmp/two.pdf' } }, ], false, ), ).rejects.toThrow(/temporary group files/); expect(sendGroup).not.toHaveBeenCalled(); expect(publish).not.toHaveBeenCalled(); }); it('reports a failed file publication with its identity and never retries the send', async () => { const cause = new Error('chat post rejected'); const publish = vi.fn().mockRejectedValueOnce(cause); const upload = vi.fn(async () => ({ fileId: 'temp', busId: 104 })); const ctx = makeCtx( fakeBridge({ apis: { message: { sendGroup: vi.fn() }, groupFile: { upload, publish } } as any, }), ); await expect( sendGroupMessage( ctx, 12345, [{ type: 'file', data: { file: '/tmp/report.pdf' } }], false, ), ).rejects.toMatchObject({ message: 'group file chat post failed (fileId=temp): chat post rejected', cause, }); expect(upload).toHaveBeenCalledOnce(); expect(publish).toHaveBeenCalledOnce(); }); it('uploads a file source without publishing, then publishes it exactly once', async () => { // Happy path for inline file sending: the bot passes a local path // directly. Delay chat publication until all uploads are checked. const upload = vi.fn( async ( _gid: number, _src: string, _name: string, _folder: string, _doUpload: boolean, ) => ({ fileId: 'auto-fid', fileHash: null }), ); const publish = vi.fn(); const bridge = fakeBridge({ apis: { message: { sendGroup: vi.fn() }, groupFile: { upload, publish }, } as any, resolveUserUid: vi.fn(), } as any); const ctx = makeCtx(bridge); await sendGroupMessage( ctx, 12345, [ { type: 'file', data: { file: '/tmp/audio.wav', name: 'audio.wav' }, }, ] as any, false, ); expect(upload).toHaveBeenCalledOnce(); const [gid, src, name, folder, doUpload] = upload.mock.calls[0]!; expect(gid).toBe(12345); expect(src).toBe('/tmp/audio.wav'); expect(name).toBe('audio.wav'); expect(folder).toBe('/'); expect(doUpload).toBe(true); expect(upload).toHaveBeenCalledWith( 12345, '/tmp/audio.wav', 'audio.wav', '/', true, false, { allowTemporary: true }, ); expect(publish).toHaveBeenCalledExactlyOnceWith(12345, 'auto-fid', undefined); }); it('percent-decodes a file:// URL basename when name is omitted (#354)', async () => { const upload = vi.fn(async () => ({ fileId: 'auto-fid', fileHash: null })); const bridge = fakeBridge({ apis: { message: { sendGroup: vi.fn() }, groupFile: { upload, publish: vi.fn() }, } as any, resolveUserUid: vi.fn(), } as any); const ctx = makeCtx(bridge); await sendGroupMessage( ctx, 12345, [ { type: 'file', data: { file: 'file:///D:/test/%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.xlsx' }, }, ] as any, false, ); expect(upload).toHaveBeenCalledOnce(); const [, src, name] = upload.mock.calls[0]!; expect(src).toBe('file:///D:/test/%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.xlsx'); expect(name).toBe('测试文件.xlsx'); }); });