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
383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
// Tests for the Event0x2DC subType=16 (GroupMsgEmojiLike) end-to-end:
|
|
// - decoder strips the 7-byte prefix and decodes GroupReactNotify
|
|
// - field13==35 yields a GroupMsgEmojiLikeEvent
|
|
// - field13!=35 surfaces under MsgPush.Unknown and emits nothing
|
|
// - converter shape matches the gocqhttp / NapCat extension layout
|
|
// - formatEvent prints something readable in the [Event] log
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
|
import { protobuf_encode } from '@snowluma/proton';
|
|
import type { GroupReactNotify } from '@snowluma/proto-defs/notify';
|
|
import { MSG_PUSH_CMD, parseMsgPush } from '@snowluma/protocol/msg-push';
|
|
import { decodeEvent0x2DC } from '@snowluma/protocol/msg-push/decoders/event-0x2dc';
|
|
import { buildContext, type MsgPushContext } from '@snowluma/protocol/msg-push/context';
|
|
import { IdentityService } from '@snowluma/protocol/identity-service';
|
|
import type { GroupMsgEmojiLikeEvent, QQEventVariant } from '@snowluma/protocol/events';
|
|
import { convertGroupMsgEmojiLike } from '../src/event-converter/to-notice';
|
|
import { GROUP_MESSAGE_EVENT, hashMessageIdInt32 } from '../src/message-id';
|
|
import { formatEvent } from '@snowluma/protocol/format';
|
|
import { subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
|
|
|
const SELF_UIN = '10001';
|
|
const GROUP_ID = 123456789;
|
|
|
|
function makeIdentity(): IdentityService {
|
|
return IdentityService.memory(SELF_UIN);
|
|
}
|
|
|
|
/** Build the raw bytes QQ would push into ctx.content for subType=16:
|
|
* a 7-byte magic prefix followed by an encoded GroupReactNotify. */
|
|
function buildReactContent(notify: Record<string, unknown>): Uint8Array {
|
|
const prefix = new Uint8Array([0, 0, 0, 0, 0, 0, 0]);
|
|
const body = protobuf_encode<GroupReactNotify>(notify as GroupReactNotify);
|
|
const out = new Uint8Array(prefix.length + body.length);
|
|
out.set(prefix, 0);
|
|
out.set(body, prefix.length);
|
|
return out;
|
|
}
|
|
|
|
function encodeVarint(value: bigint): number[] {
|
|
const out: number[] = [];
|
|
let remaining = value;
|
|
|
|
do {
|
|
let byte = Number(remaining & 0x7fn);
|
|
remaining >>= 7n;
|
|
|
|
if (remaining !== 0n) {
|
|
byte |= 0x80;
|
|
}
|
|
|
|
out.push(byte);
|
|
} while (remaining !== 0n);
|
|
|
|
return out;
|
|
}
|
|
|
|
function encodeSignedInt32Varint(value: number): number[] {
|
|
return encodeVarint(BigInt.asUintN(64, BigInt(value | 0)));
|
|
}
|
|
|
|
function varintField(fieldNumber: number, value: number[] | bigint): number[] {
|
|
const bytes = Array.isArray(value) ? value : encodeVarint(value);
|
|
return [...encodeVarint(BigInt(fieldNumber << 3)), ...bytes];
|
|
}
|
|
|
|
function messageField(fieldNumber: number, payload: number[] | Uint8Array): number[] {
|
|
const bytes = Array.from(payload);
|
|
return [
|
|
...encodeVarint(BigInt((fieldNumber << 3) | 2)),
|
|
...encodeVarint(BigInt(bytes.length)),
|
|
...bytes,
|
|
];
|
|
}
|
|
|
|
function buildPushPacket(
|
|
content: Uint8Array,
|
|
options: { sequence: number[] | bigint; msgId: number[] | bigint },
|
|
): PacketInfo {
|
|
const contentHead = [
|
|
...varintField(1, 732n),
|
|
...varintField(2, 16n),
|
|
...varintField(4, options.msgId),
|
|
...varintField(5, options.sequence),
|
|
...varintField(6, 1735000000n),
|
|
];
|
|
const body = messageField(2, content);
|
|
const message = [...messageField(2, contentHead), ...messageField(3, body)];
|
|
|
|
return {
|
|
pid: 1,
|
|
uin: SELF_UIN,
|
|
serviceCmd: MSG_PUSH_CMD,
|
|
seqId: 1,
|
|
retCode: 0,
|
|
fromClient: false,
|
|
body: Uint8Array.from(messageField(1, message)),
|
|
};
|
|
}
|
|
|
|
function makeCtx(content: Uint8Array, identity = makeIdentity()): MsgPushContext {
|
|
return {
|
|
head: { msgType: 732, subType: 16, sequence: 0, timestamp: 1735000000, msgId: 0 },
|
|
fromUin: 0,
|
|
fromUid: '',
|
|
selfUin: Number(SELF_UIN),
|
|
content,
|
|
body: undefined,
|
|
responseHead: undefined,
|
|
identity,
|
|
};
|
|
}
|
|
|
|
describe('decodeEvent0x2DC subType=16 — GroupMsgEmojiLike', () => {
|
|
it('decodes a full PushMsg whose envelope sequence uses a sign-extended 10-byte varint', () => {
|
|
const content = buildReactContent({
|
|
groupUin: BigInt(GROUP_ID),
|
|
field13: 35,
|
|
groupReactionData: {
|
|
data: {
|
|
data: {
|
|
groupReactionTarget: { seq: 4242n },
|
|
groupReactionDataContent: {
|
|
code: '76',
|
|
count: 1,
|
|
operatorUid: 'u_operator_xxx',
|
|
type: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const packet = buildPushPacket(content, {
|
|
msgId: 123n,
|
|
sequence: encodeSignedInt32Varint(-2),
|
|
});
|
|
|
|
const [event] = parseMsgPush(packet, makeIdentity()) as GroupMsgEmojiLikeEvent[];
|
|
expect(event).toMatchObject({
|
|
kind: 'group_msg_emoji_like',
|
|
groupId: GROUP_ID,
|
|
msgSeq: 4242,
|
|
emojiId: '76',
|
|
});
|
|
});
|
|
|
|
it('decodes a full PushMsg whose envelope sequence carries high uint32 bits', () => {
|
|
const content = buildReactContent({
|
|
groupUin: BigInt(GROUP_ID),
|
|
field13: 35,
|
|
groupReactionData: {
|
|
data: {
|
|
data: {
|
|
groupReactionTarget: { seq: 4242n },
|
|
groupReactionDataContent: {
|
|
code: '76',
|
|
count: 1,
|
|
operatorUid: 'u_operator_xxx',
|
|
type: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const packet = buildPushPacket(content, {
|
|
msgId: 123n,
|
|
sequence: 0x1_0000_0007n,
|
|
});
|
|
|
|
const [event] = parseMsgPush(packet, makeIdentity()) as GroupMsgEmojiLikeEvent[];
|
|
expect(event).toMatchObject({
|
|
kind: 'group_msg_emoji_like',
|
|
groupId: GROUP_ID,
|
|
msgSeq: 4242,
|
|
emojiId: '76',
|
|
});
|
|
expect(buildContext(packet, makeIdentity())?.head.sequence).toBe(7);
|
|
});
|
|
|
|
it('accepts a sign-extended negative ContentHead field 4 without losing its low 32 bits', () => {
|
|
const packet = buildPushPacket(new Uint8Array(0), {
|
|
msgId: encodeSignedInt32Varint(-1),
|
|
sequence: 7n,
|
|
});
|
|
const ctx = buildContext(packet, makeIdentity());
|
|
|
|
expect(ctx?.head.msgId).toBe(0xffffffff);
|
|
expect(ctx?.head.sequence).toBe(7);
|
|
});
|
|
|
|
it('decodes a field13=35 react add into a GroupMsgEmojiLikeEvent', () => {
|
|
const content = buildReactContent({
|
|
groupUin: BigInt(GROUP_ID),
|
|
field13: 35,
|
|
groupReactionData: {
|
|
data: {
|
|
data: {
|
|
groupReactionTarget: { seq: 4242n },
|
|
groupReactionDataContent: {
|
|
code: '76',
|
|
count: 1,
|
|
operatorUid: 'u_operator_xxx',
|
|
type: 1, // add
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const [event] = decodeEvent0x2DC(makeCtx(content)) as GroupMsgEmojiLikeEvent[];
|
|
expect(event).toBeDefined();
|
|
expect(event.kind).toBe('group_msg_emoji_like');
|
|
expect(event.groupId).toBe(GROUP_ID);
|
|
expect(event.operatorUid).toBe('u_operator_xxx');
|
|
expect(event.emojiId).toBe('76');
|
|
expect(event.msgSeq).toBe(4242);
|
|
expect(event.count).toBe(1);
|
|
expect(event.isAdd).toBe(true);
|
|
});
|
|
|
|
it('decodes type=2 as react remove (isAdd=false)', () => {
|
|
const content = buildReactContent({
|
|
groupUin: BigInt(GROUP_ID),
|
|
field13: 35,
|
|
groupReactionData: {
|
|
data: {
|
|
data: {
|
|
groupReactionTarget: { seq: 1n },
|
|
groupReactionDataContent: {
|
|
code: '⭐',
|
|
count: 1,
|
|
operatorUid: 'u_x',
|
|
type: 2,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const [event] = decodeEvent0x2DC(makeCtx(content)) as GroupMsgEmojiLikeEvent[];
|
|
expect(event.isAdd).toBe(false);
|
|
expect(event.emojiId).toBe('⭐');
|
|
});
|
|
|
|
it('drops the packet and logs to MsgPush.Unknown when field13 is not 35', () => {
|
|
const captured: LogEntry[] = [];
|
|
const unsub = subscribeLogs((e) => captured.push(e));
|
|
|
|
const content = buildReactContent({
|
|
groupUin: BigInt(GROUP_ID),
|
|
field13: 99, // not 35
|
|
groupReactionData: {
|
|
data: { data: { groupReactionDataContent: { code: 'x', operatorUid: 'u_y' } } },
|
|
},
|
|
});
|
|
|
|
const result = decodeEvent0x2DC(makeCtx(content));
|
|
unsub();
|
|
|
|
expect(result).toHaveLength(0);
|
|
const unknown = captured.find(
|
|
(e) => e.scope === 'MsgPush.Unknown' && /field13=99/.test(e.message),
|
|
);
|
|
expect(unknown).toBeDefined();
|
|
});
|
|
|
|
it('returns empty when content is shorter than the prefix length', () => {
|
|
const tooShort = new Uint8Array([1, 2, 3]); // < 7 bytes
|
|
expect(decodeEvent0x2DC(makeCtx(tooShort))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('convertGroupMsgEmojiLike — OneBot notice shape', () => {
|
|
const baseEvent: GroupMsgEmojiLikeEvent = {
|
|
kind: 'group_msg_emoji_like',
|
|
time: 1735000000,
|
|
selfUin: 10001,
|
|
groupId: 123456789,
|
|
operatorUin: 99999,
|
|
operatorUid: 'u_x',
|
|
msgSeq: 4242,
|
|
emojiId: '76',
|
|
count: 1,
|
|
isAdd: true,
|
|
};
|
|
|
|
// Real production wiring puts a hashMessageIdInt32-based resolver
|
|
// on the ctx; mirror that so message_id is the same value the
|
|
// bot would have minted for the original message.
|
|
const ctx = {
|
|
selfId: 10001,
|
|
imageUrlResolver: null,
|
|
mediaUrlResolver: null,
|
|
messageIdResolver: (
|
|
_isGroup: boolean,
|
|
sessionId: number,
|
|
sequence: number,
|
|
eventName: string,
|
|
) => hashMessageIdInt32(sequence, sessionId, eventName),
|
|
mediaSegmentSink: null,
|
|
} as never;
|
|
|
|
it('produces a group_msg_emoji_like notice with sub_type=add and resolved message_id', async () => {
|
|
const out = await convertGroupMsgEmojiLike(ctx, baseEvent);
|
|
expect(out.post_type).toBe('notice');
|
|
expect(out.notice_type).toBe('group_msg_emoji_like');
|
|
expect(out.sub_type).toBe('add');
|
|
expect(out.group_id).toBe(123456789);
|
|
expect(out.user_id).toBe(99999);
|
|
expect(out.operator_id).toBe(99999);
|
|
expect(out.message_seq).toBe(4242);
|
|
expect(out.message_id).toBe(hashMessageIdInt32(4242, 123456789, GROUP_MESSAGE_EVENT));
|
|
expect(out.likes).toEqual([{ emoji_id: '76', count: 1 }]);
|
|
});
|
|
|
|
it('flips sub_type to remove when isAdd is false', async () => {
|
|
const out = await convertGroupMsgEmojiLike(ctx, { ...baseEvent, isAdd: false });
|
|
expect(out.sub_type).toBe('remove');
|
|
});
|
|
});
|
|
|
|
describe('formatEvent — group_msg_emoji_like', () => {
|
|
let savedLevel: string | undefined;
|
|
beforeEach(() => {
|
|
savedLevel = process.env.SNOWLUMA_LOG_LEVEL;
|
|
});
|
|
afterEach(() => {
|
|
if (savedLevel === undefined) {
|
|
delete process.env.SNOWLUMA_LOG_LEVEL;
|
|
} else {
|
|
process.env.SNOWLUMA_LOG_LEVEL = savedLevel;
|
|
}
|
|
});
|
|
|
|
it('renders a one-line summary with group, operator and emoji id', () => {
|
|
const identity = IdentityService.memory(SELF_UIN);
|
|
identity.rememberGroups([
|
|
{
|
|
groupId: GROUP_ID,
|
|
groupName: 'TestGroup',
|
|
remark: '',
|
|
memberCount: 1,
|
|
memberMax: 500,
|
|
members: new Map(),
|
|
},
|
|
]);
|
|
identity.rememberGroupMembers(GROUP_ID, [
|
|
{
|
|
uin: 99999,
|
|
uid: 'u_x',
|
|
nickname: 'Alice',
|
|
card: '',
|
|
role: 'member',
|
|
level: 0,
|
|
title: '',
|
|
joinTime: 0,
|
|
lastSentTime: 0,
|
|
shutUpTime: 0,
|
|
},
|
|
]);
|
|
|
|
const event: QQEventVariant = {
|
|
kind: 'group_msg_emoji_like',
|
|
time: 0,
|
|
selfUin: Number(SELF_UIN),
|
|
groupId: GROUP_ID,
|
|
operatorUin: 99999,
|
|
operatorUid: 'u_x',
|
|
msgSeq: 100,
|
|
emojiId: '76',
|
|
count: 1,
|
|
isAdd: true,
|
|
};
|
|
|
|
const line = formatEvent(identity, event);
|
|
expect(line).toBeDefined();
|
|
expect(line).toContain('TestGroup');
|
|
expect(line).toContain('Alice');
|
|
expect(line).toContain('[76]');
|
|
expect(line).toContain('msgSeq=100');
|
|
});
|
|
});
|