Files
SnowLuma/packages/onebot/tests/forward-nested.test.ts
DrSmoothl 056188d63d chore: resolve async SQLite merge conflicts
Integrate the current development branch, preserve the album comment fixes, and align the changes with repository formatting and dependency tooling.
2026-10-13 09:55:16 +08:00

1138 lines
42 KiB
TypeScript

// Regression coverage for nested forward (forward-inside-forward).
//
// `parseForwardNodes` detects an all-`{type:'node'}` content array,
// recursively builds the inner `ForwardNodePayload[]`, and attaches it
// to the outer node as `innerForward`. `uploadForwardNodes` then drives
// the recursive long-msg upload + ARK-preview generation + msgBody
// piggyback in one pass over the tree — modelled on NapCat's
// `uploadForwardedNodesPacket` (`dev/NapCatQQ/.../SendMsg.ts:208`).
//
// These tests stub `bridge.uploadForwardNodes` so we can inspect the
// payload that the upload pipeline receives: outer nodes carry an
// `innerForward` field holding the parsed inner chain, and the
// upload helper is invoked exactly ONCE (the recursive walk happens
// inside the real implementation; mocks see only the top call).
import { describe, expect, it, vi } from 'vitest';
import type { ForwardNodePayload, MessageElement } from '@snowluma/protocol/events';
import type { BridgeInterface } from '../../src/bridge/bridge-interface';
import type { OneBotInstanceContext } from '../src/instance-context';
import {
forwardSingleMessage,
sendGroupForwardMessage,
sendPrivateForwardMessage,
} from '../src/modules/message-actions';
function fakeBridge(overrides: Partial<BridgeInterface> = {}): 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: { findMedia: () => null, 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;
}
describe('forward — nested {type:"node"} content', () => {
it('group: nested forward attaches inner chain to outer node via `innerForward`', async () => {
// Single upload call, but the outer payload now carries an
// `innerForward` field with the recursively-parsed inner chain.
// The real `uploadForwardNodes` (the production module) walks
// that tree and does the recursive long-msg uploads with NapCat
// piggyback — we're just testing the parse-side handoff here.
const uploadForwardNodes = vi.fn(
async (_nodes: any[], _groupId?: number, _userId?: number) => 'OUTER_RESID',
);
const sendGroupMessage = vi.fn(async () => ({
messageId: 1,
sequence: 100,
clientSequence: 0,
random: 1,
timestamp: 1700000000,
}));
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
const messages = [
{
type: 'node',
data: {
user_id: 111,
nickname: 'outer',
content: [
{
type: 'node',
data: {
user_id: 222,
nickname: 'inner',
content: [{ type: 'text', data: { text: 'hello from inner' } }],
},
},
],
},
},
];
const result = await sendGroupForwardMessage(ctx, 12345, messages as any);
// Exactly one upload call: the production-side recursive walker
// owns the inner upload, the OneBot parser just hands off the tree.
expect(uploadForwardNodes).toHaveBeenCalledOnce();
const [outerNodes, outerGroupId] = uploadForwardNodes.mock.calls[0]!;
expect(outerGroupId).toBe(12345);
const outerNode = (outerNodes as any[])[0]!;
expect(outerNode.userUin).toBe(111);
// Outer node's `elements` is empty — the upload pipeline replaces
// it with the real ARK preview once it has the inner res_id.
expect(outerNode.elements).toEqual([]);
// The inner chain rides along as `innerForward`.
expect(Array.isArray(outerNode.innerForward)).toBe(true);
expect(outerNode.innerForward).toHaveLength(1);
expect(outerNode.innerForward[0]).toMatchObject({
userUin: 222,
nickname: 'inner',
elements: [{ type: 'text', text: 'hello from inner' }],
});
expect(result.forwardId).toBe('OUTER_RESID');
expect(sendGroupMessage).toHaveBeenCalledOnce();
});
it('threads an explicit data.time onto the node payload, leaving absent ones for the default (#209)', async () => {
const uploadForwardNodes = vi.fn(
async (_nodes: any[], _groupId?: number, _userId?: number) => 'RESID',
);
const sendGroupMessage = vi.fn(async () => ({
messageId: 1,
sequence: 100,
clientSequence: 0,
random: 1,
timestamp: 1700000000,
}));
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
const messages = [
{
type: 'node',
data: {
user_id: 111,
nickname: 'a',
time: 1600000000,
content: [{ type: 'text', data: { text: 'hi' } }],
},
},
{
type: 'node',
data: {
user_id: 222,
nickname: 'b',
content: [{ type: 'text', data: { text: 'yo' } }],
},
},
{
type: 'node',
data: {
user_id: 333,
nickname: 'c',
time: 1700000000000,
content: [{ type: 'text', data: { text: 'ms' } }],
},
}, // ms → out of uint32 range
{
type: 'node',
data: {
user_id: 444,
nickname: 'd',
time: -5,
content: [{ type: 'text', data: { text: 'neg' } }],
},
},
];
await sendGroupForwardMessage(ctx, 12345, messages as any);
const [nodes] = uploadForwardNodes.mock.calls[0]!;
expect((nodes as any[])[0].time).toBe(1600000000); // explicit seconds carried through
expect((nodes as any[])[1].time).toBeUndefined(); // absent → default to now
expect((nodes as any[])[2].time).toBeUndefined(); // millisecond input rejected (uint32 overflow guard)
expect((nodes as any[])[3].time).toBeUndefined(); // negative rejected → default to now
});
it('private: nested forward threads userId into uploadForwardNodes', async () => {
// Same shape but routed via sendPrivateForwardMessage. The c2c path
// passes `userId` instead of `groupId` so the upload pipeline can
// pick up the recipient's UID scene for any inner media element.
const uploadForwardNodes = vi.fn(
async (_nodes: any[], _groupId?: number, _userId?: number) => 'RESID',
);
const sendPrivateMessage = vi.fn(async () => ({
messageId: 1,
sequence: 100,
clientSequence: 0,
random: 1,
timestamp: 1700000000,
}));
const bridge = fakeBridge({
apis: {
message: { sendPrivate: sendPrivateMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
const messages = [
{
type: 'node',
data: {
user_id: 111,
nickname: 'outer',
content: [
{
type: 'node',
data: {
user_id: 222,
nickname: 'inner',
content: [{ type: 'text', data: { text: 'hi' } }],
},
},
],
},
},
];
await sendPrivateForwardMessage(ctx, 67890, messages as any);
// Single call, c2c-scoped (groupId undefined, userId = recipient).
expect(uploadForwardNodes).toHaveBeenCalledOnce();
const call = uploadForwardNodes.mock.calls[0]!;
expect(call[1]).toBeUndefined();
expect(call[2]).toBe(67890);
// Inner chain attached via `innerForward`.
const outerNode = (call[0] as any[])[0]!;
expect(outerNode.innerForward).toHaveLength(1);
expect(outerNode.innerForward[0]!.userUin).toBe(222);
});
it('rejects nesting deeper than 3 levels', async () => {
// Build a 4-level nested chain. NapCat caps at 3 too, going further
// wastes long-msg uploads and risks one inner upload timing out
// and aborting the whole tree. Better to fail loud here.
const uploadForwardNodes = vi.fn(
async (_nodes: any[], _groupId?: number, _userId?: number) => 'X',
);
const sendGroupMessage = vi.fn(async () => ({
messageId: 1,
sequence: 100,
clientSequence: 0,
random: 1,
timestamp: 0,
}));
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
function wrap(content: any, depth: number): any {
if (depth === 0) {
return [{ type: 'text', data: { text: 'leaf' } }];
}
return [
{
type: 'node',
data: {
user_id: 100 + depth,
nickname: `lvl-${depth}`,
content: wrap(content, depth - 1),
},
},
];
}
await expect(sendGroupForwardMessage(ctx, 12345, wrap(null, 4) as any)).rejects.toThrow(
/depth/,
);
});
it('flat content (no nesting) still goes through the unchanged path', async () => {
// Backwards-compat: a single-level forward with plain text nodes
// calls uploadForwardNodes exactly once and never touches the
// recursive branch.
const uploadForwardNodes = vi.fn(
async (_nodes: any[], _groupId?: number, _userId?: number) => 'RES',
);
const sendGroupMessage = vi.fn(async () => ({
messageId: 1,
sequence: 100,
clientSequence: 0,
random: 1,
timestamp: 0,
}));
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
const messages = [
{
type: 'node',
data: {
user_id: 111,
nickname: 'a',
content: [{ type: 'text', data: { text: 'one' } }],
},
},
{
type: 'node',
data: {
user_id: 222,
nickname: 'b',
content: [{ type: 'text', data: { text: 'two' } }],
},
},
];
await sendGroupForwardMessage(ctx, 12345, messages as any);
expect(uploadForwardNodes).toHaveBeenCalledOnce();
const [nodes] = uploadForwardNodes.mock.calls[0]!;
expect(nodes as any[]).toHaveLength(2);
expect((nodes as any[])[0]!.elements).toEqual([{ type: 'text', text: 'one' }]);
expect((nodes as any[])[1]!.elements).toEqual([{ type: 'text', text: 'two' }]);
});
it('rejects video with hidden siblings before uploading a forward', async () => {
const uploadForwardNodes = vi.fn(async () => 'RES');
const sendGroupMessage = vi.fn();
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
await expect(
sendGroupForwardMessage(ctx, 12345, [
{
type: 'node',
data: {
user_id: 111,
nickname: 'mixed-video',
content: [
{ type: 'video', data: { file: 'https://example.com/video.mp4' } },
{ type: 'text', data: { text: 'must not survive bot forwarding' } },
],
},
},
] as any),
).rejects.toMatchObject({
code: 'UNSENDABLE_TYPE',
elementType: 'video',
});
expect(uploadForwardNodes).not.toHaveBeenCalled();
expect(sendGroupMessage).not.toHaveBeenCalled();
});
it('regenerates a cached merged forward for the target chat (#437)', async () => {
const fetch = vi.fn(async (resId: string) => {
expect(resId).toBe('OLD_RES');
return [
{
userUin: 111,
nickname: 'alice',
elements: [{ type: 'text', text: 'hi' }],
},
];
});
const upload = vi.fn(async (nodes: any[], groupId?: number, userId?: number) => {
expect(groupId).toBeUndefined();
expect(userId).toBe(67890);
expect(nodes).toEqual([
expect.objectContaining({
userUin: 111,
elements: [{ type: 'text', text: 'hi' }],
}),
]);
return 'NEW_RES';
});
const sendPrivate = vi.fn(async () => ({
messageId: 1,
sequence: 100,
clientSequence: 14,
random: 1,
timestamp: 1700000000,
}));
const bridge = fakeBridge({
apis: { message: { sendPrivate }, forward: { fetch, upload } },
} as any);
const ctx = makeCtx(bridge);
(ctx as any).messageStore = {
...ctx.messageStore,
findMedia: () => null,
findEvent: () => ({
message: [{ type: 'forward', data: { id: 'OLD_RES' } }],
}),
};
const result = await forwardSingleMessage(ctx, 77, { userId: 67890 });
expect(result.messageId).not.toBe(0);
expect(fetch).toHaveBeenCalledOnce();
expect(upload).toHaveBeenCalledOnce();
expect(sendPrivate).toHaveBeenCalledOnce();
expect(sendPrivate.mock.calls[0]![1]).toEqual([
expect.objectContaining({ type: 'forward', resId: 'NEW_RES' }),
]);
});
it.each(['image', 'record', 'video'] as const)(
'prepares fetched %s fingerprints at every nesting level (#437)',
async (type) => {
const fingerprint = {
fileId: 'source-id',
fileSize: 120,
md5Hex: 'ab'.repeat(16),
sha1Hex: 'cd'.repeat(20),
};
const media: MessageElement =
type === 'image'
? { ...fingerprint, type, width: 32, height: 24, picFormat: 1001 }
: { ...fingerprint, type, duration: 3 };
const leaf: ForwardNodePayload = { userUin: 111, nickname: 'leaf', elements: [media] };
// QQ only accepts standalone video nodes; exercise mixed content with
// images/voice and use a text sibling for the video case.
const sibling: MessageElement =
type === 'video' ? { type: 'text', text: 'nested video' } : media;
const mixed: ForwardNodePayload = {
userUin: 222,
nickname: 'mixed',
elements: [sibling, { type: 'forward', resId: 'INNER' }],
};
const outer: ForwardNodePayload[] = [
leaf,
mixed,
{
userUin: 333,
nickname: 'nested',
elements: [{ type: 'forward', resId: 'INNER' }],
},
];
const before = structuredClone(outer);
const fetch = vi.fn(async (resId: string) => (resId === 'OUTER' ? outer : [leaf]));
const upload = vi.fn(
async (_nodes: ForwardNodePayload[], _groupId?: number, _userId?: number) =>
'NEW_RES',
);
const sendPrivate = vi.fn(async () => ({
sequence: 1,
clientSequence: 2,
random: 3,
timestamp: 1700000000,
}));
const ctx = makeCtx(
fakeBridge({
apis: { forward: { fetch, upload }, message: { sendPrivate } },
} as any),
);
ctx.messageStore.findEvent = () => ({
message: [{ type: 'forward', data: { id: 'OUTER' } }],
});
await forwardSingleMessage(ctx, 77, { userId: 10001 });
const expected = expect.objectContaining({
...fingerprint,
type,
noByteFallback: true,
});
const finalNodes = upload.mock.calls.at(-1)![0];
expect(finalNodes[0]!.elements).toEqual([expected]);
expect(finalNodes[1]!.elements[0]).toEqual(type === 'video' ? sibling : expected);
expect(finalNodes[2]!.innerForward![0]!.elements).toEqual([expected]);
expect(upload.mock.calls[0]![0][0]!.elements).toEqual([expected]);
for (const [, groupId, userId] of upload.mock.calls) {
expect(groupId).toBeUndefined();
expect(userId).toBe(10001);
}
expect(outer).toEqual(before);
expect(sendPrivate).toHaveBeenCalledOnce();
},
);
it.each([
{ type: 'image', imageUrl: 'https://example.com/legacy.png', md5Hex: 'ab'.repeat(16) },
{
type: 'image',
imageUrl: 'https://example.com/partial.png',
md5Hex: 'ab'.repeat(16),
sha1Hex: 'cd'.repeat(20),
width: 0,
height: 0,
},
] satisfies MessageElement[])(
'keeps the source upload path for incomplete image fingerprints ($imageUrl)',
async (media) => {
const upload = vi.fn(async (_nodes: ForwardNodePayload[]) => 'NEW_RES');
const fetch = vi.fn(async () => [
{ userUin: 111, nickname: 'alice', elements: [media] },
]);
const sendGroup = vi.fn(async () => ({
sequence: 1,
clientSequence: 0,
random: 2,
timestamp: 1700000000,
}));
const ctx = makeCtx(
fakeBridge({ apis: { forward: { fetch, upload }, message: { sendGroup } } } as any),
);
ctx.messageStore.findEvent = () => ({
message: [{ type: 'forward', data: { id: 'OLD_RES' } }],
});
await forwardSingleMessage(ctx, 77, { groupId: 12345 });
expect(upload.mock.calls[0]![0][0]!.elements).toEqual([media]);
expect(upload.mock.calls[0]![0][0]!.elements[0]!.noByteFallback).toBeUndefined();
},
);
it('fails a merged-forward single send when the inner tree cannot be fetched (#437)', async () => {
const fetch = vi.fn(async () => {
throw new Error('download forward message failed');
});
const upload = vi.fn(async () => 'NEW_RES');
const sendPrivate = vi.fn();
const bridge = fakeBridge({
apis: { message: { sendPrivate }, forward: { fetch, upload } },
} as any);
const ctx = makeCtx(bridge);
(ctx as any).messageStore = {
...ctx.messageStore,
findMedia: () => null,
findEvent: () => ({
message: [{ type: 'forward', data: { id: 'OLD_RES' } }],
}),
};
await expect(forwardSingleMessage(ctx, 77, { userId: 67890 })).rejects.toThrow(
/download forward message failed/,
);
expect(upload).not.toHaveBeenCalled();
expect(sendPrivate).not.toHaveBeenCalled();
});
it('expands a nested forward card into an inner tree before upload (#437)', async () => {
const fetch = vi.fn(async (resId: string) => {
if (resId === 'OUTER') {
return [
{
userUin: 111,
nickname: 'outer',
elements: [{ type: 'forward', resId: 'INNER' }],
},
];
}
if (resId === 'INNER') {
return [
{
userUin: 222,
nickname: 'inner',
elements: [{ type: 'text', text: 'leaf' }],
},
];
}
throw new Error(`unexpected fetch ${resId}`);
});
const upload = vi.fn(async (nodes: any[]) => {
expect(nodes[0]).toMatchObject({
userUin: 111,
elements: [],
innerForward: [
expect.objectContaining({
userUin: 222,
elements: [{ type: 'text', text: 'leaf' }],
}),
],
});
return 'NEW_OUTER';
});
const sendGroup = vi.fn(async () => ({
messageId: 1,
sequence: 100,
clientSequence: 0,
random: 1,
timestamp: 1700000000,
}));
const bridge = fakeBridge({
apis: { message: { sendGroup }, forward: { fetch, upload } },
} as any);
const ctx = makeCtx(bridge);
(ctx as any).messageStore = {
...ctx.messageStore,
findMedia: () => null,
findEvent: () => ({
message: [{ type: 'forward', data: { id: 'OUTER' } }],
}),
};
await forwardSingleMessage(ctx, 88, { groupId: 12345 });
expect(fetch).toHaveBeenCalledTimes(2);
expect(upload).toHaveBeenCalledOnce();
expect(upload.mock.calls[0]![1]).toBe(12345);
expect(sendGroup).toHaveBeenCalledWith(12345, [
expect.objectContaining({ type: 'forward', resId: 'NEW_OUTER' }),
]);
});
it('rejects hidden siblings before forwarding a cached video message', async () => {
const sendGroupMessage = vi.fn();
const findVideo = vi.fn();
const bridge = fakeBridge({ apis: { message: { sendGroup: sendGroupMessage } } } as any);
const ctx = makeCtx(bridge);
(ctx as any).messageStore = {
...ctx.messageStore,
findMedia: () => null,
findEvent: () => ({
message: [
{ type: 'video', data: { file: 'cached-video-id' } },
{ type: 'text', data: { text: 'must not survive bot forwarding' } },
],
}),
};
(ctx as any).mediaStore = { findVideo };
await expect(forwardSingleMessage(ctx, 77, { groupId: 12345 })).rejects.toMatchObject({
code: 'UNSENDABLE_TYPE',
elementType: 'video',
});
expect(findVideo).not.toHaveBeenCalled();
expect(sendGroupMessage).not.toHaveBeenCalled();
});
it('rejects an unknown top-level entry before uploading valid siblings', async () => {
const uploadForwardNodes = vi.fn(async () => 'RES');
const sendGroupMessage = vi.fn();
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
const messages = [
{
type: 'node',
data: {
user_id: 111,
nickname: 'valid',
content: [{ type: 'text', data: { text: 'must not upload' } }],
},
},
{ type: 'surprise', data: { content: 'silently dropped before' } },
];
await expect(sendGroupForwardMessage(ctx, 12345, messages as any)).rejects.toMatchObject({
code: 'UNKNOWN_TYPE',
elementType: 'surprise',
});
expect(uploadForwardNodes).not.toHaveBeenCalled();
expect(sendGroupMessage).not.toHaveBeenCalled();
});
it('rejects object-valued node metadata before uploading', async () => {
const uploadForwardNodes = vi.fn(async () => 'RES');
const sendGroupMessage = vi.fn();
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
await expect(
sendGroupForwardMessage(ctx, 12345, [
{
type: 'node',
data: {
user_id: { accidental: 111 },
nickname: 'invalid',
content: [{ type: 'text', data: { text: 'must not upload' } }],
},
},
] as any),
).rejects.toMatchObject({
code: 'INVALID_FIELD',
elementType: 'node',
field: 'user_id',
});
expect(uploadForwardNodes).not.toHaveBeenCalled();
expect(sendGroupMessage).not.toHaveBeenCalled();
});
it('rejects a cached message node with no valid sender instead of dropping it', async () => {
const uploadForwardNodes = vi.fn(async () => 'RES');
const sendGroupMessage = vi.fn();
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
(ctx as any).messageStore = {
...ctx.messageStore,
findMedia: () => null,
findEvent: () => ({
user_id: 0,
message_type: 'group',
message: [{ type: 'text', data: { text: 'cached' } }],
sender: { nickname: 'missing-id' },
}),
};
await expect(
sendGroupForwardMessage(ctx, 12345, [
{ type: 'node', data: { id: 7 } },
{
type: 'node',
data: { user_id: 222, content: [{ type: 'text', data: { text: 'sibling' } }] },
},
] as any),
).rejects.toMatchObject({
code: 'INVALID_FIELD',
elementType: 'node',
field: 'user_id',
});
expect(uploadForwardNodes).not.toHaveBeenCalled();
expect(sendGroupMessage).not.toHaveBeenCalled();
});
it('resolves a cached node by a signed negative OneBot message_id', async () => {
const uploadForwardNodes = vi.fn(async () => 'RES');
const sendGroupMessage = vi.fn(async () => ({
messageId: 1,
sequence: 1,
clientSequence: 0,
random: 1,
timestamp: 1,
}));
const findEvent = vi.fn((id: number) =>
id === -123
? {
user_id: 111,
message_id: -123,
message_seq: 42,
message_type: 'group',
group_id: 12345,
time: 1700000000,
message: [{ type: 'text', data: { text: 'cached negative id' } }],
sender: { nickname: 'alice' },
}
: null,
);
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
(ctx as any).messageStore = { ...ctx.messageStore, findEvent, findMedia: () => null };
await sendGroupForwardMessage(ctx, 12345, [{ type: 'node', data: { id: -123 } }] as any);
expect(findEvent).toHaveBeenCalledWith(-123);
const [nodes] = uploadForwardNodes.mock.calls[0]!;
expect((nodes as any[])[0]).toMatchObject({
userUin: 111,
msgId: -123,
elements: [{ type: 'text', text: 'cached negative id' }],
});
});
it('[#372] empty group card does not replace a cached sender nickname with the QQ number', async () => {
// Group events always store sender.card as "" when the member has no
// card. `??` treats that empty string as present and then
// `nickname || String(userUin)` collapses the display name to the QQ
// number — the get_forward_msg symptom in #372.
const uploadForwardNodes = vi.fn(async () => 'RES');
const sendGroupMessage = vi.fn(async () => ({
messageId: 1,
sequence: 1,
clientSequence: 0,
random: 1,
timestamp: 1,
}));
const findEvent = vi.fn((id: number) =>
id === 7
? {
user_id: 22222,
message_id: 7,
message_seq: 42,
message_type: 'group',
group_id: 12345,
time: 1700000000,
message: [{ type: 'text', data: { text: 'cached' } }],
sender: { user_id: 22222, nickname: 'alice', card: '' },
}
: id === 8
? {
user_id: 33333,
message_id: 8,
message_seq: 43,
message_type: 'group',
group_id: 12345,
time: 1700000000,
message: [{ type: 'text', data: { text: 'nameless' } }],
sender: { user_id: 33333, nickname: '', card: '' },
}
: null,
);
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
(ctx as any).messageStore = { ...ctx.messageStore, findEvent, findMedia: () => null };
await sendGroupForwardMessage(ctx, 12345, [
{ type: 'node', data: { id: 7, nickname: 'node-override' } },
{ type: 'node', data: { id: 8, nickname: 'node-fallback' } },
] as any);
const [nodes] = uploadForwardNodes.mock.calls[0]!;
expect((nodes as any[])[0]).toMatchObject({ userUin: 22222, nickname: 'alice' });
expect((nodes as any[])[1]).toMatchObject({ userUin: 33333, nickname: 'node-fallback' });
});
it('reports a missing cached message_id as caller-invalid before upload', async () => {
const uploadForwardNodes = vi.fn(async () => 'RES');
const sendGroupMessage = vi.fn();
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
await expect(
sendGroupForwardMessage(ctx, 12345, [{ type: 'node', data: { id: -404 } }] as any),
).rejects.toMatchObject({
code: 'INVALID_FIELD',
elementType: 'node',
field: 'message_id',
});
expect(uploadForwardNodes).not.toHaveBeenCalled();
expect(sendGroupMessage).not.toHaveBeenCalled();
});
it('rejects mixed node/non-node content instead of partially dropping the node', async () => {
// Recursion only kicks in when *all* content entries are nodes —
// mixed content is ambiguous (do the non-node parts belong to the
// outer node or are they parallel siblings?), so we keep the legacy
// behaviour is ambiguous, so reject it before upload. A user who wants a
// nested forward must pass a pure node list.
const uploadForwardNodes = vi.fn(
async (_nodes: any[], _groupId?: number, _userId?: number) => 'RES',
);
const sendGroupMessage = vi.fn(async () => ({
messageId: 1,
sequence: 100,
clientSequence: 0,
random: 1,
timestamp: 0,
}));
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
const messages = [
{
type: 'node',
data: {
user_id: 111,
nickname: 'mixed',
content: [
{ type: 'text', data: { text: 'a sibling' } },
{
type: 'node',
data: {
user_id: 222,
content: [{ type: 'text', data: { text: 'lost' } }],
},
},
],
},
},
];
await expect(sendGroupForwardMessage(ctx, 12345, messages as any)).rejects.toMatchObject({
code: 'UNSENDABLE_TYPE',
elementType: 'node',
});
expect(uploadForwardNodes).not.toHaveBeenCalled();
expect(sendGroupMessage).not.toHaveBeenCalled();
});
it('[#203] node with user_id "0" or missing falls back to the bot self_id', async () => {
// Upstream frameworks (AstrBot etc.) send fake forward nodes with
// user_id "0" or omit it; the protocol端 knows self_id, so default to it
// instead of rejecting (matches NapCat/LLBot + the core builder).
const uploadForwardNodes = vi.fn(async (_nodes: any[]) => 'RESID');
const sendGroupMessage = vi.fn(async () => ({
messageId: 1,
sequence: 100,
clientSequence: 0,
random: 1,
timestamp: 1700000000,
}));
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
const messages = [
{
type: 'node',
data: {
user_id: '0',
nickname: 'PixivBot',
content: [{ type: 'text', data: { text: 'a' } }],
},
},
{
type: 'node',
data: { nickname: 'PixivBot', content: [{ type: 'text', data: { text: 'b' } }] },
}, // user_id omitted
];
await sendGroupForwardMessage(ctx, 12345, messages as any);
const [nodes] = uploadForwardNodes.mock.calls[0]!;
expect((nodes as any[])[0].userUin).toBe(10001); // "0" → self_id
expect((nodes as any[])[0].nickname).toBe('PixivBot'); // custom nickname preserved
expect((nodes as any[])[1].userUin).toBe(10001); // omitted → self_id
});
it('accepts node metadata `news` preview arrays (OneBot ecosystem compat)', async () => {
// Upstream frameworks (NapCat / LLOneBot / AstrBot etc.) attach
// `news: [{ text }]` preview metadata to forward nodes. Only scalar
// metadata was allowed before, so a standard OneBot-style node was
// rejected with `forward messages[i].news must be a scalar value`.
const uploadForwardNodes = vi.fn(async (_nodes: any[]) => 'RESID');
const sendGroupMessage = vi.fn(async () => ({
messageId: 1,
sequence: 100,
clientSequence: 0,
random: 1,
timestamp: 1700000000,
}));
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
const messages = [
{
type: 'node',
data: {
user_id: 111,
nickname: 'outer',
news: [{ text: '预览行 1' }, { text: '预览行 2' }],
summary: '共 2 条',
content: [
{
type: 'node',
data: {
user_id: 222,
nickname: 'inner',
content: [{ type: 'text', data: { text: 'hi' } }],
},
},
],
},
},
];
await sendGroupForwardMessage(ctx, 12345, messages as any);
expect(uploadForwardNodes).toHaveBeenCalledOnce();
const [nodes] = uploadForwardNodes.mock.calls[0]!;
expect((nodes as any[])[0].userUin).toBe(111);
expect((nodes as any[])[0].innerForward).toHaveLength(1);
});
it('rejects non-scalar node metadata that is not an accepted preview field', async () => {
// The news carve-out must stay narrow: any other non-scalar metadata
// (e.g. an accidental nested object) is still rejected up front.
const uploadForwardNodes = vi.fn(async (_nodes: any[]) => 'RESID');
const sendGroupMessage = vi.fn();
const bridge = fakeBridge({
apis: {
message: { sendGroup: sendGroupMessage },
forward: { upload: uploadForwardNodes },
},
} as any);
const ctx = makeCtx(bridge);
const messages = [
{
type: 'node',
data: {
user_id: 111,
nickname: 'bad',
arbitrary: { nested: true },
content: [{ type: 'text', data: { text: 'hi' } }],
},
},
];
await expect(sendGroupForwardMessage(ctx, 12345, messages as any)).rejects.toMatchObject({
code: 'INVALID_FIELD',
elementType: 'node',
field: 'arbitrary',
});
expect(uploadForwardNodes).not.toHaveBeenCalled();
expect(sendGroupMessage).not.toHaveBeenCalled();
});
it.each([
['news', 123],
['news', 'preview'],
['news', null],
['news', [{ text: false }]],
['news', [null]],
['title', 123],
['source', false],
['summary', []],
['prompt', null],
])('rejects malformed %s preview metadata before upload', async (field, value) => {
const upload = vi.fn();
const sendGroup = vi.fn();
const ctx = makeCtx(
fakeBridge({ apis: { message: { sendGroup }, forward: { upload } } } as any),
);
await expect(
sendGroupForwardMessage(ctx, 12345, [
{
type: 'node',
data: {
user_id: 111,
nickname: 'sender',
[field as string]: value,
content: [{ type: 'text', data: { text: 'hello' } }],
},
},
] as any),
).rejects.toMatchObject({ code: 'INVALID_FIELD', field });
expect(upload).not.toHaveBeenCalled();
expect(sendGroup).not.toHaveBeenCalled();
});
it('validates nested preview metadata before parsing an earlier contact card', async () => {
const getGroupRecommendArk = vi.fn();
const upload = vi.fn();
const sendGroup = vi.fn();
const ctx = makeCtx(
fakeBridge({
apis: {
message: { sendGroup },
forward: { upload },
contacts: { getGroupRecommendArk },
},
} as any),
);
await expect(
sendGroupForwardMessage(ctx, 12345, [
{
type: 'node',
data: {
user_id: 111,
content: [{ type: 'contact', data: { type: 'group', id: 12345 } }],
},
},
{
type: 'node',
data: {
user_id: 222,
content: [
{
type: 'node',
data: {
user_id: 333,
source: false,
content: [{ type: 'text', data: { text: 'hello' } }],
},
},
],
},
},
]),
).rejects.toMatchObject({ code: 'INVALID_FIELD', field: 'source' });
expect(getGroupRecommendArk).not.toHaveBeenCalled();
expect(upload).not.toHaveBeenCalled();
expect(sendGroup).not.toHaveBeenCalled();
});
});