Files
SnowLuma/packages/onebot/tests/forward-nested.test.ts
DrSmoothl 063ede4c62 fix(onebot): preserve custom forward metadata
Keep custom titles and previews available when reading uploaded forwards,
including nested messages and refreshed sender names.

Reject malformed preview metadata before processing the request.

Refs #493
2026-10-04 16:03:32 +08:00

713 lines
29 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 { 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(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 } as any,
cacheMessageMeta: vi.fn(),
mediaStore: {} as any,
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 = {
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('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 = {
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 = {
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 = {
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 = {
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 = { 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 = { 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();
});
});