Files
SnowLuma/packages/onebot/tests/send-group-file.test.ts
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

642 lines
23 KiB
TypeScript

// 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> = {}): 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');
});
});