Files
SnowLuma/packages/onebot/tests/event-converter.test.ts
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

1085 lines
38 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
convertEvent,
elementsToOneBotSegments,
type ConverterContext,
} from '../src/event-converter';
import {
buildDispatchPayload,
pickDispatchJson,
resolveReportOptions,
shapeEventForAdapter,
} from '../src/event-filter';
import type {
FriendMessage,
GroupMessage,
MessageElement,
QQEventVariant,
TempMessage,
} from '@snowluma/protocol/events';
import { PRIVATE_SENT_MESSAGE_EVENT } from '../src/message-id';
const SELF_ID = 10001;
const PEER_UIN = 22222;
const GROUP_ID = 99999;
/** Minimal ConverterContext with no resolvers (all null). Per-test
* helpers override individual fields when they need a stub. */
function bareCtx(overrides: Partial<ConverterContext> = {}): ConverterContext {
return {
selfId: SELF_ID,
imageUrlResolver: null,
mediaUrlResolver: null,
messageIdResolver: null,
mediaSegmentSink: null,
...overrides,
};
}
function makeFriendMessage(senderUin: number): FriendMessage {
return {
kind: 'friend_message',
time: 1700000000,
selfUin: SELF_ID,
senderUin,
senderNick: senderUin === SELF_ID ? 'me' : 'peer',
msgSeq: 1,
msgId: 1,
elements: [{ type: 'text', text: 'hi' }],
};
}
function makeGroupMessage(senderUin: number): GroupMessage {
return {
kind: 'group_message',
time: 1700000000,
selfUin: SELF_ID,
groupId: 99999,
groupName: 'Test Group 测试群',
senderUin,
senderNick: senderUin === SELF_ID ? 'me' : 'peer',
senderCard: '',
senderRole: 'member',
msgSeq: 1,
msgId: 1,
elements: [{ type: 'text', text: 'hi' }],
};
}
function makeTempMessage(senderUin: number): TempMessage {
return {
kind: 'temp_message',
time: 1700000000,
selfUin: SELF_ID,
senderUin,
groupId: 99999,
senderNick: senderUin === SELF_ID ? 'me' : 'peer',
msgSeq: 1,
elements: [{ type: 'text', text: 'hi' }],
};
}
describe('convertEvent — message kinds', () => {
it('keeps friend remark synchronization internal', async () => {
const out = await convertEvent(bareCtx(), {
kind: 'friend_remark_changed',
time: 1700000000,
selfUin: SELF_ID,
userUid: 'u_friend',
userUin: PEER_UIN,
remark: 'new remark',
});
expect(out).toBeNull();
});
it('friend_message peer → post_type "message", sub_type "friend"', async () => {
const out = await convertEvent(bareCtx(), makeFriendMessage(PEER_UIN));
expect(out).not.toBeNull();
expect(out!.post_type).toBe('message');
expect(out!.message_type).toBe('private');
expect(out!.sub_type).toBe('friend');
expect(out!.user_id).toBe(PEER_UIN);
expect(out!.self_id).toBe(SELF_ID);
expect(out!.raw_message).toBe('hi');
});
it('friend_message self → post_type "message_sent"', async () => {
const event = { ...makeFriendMessage(SELF_ID), peerUin: PEER_UIN } as FriendMessage;
const resolver = (_isGroup: boolean, sessionId: number, seq: number) => sessionId + seq;
const out = await convertEvent(bareCtx({ messageIdResolver: resolver }), event);
expect(out!.post_type).toBe('message_sent');
expect(out!.target_id).toBe(PEER_UIN);
expect(out!.message_id).toBe(PEER_UIN + 1);
});
it.each([makeFriendMessage(PEER_UIN), makeTempMessage(PEER_UIN)])(
'exposes only QQ-marked auto replies in $kind',
async (event) => {
const manual = await convertEvent(bareCtx(), {
...event,
elements: [{ type: 'text', text: '[自动回复] 稍后联系' }],
});
const automatic = await convertEvent(bareCtx(), { ...event, autoReply: true });
expect(manual).toMatchObject({
message: [{ type: 'text', data: { text: '[自动回复] 稍后联系' } }],
raw_message: '&#91;自动回复&#93; 稍后联系',
});
expect(manual).not.toHaveProperty('auto_reply');
expect(automatic).toMatchObject({
auto_reply: true,
message: [{ type: 'text', data: { text: 'hi' } }],
raw_message: 'hi',
});
},
);
it('group_message peer → post_type "message", sub_type "normal", group_id set', async () => {
const out = await convertEvent(bareCtx(), makeGroupMessage(PEER_UIN));
expect(out!.post_type).toBe('message');
expect(out!.message_type).toBe('group');
expect(out!.sub_type).toBe('normal');
expect(out!.group_id).toBe(GROUP_ID);
expect(out!.group_name).toBe('Test Group 测试群');
});
it('group_message self → post_type "message_sent"', async () => {
const out = await convertEvent(bareCtx(), makeGroupMessage(SELF_ID));
expect(out!.post_type).toBe('message_sent');
});
it('temp_message peer → post_type "message", sub_type "group", sender.group_id set', async () => {
const out = await convertEvent(bareCtx(), makeTempMessage(PEER_UIN));
expect(out!.post_type).toBe('message');
expect(out!.sub_type).toBe('group');
// Source group is surfaced on sender.group_id so a client can reply via
// send_private_msg(user_id, group_id=...).
expect((out!.sender as { group_id?: number }).group_id).toBe(99999);
});
it('temp_message self → post_type "message_sent"', async () => {
const out = await convertEvent(bareCtx(), makeTempMessage(SELF_ID));
expect(out!.post_type).toBe('message_sent');
expect(out!.sub_type).toBe('group');
});
it('messageIdResolver overrides the raw msgSeq', async () => {
const ctx = bareCtx({ messageIdResolver: (_g, _s, seq) => seq * 10 });
const out = await convertEvent(ctx, makeFriendMessage(PEER_UIN));
expect(out!.message_id).toBe(10);
});
});
describe('convertEvent — notice kinds', () => {
it('group_member_join: protocol admission type overrides actor identity (#237)', async () => {
const approve = await convertEvent(bareCtx(), {
kind: 'group_member_join',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: PEER_UIN,
operatorUin: 33333,
joinType: 'approve',
} as QQEventVariant);
expect(approve!.notice_type).toBe('group_increase');
expect(approve!.sub_type).toBe('approve');
const invite = await convertEvent(bareCtx(), {
kind: 'group_member_join',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: PEER_UIN,
operatorUin: PEER_UIN,
joinType: 'invite',
} as QQEventVariant);
expect(invite!.sub_type).toBe('invite');
});
it('group_member_join: falls back to actor identity when admission type is absent', async () => {
const approve = await convertEvent(bareCtx(), {
kind: 'group_member_join',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: PEER_UIN,
operatorUin: PEER_UIN,
} as QQEventVariant);
expect(approve!.sub_type).toBe('approve');
const invite = await convertEvent(bareCtx(), {
kind: 'group_member_join',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: PEER_UIN,
operatorUin: 33333,
} as QQEventVariant);
expect(invite!.sub_type).toBe('invite');
});
it('group_member_leave: leave / kick / kick_me / disband', async () => {
const leave = await convertEvent(bareCtx(), {
kind: 'group_member_leave',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: PEER_UIN,
operatorUin: 0,
leaveType: 'leave',
} as QQEventVariant);
expect(leave!.sub_type).toBe('leave');
const kick = await convertEvent(bareCtx(), {
kind: 'group_member_leave',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: PEER_UIN,
operatorUin: 33333,
leaveType: 'kick',
} as QQEventVariant);
expect(kick!.sub_type).toBe('kick');
const kickMe = await convertEvent(bareCtx(), {
kind: 'group_member_leave',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: SELF_ID,
operatorUin: 33333,
leaveType: 'kick',
} as QQEventVariant);
expect(kickMe!.sub_type).toBe('kick_me');
const disband = await convertEvent(bareCtx(), {
kind: 'group_member_leave',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: SELF_ID,
operatorUin: 33333,
leaveType: 'disband',
} as QQEventVariant);
expect(disband!.sub_type).toBe('disband');
});
it('group_mute: ban vs lift_ban driven by duration', async () => {
const ban = await convertEvent(bareCtx(), {
kind: 'group_mute',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: PEER_UIN,
operatorUin: 33333,
duration: 600,
} as QQEventVariant);
expect(ban!.sub_type).toBe('ban');
expect(ban!.duration).toBe(600);
const lift = await convertEvent(bareCtx(), {
kind: 'group_mute',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: PEER_UIN,
operatorUin: 33333,
duration: 0,
} as QQEventVariant);
expect(lift!.sub_type).toBe('lift_ban');
});
it('group_admin: set vs unset', async () => {
const set = await convertEvent(bareCtx(), {
kind: 'group_admin',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: PEER_UIN,
set: true,
} as QQEventVariant);
expect(set!.notice_type).toBe('group_admin');
expect(set!.sub_type).toBe('set');
const unset = await convertEvent(bareCtx(), {
kind: 'group_admin',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: PEER_UIN,
set: false,
} as QQEventVariant);
expect(unset!.sub_type).toBe('unset');
});
it('friend_recall: passes through user + sequence-as-message_id', async () => {
const out = await convertEvent(bareCtx(), {
kind: 'friend_recall',
time: 1,
selfUin: SELF_ID,
userUin: PEER_UIN,
msgSeq: 77,
} as QQEventVariant);
expect(out!.notice_type).toBe('friend_recall');
expect(out!.user_id).toBe(PEER_UIN);
expect(out!.message_id).toBe(77);
});
it('group_recall: carries operator, author, message_id', async () => {
const out = await convertEvent(bareCtx(), {
kind: 'group_recall',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
operatorUin: 33333,
authorUin: PEER_UIN,
msgSeq: 99,
} as QQEventVariant);
expect(out!.notice_type).toBe('group_recall');
expect(out!.operator_id).toBe(33333);
expect(out!.user_id).toBe(PEER_UIN);
expect(out!.message_id).toBe(99);
});
it('friend_poke: identifies the private peer, sender, and target independently', async () => {
const out = await convertEvent(bareCtx(), {
kind: 'friend_poke',
time: 1,
selfUin: SELF_ID,
peerUin: PEER_UIN,
senderUin: SELF_ID,
targetUin: SELF_ID,
action: '戳了戳',
suffix: '一下',
actionImgUrl: 'http://x',
} as QQEventVariant);
expect(out).toEqual({
time: 1,
self_id: SELF_ID,
post_type: 'notice',
notice_type: 'notify',
sub_type: 'poke',
user_id: PEER_UIN,
sender_id: SELF_ID,
target_id: SELF_ID,
action: '戳了戳',
suffix: '一下',
action_img_url: 'http://x',
});
});
it('group_poke: same shape as friend_poke + group_id', async () => {
const out = await convertEvent(bareCtx(), {
kind: 'group_poke',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: PEER_UIN,
targetUin: SELF_ID,
action: '戳',
suffix: '',
actionImgUrl: '',
} as QQEventVariant);
expect(out!.notice_type).toBe('notify');
expect(out!.sub_type).toBe('poke');
expect(out!.group_id).toBe(GROUP_ID);
});
it('group_essence: add vs delete + carries random + sequence', async () => {
const add = await convertEvent(bareCtx(), {
kind: 'group_essence',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
senderUin: PEER_UIN,
operatorUin: 33333,
msgSeq: 50,
random: 123,
set: true,
} as QQEventVariant);
expect(add!.notice_type).toBe('essence');
expect(add!.sub_type).toBe('add');
expect(add!.random).toBe(123);
expect(add!.message_seq).toBe(50);
const del = await convertEvent(bareCtx(), {
kind: 'group_essence',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
senderUin: PEER_UIN,
operatorUin: 33333,
msgSeq: 50,
random: 123,
set: false,
} as QQEventVariant);
expect(del!.sub_type).toBe('delete');
});
it('group_file_upload: emits group_upload with file metadata', async () => {
const out = await convertEvent(bareCtx(), {
kind: 'group_file_upload',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
userUin: PEER_UIN,
fileId: 'f1',
fileName: 'doc.txt',
fileSize: 1024,
busId: 102,
} as QQEventVariant);
expect(out!.notice_type).toBe('group_upload');
expect((out!.file as Record<string, unknown>).id).toBe('f1');
expect((out!.file as Record<string, unknown>).size).toBe(1024);
});
it('friend_add: emits friend_add notice', async () => {
const out = await convertEvent(bareCtx(), {
kind: 'friend_add',
time: 1,
selfUin: SELF_ID,
userUin: PEER_UIN,
} as QQEventVariant);
expect(out!.notice_type).toBe('friend_add');
expect(out!.user_id).toBe(PEER_UIN);
});
});
describe('convertEvent — request kinds', () => {
it('friend_request: request/friend with comment + flag', async () => {
const out = await convertEvent(bareCtx(), {
kind: 'friend_request',
time: 1,
selfUin: SELF_ID,
fromUin: PEER_UIN,
message: '加个好友',
flag: 'flag-abc',
} as QQEventVariant);
expect(out!.post_type).toBe('request');
expect(out!.request_type).toBe('friend');
expect(out!.comment).toBe('加个好友');
expect(out!.flag).toBe('flag-abc');
});
it('group_invite: request/group with sub_type defaulting to "invite"', async () => {
const out = await convertEvent(bareCtx(), {
kind: 'group_invite',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
fromUin: PEER_UIN,
message: '邀请',
flag: 'gflag',
subType: '',
} as QQEventVariant);
expect(out!.post_type).toBe('request');
expect(out!.request_type).toBe('group');
expect(out!.sub_type).toBe('invite');
const withSubType = await convertEvent(bareCtx(), {
kind: 'group_invite',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
fromUin: PEER_UIN,
message: '',
flag: 'gflag',
subType: 'add',
} as QQEventVariant);
expect(withSubType!.sub_type).toBe('add');
});
it('group_invite: reports the invitee without replacing user_id (#394)', async () => {
const out = await convertEvent(bareCtx(), {
kind: 'group_invite',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
fromUin: PEER_UIN,
invitedUin: 4242,
message: '',
flag: 'gflag',
subType: 'invite',
} as QQEventVariant);
expect(out!.user_id).toBe(PEER_UIN);
expect(out!.invited_id).toBe(4242);
});
it('group_invite: omits invited_id when the invitee is unknown', async () => {
const out = await convertEvent(bareCtx(), {
kind: 'group_invite',
time: 1,
selfUin: SELF_ID,
groupId: GROUP_ID,
fromUin: PEER_UIN,
message: '',
flag: 'gflag',
subType: 'invite',
} as QQEventVariant);
expect(out).not.toHaveProperty('invited_id');
});
});
describe('convertEvent — message elements', () => {
// Round-trips one element through elementsToOneBotSegments and
// asserts the OneBot segment shape. Resolvers are wired only when
// the element type needs them. JsonArray's index type is JsonValue
// (a union); cast back to the segment shape so test bodies can read
// `.type` and `.data` without typeguarding every line.
type Segment = { type: string; data: Record<string, unknown> };
async function segment(
element: MessageElement,
opts: Partial<ConverterContext> = {},
): Promise<Segment> {
const ctx = bareCtx(opts);
const segments = await elementsToOneBotSegments(ctx, [element], false, PEER_UIN);
return segments[0] as unknown as Segment;
}
it('text -> { type:"text", data:{ text } }', async () => {
const seg = await segment({ type: 'text', text: 'hello' });
expect(seg).toEqual({ type: 'text', data: { text: 'hello' } });
});
it('face -> { type:"face", data:{ id: stringified } }', async () => {
const seg = await segment({ type: 'face', faceId: 12 });
expect(seg).toEqual({ type: 'face', data: { id: '12' } });
});
it('image: uses imageUrlResolver when wired, falls back to element.imageUrl', async () => {
const seg = await segment(
{ type: 'image', fileId: 'fid', imageUrl: 'http://fallback' },
{ imageUrlResolver: () => 'http://resolved' },
);
expect(seg.type).toBe('image');
expect((seg.data as Record<string, unknown>).url).toBe('http://resolved');
expect((seg.data as Record<string, unknown>).file).toBe('fid');
expect((seg.data as Record<string, unknown>).sub_type).toBe(0);
expect((seg.data as Record<string, unknown>).summary).toBe('');
});
it('image without resolver uses element.imageUrl', async () => {
const seg = await segment({ type: 'image', fileId: 'fid', imageUrl: 'http://x' });
expect((seg.data as Record<string, unknown>).url).toBe('http://x');
});
it('image: falls back to element.url when imageUrl is empty (#441)', async () => {
const source = 'https://cdn.example/bot-built.png';
const seg = await segment({ type: 'image', url: source });
expect(seg).toEqual({
type: 'image',
data: {
url: source,
file: source,
sub_type: 0,
summary: '',
},
});
});
it('image: does not emit inline base64 as file/url (#463)', async () => {
const source = `base64://${'A'.repeat(32)}`;
const seg = await segment({ type: 'image', url: source });
expect(seg).toEqual({
type: 'image',
data: {
url: '',
file: '',
sub_type: 0,
summary: '',
},
});
});
it('image: keeps fileId when the only source is inline (#463)', async () => {
const seg = await segment({
type: 'image',
fileId: 'fid',
url: `base64://${'A'.repeat(32)}`,
});
expect((seg.data as Record<string, unknown>).file).toBe('fid');
expect((seg.data as Record<string, unknown>).url).toBe('');
});
it('image: resolver sees the upload source when only url is present (#441)', async () => {
const source = 'https://cdn.example/bot-built.png';
const seen: string[] = [];
const seg = await segment(
{ type: 'image', url: source },
{
imageUrlResolver: (element) => {
seen.push(element.type === 'image' ? (element.imageUrl ?? '') : '');
return `${element.type === 'image' ? element.imageUrl : ''}&signed=1`;
},
},
);
expect(seen).toEqual([source]);
expect((seg.data as Record<string, unknown>).url).toBe(`${source}&signed=1`);
expect((seg.data as Record<string, unknown>).file).toBe(source);
});
it('image: forwards optional sub_type and summary for custom emoji/stickers', async () => {
const seg = await segment({
type: 'image',
fileId: 'fid',
imageUrl: 'http://x',
subType: 1,
summary: '[动画表情]',
});
expect(seg).toEqual({
type: 'image',
data: {
url: 'http://x',
file: 'fid',
sub_type: 1,
summary: '[动画表情]',
},
});
});
it('image: preserves the flash marker on the OneBot segment', async () => {
const seg = await segment({
type: 'image',
fileId: 'fid',
imageUrl: 'http://x',
flash: true,
});
expect(seg).toMatchObject({
type: 'image',
data: { type: 'flash' },
});
});
it('at: targetUin -> qq string', async () => {
const seg = await segment({ type: 'at', targetUin: PEER_UIN });
expect(seg).toEqual({ type: 'at', data: { qq: String(PEER_UIN) } });
});
it('at all: uid="all" -> qq:"all"', async () => {
const seg = await segment({ type: 'at', uid: 'all', targetUin: 0 });
expect((seg.data as Record<string, unknown>).qq).toBe('all');
});
it('reply: replySeq is resolved through messageIdResolver when wired', async () => {
const seg = await segment(
{ type: 'reply', replySeq: 5 },
{ messageIdResolver: () => 9999 },
);
expect(seg).toEqual({ type: 'reply', data: { id: '9999' } });
});
it('reply: self-sent private targets use the outgoing lookup namespace', async () => {
const calls: unknown[][] = [];
const seg = await segment(
{
type: 'reply',
replySeq: 5,
replySenderUin: SELF_ID,
replyTime: 1234,
replyElements: [{ type: 'text', text: 'quoted text' }],
},
{
messageIdResolver: (...args) => {
calls.push(args);
return 9999;
},
},
);
expect(seg).toEqual({ type: 'reply', data: { id: '9999' } });
expect(calls[0]).toEqual([
false,
PEER_UIN,
5,
PRIVATE_SENT_MESSAGE_EVENT,
1234,
[{ type: 'text', text: 'quoted text' }],
undefined,
]);
});
it('reply: replySeq=0 is omitted without calling the resolver', async () => {
let resolverCalls = 0;
const seg = await segment(
{ type: 'reply', replySeq: 0 },
{
messageIdResolver: () => {
resolverCalls++;
return 1;
},
},
);
expect(seg).toBeUndefined();
expect(resolverCalls).toBe(0);
});
it('record: uses mediaUrlResolver for url', async () => {
const seg = await segment(
{ type: 'record', fileName: 'a.silk', fileId: 'fid', url: 'fallback' },
{ mediaUrlResolver: async () => 'resolved' },
);
expect(seg.type).toBe('record');
expect((seg.data as Record<string, unknown>).url).toBe('resolved');
expect((seg.data as Record<string, unknown>).file).toBe('a.silk');
});
it('video: same shape as record', async () => {
const seg = await segment(
{ type: 'video', fileName: 'a.mp4', fileId: 'fid', url: 'fallback' },
{ mediaUrlResolver: async () => 'resolved' },
);
expect(seg.type).toBe('video');
expect((seg.data as Record<string, unknown>).url).toBe('resolved');
});
it('json: text is forwarded as data.data', async () => {
const seg = await segment({ type: 'json', text: '{"a":1}' });
expect(seg).toEqual({ type: 'json', data: { data: '{"a":1}' } });
});
it('xml: text + resid (defaults to 35)', async () => {
const seg = await segment({ type: 'xml', text: '<x/>' });
expect((seg.data as Record<string, unknown>).resid).toBe(35);
const seg2 = await segment({ type: 'xml', text: '<x/>', subType: 50 });
expect((seg2.data as Record<string, unknown>).resid).toBe(50);
});
it('markdown: exposes the original content on receive', async () => {
const seg = await segment({ type: 'markdown', text: '# Bot answer' });
expect(seg).toEqual({ type: 'markdown', data: { content: '# Bot answer' } });
});
it.each([true, false])(
'markdown: adds readable text while retaining rich content (group=%s)',
async (isGroup) => {
const content = '# Bot answer\n**天气**:晴 ☀️';
const segments = await elementsToOneBotSegments(
bareCtx(),
[
{ type: 'at', targetUin: PEER_UIN },
{ type: 'markdown', text: content },
{ type: 'text', text: 'after' },
],
isGroup,
isGroup ? GROUP_ID : PEER_UIN,
);
expect(segments).toEqual([
{ type: 'at', data: { qq: String(PEER_UIN) } },
{ type: 'markdown', data: { content } },
{ type: 'text', data: { text: content } },
{ type: 'text', data: { text: 'after' } },
]);
expect(
segments
.filter((s) => typeof s === 'object' && s?.type === 'text')
.map((s) => (s as { data: { text: string } }).data.text)
.join(''),
).toBe(`${content}after`);
},
);
it('markdown: does not add empty text or text copies of unrelated rich elements', async () => {
const segments = await elementsToOneBotSegments(
bareCtx(),
[
{ type: 'markdown', text: '' },
{ type: 'json', text: '{"title":"card"}' },
],
true,
GROUP_ID,
);
expect(segments.map((s) => (s as { type: string }).type)).toEqual(['markdown', 'json']);
});
it('inline keyboard: exposes rows and actionable button metadata', async () => {
const seg = await segment({
type: 'inline_keyboard',
botAppid: '9007199254740993',
rows: [
{
buttons: [
{
id: 'btn-1',
label: 'Run',
visitedLabel: 'Done',
style: 1,
type: 2,
clickLimit: 3,
unsupportedTips: 'Admins only',
data: 'callback-data',
atBotShowChannelList: true,
permissionType: 2,
specifyRoleIds: ['admin'],
specifyUserIds: ['u_10001'],
isReply: false,
enter: true,
anchor: 9,
},
],
},
],
});
expect(seg).toEqual({
type: 'inline_keyboard',
data: {
bot_appid: '9007199254740993',
rows: [
{
buttons: [
{
id: 'btn-1',
label: 'Run',
visited_label: 'Done',
style: 1,
type: 2,
click_limit: 3,
unsupport_tips: 'Admins only',
data: 'callback-data',
at_bot_show_channel_list: true,
permission_type: 2,
specify_role_ids: ['admin'],
specify_user_ids: ['u_10001'],
is_reply: false,
enter: true,
anchor: 9,
},
],
},
],
},
});
});
it('file: canonical file/file_id/file_size + legacy name/size/id + url + file_hash', async () => {
const seg = await segment(
{ type: 'file', fileName: 'doc.pdf', fileSize: 7, fileId: 'fid', fileHash: 'h' },
{ mediaUrlResolver: async () => 'http://download' },
);
expect(seg.type).toBe('file');
expect(seg.data).toEqual({
// NapCat/LLOneBot-style canonical fields
file: 'doc.pdf',
file_id: 'fid',
file_size: 7,
// legacy SnowLuma fields
name: 'doc.pdf',
size: 7,
id: 'fid',
url: 'http://download',
file_hash: 'h',
});
});
it('mface: unified to an image segment carrying market-face markers', async () => {
const emojiId = '235a82d9c0acd2e2db6e0b94e1a1c4f3';
const seg = await segment({
type: 'mface',
text: '可爱',
emojiId,
emojiPackageId: 12,
emojiKey: 'abc',
});
expect(seg).toEqual({
type: 'image',
data: {
file: `23-${emojiId}.gif`,
url: `https://gxh.vip.qq.com/club/item/parcel/item/23/${emojiId}/raw300.gif`,
summary: '可爱',
sub_type: 0,
emoji_id: emojiId,
emoji_package_id: 12,
key: 'abc',
},
});
});
it('poke: subType forwarded as data.type', async () => {
const seg = await segment({ type: 'poke', subType: 3 });
expect(seg).toEqual({ type: 'poke', data: { type: 3 } });
});
it('mediaSegmentSink fires once per image/record/video segment', async () => {
const calls: string[] = [];
const sink = (mediaType: string) => {
calls.push(mediaType);
};
const ctx = bareCtx({
mediaSegmentSink: sink as ConverterContext['mediaSegmentSink'],
mediaUrlResolver: async () => '',
});
await elementsToOneBotSegments(
ctx,
[
{ type: 'image', fileId: 'i', imageUrl: '' },
{ type: 'record', fileName: 'r.silk', fileId: 'r' },
{ type: 'video', fileName: 'v.mp4', fileId: 'v' },
{ type: 'text', text: 'not media' },
],
true,
GROUP_ID,
);
expect(calls).toEqual(['image', 'record', 'video']);
});
});
describe('shapeEventForAdapter', () => {
const baseEvent = {
time: 1,
self_id: SELF_ID,
post_type: 'message_sent' as const,
message_type: 'private',
sub_type: 'friend',
message_id: 7,
user_id: SELF_ID,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
font: 0,
sender: { user_id: SELF_ID, nickname: 'me', sex: 'unknown', age: 0 },
};
it('drops self message_sent when adapter has reportSelfMessage=false', () => {
const opts = resolveReportOptions({ messageFormat: 'array', reportSelfMessage: false });
expect(shapeEventForAdapter(baseEvent, opts)).toBeNull();
});
it('keeps self message_sent when adapter has reportSelfMessage=true', () => {
const opts = resolveReportOptions({ messageFormat: 'array', reportSelfMessage: true });
const shaped = shapeEventForAdapter(baseEvent, opts);
expect(shaped).not.toBeNull();
expect(shaped!.post_type).toBe('message_sent');
});
it('rewrites message to CQ string when format=string', () => {
const opts = resolveReportOptions({ messageFormat: 'string', reportSelfMessage: true });
const shaped = shapeEventForAdapter(baseEvent, opts);
expect(shaped).not.toBeNull();
expect(shaped!.message).toBe('hello');
// raw_message is preserved untouched.
expect(shaped!.raw_message).toBe('hello');
});
it('keeps message as array when format=array (default)', () => {
const opts = resolveReportOptions({ messageFormat: 'array', reportSelfMessage: true });
const shaped = shapeEventForAdapter(baseEvent, opts);
expect(Array.isArray(shaped!.message)).toBe(true);
});
it('defaults partially-deserialized adapters to array format and no self report', () => {
const opts = resolveReportOptions({});
expect(opts.messageFormat).toBe('array');
expect(opts.reportSelfMessage).toBe(false);
});
it('passes through non-message events unchanged', () => {
const noticeEvent = {
time: 1,
self_id: SELF_ID,
post_type: 'notice',
notice_type: 'group_increase',
group_id: 1,
user_id: 2,
};
const opts = resolveReportOptions({ messageFormat: 'string', reportSelfMessage: false });
expect(shapeEventForAdapter(noticeEvent, opts)).toBe(noticeEvent);
});
});
describe('buildDispatchPayload + pickDispatchJson', () => {
const messageEvent = {
time: 1,
self_id: SELF_ID,
post_type: 'message',
message_type: 'private',
sub_type: 'friend',
message_id: 7,
user_id: PEER_UIN,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
font: 0,
sender: { user_id: PEER_UIN, nickname: 'peer', sex: 'unknown', age: 0 },
};
const selfMessageEvent = { ...messageEvent, post_type: 'message_sent', user_id: SELF_ID };
const noticeEvent = {
time: 1,
self_id: SELF_ID,
post_type: 'notice',
notice_type: 'group_increase',
group_id: 1,
user_id: 2,
};
it('builds at most two distinct JSON variants for a message event', () => {
const payload = buildDispatchPayload(messageEvent);
expect(payload.isSelfMessage).toBe(false);
const arr = JSON.parse(payload.arrayJson);
const str = JSON.parse(payload.stringJson);
expect(Array.isArray(arr.message)).toBe(true);
expect(typeof str.message).toBe('string');
expect(str.message).toBe('hello');
expect(str.raw_message).toBe('hello');
// every other field should be byte-identical
const { message: _arrMsg, ...arrRest } = arr;
const { message: _strMsg, ...strRest } = str;
expect(arrRest).toEqual(strRest);
});
it('flags self messages and uses identical JSON for non-message events', () => {
const selfPayload = buildDispatchPayload(selfMessageEvent);
expect(selfPayload.isSelfMessage).toBe(true);
const noticePayload = buildDispatchPayload(noticeEvent);
expect(noticePayload.isSelfMessage).toBe(false);
// Non-message events do not need a separate string variant.
expect(noticePayload.arrayJson).toBe(noticePayload.stringJson);
});
it('routes connections to the right pre-serialized variant in O(1)', () => {
const payload = buildDispatchPayload(messageEvent);
const arrayOpt = resolveReportOptions({ messageFormat: 'array', reportSelfMessage: false });
const stringOpt = resolveReportOptions({
messageFormat: 'string',
reportSelfMessage: false,
});
expect(pickDispatchJson(payload, arrayOpt)).toBe(payload.arrayJson);
expect(pickDispatchJson(payload, stringOpt)).toBe(payload.stringJson);
});
it('drops self-message dispatches for adapters that opt out', () => {
const payload = buildDispatchPayload(selfMessageEvent);
const offOpt = resolveReportOptions({ messageFormat: 'array', reportSelfMessage: false });
expect(pickDispatchJson(payload, offOpt)).toBeNull();
const onOpt = resolveReportOptions({ messageFormat: 'array', reportSelfMessage: true });
const json = pickDispatchJson(payload, onOpt);
expect(json).not.toBeNull();
expect(JSON.parse(json!).post_type).toBe('message_sent');
});
it('serializes only twice regardless of fan-out size', () => {
const payload = buildDispatchPayload(messageEvent);
// Mixed fleet of adapters with different formats / self-report options.
const adapters = Array.from({ length: 50 }, (_, i) =>
i % 2 === 0
? { messageFormat: 'array' as const, reportSelfMessage: false }
: { messageFormat: 'string' as const, reportSelfMessage: true },
);
const seen = new Set(
adapters
.map((opts) => pickDispatchJson(payload, opts))
.filter((j): j is string => j !== null),
);
// No matter the fan-out, only at most the two prebuilt strings appear.
expect(seen.size).toBeLessThanOrEqual(2);
expect(seen.has(payload.arrayJson)).toBe(true);
expect(seen.has(payload.stringJson)).toBe(true);
});
});