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.
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
|
import type { Elem } from '@snowluma/proto-defs/element';
|
|
import { buildSendElems } from '@snowluma/protocol/element-builder';
|
|
import { decodeRichBody } from '@snowluma/protocol/msg-push/rich-body-decoder';
|
|
import type { ForwardNodePayload } from '@snowluma/protocol/events';
|
|
import type { OneBotInstanceContext } from '../src/instance-context';
|
|
import type { JsonObject } from '../src/types';
|
|
import { elementsToOneBotSegments } from '../src/event-converter';
|
|
import { parseMessage } from '../src/message-parser';
|
|
import { ApiHandler, type ApiActionContext } from '../src/api-handler';
|
|
import {
|
|
getForwardMessage,
|
|
sendGroupForwardMessage,
|
|
sendPrivateForwardMessage,
|
|
uploadForwardMessage,
|
|
} from '../src/modules/message-actions';
|
|
|
|
const time = 1700000000;
|
|
|
|
function fixture(nodes: ForwardNodePayload[] = []) {
|
|
const upload = vi.fn(async (_nodes: ForwardNodePayload[]) => 'forward-id');
|
|
const send = vi.fn(async () => ({
|
|
messageId: 9,
|
|
sequence: 100,
|
|
clientSequence: 0,
|
|
random: 1,
|
|
timestamp: time,
|
|
}));
|
|
const findEvent = vi.fn(() => null);
|
|
const ctx = {
|
|
uin: '10001',
|
|
selfId: 10001,
|
|
bridge: {
|
|
identity: { nickname: 'SnowLuma' },
|
|
apis: {
|
|
forward: { upload, fetch: vi.fn(async () => nodes) },
|
|
message: { sendGroup: send, sendPrivate: send },
|
|
},
|
|
},
|
|
messageStore: { findEvent, findMedia: () => null, storeEvent: vi.fn() },
|
|
converterCtx: {
|
|
selfId: 10001,
|
|
imageUrlResolver: null,
|
|
mediaUrlResolver: null,
|
|
messageIdResolver: null,
|
|
mediaSegmentSink: null,
|
|
},
|
|
cacheMessageMeta: vi.fn(),
|
|
mediaStore: {},
|
|
musicSignUrl: '',
|
|
} as unknown as OneBotInstanceContext;
|
|
return { ctx, upload, send, findEvent };
|
|
}
|
|
|
|
function node(data: JsonObject): JsonObject {
|
|
return {
|
|
type: 'node',
|
|
data: {
|
|
user_id: 20002,
|
|
nickname: 'sender',
|
|
time,
|
|
content: [
|
|
{ type: 'reply', data },
|
|
{ type: 'text', data: { text: 'answer' } },
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('custom replies inside merged forwards (#241)', () => {
|
|
it.each(['group', 'private', 'upload'] as const)(
|
|
'%s preserves custom quote text on the wire without looking up an id',
|
|
async (scene) => {
|
|
const { ctx, upload, findEvent } = fixture();
|
|
const messages = [
|
|
node({
|
|
id: '-123',
|
|
message: [{ type: 'text', data: { text: '原文\nSnowLuma 😀' } }],
|
|
qq: '30003',
|
|
time: String(time - 10),
|
|
seq: '42',
|
|
}),
|
|
];
|
|
|
|
if (scene === 'group') {
|
|
await sendGroupForwardMessage(ctx, 40004, messages);
|
|
} else if (scene === 'private') {
|
|
await sendPrivateForwardMessage(ctx, 40004, messages);
|
|
} else {
|
|
await uploadForwardMessage(ctx, messages);
|
|
}
|
|
|
|
expect(findEvent).not.toHaveBeenCalledWith(-123);
|
|
const elements = upload.mock.calls[0]![0][0]!.elements;
|
|
const wire = await buildSendElems(elements);
|
|
const src = protobuf_decode<Elem>(protobuf_encode<Elem>(wire[0]!)).srcMsg;
|
|
expect(src).toMatchObject({ origSeqs: [42], senderUin: 30003n, time: time - 10 });
|
|
expect(src?.elemsRaw?.map((raw) => protobuf_decode<Elem>(raw).text?.str).join('')).toBe(
|
|
'原文\nSnowLuma 😀',
|
|
);
|
|
expect(wire[1]?.text?.str).toBe('answer');
|
|
const decoded = decodeRichBody(
|
|
{
|
|
richText: {
|
|
elems: wire.map((element) =>
|
|
protobuf_decode<Elem>(protobuf_encode<Elem>(element)),
|
|
),
|
|
},
|
|
},
|
|
scene === 'group',
|
|
);
|
|
expect(decoded[0]).toMatchObject({
|
|
type: 'reply',
|
|
replySeq: 42,
|
|
replySenderUin: 30003,
|
|
replyTime: time - 10,
|
|
replyElements: [{ type: 'text', text: '原文\nSnowLuma 😀' }],
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each(
|
|
[
|
|
'SnowLuma',
|
|
{ type: 'text', data: { text: 'SnowLuma' } },
|
|
[{ type: 'text', data: { text: 'SnowLuma' } }],
|
|
].map((message) => ({ message })),
|
|
)('uses node metadata when only message is supplied ($message)', async ({ message }) => {
|
|
const { ctx, upload } = fixture();
|
|
await uploadForwardMessage(ctx, [node({ message })]);
|
|
expect(upload.mock.calls[0]![0][0]?.elements[0]).toMatchObject({
|
|
type: 'reply',
|
|
replySeq: 1,
|
|
replySenderUin: 20002,
|
|
replyTime: time,
|
|
replyElements: [{ type: 'text', text: 'SnowLuma' }],
|
|
});
|
|
});
|
|
|
|
it('round-trips fetched quote content and metadata without a local message record', async () => {
|
|
const { ctx, upload } = fixture([
|
|
{
|
|
userUin: 20002,
|
|
nickname: 'sender',
|
|
time,
|
|
groupId: 40004,
|
|
messageType: 'group',
|
|
elements: [
|
|
{
|
|
type: 'reply',
|
|
replySeq: 42,
|
|
replySenderUin: 30003,
|
|
replyTime: time - 10,
|
|
replyElements: [{ type: 'text', text: 'quoted' }],
|
|
},
|
|
{ type: 'text', text: 'answer' },
|
|
],
|
|
},
|
|
]);
|
|
const [fetched] = await getForwardMessage(ctx, 'original');
|
|
expect((fetched!.message as JsonObject[])[0]?.data).toMatchObject({
|
|
id: expect.any(String),
|
|
message: [{ type: 'text', data: { text: 'quoted' } }],
|
|
qq: '30003',
|
|
time: String(time - 10),
|
|
seq: '42',
|
|
});
|
|
await uploadForwardMessage(ctx, [
|
|
{
|
|
type: 'node',
|
|
data: {
|
|
user_id: fetched!.user_id,
|
|
nickname: 'sender',
|
|
content: fetched!.message,
|
|
},
|
|
},
|
|
]);
|
|
expect(upload.mock.calls[0]![0][0]?.elements[0]).toMatchObject({
|
|
type: 'reply',
|
|
replySeq: 42,
|
|
replySenderUin: 30003,
|
|
replyTime: time - 10,
|
|
replyElements: [{ type: 'text', text: 'quoted' }],
|
|
});
|
|
});
|
|
|
|
it('summarizes quoted media without uploading it, and works in nested forwards', async () => {
|
|
const { ctx, upload } = fixture();
|
|
await uploadForwardMessage(ctx, [
|
|
{
|
|
type: 'node',
|
|
data: {
|
|
user_id: 10001,
|
|
content: [
|
|
node({
|
|
message: [
|
|
{ type: 'text', data: { text: 'photo' } },
|
|
{ type: 'image', data: { file: 'unavailable.png' } },
|
|
{ type: 'at', data: { qq: 'all' } },
|
|
],
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
expect(upload.mock.calls[0]![0][0]?.innerForward?.[0]?.elements[0]?.replyElements).toEqual([
|
|
{ type: 'text', text: 'photo[图片]@全体成员' },
|
|
]);
|
|
});
|
|
|
|
it('keeps id-only replies when QQ supplies no inline content', async () => {
|
|
const { ctx } = fixture([
|
|
{
|
|
userUin: 20002,
|
|
nickname: 'sender',
|
|
time,
|
|
elements: [
|
|
{ type: 'reply', replySeq: 42 },
|
|
{ type: 'text', text: 'answer' },
|
|
],
|
|
},
|
|
]);
|
|
const [fetched] = await getForwardMessage(ctx, 'original');
|
|
expect((fetched!.message as JsonObject[])[0]).toEqual({
|
|
type: 'reply',
|
|
data: { id: '42' },
|
|
});
|
|
});
|
|
|
|
it('accepts the full uint32 sequence range and CQ text within a quote', async () => {
|
|
const { ctx, upload } = fixture();
|
|
await uploadForwardMessage(ctx, [
|
|
node({ message: 'hello[CQ:at,qq=all]', seq: 0xffffffff, time: 0 }),
|
|
]);
|
|
const wire = await buildSendElems(upload.mock.calls[0]![0][0]!.elements);
|
|
const src = protobuf_decode<Elem>(protobuf_encode<Elem>(wire[0]!)).srcMsg;
|
|
expect(src?.origSeqs).toEqual([0xffffffff]);
|
|
expect(src?.elemsRaw?.map((raw) => protobuf_decode<Elem>(raw).text?.str).join('')).toBe(
|
|
'hello@全体成员',
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{ message: [] },
|
|
{ message: '' },
|
|
{ message: null },
|
|
{ message: 123 },
|
|
{ message: [{ type: 'text', data: { text: {} } }] },
|
|
{ message: [{ type: 'unknown', data: {} }] },
|
|
{ message: [{ type: 'music', data: { type: 'qq', id: '1' } }] },
|
|
{ message: [{ type: 'node', data: { content: 'nested' } }] },
|
|
{ message: [{ type: 'reply', data: { message: 'nested' } }] },
|
|
{ message: 'quote', seq: 0 },
|
|
{ message: 'quote', seq: 0x100000000 },
|
|
{ message: 'quote', qq: -1 },
|
|
{ message: 'quote', qq: true },
|
|
{ message: 'quote', time: 1700000000000 },
|
|
{ message: 'quote', time: -1 },
|
|
{ message: 'quote', time: null },
|
|
{ message: 'quote', time: '' },
|
|
{ message: 'quote', time: ' ' },
|
|
{ message: 'quote', time: 1.5 },
|
|
{ message: 'quote', qq: null },
|
|
{ message: 'quote', seq: '' },
|
|
] satisfies JsonObject[])(
|
|
'rejects invalid custom reply input before uploading (%j)',
|
|
async (data) => {
|
|
const { ctx, upload, send } = fixture();
|
|
await expect(sendGroupForwardMessage(ctx, 40004, [node(data)])).rejects.toThrow();
|
|
expect(upload).not.toHaveBeenCalled();
|
|
expect(send).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('reports malformed custom quotes as BAD_REQUEST at the action boundary', async () => {
|
|
const { ctx, upload, send } = fixture();
|
|
const api = new ApiHandler({
|
|
sendGroupForwardMsg: (groupId, messages) =>
|
|
sendGroupForwardMessage(ctx, groupId, messages),
|
|
} as ApiActionContext);
|
|
const result = await api.handle('send_group_forward_msg', {
|
|
group_id: 40004,
|
|
messages: [node({ message: 'quote', seq: 0 })],
|
|
});
|
|
expect(result).toMatchObject({
|
|
status: 'failed',
|
|
retcode: 1400,
|
|
wording: expect.stringContaining('reply.seq'),
|
|
});
|
|
expect(upload).not.toHaveBeenCalled();
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps ordinary reply id parsing and ordinary received reply segments unchanged', async () => {
|
|
expect(await parseMessage([{ type: 'reply', data: { id: 42 } }], false)).toEqual([
|
|
{ type: 'reply', replySeq: 42 },
|
|
]);
|
|
const { ctx } = fixture();
|
|
expect(
|
|
await elementsToOneBotSegments(
|
|
ctx.converterCtx,
|
|
[
|
|
{
|
|
type: 'reply',
|
|
replySeq: 42,
|
|
replySenderUin: 30003,
|
|
replyTime: time,
|
|
replyElements: [{ type: 'text', text: 'quoted' }],
|
|
},
|
|
],
|
|
true,
|
|
40004,
|
|
),
|
|
).toEqual([{ type: 'reply', data: { id: expect.any(String) } }]);
|
|
await expect(
|
|
parseMessage(
|
|
[
|
|
{ type: 'reply', data: { message: 'quote' } },
|
|
{ type: 'text', data: { text: 'answer' } },
|
|
],
|
|
false,
|
|
),
|
|
).rejects.toThrow(/forward/);
|
|
});
|
|
});
|