Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
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/);
|
|
});
|
|
});
|