Files
SnowLuma/packages/core/tests/msg-push-handler.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

1094 lines
39 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import { parseMsgPush, MSG_PUSH_CMD } from '@snowluma/protocol/msg-push';
import { IdentityService } from '@snowluma/protocol/identity-service';
import { GroupInviteTracker } from '@snowluma/protocol/group-invite-tracker';
import { IncomingPacketPipeline } from '@snowluma/protocol/packet-pipeline';
import { BridgeEventBus } from '@snowluma/protocol/event-bus';
import type { GroupMemberInfo, QQGroupInfo } from '@snowluma/protocol/qq-info';
// Proton's call-site analyzer keys off the literal type identifier
// printed in `protobuf_encode<X>` — it walks imports by *original*
// name (not alias). If both `import { X } from 'proto/notify'` and
// `import { X as Y } from 'events'` appear, the analyzer's import
// resolver picks up the second one and the proton-side definition
// loses, so call sites referencing `X` go un-replaced. Side-step by
// re-aliasing the event-side type's *source* (not just the local
// name) — we don't need to import it directly since assertions can
// inline-extract the kind.
import type {
GroupChange,
NewFriend,
FriendRecall,
OperatorInfo,
SelfJoinInGroup,
GroupAdmin,
InputStatusNotify,
NotifyMessageBody,
GroupNameChange,
GroupSpecialTitleChange,
OnlineDeviceNotify,
ProfileLikeTip,
FriendRemarkChangedNotify,
} from '@snowluma/proto-defs/notify';
import type { PushMsg } from '@snowluma/proto-defs/message';
import type { GroupMemberJoin, FriendAddEvent, QQEventVariant } from '@snowluma/protocol/events';
// Local alias for the event-side recall type — derived via Extract so
// we avoid an `import { FriendRecall as X }` that collides with the
// proton import above.
type FriendRecallEvent = Extract<QQEventVariant, { kind: 'friend_recall' }>;
type GroupAdminEvent = Extract<QQEventVariant, { kind: 'group_admin' }>;
import type { PacketInfo } from '@snowluma/common/protocol-types';
import { subscribeLogs } from '@snowluma/common/logger';
const SELF_UIN = '10001';
const GROUP_ID = 123456789;
function makeGroupMember(uin: number, uid: string): GroupMemberInfo {
return {
uin,
uid,
nickname: '',
card: '',
isRobot: false,
role: 'member',
level: 0,
title: '',
joinTime: 0,
lastSentTime: 0,
shutUpTime: 0,
};
}
function makeGroup(members: GroupMemberInfo[] = []): QQGroupInfo {
return {
groupId: GROUP_ID,
groupName: '',
remark: '',
memberCount: members.length,
memberMax: 500,
members: new Map(members.map((member) => [member.uin, member])),
};
}
function makeIdentity(members: GroupMemberInfo[] = []): IdentityService {
const identity = IdentityService.memory(SELF_UIN);
identity.rememberGroups([makeGroup(members)]);
if (members.length) {
identity.rememberGroupMembers(GROUP_ID, members);
}
return identity;
}
function makeGroupIncreasePacket(
memberUid: string,
operatorUid = '',
fromUin = GROUP_ID,
operatorBytes: Uint8Array = Buffer.from(operatorUid, 'utf8'),
operationType = 130,
): PacketInfo {
const content = protobuf_encode<GroupChange>({
groupUin: GROUP_ID,
memberUid,
decreaseType: operationType,
operatorBytes,
});
const body = protobuf_encode<PushMsg>({
message: {
responseHead: { fromUin },
contentHead: { msgType: 33, timestamp: 1710000000 },
body: { msgContent: content },
},
status: 0,
});
return {
pid: 1,
uin: SELF_UIN,
serviceCmd: MSG_PUSH_CMD,
seqId: 1,
retCode: 0,
fromClient: false,
body,
};
}
function makeGroupDecreasePacket(
memberUid: string,
decreaseType: number,
operatorBytes: Uint8Array,
fromUin = GROUP_ID,
): PacketInfo {
const content = protobuf_encode<GroupChange>({
groupUin: GROUP_ID,
memberUid,
decreaseType,
operatorBytes,
});
const body = protobuf_encode<PushMsg>({
message: {
responseHead: { fromUin },
contentHead: { msgType: 34, timestamp: 1710000000 },
body: { msgContent: content },
},
status: 0,
});
return {
pid: 1,
uin: SELF_UIN,
serviceCmd: MSG_PUSH_CMD,
seqId: 1,
retCode: 0,
fromClient: false,
body,
};
}
function makeNestedOperatorInfoFixture(): Uint8Array {
// QQ may wrap GroupChange.operatorBytes in OperatorInfo and append metadata
// after the UID, including for GroupChange.decreaseType=131.
return Buffer.from('0a180a0a755f6f70657261746f7210061a060102030405062001', 'hex');
}
describe('parseMsgPush group member increase', () => {
it('does not fall back to the group id when a joining uid is unresolved', () => {
const [event] = parseMsgPush(
makeGroupIncreasePacket('u_new_member'),
makeIdentity(),
) as GroupMemberJoin[];
expect(event.kind).toBe('group_member_join');
expect(event.groupId).toBe(GROUP_ID);
expect(event.userUin).toBe(0);
expect(event.operatorUin).toBe(0);
expect(event.userUid).toBe('u_new_member');
});
it('decodes the raw operator uid used by member-increase pushes (#228)', () => {
const member = makeGroupMember(22222, 'u_member');
const operator = makeGroupMember(33333, 'u_operator');
const [event] = parseMsgPush(
makeGroupIncreasePacket(member.uid, operator.uid),
makeIdentity([member, operator]),
) as GroupMemberJoin[];
expect(event.userUin).toBe(member.uin);
expect(event.operatorUin).toBe(operator.uin);
expect(event.userUid).toBe(member.uid);
expect(event.operatorUid).toBe(operator.uid);
});
it('decodes nested operator info used by member-increase pushes', () => {
const member = makeGroupMember(22222, 'u_member');
const operator = makeGroupMember(33333, 'u_operator');
const [event] = parseMsgPush(
makeGroupIncreasePacket(member.uid, '', GROUP_ID, makeNestedOperatorInfoFixture()),
makeIdentity([member, operator]),
) as GroupMemberJoin[];
expect(event.userUin).toBe(member.uin);
expect(event.operatorUin).toBe(operator.uin);
expect(event.userUid).toBe(member.uid);
expect(event.operatorUid).toBe(operator.uid);
});
it('derives approve vs invite from the member-change operation code (#237)', () => {
const member = makeGroupMember(22222, 'u_member');
const operator = makeGroupMember(33333, 'u_operator');
const identity = makeIdentity([member, operator]);
const [approved] = parseMsgPush(
makeGroupIncreasePacket(
member.uid,
operator.uid,
GROUP_ID,
Buffer.from(operator.uid, 'utf8'),
130,
),
identity,
) as GroupMemberJoin[];
const [invited] = parseMsgPush(
makeGroupIncreasePacket(
member.uid,
operator.uid,
GROUP_ID,
Buffer.from(operator.uid, 'utf8'),
131,
),
identity,
) as GroupMemberJoin[];
const [unknownHighBits] = parseMsgPush(
makeGroupIncreasePacket(
member.uid,
operator.uid,
GROUP_ID,
Buffer.from(operator.uid, 'utf8'),
0x103,
),
identity,
) as GroupMemberJoin[];
expect(approved.joinType).toBe('approve');
expect(invited.joinType).toBe('invite');
expect(unknownHighBits.joinType).toBe('approve');
});
it('reports malformed raw operator bytes with bounded context', () => {
const captured: string[] = [];
const unsubscribe = subscribeLogs((entry) => {
if (entry.scope === 'MsgPush') {
captured.push(entry.message);
}
});
try {
const events = parseMsgPush(
makeGroupIncreasePacket('u_new_member', '', GROUP_ID, new Uint8Array([0xff])),
makeIdentity(),
);
expect(events).toEqual([]);
expect(
captured.some(
(message) =>
message.includes('group member increase operator UID') &&
message.includes('bytes=1') &&
message.includes('preview=ff'),
),
).toBe(true);
} finally {
unsubscribe();
}
});
});
describe('parseMsgPush group member decrease', () => {
it('ignores structured field 5 metadata for a voluntary leave (#237)', () => {
const member = makeGroupMember(22222, 'u_member');
const [event] = parseMsgPush(
makeGroupDecreasePacket(member.uid, 130, new Uint8Array([0x12, 0x02, 0x08, 0x00])),
makeIdentity([member]),
) as Extract<QQEventVariant, { kind: 'group_member_leave' }>[];
expect(event.leaveType).toBe('leave');
expect(event.userUin).toBe(member.uin);
expect(event.operatorUin).toBe(member.uin);
expect(event.operatorUid).toBe(member.uid);
});
it('decodes a raw operator uid for ordinary member removals', () => {
const member = makeGroupMember(22222, 'u_member');
const operator = makeGroupMember(33333, 'u_operator');
const [event] = parseMsgPush(
makeGroupDecreasePacket(member.uid, 131, Buffer.from(operator.uid, 'utf8')),
makeIdentity([member, operator]),
) as Extract<QQEventVariant, { kind: 'group_member_leave' }>[];
expect(event.userUin).toBe(member.uin);
expect(event.operatorUin).toBe(operator.uin);
expect(event.operatorUid).toBe(operator.uid);
expect(event.leaveType).toBe('kick');
});
it('decodes nested operator info for ordinary member removals (#253)', () => {
const member = makeGroupMember(22222, 'u_member');
const operator = makeGroupMember(33333, 'u_operator');
const [event] = parseMsgPush(
makeGroupDecreasePacket(member.uid, 131, makeNestedOperatorInfoFixture()),
makeIdentity([member, operator]),
) as Extract<QQEventVariant, { kind: 'group_member_leave' }>[];
expect(event.userUin).toBe(member.uin);
expect(event.operatorUin).toBe(operator.uin);
expect(event.operatorUid).toBe(operator.uid);
expect(event.leaveType).toBe('kick');
});
it('decodes nested operator info when the bot itself is kicked', () => {
const operator = makeGroupMember(33333, 'u_operator');
const operatorBytes = protobuf_encode<OperatorInfo>({
operatorField: { uid: operator.uid },
});
const [event] = parseMsgPush(
makeGroupDecreasePacket('u_self', 3, operatorBytes),
makeIdentity([operator]),
) as Extract<QQEventVariant, { kind: 'group_member_leave' }>[];
expect(event.operatorUin).toBe(operator.uin);
expect(event.operatorUid).toBe(operator.uid);
expect(event.leaveType).toBe('kick');
});
});
describe('parseMsgPush Event0x210 subType=38 (acknowledged-but-silent)', () => {
// Tracked back to the stock-QQ-android decompiled decoder
// `com.tencent.imcore.message.ext.codec.decoder.msgType0x210.SubType0x26`
// — it's the QQ-client-internal "troop shortcut bar" / discussion
// app state push (badge counts + in-app tips), not a chat event.
// We acknowledge it via `Event0x210SubType.GroupAppStatePush` and
// drop silently — no OneBot-level event, and — critically —
// **no** "unknown subType=38" fallback log spam anymore.
function makeEvent0x210Packet(subType: number, content = new Uint8Array(0)): PacketInfo {
const body = protobuf_encode<PushMsg>({
message: {
responseHead: { fromUin: 22222, type: 0, sigMap: 0 },
// 528 = Event0x210
contentHead: { msgType: 528, subType, timestamp: 1710000000 },
body: { msgContent: content },
},
status: 0,
});
return {
pid: 1,
uin: SELF_UIN,
serviceCmd: MSG_PUSH_CMD,
seqId: 1,
retCode: 0,
fromClient: false,
body,
};
}
it('returns [] for subType 38 without falling through to the MsgPush.Unknown log', () => {
const captured: string[] = [];
const unsubscribe = subscribeLogs((entry) => {
// Only care about the spam that the prior implementation produced
// ("MsgPush.Unknown" with subType=38). Other modules log freely
// during identity setup etc., we ignore them.
if (entry.scope === 'MsgPush.Unknown' && /subType=38/.test(entry.message)) {
captured.push(entry.message);
}
});
try {
const events = parseMsgPush(makeEvent0x210Packet(38), makeIdentity());
expect(events).toEqual([]);
expect(captured).toEqual([]); // no "unknown subType=38" debug log
} finally {
unsubscribe();
}
});
it('still flags genuinely-unknown subTypes via MsgPush.Unknown (regression guard)', () => {
// Counterpart to the silent-on-38 case: anything we *haven't*
// claimed in the enum should still hit the fallback so it's
// visible to whoever's tailing debug logs.
const captured: string[] = [];
const unsubscribe = subscribeLogs((entry) => {
if (entry.scope === 'MsgPush.Unknown' && /subType=999/.test(entry.message)) {
captured.push(entry.message);
}
});
try {
parseMsgPush(makeEvent0x210Packet(999), makeIdentity());
expect(captured.length).toBeGreaterThan(0);
} finally {
unsubscribe();
}
});
});
// ── P1 coverage extension: subTypes confirmed by LagrangeGo / acidify
// that we were previously dropping into the "unknown" fallback. ──
function makeEvent0x210PacketAny(subType: number, content: Uint8Array): PacketInfo {
const body = protobuf_encode<PushMsg>({
message: {
responseHead: { fromUin: 22222, type: 0, sigMap: 0 },
contentHead: { msgType: 528, subType, timestamp: 1710000000 },
body: { msgContent: content },
},
status: 0,
});
return {
pid: 1,
uin: SELF_UIN,
serviceCmd: MSG_PUSH_CMD,
seqId: 1,
retCode: 0,
fromClient: false,
body,
};
}
describe('parseMsgPush Event0x210 subType=179/226 (NewFriend → friend_add)', () => {
// 179 fires when bot sent a request and other side accepted; 226
// when bot accepted other side's request. Both share the
// `NewFriend` wire shape (LagrangeGo registers both via
// case-fallthrough in `client/listener.go:248`). We surface them
// identically as `kind: 'friend_add'`.
function makeNewFriendPacket(
subType: 179 | 226,
friendUid: string,
time = 1710000000,
): PacketInfo {
const content = protobuf_encode<NewFriend>({
field1: 0,
info: { uid: friendUid, time, message: 'hello', nickName: 'Alice' },
});
return makeEvent0x210PacketAny(subType, content);
}
it('emits friend_add for subType 179', () => {
const member = makeGroupMember(99999, 'u_new_friend');
const [event] = parseMsgPush(
makeNewFriendPacket(179, member.uid),
makeIdentity([member]),
) as FriendAddEvent[];
expect(event.kind).toBe('friend_add');
expect(event.userUin).toBe(99999);
expect(event.time).toBe(1710000000);
});
it('emits friend_add for subType 226 (same wire shape, different surface)', () => {
const member = makeGroupMember(88888, 'u_other_friend');
const [event] = parseMsgPush(
makeNewFriendPacket(226, member.uid),
makeIdentity([member]),
) as FriendAddEvent[];
expect(event.kind).toBe('friend_add');
expect(event.userUin).toBe(88888);
});
it('falls back to the packet routingHead.fromUin when the uid is not in the local cache', () => {
// No member registered for `u_unknown_uid`, but the packet itself
// carries the new friend's uin on `fromUin` (22222 in
// makeEvent0x210PacketAny). That's the wire-level convention
// (LagrangeGo emits unconditionally then calls `ResolveUin` post
// hoc, see `client/listener.go:259`). Without the fallback the
// event would either drop or surface `user_id: 0`, both of which
// confuse OneBot consumers.
const [event] = parseMsgPush(
makeNewFriendPacket(179, 'u_unknown_uid'),
makeIdentity(),
) as FriendAddEvent[];
expect(event.kind).toBe('friend_add');
expect(event.userUin).toBe(22222); // the packet's fromUin
});
});
describe('parseMsgPush Event0x210 subType=364 (friend remark synchronization)', () => {
function makeFriendRemarkPacket(uid: string, uin: number, remark: string): PacketInfo {
const content = protobuf_encode<FriendRemarkChangedNotify>({
change: {
target: { uid, uin: BigInt(uin) },
remark,
},
updateTime: 1710000000,
nickname: 'Alice',
changeType: 1,
});
return makeEvent0x210PacketAny(364, content);
}
it('decodes a pure internal synchronization event', () => {
const identity = makeIdentity();
identity.rememberFriends([
{
uin: 22222,
uid: 'u_friend',
nickname: 'Alice',
remark: 'old-remark',
},
]);
expect(
parseMsgPush(makeFriendRemarkPacket('u_friend', 22222, 'new-remark'), identity),
).toEqual([
{
kind: 'friend_remark_changed',
time: 1710000000,
selfUin: Number(SELF_UIN),
userUid: 'u_friend',
userUin: 22222,
remark: 'new-remark',
},
]);
expect(identity.findFriend(22222)?.remark).toBe('old-remark');
});
it('applies the synchronization at the packet-pipeline boundary', async () => {
const identity = makeIdentity();
identity.rememberFriends([
{
uin: 22222,
uid: 'u_friend',
nickname: 'Alice',
remark: 'old-remark',
},
]);
const pipeline = new IncomingPacketPipeline({
identity,
events: new BridgeEventBus(),
fetchGroupList: vi.fn(async () => {}),
fetchGroupMemberList: vi.fn(async () => {}),
resolveGroupJoinRequest: vi.fn(async () => null),
groupInvites: new GroupInviteTracker(),
});
pipeline.registerCmd(MSG_PUSH_CMD, parseMsgPush);
await pipeline.process(makeFriendRemarkPacket('u_friend', 22222, 'new-remark'));
expect(identity.findFriend(22222)?.remark).toBe('new-remark');
});
});
describe('parseMsgPush Event0x210 subType=277 (input status → 对方正在输入)', () => {
// RE'd from wrapper.linux.node aio_input_state_worker.cc: 0x210/0x115 sys_msg,
// body = InputStatusNotify {fromUid, toUid, notifyItem{eventType@f4}}.
type FriendInputStatusEvent = Extract<QQEventVariant, { kind: 'friend_input_status' }>;
function makeInputStatusPacket(fromUid: string, eventType: number): PacketInfo {
const content = protobuf_encode<InputStatusNotify>({
fromUid,
toUid: 'u_self',
notifyItem: { eventType },
});
return makeEvent0x210PacketAny(277, content);
}
it('emits friend_input_status (typing) for eventType 1', () => {
const member = makeGroupMember(77777, 'u_typer');
const [event] = parseMsgPush(
makeInputStatusPacket(member.uid, 1),
makeIdentity([member]),
) as FriendInputStatusEvent[];
expect(event.kind).toBe('friend_input_status');
expect(event.userUin).toBe(77777);
expect(event.eventType).toBe(1);
expect(event.statusText).toBe('对方正在输入...');
});
it('surfaces eventType 3 as the voice-recording status text', () => {
const member = makeGroupMember(77777, 'u_typer');
const [event] = parseMsgPush(
makeInputStatusPacket(member.uid, 3),
makeIdentity([member]),
) as FriendInputStatusEvent[];
expect(event.eventType).toBe(3);
expect(event.statusText).toBe('对方正在讲话...');
});
it('drops a body with no fromUid (nothing to key user_id on)', () => {
const events = parseMsgPush(
makeEvent0x210PacketAny(
277,
protobuf_encode<InputStatusNotify>({ notifyItem: { eventType: 1 } }),
),
makeIdentity(),
);
expect(events).toEqual([]);
});
});
describe('parseMsgPush Event0x210 subType=349 (online-device snapshot)', () => {
it('decodes the complete device fields recovered from the QQ wire handler', () => {
const content = protobuf_encode<OnlineDeviceNotify>({
appId: 537242075,
instanceId: 101,
clientType: 1,
platform: 3,
devices: [
{
appId: 537242075,
instanceId: 202,
clientType: 1,
platform: 3,
deviceName: 'DESKTOP-TEST',
},
{
appId: 537242075,
instanceId: 203,
clientType: 5,
platform: 3,
deviceName: 'DESKTOP-DUPLICATE',
},
{
appId: 537242075,
instanceId: 303,
clientType: 4,
platform: 2,
deviceName: 'Pixel 9',
},
],
});
const events = parseMsgPush(makeEvent0x210PacketAny(349, content), makeIdentity());
expect(events).toEqual([
{
kind: 'online_devices_changed',
time: 1710000000,
selfUin: Number(SELF_UIN),
devices: [
{
appId: 537242075,
instanceId: 202,
clientType: 1,
platform: 3,
deviceName: 'DESKTOP-TEST',
deviceKind: 'computer',
},
{
appId: 537242075,
instanceId: 303,
clientType: 4,
platform: 2,
deviceName: 'Pixel 9',
deviceKind: 'phone',
},
],
},
]);
});
it('normalizes packed wire client types before classification and deduplication', () => {
const content = protobuf_encode<OnlineDeviceNotify>({
devices: [
{
appId: 1001,
instanceId: 202,
clientType: 83714, // 0x14702 -> phone client type 2
platform: 3,
deviceName: 'BOT-HOST',
},
{
appId: 1001,
instanceId: 203,
clientType: 65799, // 0x10107 -> phone client type 7
platform: 2,
deviceName: 'MOBILE-DUPLICATE',
},
],
});
const events = parseMsgPush(makeEvent0x210PacketAny(349, content), makeIdentity());
expect(events).toEqual([
{
kind: 'online_devices_changed',
time: 1710000000,
selfUin: Number(SELF_UIN),
devices: [
{
appId: 1001,
instanceId: 202,
clientType: 2,
platform: 3,
deviceName: 'BOT-HOST',
deviceKind: 'phone',
},
],
},
]);
});
it('rejects entries that omit required identity fields', () => {
const content = protobuf_encode<OnlineDeviceNotify>({
devices: [{ instanceId: 202, platform: 3, deviceName: 'BROKEN' }],
});
const captured: string[] = [];
const unsubscribe = subscribeLogs((entry) => {
if (entry.scope === 'MsgPush' && /online-device entry 0/.test(entry.message)) {
captured.push(entry.message);
}
});
try {
expect(parseMsgPush(makeEvent0x210PacketAny(349, content), makeIdentity())).toEqual([]);
expect(captured).toHaveLength(1);
expect(captured[0]).toMatch(/appIdPresent=false clientTypePresent=false/);
} finally {
unsubscribe();
}
});
});
describe('parseMsgPush Event0x210 subType=39 (profile like → 名片赞)', () => {
// 0x210/39 is multiplexed; body.msgContent decodes as ProfileLikeTip and is a
// like only when inner msgType==0 && subType==203. Proto shape from NapCat's
// raw-bytes decoder (pending a real like capture to validate byte-exactly).
type FriendProfileLikeEvent = Extract<QQEventVariant, { kind: 'friend_profile_like' }>;
function makeLikePacket(opts: {
msgType?: number;
subType?: number;
uin?: number;
nick?: string;
txt?: string;
time?: number;
}): PacketInfo {
const content = protobuf_encode<ProfileLikeTip>({
msgType: opts.msgType ?? 0,
subType: opts.subType ?? 203,
content: {
msg: {
time: opts.time ?? 1710001111,
detail: {
txt: opts.txt ?? '',
uin: BigInt(opts.uin ?? 0),
nickname: opts.nick ?? '',
},
},
},
});
return makeEvent0x210PacketAny(39, content);
}
it('emits friend_profile_like with liker uin, nick and parsed count', () => {
// txt shape taken from a real capture: "赞了我的资料卡N次".
const [event] = parseMsgPush(
makeLikePacket({ uin: 1206069534, nick: '墨白灵MotricLuck', txt: '赞了我的资料卡2次' }),
makeIdentity(),
) as FriendProfileLikeEvent[];
expect(event.kind).toBe('friend_profile_like');
expect(event.operatorUin).toBe(1206069534);
expect(event.operatorNick).toBe('墨白灵MotricLuck');
expect(event.times).toBe(2);
expect(event.time).toBe(1710001111);
});
it('ignores a non-like 39 variant (inner subType != 203)', () => {
const events = parseMsgPush(
makeLikePacket({ subType: 999, uin: 1787882683 }),
makeIdentity(),
);
expect(events).toEqual([]);
});
});
describe('parseMsgPush Event0x2DC subType=16 field13=6 (group special title)', () => {
// Built from a REAL on-wire capture: eventParam f2 = the gray-tip template
// "恭喜<{member}>获得群主授予的<{…"text":TITLE…}>头衔", f5 = member uin. The title
// is the last <{…}> token's text.
type GroupTitleChangeEvent = Extract<QQEventVariant, { kind: 'group_title_change' }>;
const TIP =
'恭喜<{"cmd":5,"data":"3433035623","text":"星屿"}>获得群主授予的' +
'<{"cmd":1,"data":"https://qun.qq.com/x?medal=302&uin=3433035623","text":"摸鱼冠军",' +
'"url":"https://qun.qq.com/x?medal=302&uin=3433035623"}>头衔';
function makeTitlePacket(memberUin: number, tip: string): PacketInfo {
const notify = protobuf_encode<NotifyMessageBody>({
groupUin: GROUP_ID,
field13: 6,
eventParam: protobuf_encode<GroupSpecialTitleChange>({ tipText: tip, memberUin }),
});
const content = new Uint8Array(7 + notify.length);
content.set(notify, 7);
const body = protobuf_encode<PushMsg>({
message: {
responseHead: { fromUin: 0, type: 0, sigMap: 0 },
contentHead: { msgType: 732, subType: 16, timestamp: 1710000000 },
body: { msgContent: content },
},
status: 0,
});
return {
pid: 1,
uin: SELF_UIN,
serviceCmd: MSG_PUSH_CMD,
seqId: 1,
retCode: 0,
fromClient: false,
body,
};
}
it('emits group_title_change with the granted title + member', () => {
const [event] = parseMsgPush(
makeTitlePacket(22222, TIP),
makeIdentity([makeGroupMember(22222, 'u_member')]),
) as GroupTitleChangeEvent[];
expect(event.kind).toBe('group_title_change');
expect(event.groupId).toBe(GROUP_ID);
expect(event.userUin).toBe(22222);
expect(event.title).toBe('摸鱼冠军'); // last <{…}> token's text
});
it('drops a title tip with no parseable title token', () => {
const events = parseMsgPush(makeTitlePacket(22222, '恭喜获得头衔'), makeIdentity());
expect(events).toEqual([]);
});
});
describe('parseMsgPush Event0x2DC subType=16 field13=12 (group name change)', () => {
// RE cross-ref: Lagrange Event0x2DCSubType16Field13.GroupNameChangeNotice = 12,
// EventParam(f5) → GroupNameChange{f2=name}, operator = NotifyMessageBody.f21.
// The 0x2DC subType16 body carries a 7-byte prefix (groupUin+byte+len) before
// the NotifyMessageBody proto — the decoder strips it via subarray(7).
type GroupNameChangeEvent = Extract<QQEventVariant, { kind: 'group_name_change' }>;
function makeGroupNamePacket(groupUin: number, operatorUid: string, name: string): PacketInfo {
const notify = protobuf_encode<NotifyMessageBody>({
groupUin,
field13: 12,
operatorUid,
eventParam: protobuf_encode<GroupNameChange>({ name }),
});
const content = new Uint8Array(7 + notify.length); // 7-byte prefix + proto
content.set(notify, 7);
const body = protobuf_encode<PushMsg>({
message: {
responseHead: { fromUin: 0, type: 0, sigMap: 0 },
contentHead: { msgType: 732, subType: 16, timestamp: 1710000000 },
body: { msgContent: content },
},
status: 0,
});
return {
pid: 1,
uin: SELF_UIN,
serviceCmd: MSG_PUSH_CMD,
seqId: 1,
retCode: 0,
fromClient: false,
body,
};
}
it('emits group_name_change with the new name + operator', () => {
const member = makeGroupMember(54321, 'u_operator');
const [event] = parseMsgPush(
makeGroupNamePacket(GROUP_ID, member.uid, '新的群名'),
makeIdentity([member]),
) as GroupNameChangeEvent[];
expect(event.kind).toBe('group_name_change');
expect(event.groupId).toBe(GROUP_ID);
expect(event.name).toBe('新的群名');
expect(event.operatorUin).toBe(54321);
});
it('drops a name-change body with an empty name', () => {
const events = parseMsgPush(
makeGroupNamePacket(GROUP_ID, 'u_operator', ''),
makeIdentity(),
);
expect(events).toEqual([]);
});
});
describe('parseMsgPush Event0x210 subType=139 (self-recall direction)', () => {
// 138 = friend recalled their own message sent to bot
// (peer in friend_recall event = fromUid)
// 139 = bot recalled own message sent to friend
// (peer in friend_recall event = toUid)
// Same wire shape; the subType is the direction discriminator.
// Acidify makes the same split in `parseFriendRecall:380`. Without
// 139, self-recalls used to fall through and disappear.
function makeFriendRecallPacket(
subType: 138 | 139,
fromUid: string,
toUid: string,
): PacketInfo {
const content = protobuf_encode<FriendRecall>({
info: {
fromUid,
toUid,
clientSequence: 4242,
newId: 0n,
time: 1710000000,
random: 0,
pkgNum: 1,
pkgIndex: 0,
divSeq: 1,
},
});
return makeEvent0x210PacketAny(subType, content);
}
it('subType 138 keeps fromUid as the resolved peer (other recalled their own msg to us)', () => {
const friend = makeGroupMember(77777, 'u_friend');
const [event] = parseMsgPush(
makeFriendRecallPacket(138, friend.uid, 'u_self'),
makeIdentity([friend]),
) as FriendRecallEvent[];
expect(event.kind).toBe('friend_recall');
expect(event.userUin).toBe(77777);
expect(event.msgSeq).toBe(4242);
expect(event.clientSeq).toBe(4242);
expect(event.recalledBySelf).toBe(false);
});
it('subType 139 picks toUid as the peer (we recalled our msg sent to friend)', () => {
const friend = makeGroupMember(77777, 'u_friend');
const [event] = parseMsgPush(
makeFriendRecallPacket(139, 'u_self', friend.uid),
makeIdentity([friend]),
) as FriendRecallEvent[];
expect(event.kind).toBe('friend_recall');
expect(event.userUin).toBe(77777); // resolved from toUid (the friend), not fromUid (us)
expect(event.recalledBySelf).toBe(true);
});
});
describe('parseMsgPush PkgType 85 (bot self-joined a group)', () => {
// PkgType 33 (GroupMemberIncreaseNotice) fires for *other* members
// joining; the bot's own join after `set_group_add_request` approval
// / invite-accept arrives at 85 instead. We surface it as
// `group_member_join` with `userUin = selfUin` so the OneBot
// converter naturally produces `notice.group_increase`.
function makeSelfJoinPacket(groupId: number, operatorUid = ''): PacketInfo {
const content = protobuf_encode<SelfJoinInGroup>({
groupUin: BigInt(groupId),
field2: 1,
operatorUid,
field4: 0,
field6: 48,
field7: '',
});
const body = protobuf_encode<PushMsg>({
message: {
responseHead: { fromUin: groupId, type: 0, sigMap: 0 },
contentHead: { msgType: 85, subType: 0, timestamp: 1710000000 },
body: { msgContent: content },
},
status: 0,
});
return {
pid: 1,
uin: SELF_UIN,
serviceCmd: MSG_PUSH_CMD,
seqId: 1,
retCode: 0,
fromClient: false,
body,
};
}
it('emits group_member_join with userUin == selfUin', () => {
const [event] = parseMsgPush(
makeSelfJoinPacket(GROUP_ID),
makeIdentity(),
) as GroupMemberJoin[];
expect(event.kind).toBe('group_member_join');
expect(event.groupId).toBe(GROUP_ID);
expect(event.userUin).toBe(Number(SELF_UIN));
});
it('resolves operator uid through identity when present', () => {
const admin = makeGroupMember(11111, 'u_admin');
const [event] = parseMsgPush(
makeSelfJoinPacket(GROUP_ID, admin.uid),
makeIdentity([admin]),
) as GroupMemberJoin[];
expect(event.operatorUin).toBe(admin.uin);
expect(event.operatorUid).toBe(admin.uid);
});
});
describe('parseMsgPush PkgType 44 (group admin set/unset) keeps the member cache fresh', () => {
// Regression for #93: get_group_member_info serves the cache. Role
// patch is IncomingPacketPipeline observation, not Decoder.
function makeGroupAdminPacket(adminUid: string, set: boolean, fromUin = GROUP_ID): PacketInfo {
const extra = { adminUid };
const content = protobuf_encode<GroupAdmin>({
groupUin: GROUP_ID,
body: set ? { extraEnable: extra } : { extraDisable: extra },
});
const body = protobuf_encode<PushMsg>({
message: {
responseHead: { fromUin },
contentHead: { msgType: 44, timestamp: 1710000000 },
body: { msgContent: content },
},
status: 0,
});
return {
pid: 1,
uin: SELF_UIN,
serviceCmd: MSG_PUSH_CMD,
seqId: 1,
retCode: 0,
fromClient: false,
body,
};
}
async function processAdmin(identity: IdentityService, packet: PacketInfo): Promise<void> {
const pipeline = new IncomingPacketPipeline({
identity,
events: new BridgeEventBus(),
fetchGroupList: vi.fn(async () => {}),
fetchGroupMemberList: vi.fn(async () => {}),
resolveGroupJoinRequest: vi.fn(async () => null),
groupInvites: new GroupInviteTracker(),
});
pipeline.registerCmd(MSG_PUSH_CMD, parseMsgPush);
await pipeline.process(packet);
}
it('Decoder emits the event without writing Identity', () => {
const member = makeGroupMember(22222, 'u_member');
const identity = makeIdentity([member]);
const [event] = parseMsgPush(
makeGroupAdminPacket(member.uid, true),
identity,
) as GroupAdminEvent[];
expect(event.kind).toBe('group_admin');
expect(event.userUin).toBe(member.uin);
expect(event.set).toBe(true);
expect(identity.findGroupMember(GROUP_ID, member.uin)?.role).toBe('member');
});
it('promotes a cached member to admin at the packet-pipeline boundary', async () => {
const member = makeGroupMember(22222, 'u_member');
const identity = makeIdentity([member]);
await processAdmin(identity, makeGroupAdminPacket(member.uid, true));
expect(identity.findGroupMember(GROUP_ID, member.uin)?.role).toBe('admin');
});
it('demotes a cached admin back to member on an unset push', async () => {
const admin = { ...makeGroupMember(22222, 'u_admin'), role: 'admin' };
const identity = makeIdentity([admin]);
await processAdmin(identity, makeGroupAdminPacket(admin.uid, false));
expect(identity.findGroupMember(GROUP_ID, admin.uin)?.role).toBe('member');
});
it('never downgrades the owner', async () => {
const owner = { ...makeGroupMember(22222, 'u_owner'), role: 'owner' };
const identity = makeIdentity([owner]);
await processAdmin(identity, makeGroupAdminPacket(owner.uid, false));
expect(identity.findGroupMember(GROUP_ID, owner.uin)?.role).toBe('owner');
});
it('is a no-op (no throw, no phantom member) when the target is not cached', async () => {
const identity = makeIdentity();
const [event] = parseMsgPush(
makeGroupAdminPacket('u_unknown', true),
identity,
) as GroupAdminEvent[];
await processAdmin(identity, makeGroupAdminPacket('u_unknown', true));
expect(event.kind).toBe('group_admin');
expect(event.userUin).toBe(0);
expect(identity.findGroupMember(GROUP_ID, GROUP_ID)).toBeNull();
});
});