Move database work off the main thread on Node.js 24 and improve message handling and runtime diagnostics. Preserve migration, restart, and concurrency coverage with the async SQLite driver.
1138 lines
42 KiB
TypeScript
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();
|
|
});
|
|
});
|