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
1094 lines
39 KiB
TypeScript
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();
|
|
});
|
|
});
|