Files
SnowLuma/packages/protocol/tests/msg-push/sysmsg-dedup.test.ts
T
DrSmoothl 1bdb8a40c4 fix(protocol): dedup duplicate system pushes (#137 official-robot double group_increase)
Inviting an official robot (e.g. 2854207029) into a group made QQ push the
group_member_increase notice twice, so SnowLuma emitted two
notice.group_increase events; a normal member produced one.

Root cause (RE of wrapper.linux.node): QQ NT dedups every received system
message in sys_msg_mgr.cc::ProcessRecvSysMsg (sub_37BADE0) by a global_key
`{peerUid}_{chatType}_{msg_seq}_{random}` (sub_37BC4E0) — an already-seen key
is dropped ("ignore dup msg") before any listener sees it. The per-message
discriminators msg_seq (attr 40003 = contentHead field 5) and random (attr
40002 = contentHead field 4) are exactly head.sequence and head.msgId
(msg_header_codec_helper.cc sub_37C8AD0). Kernel-based bots (NapCat/LLOneBot)
receive events after this dedup; SnowLuma reads the raw OlPush before it.

Mirror it: SysMsgDedup, a per-account bounded ring that drops a duplicate
system push by (msgType, subType, fromUin, sequence, msgId). Push-level (drops
the whole duplicate like NT), live path only (Bridge owns the tracker; the
forward re-parse passes none), system kinds only (chat messages excluded), and
never dedups a push lacking a server identity (sequence or msgId 0).
2026-06-23 20:36:24 +08:00

119 lines
5.8 KiB
TypeScript

// Regression for #137: inviting an official robot (e.g. 2854207029) into a
// group makes QQ push the `group_member_increase` notice TWICE, so SnowLuma
// reported two `notice.group_increase` while a normal member produced one.
// QQ NT dedups system messages in `sys_msg_mgr.cc::ProcessRecvSysMsg` by a
// global key whose per-message discriminators are msg_seq (contentHead field 5
// = head.sequence) and random (contentHead field 4 = head.msgId); kernel-based
// bots see events only after that dedup. SnowLuma reads the raw OlPush, so
// parseMsgPush must replicate the drop for system pushes — but only when given
// a dedup tracker (the live path), and never for chat messages.
import { describe, expect, it } from 'vitest';
import type { PacketInfo } from '@snowluma/common/protocol-types';
import { protobuf_encode } from '@snowluma/proton';
import type { PushMsg, PushMsgBody } from '@snowluma/proto-defs/message';
import type { GroupChange } from '@snowluma/proto-defs/notify';
import type { IdentityService } from '../../src/identity-service';
import { parseMsgPush, SysMsgDedup } from '../../src/msg-push';
const identity = { findUinByUid: () => null, findFriend: () => undefined } as unknown as IdentityService;
function pushPacket(message: PushMsgBody): PacketInfo {
return {
pid: 0,
uin: '2000000001',
serviceCmd: 'trpc.msg.olpush.OlPushService.MsgPush',
seqId: 0,
retCode: 0,
fromClient: false,
body: protobuf_encode<PushMsg>({ message }),
};
}
// A type-33 group member-increase push (the #137 shape). `memberUid` is a
// numeric string so resolveUidToUin returns it without touching identity.
function memberIncreasePush(opts: { groupId: number; memberUin: number; sequence: number; msgId: number }): PacketInfo {
return pushPacket({
responseHead: { fromUin: opts.groupId, fromUid: '' },
contentHead: { msgType: 33, subType: 0, sequence: opts.sequence, timestamp: 1781540572, msgId: opts.msgId },
body: { msgContent: protobuf_encode<GroupChange>({ groupUin: opts.groupId, memberUid: String(opts.memberUin) }) },
});
}
describe('SysMsgDedup', () => {
it('flags the second push with the same (msgType, subType, fromUin, seq, msgId)', () => {
const d = new SysMsgDedup();
const head = { msgType: 33, subType: 0, sequence: 500, msgId: 12345 };
expect(d.seenDuplicate(head, 700)).toBe(false);
expect(d.seenDuplicate(head, 700)).toBe(true);
});
it('treats a different seq, msgId, type, or peer as a distinct push', () => {
const d = new SysMsgDedup();
const base = { msgType: 33, subType: 0, sequence: 500, msgId: 12345 };
expect(d.seenDuplicate(base, 700)).toBe(false);
expect(d.seenDuplicate({ ...base, sequence: 501 }, 700)).toBe(false);
expect(d.seenDuplicate({ ...base, msgId: 99999 }, 700)).toBe(false);
expect(d.seenDuplicate({ ...base, msgType: 34 }, 700)).toBe(false);
expect(d.seenDuplicate(base, 701)).toBe(false);
});
it('never dedups a push with no server identity (seq or msgId 0)', () => {
const d = new SysMsgDedup();
expect(d.seenDuplicate({ msgType: 33, subType: 0, sequence: 0, msgId: 0 }, 700)).toBe(false);
expect(d.seenDuplicate({ msgType: 33, subType: 0, sequence: 0, msgId: 0 }, 700)).toBe(false);
expect(d.seenDuplicate({ msgType: 33, subType: 0, sequence: 5, msgId: 0 }, 700)).toBe(false);
expect(d.seenDuplicate({ msgType: 33, subType: 0, sequence: 0, msgId: 5 }, 700)).toBe(false);
});
it('evicts the oldest key once capacity is exceeded (bounded memory)', () => {
const d = new SysMsgDedup(2);
const k = (sequence: number) => ({ msgType: 33, subType: 0, sequence, msgId: 1 });
expect(d.seenDuplicate(k(1), 1)).toBe(false); // [1]
expect(d.seenDuplicate(k(2), 1)).toBe(false); // [1,2]
expect(d.seenDuplicate(k(3), 1)).toBe(false); // [2,3] — evicts 1
expect(d.seenDuplicate(k(1), 1)).toBe(false); // 1 was evicted → seen as new again
expect(d.seenDuplicate(k(3), 1)).toBe(true); // 3 still tracked
});
});
describe('parseMsgPush — system-push dedup (#137)', () => {
it('drops the second identical official-robot member-increase push', () => {
const dedup = new SysMsgDedup();
const push = () => memberIncreasePush({ groupId: 700, memberUin: 2854207029, sequence: 800, msgId: 555 });
const first = parseMsgPush(push(), identity, dedup);
expect(first).toHaveLength(1);
expect(first[0]).toMatchObject({ kind: 'group_member_join', groupId: 700, userUin: 2854207029 });
const second = parseMsgPush(push(), identity, dedup);
expect(second).toEqual([]);
});
it('keeps both pushes when no dedup tracker is supplied (forward re-parse path)', () => {
const push = () => memberIncreasePush({ groupId: 700, memberUin: 2854207029, sequence: 800, msgId: 555 });
expect(parseMsgPush(push(), identity)).toHaveLength(1);
expect(parseMsgPush(push(), identity)).toHaveLength(1);
});
it('keeps distinct member-increase events (different members → different seq/msgId)', () => {
const dedup = new SysMsgDedup();
const a = parseMsgPush(memberIncreasePush({ groupId: 700, memberUin: 111, sequence: 800, msgId: 555 }), identity, dedup);
const b = parseMsgPush(memberIncreasePush({ groupId: 700, memberUin: 222, sequence: 801, msgId: 556 }), identity, dedup);
expect(a).toHaveLength(1);
expect(b).toHaveLength(1);
expect(b[0]).toMatchObject({ userUin: 222 });
});
it('does not dedup chat messages, even with an identical (seq, msgId)', () => {
const dedup = new SysMsgDedup();
const chat = (): PacketInfo => pushPacket({
responseHead: { fromUin: 10001, fromUid: 'u_x' },
contentHead: { msgType: 166, subType: 0, sequence: 900, timestamp: 1781540572, msgId: 7 },
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
});
expect(parseMsgPush(chat(), identity, dedup)).toHaveLength(1);
expect(parseMsgPush(chat(), identity, dedup)).toHaveLength(1);
});
});