SnowLuma v2.4.2 source snapshot
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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { FileExtra } from '@snowluma/proto-defs/message';
import {
classifyMessageSurvival,
isBlankMessage,
isC2cControlPush,
} from '../../src/msg-push/blank-filter';
const privateHead = { msgType: 166, c2cCmd: 0 };
const controlHead = { msgType: 166, c2cCmd: 75 };
const groupHead = { msgType: 82, c2cCmd: 0 };
describe('classifyMessageSurvival', () => {
it('drops C2C control even when elements decoded', () => {
expect(isC2cControlPush(controlHead)).toBe(true);
expect(
classifyMessageSurvival(controlHead, [{ type: 'text' }], {
richText: { elems: [{ text: { str: 'noise' } }] },
}),
).toBe('drop-control');
});
it('drops a genuinely empty non-control body', () => {
expect(isBlankMessage([], { richText: { elems: [] } })).toBe(true);
expect(classifyMessageSurvival(privateHead, [], { richText: { elems: [] } })).toBe(
'drop-blank',
);
expect(classifyMessageSurvival(groupHead, [], { richText: { elems: [] } })).toBe(
'drop-blank',
);
});
it('keeps an empty decode when the body still carried content', () => {
const body = {
richText: { elems: [{ commonElem: { serviceType: 999, businessType: 0 } }] },
};
expect(isBlankMessage([], body)).toBe(false);
expect(classifyMessageSurvival(privateHead, [], body)).toBe('keep-undecoded');
});
it('keeps a message that decoded to elements', () => {
expect(
classifyMessageSurvival(privateHead, [{ type: 'text' }], {
richText: { elems: [{ text: { str: 'hi' } }] },
}),
).toBe('keep');
});
it('drops an offline-file download receipt even if a file element decoded (#442)', () => {
const body = {
msgContent: protobuf_encode<FileExtra>({
file: {
fileUuid: 'fid-sent-by-bot',
fileName: 'doc.bin',
fileSize: 6119608n,
subcmd: 1,
downloadFlag: 2,
},
}),
};
expect(classifyMessageSurvival(privateHead, [{ type: 'file' }], body)).toBe('drop-receipt');
});
it('classifies subcommand receipts consistently for history and push consumers (#477)', () => {
const file = {
fileType: 1,
subcmd: 2,
fileUuid: 'already-sent',
fileName: 'test.json',
fileSize: 42n,
};
for (const body of [
{ msgContent: protobuf_encode<FileExtra>({ file }) },
{ richText: { notOnlineFile: file } },
]) {
expect(classifyMessageSurvival(privateHead, [{ type: 'file' }], body)).toBe(
'drop-receipt',
);
}
expect(
classifyMessageSurvival(privateHead, [{ type: 'file' }], {
richText: { notOnlineFile: { ...file, fileType: 3 } },
}),
).toBe('keep');
});
});
@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest';
import {
protobuf_encode,
type bool,
type pb,
type pb_repeated,
type uint_32,
type uint_64,
} from '@snowluma/proton';
import type { MessageBody } from '@snowluma/proto-defs/message';
import { assertValidMessageElement } from '../../src/element-manifest';
import { decodeRichBody } from '../../src/msg-push/rich-body-decoder';
interface MarkdownFixture {
content?: pb<1, string>;
processMsg?: pb<4, string>;
summary?: pb<5, string>;
extType?: pb<6, uint_32>;
}
interface KeyboardPermissionFixture {
type?: pb<1, uint_32>;
roleIds?: pb_repeated<2, string>;
userIds?: pb_repeated<3, string>;
}
interface KeyboardActionFixture {
type?: pb<1, uint_32>;
permission?: pb<2, KeyboardPermissionFixture>;
clickLimit?: pb<3, uint_32>;
unsupportedTips?: pb<4, string>;
data?: pb<5, string>;
atBotShowChannelList?: pb<6, bool>;
reply?: pb<7, bool>;
enter?: pb<8, bool>;
anchor?: pb<9, uint_32>;
}
interface KeyboardRenderFixture {
label?: pb<1, string>;
visitedLabel?: pb<2, string>;
style?: pb<3, uint_32>;
}
interface KeyboardButtonFixture {
id?: pb<1, string>;
render?: pb<2, KeyboardRenderFixture>;
action?: pb<3, KeyboardActionFixture>;
}
interface KeyboardRowFixture {
buttons?: pb_repeated<1, KeyboardButtonFixture>;
}
interface KeyboardFixture {
rows?: pb_repeated<1, KeyboardRowFixture>;
botAppid?: pb<2, uint_64>;
}
interface KeyboardExtraFixture {
keyboard?: pb<1, KeyboardFixture>;
}
function commonBody(serviceType: number, businessType: number, pbElem: Uint8Array): MessageBody {
return {
richText: {
elems: [{ commonElem: { serviceType, businessType, pbElem } } as never],
},
};
}
describe('decodeRichBody / bot markdown (#337)', () => {
it('emits the original markdown and removes its plain-text compatibility sibling', () => {
const pbElem = protobuf_encode<MarkdownFixture>({
content: '# Weather\n**Sunny**',
processMsg: 'Weather: Sunny',
summary: 'Weather',
extType: 3,
});
const body: MessageBody = {
richText: {
elems: [
{ text: { str: 'Weather: Sunny' } } as never,
{ commonElem: { serviceType: 45, businessType: 1, pbElem } } as never,
],
},
};
expect(decodeRichBody(body, true)).toEqual([
{ type: 'markdown', text: '# Weather\n**Sunny**' },
]);
});
it('decodes the current extended bot-markdown business type', () => {
const pbElem = protobuf_encode<MarkdownFixture>({ content: '## Agent answer' });
expect(decodeRichBody(commonBody(45, 4, pbElem), true)).toEqual([
{ type: 'markdown', text: '## Agent answer' },
]);
});
it('keeps fallback text when the markdown protobuf is malformed', () => {
const body: MessageBody = {
richText: {
elems: [
{ text: { str: 'readable fallback' } } as never,
{
commonElem: {
serviceType: 45,
businessType: 1,
pbElem: Uint8Array.of(0x0a, 0x05, 0x41),
},
} as never,
],
},
};
expect(decodeRichBody(body, true)).toEqual([{ type: 'text', text: 'readable fallback' }]);
});
});
describe('decodeRichBody / inline keyboard (#337)', () => {
const pbElem = protobuf_encode<KeyboardExtraFixture>({
keyboard: {
botAppid: 9_007_199_254_740_993n,
rows: [
{
buttons: [
{
id: 'btn-1',
render: { label: 'Run', visitedLabel: 'Done', style: 1 },
action: {
type: 2,
permission: { type: 2, roleIds: ['admin'], userIds: ['u_10001'] },
clickLimit: 3,
unsupportedTips: 'Admins only',
data: 'callback-data',
atBotShowChannelList: true,
reply: false,
enter: true,
anchor: 9,
},
},
],
},
],
},
});
it.each([46, 50, 51])(
'decodes service %i business 1 without losing uint64 app id',
(serviceType) => {
const elements = decodeRichBody(commonBody(serviceType, 1, pbElem), true);
expect(elements).toEqual([
{
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(() => assertValidMessageElement(elements[0], 'D')).not.toThrow();
},
);
it('does not reinterpret an unsupported business type as a keyboard', () => {
expect(decodeRichBody(commonBody(46, 2, pbElem), true)).toEqual([]);
});
});
@@ -0,0 +1,345 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CLIENT_MESSAGE_RECEIPT_CMD, type PacketInfo } from '@snowluma/common/protocol-types';
import type { PacketSender, SendPacketResult } from '@snowluma/common/packet-sender';
import type { SendMessageResponse } from '@snowluma/proto-defs/action';
import type { SsoGetC2cMsg, SsoGetC2cMsgResponse } from '@snowluma/proto-defs/get-c2c-msg';
import type { PushMsgBody } from '@snowluma/proto-defs/message';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import { IdentityService } from '../../src/identity-service';
import {
decodeClientMessageReceipt,
fetchClientMessage,
} from '../../src/msg-push/client-message-receipt';
import { SSO_GET_C2C_MSG_CMD } from '../../src/msg-push/fetch-c2c-history';
const self = '10001';
const peer = 'u_test_peer';
const time = 1_700_000_000;
const identities: IdentityService[] = [];
afterEach(async () => {
vi.useRealTimers();
await Promise.all(identities.splice(0).map((identity) => identity.close()));
});
function packet(ack: SendMessageResponse = { privateSequence: 87, timestamp1: time }): PacketInfo {
const uid = Buffer.from(peer);
const header = Buffer.alloc(8);
header[0] = 1;
header[1] = uid.length;
header.writeUInt32LE(9, 4);
return {
pid: 1234,
uin: self,
seqId: 42,
retCode: 0,
fromClient: false,
source: 'client-message',
serviceCmd: CLIENT_MESSAGE_RECEIPT_CMD,
body: Buffer.concat([header, uid, protobuf_encode<SendMessageResponse>(ack)]),
};
}
function message(): PushMsgBody {
return {
responseHead: { fromUin: Number(self), fromUid: 'u_test_self', toUin: 20002, toUid: peer },
contentHead: { msgType: 166, sequence: 9, ntMsgSeq: 87, timestamp: time, msgId: 123456 },
body: { richText: { elems: [{ text: { str: 'hello' } }, { face: { index: 1 } }] } },
};
}
function lookupResponse(response: SsoGetC2cMsgResponse): SendPacketResult {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(protobuf_encode<SsoGetC2cMsgResponse>(response)),
};
}
function setup(messages = [message()], friendUid = peer) {
const identity = IdentityService.memory(self);
identities.push(identity);
const sendPacket = vi.fn<PacketSender['sendPacket']>(async () => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(protobuf_encode<SsoGetC2cMsgResponse>({ friendUid, messages })),
}));
const receipt = decodeClientMessageReceipt(packet(), self);
return {
sendPacket,
identity,
receipt,
run: () => fetchClientMessage({ sendPacket }, identity, receipt),
};
}
describe('client message receipts', () => {
it('decodes a confirmation and retrieves exactly its conversation and sequence', async () => {
const ctx = setup();
expect(ctx.receipt).toEqual({
peerUid: peer,
clientSequence: 9,
sequence: 87,
timestamp: time,
});
const event = await ctx.run();
expect(ctx.sendPacket).toHaveBeenCalledOnce();
const [cmd, body] = ctx.sendPacket.mock.calls[0]!;
expect(cmd).toBe(SSO_GET_C2C_MSG_CMD);
expect(protobuf_decode<SsoGetC2cMsg>(body)).toMatchObject({
friendUid: peer,
startSequence: 87,
endSequence: 87,
});
expect(event).toMatchObject({
kind: 'friend_message',
selfUin: 10001,
senderUin: 10001,
peerUin: 20002,
clientSeq: 9,
ntMsgSeq: 87,
sequenceAuthoritative: true,
time,
elements: [
{ type: 'text', text: 'hello' },
{ type: 'face', faceId: 1 },
],
});
});
it.each([
{ source: undefined },
{ serviceCmd: 'MessageSvc.PbSendMsg' },
{ uin: '20002' },
{ retCode: -1 },
] satisfies Partial<PacketInfo>[])('rejects invalid provenance or status: %j', (patch) => {
expect(() => decodeClientMessageReceipt({ ...packet(), ...patch }, self)).toThrow();
});
it.each([
{ result: 1 },
{ reviewResult: 1 },
{ serverResult: { code: -1 } },
{ privateSequence: 0 },
{ timestamp1: 0 },
{ groupSequence: 5 },
] satisfies SendMessageResponse[])(
'rejects an unsuccessful or incomplete confirmation: %j',
(patch) => {
expect(() =>
decodeClientMessageReceipt(
packet({ privateSequence: 87, timestamp1: time, ...patch }),
self,
),
).toThrow();
},
);
it.each([
[
'version',
(body: Buffer) => {
body[0] = 2;
},
],
[
'reserved',
(body: Buffer) => {
body[2] = 1;
},
],
[
'uid length',
(body: Buffer) => {
body[1] = 129;
},
],
[
'uid',
(body: Buffer) => {
body[8] = 0xff;
},
],
[
'client sequence',
(body: Buffer) => {
body.writeUInt32LE(0, 4);
},
],
] as const)('rejects a malformed %s', (_name, mutate) => {
const pkt = packet();
const body = Buffer.from(pkt.body);
mutate(body);
expect(() => decodeClientMessageReceipt({ ...pkt, body }, self)).toThrow();
});
it('rejects truncated and excessive bodies', () => {
for (const body of [
new Uint8Array(),
packet().body.subarray(0, 8 + peer.length),
new Uint8Array(8 + 128 + 65537),
]) {
expect(() => decodeClientMessageReceipt({ ...packet(), body }, self)).toThrow();
}
});
it.each([
[
'sender',
(msg: PushMsgBody) => {
msg.responseHead!.fromUin = 20002;
},
],
[
'recipient',
(msg: PushMsgBody) => {
msg.responseHead!.toUid = 'u_other';
},
],
[
'missing recipient',
(msg: PushMsgBody) => {
delete msg.responseHead!.toUin;
},
],
[
'client sequence',
(msg: PushMsgBody) => {
msg.contentHead!.sequence = 10;
},
],
[
'server sequence',
(msg: PushMsgBody) => {
msg.contentHead!.ntMsgSeq = 88;
},
],
[
'timestamp',
(msg: PushMsgBody) => {
msg.contentHead!.timestamp = time + 1;
},
],
[
'group',
(msg: PushMsgBody) => {
msg.responseHead!.grp = { groupCode: 30003 };
},
],
[
'type',
(msg: PushMsgBody) => {
msg.contentHead!.msgType = 82;
},
],
[
'empty content',
(msg: PushMsgBody) => {
msg.body = {};
},
],
[
'unknown content',
(msg: PushMsgBody) => {
msg.body = { richText: { elems: [{ commonElem: { serviceType: 999 } }] } };
},
],
[
'control',
(msg: PushMsgBody) => {
msg.contentHead!.c2cCmd = 1;
},
],
] as const)('rejects an incorrect %s in the lookup', async (_name, mutate) => {
const msg = message();
mutate(msg);
await expect(setup([msg]).run()).rejects.toThrow();
});
it('rejects missing, ambiguous and cross-conversation results', async () => {
await expect(setup([]).run()).rejects.toThrow('expected one');
await expect(setup([message(), message()]).run()).rejects.toThrow('expected one');
await expect(setup([message()], 'u_other').run()).rejects.toThrow('conversation');
});
it('retries only the confirmed message while storage catches up', async () => {
const ctx = setup();
vi.useFakeTimers();
ctx.sendPacket.mockResolvedValueOnce(
lookupResponse({ result: 100000301, friendUid: peer, messages: [] }),
);
const work = ctx.run();
await vi.advanceTimersByTimeAsync(99);
expect(ctx.sendPacket).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(await work).toMatchObject({ ntMsgSeq: 87, clientSeq: 9 });
expect(ctx.sendPacket).toHaveBeenCalledTimes(2);
expect(ctx.sendPacket.mock.calls[1]).toEqual(ctx.sendPacket.mock.calls[0]);
});
it('reports a bounded failure when storage never contains the confirmed message', async () => {
const ctx = setup();
vi.useFakeTimers();
ctx.sendPacket.mockResolvedValue(
lookupResponse({ result: 100000301, friendUid: peer, messages: [] }),
);
const rejected = expect(ctx.run()).rejects.toThrow('still unavailable after 6 attempts');
await vi.runAllTimersAsync();
await rejected;
expect(ctx.sendPacket).toHaveBeenCalledTimes(6);
});
it.each([
{ result: 100000302, friendUid: peer, messages: [] },
{ result: 100000301, friendUid: 'u_other', messages: [] },
{ result: 100000301, messages: [] },
{ result: 100000301, friendUid: peer, messages: [message()] },
] satisfies SsoGetC2cMsgResponse[])(
'does not retry unrelated or inconsistent errors: %j',
async (response) => {
const ctx = setup();
ctx.sendPacket.mockResolvedValue(lookupResponse(response));
await expect(ctx.run()).rejects.toThrow();
expect(ctx.sendPacket).toHaveBeenCalledOnce();
},
);
it('preserves distinct identities for equal text', async () => {
const first = setup();
const msg = message();
msg.contentHead = { ...msg.contentHead, sequence: 10, ntMsgSeq: 88 };
const second = setup([msg]);
const event = await fetchClientMessage(second, second.identity, {
...second.receipt,
clientSequence: 10,
sequence: 88,
});
expect((await first.run()).ntMsgSeq).toBe(87);
expect(event.ntMsgSeq).toBe(88);
expect(event.elements).toEqual((await first.run()).elements);
});
it.each([
{ success: false, gotResponse: false, responseData: null },
{ success: true, gotResponse: false, responseData: null },
{ success: true, gotResponse: true, responseData: Buffer.alloc(0) },
] satisfies Partial<SendPacketResult>[])(
'surfaces unsuccessful lookup transport: %j',
async (result) => {
const ctx = setup();
ctx.sendPacket.mockResolvedValue({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: null,
...result,
});
await expect(ctx.run()).rejects.toThrow('Client message lookup');
},
);
});
@@ -0,0 +1,287 @@
// Regression for #102: a group invite makes QQ emit a content-less c2c push
// (msgType=166/0, body.richText.elems=[]) right after the invite card. That
// phantom used to surface to OneBot clients as a confusing "[空消息]".
// parseMsgPush must drop a message that decodes to zero elements *when its body
// is genuinely empty*, while still keeping (and warning about) a message that
// carried content we merely failed to decode.
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
import type { PacketInfo } from '@snowluma/common/protocol-types';
import { protobuf_encode } from '@snowluma/proton';
import type { FileExtra, PushMsg, PushMsgBody } from '@snowluma/proto-defs/message';
import { afterEach, describe, expect, it } from 'vitest';
import type { IdentityService } from '../../src/identity-service';
import { parseMsgPush } from '../../src/msg-push';
const identity = { findFriend: () => undefined } as unknown as IdentityService;
const previousLogLevel = getLogLevel();
afterEach(() => {
setLogLevel(previousLogLevel);
});
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 }),
};
}
function traceMessages(entries: LogEntry[]): string[] {
return entries.filter((entry) => entry.level === 'trace').map((entry) => entry.message);
}
describe('parseMsgPush — empty message drop (#102)', () => {
it('records an invalid push context before returning no events', () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
try {
const pkt = pushPacket({
responseHead: { fromUin: 10001, fromUid: 'u_x' },
});
expect(parseMsgPush(pkt, identity)).toEqual([]);
expect(traceMessages(entries)).toContain(
'packet_branch serviceCmd="trpc.msg.olpush.OlPushService.MsgPush" seqId=0 branch=push_context_invalid',
);
} finally {
unsubscribe();
}
});
it('records the precise branch when dropping a genuinely empty message', () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
try {
const out = parseMsgPush(
pushPacket({
responseHead: { fromUin: 10001, fromUid: 'u_x' },
contentHead: {
msgType: 166,
subType: 0,
sequence: 59962,
timestamp: 1781540572,
msgId: 1963990184,
},
body: { richText: { elems: [] } },
}),
identity,
);
expect(out).toEqual([]);
expect(traceMessages(entries)).toContain(
'packet_branch serviceCmd="trpc.msg.olpush.OlPushService.MsgPush" seqId=0 branch=empty_message msgType=166 subType=0 messageSeq=59962',
);
} finally {
unsubscribe();
}
});
it('keeps a friend message that actually has content', () => {
const out = parseMsgPush(
pushPacket({
responseHead: { fromUin: 10001, fromUid: 'u_x' },
contentHead: {
msgType: 166,
subType: 0,
sequence: 32743,
ntMsgSeq: 63214,
timestamp: 1781540572,
msgId: 1,
},
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
}),
identity,
);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
kind: 'friend_message',
senderUid: 'u_x',
msgSeq: 32743,
ntMsgSeq: 63214,
clientSeq: 32743,
elements: [{ type: 'text', text: 'hi' }],
});
});
it('drops an offline-file download receipt instead of a friend file message (#442)', () => {
const extra = protobuf_encode<FileExtra>({
file: {
fileUuid: 'fid-sent-by-bot',
fileName: 'doc.bin',
fileSize: 6119608n,
subcmd: 1,
downloadFlag: 2,
fileHash: 'abc',
},
});
const out = parseMsgPush(
pushPacket({
responseHead: { fromUin: 10001, fromUid: 'u_x' },
contentHead: { msgType: 529, subType: 0, sequence: 1, timestamp: 1, msgId: 1 },
body: { msgContent: extra },
}),
identity,
);
expect(out).toEqual([]);
});
it('keeps a real incoming c2c file (#442)', () => {
const extra = protobuf_encode<FileExtra>({
file: {
fileType: 1,
fileUuid: 'fid-from-user',
fileName: 'doc.bin',
fileSize: 10n,
subcmd: 1,
},
});
const out = parseMsgPush(
pushPacket({
responseHead: { fromUin: 10001, fromUid: 'u_x' },
contentHead: { msgType: 529, subType: 0, sequence: 2, timestamp: 1, msgId: 2 },
body: { msgContent: extra },
}),
identity,
);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
kind: 'friend_message',
elements: [{ type: 'file', fileId: 'fid-from-user', fileName: 'doc.bin' }],
});
});
it.each(['msgContent', 'richText'] as const)(
'drops a subcommand receipt in %s without a download flag (#477)',
(location) => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const file = {
fileType: 1,
subcmd: 2,
fileUuid: 'already-sent',
fileName: 'test.json',
fileSize: 42n,
};
try {
const out = parseMsgPush(
pushPacket({
responseHead: { fromUin: 10001, fromUid: 'u_x' },
contentHead: {
msgType: 529,
subType: 4,
c2cCmd: 4,
sequence: 10,
timestamp: 1,
msgId: 1,
},
body:
location === 'msgContent'
? { msgContent: protobuf_encode<FileExtra>({ file }) }
: { richText: { notOnlineFile: file } },
}),
identity,
);
expect(out).toEqual([]);
expect(
traceMessages(entries).some((message) =>
message.includes('branch=offline_file_receipt'),
),
).toBe(true);
} finally {
unsubscribe();
}
},
);
it('records the precise branch for a C2C control push with content', () => {
// 166 + c2c_cmd ∈ {1,73,75,129,131,133,135,192} is a control/system signal
// QQ NT routes via OnRecvSysMsg, never a bubble — drop regardless of body.
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
try {
const out = parseMsgPush(
pushPacket({
responseHead: { fromUin: 10001, fromUid: 'u_x' },
contentHead: {
msgType: 166,
subType: 0,
c2cCmd: 75,
sequence: 200,
timestamp: 1781540572,
msgId: 3,
},
body: { richText: { elems: [{ text: { str: 'noise' } }] } },
}),
identity,
);
expect(out).toEqual([]);
expect(traceMessages(entries)).toContain(
'packet_branch serviceCmd="trpc.msg.olpush.OlPushService.MsgPush" seqId=0 branch=c2c_control_push msgType=166 subType=0 c2cCmd=75 messageSeq=200',
);
} finally {
unsubscribe();
}
});
it('keeps a normal 166 message whose c2cCmd is not a control command', () => {
const out = parseMsgPush(
pushPacket({
responseHead: { fromUin: 10001, fromUid: 'u_x' },
contentHead: {
msgType: 166,
subType: 0,
c2cCmd: 0,
sequence: 201,
timestamp: 1781540572,
msgId: 4,
},
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
}),
identity,
);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
kind: 'friend_message',
elements: [{ type: 'text', text: 'hi' }],
});
});
it('keeps an empty-decoded message whose body still carried content (missing decoder)', () => {
// A commonElem service type the rich-body decoder ignores → 0 elements, but
// the body was not empty, so the message must survive (and be warned about),
// not be silently dropped.
const out = parseMsgPush(
pushPacket({
responseHead: { fromUin: 10001, fromUid: 'u_x' },
contentHead: {
msgType: 166,
subType: 0,
sequence: 101,
timestamp: 1781540572,
msgId: 2,
},
body: {
richText: { elems: [{ commonElem: { serviceType: 999, businessType: 0 } }] },
},
}),
identity,
);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ kind: 'friend_message', elements: [] });
});
});
@@ -0,0 +1,495 @@
// Round-trips the SsoGetC2cMsg request/response protos (proton codegen + field
// tags) and the fetched private-history decode path (each returned PushMsgBody
// re-uses the regular friend decoder).
import { deflateSync } from 'node:zlib';
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
import { afterEach, describe, expect, it } from 'vitest';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type {
SsoGetC2cMsg,
SsoGetC2cMsgResponse,
SsoGetRoamMsg,
SsoGetRoamMsgResponse,
} from '@snowluma/proto-defs/get-c2c-msg';
import type { PushMsgBody } from '@snowluma/proto-defs/message';
import type { IdentityService } from '../../src/identity-service';
import { buildContextFromMessage } from '../../src/msg-push/context';
import { decodeFriendMessage } from '../../src/msg-push/decoders/friend-message';
import {
MSG_PUSH_CMD,
SSO_GET_C2C_MSG_CMD,
SSO_GET_ROAM_MSG_CMD,
fetchC2cMessageRange,
fetchC2cRoamMessagePage,
parseMsgPushOrThrow,
} from '../../src/msg-push';
import { m, s, v } from '../oidb-services/_pb-oracle';
const identity = { findFriend: () => undefined } as unknown as IdentityService;
const previousLogLevel = getLogLevel();
afterEach(() => {
setLogLevel(previousLogLevel);
});
function okResult(data: Uint8Array): SendPacketResult {
return { success: true, gotResponse: true, responseData: data } as SendPacketResult;
}
describe('private auto replies', () => {
it.each([
{ name: 'missing flag', flag: [], text: '稍后联系', expected: undefined },
{ name: 'explicit zero', flag: v(10, 0), text: '[自动回复] 稍后联系', expected: undefined },
{ name: 'set flag', flag: v(10, 1), text: '稍后联系', expected: true },
{ name: 'other nonzero flag', flag: v(10, 2), text: '[自动回复] 稍后联系', expected: true },
])(
'decodes $name from a live push and both history responses',
async ({ flag, text, expected }) => {
const head = [...v(1, 166), ...v(5, 7), ...flag, ...v(11, 42)];
const textElement = m(1, s(1, text));
const wireMessage = [
...m(1, v(1, 222)),
...m(2, head),
...m(3, m(1, m(2, textElement))),
];
const live = parseMsgPushOrThrow(
{
pid: 0,
uin: '10001',
serviceCmd: MSG_PUSH_CMD,
seqId: 1,
retCode: 0,
fromClient: false,
body: Buffer.from(m(1, wireMessage)),
},
identity,
);
// Both history endpoints carry repeated messages in field 7.
const sender = { sendRawPacket: async () => okResult(Buffer.from(m(7, wireMessage))) };
const range = await fetchC2cMessageRange(sender, identity, 10001, 'u_friend', 42, 42);
const roam = await fetchC2cRoamMessagePage(sender, identity, 10001, 'u_friend', 100, 1);
for (const messages of [live, range, roam.messages]) {
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
kind: 'friend_message',
elements: [{ type: 'text', text }],
});
const [event] = messages;
expect(event.kind === 'friend_message' && event.autoReply).toBe(expected);
}
},
);
});
function inviteCardMessage(sequence: number): PushMsgBody {
const json = JSON.stringify({
app: 'com.tencent.qun.invite',
meta: {
news: { jumpUrl: `mqqapi://group/invite_join?groupcode=12345&msgseq=${sequence}` },
},
});
const compressed = deflateSync(Buffer.from(json));
const data = new Uint8Array(compressed.length + 1);
data[0] = 1;
data.set(compressed, 1);
return {
responseHead: { fromUin: 222, forward: { friendName: 'Bob' } },
contentHead: {
msgType: 166,
sequence,
ntMsgSeq: sequence,
timestamp: 1_700_000_000 + sequence,
msgId: sequence,
},
body: { richText: { elems: [{ lightApp: { data } }] } },
};
}
describe('fetchC2cMessageRange / SsoGetC2cMsg', () => {
it('sends the right command and request field tags', async () => {
let captured: { cmd: string; body: Uint8Array } | null = null;
const sender = {
sendRawPacket: async (cmd: string, body: Uint8Array) => {
captured = { cmd, body };
return okResult(
protobuf_encode<SsoGetC2cMsgResponse>({ friendUid: 'u_friend', messages: [] }),
);
},
};
await fetchC2cMessageRange(sender, identity, 10001, 'u_friend', 100, 120);
expect(captured!.cmd).toBe(SSO_GET_C2C_MSG_CMD);
const req = protobuf_decode<SsoGetC2cMsg>(captured!.body);
expect(req.friendUid).toBe('u_friend');
expect(req.startSequence).toBe(100);
expect(req.endSequence).toBe(120);
});
it('decodes returned friend messages (seq/sender/self uin), oldest→newest', async () => {
const resp = protobuf_encode<SsoGetC2cMsgResponse>({
friendUid: 'u_friend',
messages: [
{
responseHead: { fromUin: 222, forward: { friendName: 'Bob' } },
contentHead: {
msgType: 166,
sequence: 9120,
ntMsgSeq: 120,
timestamp: 1700000120,
msgId: 7120,
},
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
},
{
responseHead: { fromUin: 111, forward: { friendName: 'Alice' } },
contentHead: {
msgType: 166,
sequence: 9110,
ntMsgSeq: 110,
timestamp: 1700000110,
msgId: 7110,
},
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
},
{
responseHead: { fromUin: 10001, toUin: 333, forward: { friendName: 'Self' } },
contentHead: {
msgType: 166,
sequence: 9115,
ntMsgSeq: 115,
timestamp: 1700000115,
msgId: 7115,
},
body: { richText: { elems: [{ text: { str: 'sent' } }] } },
},
],
});
const sender = { sendRawPacket: async () => okResult(resp) };
const out = await fetchC2cMessageRange(sender, identity, 10001, 'u_friend', 100, 120);
expect(out.map((m) => m.ntMsgSeq)).toEqual([110, 115, 120]); // sorted by server sequence
expect(out.every((m) => m.kind === 'friend_message')).toBe(true);
expect(out.every((m) => m.selfUin === 10001)).toBe(true);
expect(out[0]).toMatchObject({
msgSeq: 9110,
ntMsgSeq: 110,
clientSeq: 9110,
senderUin: 111,
senderNick: 'Alice',
});
expect(out[1]).toMatchObject({
msgSeq: 9115,
ntMsgSeq: 115,
clientSeq: 9115,
senderUin: 10001,
peerUin: 333,
senderNick: 'Self',
});
expect(out[2]).toMatchObject({
msgSeq: 9120,
ntMsgSeq: 120,
clientSeq: 9120,
senderUin: 222,
senderNick: 'Bob',
});
});
it('drops content-less blank messages, keeps real ones (#102 parity)', async () => {
const resp = protobuf_encode<SsoGetC2cMsgResponse>({
friendUid: 'u_friend',
messages: [
{
// genuinely-blank control push (the invite phantom) → dropped
responseHead: { fromUin: 111, forward: { friendName: 'Alice' } },
contentHead: {
msgType: 166,
sequence: 9110,
ntMsgSeq: 110,
timestamp: 1700000110,
msgId: 7110,
},
body: { richText: { elems: [] } },
},
{
// real message → kept
responseHead: { fromUin: 222, forward: { friendName: 'Bob' } },
contentHead: {
msgType: 166,
sequence: 9120,
ntMsgSeq: 120,
timestamp: 1700000120,
msgId: 7120,
},
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
},
],
});
const out = await fetchC2cMessageRange(
{ sendRawPacket: async () => okResult(resp) },
identity,
10001,
'u_friend',
100,
120,
);
expect(out.map((m) => m.ntMsgSeq)).toEqual([120]);
expect(out[0]).toMatchObject({ senderUin: 222, elements: [{ type: 'text', text: 'hi' }] });
});
it('keeps and warns on a body that had content but decoded to no elements', async () => {
const resp = protobuf_encode<SsoGetC2cMsgResponse>({
friendUid: 'u_friend',
messages: [
{
responseHead: { fromUin: 222, forward: { friendName: 'Bob' } },
contentHead: {
msgType: 166,
sequence: 9120,
ntMsgSeq: 120,
timestamp: 1700000120,
msgId: 7120,
},
body: {
richText: {
elems: [{ commonElem: { serviceType: 999, businessType: 0 } }],
},
},
},
],
});
const warnings: string[] = [];
setLogLevel('warn');
const unsubscribe = subscribeLogs((entry: LogEntry) => {
if (entry.level === 'warn') {
warnings.push(entry.message);
}
});
try {
const out = await fetchC2cMessageRange(
{ sendRawPacket: async () => okResult(resp) },
identity,
10001,
'u_friend',
100,
120,
);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ kind: 'friend_message', ntMsgSeq: 120, elements: [] });
expect(warnings.some((message) => message.includes('missing decoder'))).toBe(true);
} finally {
unsubscribe();
}
});
it('rejects a visible message without the canonical NT sequence', async () => {
const resp = protobuf_encode<SsoGetC2cMsgResponse>({
friendUid: 'u_friend',
messages: [
{
responseHead: { fromUin: 222, forward: { friendName: 'Bob' } },
contentHead: {
msgType: 166,
sequence: 9120,
timestamp: 1700000120,
msgId: 7120,
},
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
},
],
});
await expect(
fetchC2cMessageRange(
{ sendRawPacket: async () => okResult(resp) },
identity,
10001,
'u_friend',
100,
120,
),
).rejects.toThrow('canonical NT sequence');
});
it('surfaces a failed packet, while invalid input is rejected before sending', async () => {
const failSender = {
sendRawPacket: async () => ({ success: false, gotResponse: false }) as SendPacketResult,
};
await expect(
fetchC2cMessageRange(failSender, identity, 10001, 'u_x', 100, 120),
).rejects.toThrow('SsoGetC2cMsg');
let sent = false;
const guardSender = {
sendRawPacket: async () => {
sent = true;
return okResult(new Uint8Array());
},
};
expect(await fetchC2cMessageRange(guardSender, identity, 10001, '', 100, 120)).toEqual([]); // empty uid
expect(await fetchC2cMessageRange(guardSender, identity, 10001, 'u_x', 200, 100)).toEqual(
[],
); // start > end
expect(sent).toBe(false);
});
it('rejects a response attributed to a different private peer', async () => {
const response = protobuf_encode<SsoGetC2cMsgResponse>({
friendUid: 'u_other',
messages: [],
});
await expect(
fetchC2cMessageRange(
{ sendRawPacket: async () => okResult(response) },
identity,
10001,
'u_friend',
100,
120,
),
).rejects.toThrow('friend uid mismatch');
});
it('parses invite-card facts on friend messages without writing Identity', async () => {
const live = buildContextFromMessage(inviteCardMessage(222), 10001, identity, false);
expect(live).not.toBeNull();
expect(decodeFriendMessage(live!)).toMatchObject([
{
kind: 'friend_message',
inviteCardGroupUin: 12345,
inviteCardSequence: 222,
},
]);
const historyResponse = protobuf_encode<SsoGetC2cMsgResponse>({
friendUid: 'u_friend',
messages: [inviteCardMessage(111)],
});
const out = await fetchC2cMessageRange(
{ sendRawPacket: async () => okResult(historyResponse) },
identity,
10001,
'u_friend',
100,
120,
);
expect(out).toMatchObject([
{
kind: 'friend_message',
inviteCardGroupUin: 12345,
inviteCardSequence: 111,
},
]);
});
});
describe('fetchC2cRoamMessagePage / SsoGetRoamMsg', () => {
it('rejects business errors before interpreting an empty history page', async () => {
const response = Buffer.from([...v(1, 123), ...s(2, 'request rejected')]);
await expect(
fetchC2cRoamMessagePage(
{ sendRawPacket: async () => okResult(response) },
identity,
10001,
'u_friend',
1700000100,
200,
),
).rejects.toThrow('result=123 request rejected');
});
it.each([
{ bytes: [...v(1, 100000301)], cursor: { time: 0, random: 0 } },
{
bytes: [...s(3, 'u_friend'), ...v(4, 1), ...v(5, 1700000000), ...v(6, 77)],
cursor: { time: 1700000000, random: 77 },
},
])('preserves a server-confirmed end of history', async ({ bytes, cursor }) => {
const result = await fetchC2cRoamMessagePage(
{ sendRawPacket: async () => okResult(Buffer.from(bytes)) },
identity,
10001,
'u_friend',
1700000100,
20,
);
expect(result).toEqual({ messages: [], cursor, isComplete: true });
});
it('encodes the timestamp cursor and orders both sides by canonical NT sequence', async () => {
let captured: { cmd: string; body: Uint8Array } | null = null;
const response = protobuf_encode<SsoGetRoamMsgResponse>({
friendUid: 'u_friend',
timestamp: 1700000000,
random: 77,
messages: [
{
responseHead: { fromUin: 10001, toUin: 222, forward: { friendName: 'Self' } },
contentHead: {
msgType: 166,
sequence: 32743,
ntMsgSeq: 63214,
timestamp: 1700000001,
msgId: 20,
},
body: { richText: { elems: [{ text: { str: 'out' } }] } },
},
{
responseHead: { fromUin: 222, forward: { friendName: 'Bob' } },
contentHead: {
msgType: 166,
sequence: 32742,
ntMsgSeq: 63213,
timestamp: 1700000002,
msgId: 10,
},
body: { richText: { elems: [{ text: { str: 'in' } }] } },
},
],
});
const sender = {
sendRawPacket: async (cmd: string, body: Uint8Array) => {
captured = { cmd, body };
return okResult(response);
},
};
const page = await fetchC2cRoamMessagePage(
sender,
identity,
10001,
'u_friend',
1700000100,
20,
9,
);
expect(captured!.cmd).toBe(SSO_GET_ROAM_MSG_CMD);
const request = protobuf_decode<SsoGetRoamMsg>(captured!.body);
expect(request).toMatchObject({
friendUid: 'u_friend',
time: 1700000100,
random: 9,
count: 20,
direction: true,
});
expect(page.cursor).toEqual({ time: 1700000000, random: 77 });
expect(
page.messages.map((message) => ({
sender: message.senderUin,
sequence: message.ntMsgSeq,
messageSequence: message.msgSeq,
clientSequence: message.clientSeq,
})),
).toEqual([
{ sender: 222, sequence: 63213, messageSequence: 32742, clientSequence: 32742 },
{ sender: 10001, sequence: 63214, messageSequence: 32743, clientSequence: 32743 },
]);
});
});
@@ -0,0 +1,416 @@
// Round-trips the SsoGetGroupMsg request/response protos (proton codegen +
// field tags) and the fetched-history decode path (each returned PushMsgBody
// re-uses the regular group decoder).
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
import { afterEach, describe, expect, it } from 'vitest';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { SsoGetGroupMsg, SsoGetGroupMsgResponse } from '@snowluma/proto-defs/get-group-msg';
import type { IdentityService } from '../../src/identity-service';
import { SSO_GET_GROUP_MSG_CMD, fetchGroupMessageRange } from '../../src/msg-push';
const identity = {
findGroupMember: () => undefined,
findGroup: () => null,
} as unknown as IdentityService;
const previousLogLevel = getLogLevel();
afterEach(() => {
setLogLevel(previousLogLevel);
});
function okResult(data: Uint8Array): SendPacketResult {
return { success: true, gotResponse: true, responseData: data } as SendPacketResult;
}
describe('fetchGroupMessageRange / SsoGetGroupMsg', () => {
it('sends the right command and request field tags', async () => {
let captured: { cmd: string; body: Uint8Array } | null = null;
const sender = {
sendRawPacket: async (cmd: string, body: Uint8Array) => {
captured = { cmd, body };
return okResult(
protobuf_encode<SsoGetGroupMsgResponse>({
body: { groupUin: 9999, messages: [] },
}),
);
},
};
await fetchGroupMessageRange(sender, identity, 10001, 9999, 100, 120);
expect(captured!.cmd).toBe(SSO_GET_GROUP_MSG_CMD);
const req = protobuf_decode<SsoGetGroupMsg>(captured!.body);
expect(req.info?.groupUin).toBe(9999);
expect(req.info?.startSequence).toBe(100);
expect(req.info?.endSequence).toBe(120);
expect(req.direction).toBe(true);
});
it('decodes returned group messages (seq/group/sender, self uin), oldest→newest', async () => {
const resp = protobuf_encode<SsoGetGroupMsgResponse>({
body: {
groupUin: 9999,
startSequence: 100,
endSequence: 120,
messages: [
{
responseHead: { fromUin: 222, grp: { groupUin: 9999, memberName: 'Bob' } },
contentHead: {
msgType: 82,
sequence: 120,
timestamp: 1700000120,
msgId: 5120,
},
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
},
{
responseHead: {
fromUin: 111,
grp: { groupUin: 9999, memberName: 'Alice' },
},
contentHead: {
msgType: 82,
sequence: 110,
timestamp: 1700000110,
msgId: 5110,
},
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
},
],
},
});
const sender = { sendRawPacket: async () => okResult(resp) };
const out = await fetchGroupMessageRange(sender, identity, 10001, 9999, 100, 120);
expect(out.map((m) => m.msgSeq)).toEqual([110, 120]); // sorted ascending
expect(out.every((m) => m.kind === 'group_message')).toBe(true);
expect(out.every((m) => m.groupId === 9999)).toBe(true);
expect(out.every((m) => m.selfUin === 10001)).toBe(true);
expect(out[0]).toMatchObject({ msgSeq: 110, senderUin: 111, senderNick: 'Alice' });
expect(out[1]).toMatchObject({ msgSeq: 120, senderUin: 222, senderNick: 'Bob' });
});
it('[#1] prefers the fresher group card in grp.memberCard (field 4) over a stale cache card', async () => {
const stale = {
uin: 222,
uid: '',
nickname: 'BaseNick',
card: 'StaleCard',
role: 'member',
level: 0,
title: '',
joinTime: 0,
lastSentTime: 0,
shutUpTime: 0,
};
const idWithMember = {
findGroupMember: (_g: number, u: number) => (u === 222 ? stale : undefined),
findGroup: () => null,
} as unknown as IdentityService;
const resp = protobuf_encode<SsoGetGroupMsgResponse>({
body: {
groupUin: 9999,
messages: [
{
responseHead: {
fromUin: 222,
grp: { groupUin: 9999, memberCard: Buffer.from('FreshCard') },
}, // field2 empty, field4 = fresh card
contentHead: {
msgType: 82,
sequence: 130,
timestamp: 1700000130,
msgId: 5130,
},
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
},
],
},
});
const out = await fetchGroupMessageRange(
{ sendRawPacket: async () => okResult(resp) },
idWithMember,
10001,
9999,
100,
140,
);
// Base nickname from cache; card overridden by the fresher field-4 value.
expect(out[0]).toMatchObject({
senderUin: 222,
senderNick: 'BaseNick',
senderCard: 'FreshCard',
});
});
it('[#1] keeps the cache card when field 4 equals the base nickname (no active group card)', async () => {
const m = {
uin: 222,
uid: '',
nickname: 'BaseNick',
card: 'CachedCard',
role: 'member',
level: 0,
title: '',
joinTime: 0,
lastSentTime: 0,
shutUpTime: 0,
};
const id = {
findGroupMember: () => m,
findGroup: () => null,
} as unknown as IdentityService;
const resp = protobuf_encode<SsoGetGroupMsgResponse>({
body: {
groupUin: 9999,
messages: [
{
responseHead: {
fromUin: 222,
grp: { groupUin: 9999, memberCard: Buffer.from('BaseNick') },
}, // field4 == base nick → not a card
contentHead: {
msgType: 82,
sequence: 130,
timestamp: 1700000130,
msgId: 5130,
},
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
},
],
},
});
const out = await fetchGroupMessageRange(
{ sendRawPacket: async () => okResult(resp) },
id,
10001,
9999,
100,
140,
);
expect(out[0]).toMatchObject({ senderNick: 'BaseNick', senderCard: 'CachedCard' });
});
it('drops content-less blank messages, keeps real ones (#102 parity)', async () => {
const resp = protobuf_encode<SsoGetGroupMsgResponse>({
body: {
groupUin: 9999,
messages: [
{
// genuinely-blank control push → dropped
responseHead: {
fromUin: 111,
grp: { groupUin: 9999, memberName: 'Alice' },
},
contentHead: {
msgType: 82,
sequence: 110,
timestamp: 1700000110,
msgId: 5110,
},
body: { richText: { elems: [] } },
},
{
// real message → kept
responseHead: { fromUin: 222, grp: { groupUin: 9999, memberName: 'Bob' } },
contentHead: {
msgType: 82,
sequence: 120,
timestamp: 1700000120,
msgId: 5120,
},
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
},
],
},
});
const out = await fetchGroupMessageRange(
{ sendRawPacket: async () => okResult(resp) },
identity,
10001,
9999,
100,
120,
);
expect(out.map((m) => m.msgSeq)).toEqual([120]);
expect(out[0]).toMatchObject({ senderUin: 222, elements: [{ type: 'text', text: 'hi' }] });
});
it('keeps and warns on a body that had content but decoded to no elements', async () => {
const resp = protobuf_encode<SsoGetGroupMsgResponse>({
body: {
groupUin: 9999,
messages: [
{
responseHead: { fromUin: 222, grp: { groupUin: 9999, memberName: 'Bob' } },
contentHead: {
msgType: 82,
sequence: 120,
timestamp: 1700000120,
msgId: 5120,
},
body: {
richText: {
elems: [{ commonElem: { serviceType: 999, businessType: 0 } }],
},
},
},
],
},
});
const warnings: string[] = [];
setLogLevel('warn');
const unsubscribe = subscribeLogs((entry: LogEntry) => {
if (entry.level === 'warn') {
warnings.push(entry.message);
}
});
try {
const out = await fetchGroupMessageRange(
{ sendRawPacket: async () => okResult(resp) },
identity,
10001,
9999,
100,
120,
);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ kind: 'group_message', msgSeq: 120, elements: [] });
expect(warnings.some((message) => message.includes('missing decoder'))).toBe(true);
} finally {
unsubscribe();
}
});
it('drops QQ null roam records even when they carry a deleted placeholder (#254)', async () => {
const resp = protobuf_encode<SsoGetGroupMsgResponse>({
body: {
groupUin: 9999,
messages: [
{
responseHead: { fromUin: 0, grp: { groupUin: 9999 } },
contentHead: { msgType: 82, sequence: 110, timestamp: 0, msgId: 5110 },
body: { richText: { elems: [{ text: { str: '[已删除]' } }] } },
},
{
responseHead: { fromUin: 222, grp: { groupUin: 9999, memberName: 'Bob' } },
contentHead: {
msgType: 82,
sequence: 120,
timestamp: 1700000120,
msgId: 5120,
},
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
},
],
},
});
const out = await fetchGroupMessageRange(
{ sendRawPacket: async () => okResult(resp) },
identity,
10001,
9999,
100,
120,
);
expect(out.map((message) => message.msgSeq)).toEqual([120]);
});
it('keeps failed or empty transport responses observable', async () => {
const failSender = {
sendRawPacket: async () =>
({
success: false,
gotResponse: false,
errorMessage: 'history unavailable',
}) as SendPacketResult,
};
await expect(
fetchGroupMessageRange(failSender, identity, 10001, 9999, 100, 120),
).rejects.toThrow('history unavailable');
const emptySender = {
sendRawPacket: async () =>
({
success: true,
gotResponse: false,
}) as SendPacketResult,
};
await expect(
fetchGroupMessageRange(emptySender, identity, 10001, 9999, 100, 120),
).rejects.toThrow('response is empty');
});
it('rejects malformed history entries instead of silently omitting them', async () => {
const missingHead = protobuf_encode<SsoGetGroupMsgResponse>({
body: {
groupUin: 9999,
messages: [
{
responseHead: { fromUin: 222, grp: { groupUin: 9999 } },
body: { richText: { elems: [{ text: { str: 'missing head' } }] } },
},
],
},
});
await expect(
fetchGroupMessageRange(
{ sendRawPacket: async () => okResult(missingHead) },
identity,
10001,
9999,
100,
120,
),
).rejects.toThrow(/message 0 has no content head.*group=9999 range=100-120/);
const invalidSequence = protobuf_encode<SsoGetGroupMsgResponse>({
body: {
groupUin: 9999,
messages: [
{
responseHead: { fromUin: 222, grp: { groupUin: 9999 } },
contentHead: {
msgType: 82,
sequence: 0,
timestamp: 1_700_000_000,
msgId: 1,
},
body: { richText: { elems: [{ text: { str: 'invalid sequence' } }] } },
},
],
},
});
await expect(
fetchGroupMessageRange(
{ sendRawPacket: async () => okResult(invalidSequence) },
identity,
10001,
9999,
100,
120,
),
).rejects.toThrow(/message 0 has invalid sequence 0.*group=9999 range=100-120/);
});
it('rejects an out-of-range request before sending', async () => {
// start > end is rejected before any send
let sent = false;
const guardSender = {
sendRawPacket: async () => {
sent = true;
return okResult(new Uint8Array());
},
};
expect(await fetchGroupMessageRange(guardSender, identity, 10001, 9999, 200, 100)).toEqual(
[],
);
expect(sent).toBe(false);
});
});
@@ -0,0 +1 @@
0ad2190a43088dc2daaf0d1218755f5f4b593230365877546767686e5857376954796a4d6718aa1928bec6acea0c3218755f74676a704f715241555444556f784775433953375551123508a601180b2080c4e2e40d28d32230efe2f5d30638015883016080c4e2e48d80808001708201e001d3228002cfb398af8beee6cb711ad3180ad0180a21080010efe2f5d3061880c4e2e40d2000280a300038860140024a06e5ae8be4bd13129916aa039516082d128e160ac5145be997aae4bca05d286d71716170693a2f2f6d61726b646f776e2f6e6f64653f6e6f6465547970653d726963687569266a736f6e3d2537422532326275734964253232253341253232466c6173685472616e7366657225323225324325323274656d706c6174654964253232253341253232666c61736825323225324325323276657273696f6e253232253341322532432532326c61796f7574253232253341253742253232766965774964253232253341253232666c6173685f66696c6525323225324325323277696474682532322533412d322532432532326865696768742532322533412d32253243253232646972656374696f6e253232253341253232686f72697a6f6e74616c2532322532432532326c61796f757425323225334125354225374225323276696577496425323225334125323270726f67726573734c656674253232253243253232766965775479706525323225334125323263697263756c617250726f67726573732532322532432532326865696768742532322533413238253243253232776964746825323225334132382532432532326d617267696e5269676874253232253341382532432532326772617669747925323225334125323263656e746572566572746963616c25323225374425324325374225323276696577496425323225334125323266696c65253232253243253232646972656374696f6e253232253341253232766572746963616c2532322532432532326865696768742532322533412d3225324325323277696474682532322533413236332532432532326c61796f7574253232253341253542253742253232766965774964253232253341253232696d6167652532322532432532327669657754797065253232253341253232696d61676525323225324325323277696474682532322533412d312532432532326865696768742532322533413138302537442532432537422532327669657749642532322533412532327469746c6525323225324325323276696577547970652532322533412532327465787425323225324325323277696474682532322533412d312532432532326865696768742532322533412d322532432532326d617267696e546f7025323225334131322532432532326d617267696e4c65667425323225334131322532432532326d617267696e526967687425323225334131322537442532432537422532327669657749642532322533412532326465736325323225324325323276696577547970652532322533412532327465787425323225324325323277696474682532322533412d312532432532326865696768742532322533412d322532432532326d617267696e4c65667425323225334131322532432532326d617267696e546f70253232253341342532432532326d617267696e526967687425323225334131322537442532432537422532327669657749642532322533412532326469766964657225323225324325323277696474682532322533412d31253243253232686569676874253232253341302e352532432532326d617267696e546f7025323225334131332537442532432537422532327669657749642532322533412532327461696c253232253243253232646972656374696f6e253232253341253232686f72697a6f6e74616c25323225324325323277696474682532322533412d3125324325323268656967687425323225334132322532432532326d617267696e4c65667425323225334131322532432532326c61796f75742532322533412535422537422532327669657749642532322533412532327461696c49636f6e2532322532432532327669657754797065253232253341253232696d6167652532322532432532327769647468253232253341313225324325323268656967687425323225334131322532432532326772617669747925323225334125323263656e746572566572746963616c2532322537442532432537422532327669657749642532322533412532327461696c5465787425323225324325323276696577547970652532322533412532327465787425323225324325323277696474682532322533412d322532432532326865696768742532322533412d322532432532326772617669747925323225334125323263656e746572566572746963616c2532322532432532326d617267696e4c6566742532322533413425374425354425374425354425374425324325374225323276696577496425323225334125323270726f67726573735269676874253232253243253232766965775479706525323225334125323263697263756c617250726f67726573732532322532432532326865696768742532322533413238253243253232776964746825323225334132382532432532326d617267696e4c656674253232253341382532432532326772617669747925323225334125323263656e746572566572746963616c25323225374425354425374425324325323261747472696275746573253232253341253742253232766965774964253232253341253232666c6173685f66696c652532322532432532326174747269627574657325323225334125354225374225323276696577496425323225334125323270726f67726573734c65667425323225324325323270726f67726573732532322533413025324325323273746174652532322533412532326e6f6e652532322532432532326576656e74253232253341253742253232696e697425323225334125374225323276697369626c6543747225323225334125374225323276697369626c652532322533412532322532342532346c65667450726f677265737356697369626c6525323225324325323276697369626c65426568617665253232253341253232676f6e652532322532432532327372632532322533412532323125323225374425374425374425374425324325374225323276696577496425323225334125323266696c652532322532432532327261646975732532322533413130253243253232736368656d612532322533412532326d7171726f75746572253341253246253246666c6173685f7472616e736665722532466f70656e5f66696c6573657425334666696c657365745f69642533445b555549445d253543753030323676657273696Line truncated
@@ -0,0 +1,242 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import { decodeRichBody } from '../../src/msg-push/rich-body-decoder';
import type { MessageBody, PushMsg } from '@snowluma/proto-defs/message';
import type { MarkdownData } from '@snowluma/proto-defs/action';
// Build a svc=45 richui markdown commonElem carrying a FlashTransfer card, the
// way older QQ clients (≤9.9.30) deliver a 闪传 file (#199/#200).
function flashBody(json: unknown): MessageBody {
const markdown = `[闪传](mqqapi://markdown/node?nodeType=richui&json=${encodeURIComponent(JSON.stringify(json))})`;
const pbElem = protobuf_encode<MarkdownData>({ content: markdown });
return {
richText: {
elems: [{ commonElem: { serviceType: 45, businessType: 3, pbElem } } as never],
},
};
}
describe('decodeRichBody / 闪传 flash_file (#199/#200)', () => {
it('extracts filesetId, title, sceneType from a nested FlashTransfer card', () => {
const out = decodeRichBody(
flashBody({
busId: 'FlashTransfer',
templateId: 'flash',
version: 2,
layout: { viewId: 'flash_file', width: -2, height: -2 },
data: { fileSetId: 'fs-abc-123', title: 'report.pdf', sceneType: 2 },
}),
true,
);
expect(out).toEqual([
{ type: 'flash_file', filesetId: 'fs-abc-123', fileName: 'report.pdf', sceneType: 2 },
]);
});
it('recursive search finds the fields at any depth + accepts the filesetId alias', () => {
const out = decodeRichBody(
flashBody({
busId: 'FlashTransfer',
templateId: 'flash',
layout: { deep: { nested: { filesetId: 'fs-deep', name: 'a.zip' } } },
}),
true,
);
expect(out).toEqual([
{ type: 'flash_file', filesetId: 'fs-deep', fileName: 'a.zip', sceneType: 0 },
]);
});
it('drops a malformed card that has no fileset identity', () => {
const out = decodeRichBody(
flashBody({ busId: 'FlashTransfer', templateId: 'flash' }),
true,
);
expect(out).toEqual([]);
});
it('does not misclassify a non-FlashTransfer markdown card as flash_file', () => {
const out = decodeRichBody(
flashBody({ busId: 'SomethingElse', templateId: 'button' }),
true,
);
expect(out.some((e) => e.type === 'flash_file')).toBe(false);
expect(out[0]?.type).toBe('markdown');
});
});
// Current NT cards (#358) put fileset identity on MarkdownData.extInfo
// (extType=1 / DecodeMdExtInfoFileTransfer) and in the click scheme. The
// richui JSON no longer has `data.fileSetId`.
function currentNtFlashBody(
json: unknown,
ext?: MarkdownData['extInfo'],
summary?: string,
): MessageBody {
const markdown = `[闪传](mqqapi://markdown/node?nodeType=richui&json=${encodeURIComponent(JSON.stringify(json))})`;
const pbElem = protobuf_encode<MarkdownData>({
content: markdown,
summary,
extType: ext ? 1 : undefined,
extInfo: ext,
});
return {
richText: {
elems: [{ commonElem: { serviceType: 45, businessType: 3, pbElem } } as never],
},
};
}
const currentNtCardJson = {
busId: 'FlashTransfer',
templateId: 'flash',
version: 2,
layout: { viewId: 'flash_file', width: -2, height: -2, direction: 'horizontal' },
attributes: {
viewId: 'flash_file',
attributes: [
{
viewId: 'file',
scheme: 'mqqrouter://flash_transfer/open_fileset?fileset_id=fs-nt-358&version=1&channel_id=1&src_type=internal&scene_type=1',
},
],
},
};
describe('decodeRichBody / 闪传 flash_file (#358 current NT card)', () => {
it('reads filesetId and name from markdown extInfo when the JSON has no data.fileSetId', () => {
const out = decodeRichBody(
currentNtFlashBody(
currentNtCardJson,
{
filesetId: 'fs-nt-358',
name: 'cpwd截图.zip',
fileSize: 9196055,
expireTime: 1787815533,
},
'[QQ闪传] cpwd截图.zip',
),
true,
);
expect(out).toEqual([
{
type: 'flash_file',
filesetId: 'fs-nt-358',
fileName: 'cpwd截图.zip',
sceneType: 1,
},
]);
});
it('reads the cover URL from extInfo.thumbnail', () => {
const out = decodeRichBody(
currentNtFlashBody(currentNtCardJson, {
filesetId: 'fs-nt-358',
name: 'a.zip',
thumbnail: {
download: { field1: 2, downloadUrl: 'https://example.test/cover.jpg' },
},
}),
true,
);
expect(out).toEqual([
{
type: 'flash_file',
filesetId: 'fs-nt-358',
fileName: 'a.zip',
sceneType: 1,
thumbUrl: 'https://example.test/cover.jpg',
},
]);
});
it('falls back to the open_fileset scheme when extInfo is absent', () => {
const out = decodeRichBody(currentNtFlashBody(currentNtCardJson), true);
expect(out).toEqual([
{
type: 'flash_file',
filesetId: 'fs-nt-358',
fileName: '',
sceneType: 1,
},
]);
});
it('still decodes from extInfo when the richui JSON is truncated', () => {
const markdown =
'[闪传](mqqapi://markdown/node?nodeType=richui&json=%7B%22busId%22%3A%22FlashTransfer%22%2C%22src%22%3A%22';
const pbElem = protobuf_encode<MarkdownData>({
content: markdown,
summary: '[QQ闪传] report.pdf',
extType: 1,
extInfo: { filesetId: 'fs-from-ext', name: 'report.pdf' },
});
const out = decodeRichBody(
{
richText: {
elems: [{ commonElem: { serviceType: 45, businessType: 3, pbElem } } as never],
},
},
true,
);
expect(out).toEqual([
{
type: 'flash_file',
filesetId: 'fs-from-ext',
fileName: 'report.pdf',
sceneType: 0,
},
]);
});
it('drops the compatibility text sibling after a successful flash_file decode', () => {
const markdown = `[闪传](mqqapi://markdown/node?nodeType=richui&json=${encodeURIComponent(JSON.stringify(currentNtCardJson))})`;
const pbElem = protobuf_encode<MarkdownData>({
content: markdown,
extType: 1,
extInfo: { filesetId: 'fs-nt-358', name: 'a.zip' },
});
const out = decodeRichBody(
{
richText: {
elems: [
{ commonElem: { serviceType: 45, businessType: 3, pbElem } } as never,
{ text: { str: '[闪传]' } } as never,
],
},
},
true,
);
expect(out).toEqual([
{
type: 'flash_file',
filesetId: 'fs-nt-358',
fileName: 'a.zip',
sceneType: 1,
},
]);
});
it('decodes the sanitized #358 C2C push (extInfo + open_fileset scheme)', () => {
const hex = readFileSync(
fileURLToPath(new URL('./fixtures/issue-358-flash-push.hex', import.meta.url)),
'utf8',
).trim();
const push = protobuf_decode<PushMsg>(Buffer.from(hex, 'hex'));
const out = decodeRichBody(push.message?.body, false);
// Reporter redacted the richui JSON (unterminated src), so scene_type
// cannot be recovered from the scheme. extInfo still supplies fileset + name
// and the cover URL (placeholder).
expect(out).toEqual([
{
type: 'flash_file',
filesetId: '[UUID]',
fileName: 'cpwd截图.zip',
sceneType: 0,
thumbUrl: '[FILE_URL]',
},
]);
});
});
@@ -0,0 +1,75 @@
import type { PacketInfo } from '@snowluma/common/protocol-types';
import type { PushMsg } from '@snowluma/proto-defs/message';
import type { GeneralGrayTipInfo } from '@snowluma/proto-defs/notify';
import { protobuf_encode } from '@snowluma/proton';
import { describe, expect, it } from 'vitest';
import { IdentityService } from '../../src/identity-service';
import { parseMsgPush } from '../../src/msg-push';
const SELF_UIN = 10001;
const FRIEND_A = 20001;
const FRIEND_B = 20002;
const identity = IdentityService.memory(String(SELF_UIN));
function friendPokePacket(peerUin: number, senderUin: number, targetUin: number): PacketInfo {
const sentBySelf = senderUin === SELF_UIN;
const grayTip = protobuf_encode<GeneralGrayTipInfo>({
busiType: 12n,
msgTemplParam: [
{ name: 'uin_str1', value: String(senderUin) },
{ name: 'uin_str2', value: String(targetUin) },
{ name: 'action_str', value: '拍了拍' },
{ name: 'suffix_str', value: '一下' },
],
});
const body = protobuf_encode<PushMsg>({
message: {
responseHead: sentBySelf
? { fromUin: SELF_UIN, toUin: peerUin }
: { fromUin: peerUin, toUin: SELF_UIN },
contentHead: {
msgType: 528,
subType: 290,
timestamp: 1710000000,
},
body: { msgContent: grayTip },
},
});
return {
pid: 1,
uin: String(SELF_UIN),
serviceCmd: 'trpc.msg.olpush.OlPushService.MsgPush',
seqId: 1,
retCode: 0,
fromClient: false,
body,
};
}
describe('parseMsgPush — private poke conversation identity (#285)', () => {
it('preserves the private peer independently of the poke sender and target', () => {
const events = [
...parseMsgPush(friendPokePacket(FRIEND_A, SELF_UIN, SELF_UIN), identity),
...parseMsgPush(friendPokePacket(FRIEND_B, SELF_UIN, SELF_UIN), identity),
...parseMsgPush(friendPokePacket(FRIEND_B, FRIEND_B, SELF_UIN), identity),
];
expect(
events.map((event) => {
const poke = event as unknown as Record<string, unknown>;
return {
kind: poke.kind,
peerUin: poke.peerUin,
senderUin: poke.senderUin,
targetUin: poke.targetUin,
};
}),
).toEqual([
{ kind: 'friend_poke', peerUin: FRIEND_A, senderUin: SELF_UIN, targetUin: SELF_UIN },
{ kind: 'friend_poke', peerUin: FRIEND_B, senderUin: SELF_UIN, targetUin: SELF_UIN },
{ kind: 'friend_poke', peerUin: FRIEND_B, senderUin: FRIEND_B, targetUin: SELF_UIN },
]);
});
});
@@ -0,0 +1,97 @@
import type { GroupInvitation, GroupInvite } from '@snowluma/proto-defs/notify';
import { protobuf_encode } from '@snowluma/proton';
import { describe, expect, it } from 'vitest';
import { IdentityService } from '../../src/identity-service';
import type { MsgPushContext } from '../../src/msg-push/context';
import {
decodeGroupInvitation,
decodeGroupInvite,
} from '../../src/msg-push/decoders/group-join-request';
const SELF_UIN = 10001;
const GROUP_ID = 12345;
function ctx(
content: Uint8Array,
identity = IdentityService.memory(String(SELF_UIN)),
): MsgPushContext {
return {
head: {
msgType: 33,
subType: 0,
c2cCmd: 0,
sequence: 1,
ntMsgSeq: 0,
timestamp: 1_710_000_000,
msgId: 1,
},
fromUin: GROUP_ID,
fromUid: '',
selfUin: SELF_UIN,
content,
body: undefined,
responseHead: undefined,
identity,
isHistorical: false,
};
}
describe('group invitation push carries the invitee (#394)', () => {
it('ignores unrelated notification commands', () => {
const content = protobuf_encode<GroupInvitation>({
cmd: 86,
info: {
inner: {
groupUin: GROUP_ID,
targetUid: 'u_invitee',
invitorUid: 'u_inviter',
},
},
});
expect(decodeGroupInvitation(ctx(content))).toEqual([]);
});
it('keeps the inviter as from* and the invitee as invited*', () => {
const identity = IdentityService.memory(String(SELF_UIN));
identity.rememberRequestIdentity({ uid: 'u_inviter', uin: 456, source: 'group_request' });
identity.rememberRequestIdentity({ uid: 'u_invitee', uin: 789, source: 'group_request' });
const content = protobuf_encode<GroupInvitation>({
cmd: 87,
info: {
inner: {
groupUin: GROUP_ID,
targetUid: 'u_invitee',
invitorUid: 'u_inviter',
},
},
});
const [event] = decodeGroupInvitation(ctx(content, identity));
expect(event).toMatchObject({
kind: 'group_invite',
groupId: GROUP_ID,
fromUin: 456,
fromUid: 'u_inviter',
invitedUin: 789,
invitedUid: 'u_invitee',
subType: 'invite',
});
});
it('treats a self-invite as inviting the logged-in account', () => {
const content = protobuf_encode<GroupInvite>({
groupUin: GROUP_ID,
invitorUid: 'u_inviter',
});
const [event] = decodeGroupInvite(ctx(content));
expect(event).toMatchObject({
kind: 'group_invite',
fromUid: 'u_inviter',
invitedUin: SELF_UIN,
subType: 'invite',
});
});
});
@@ -0,0 +1,77 @@
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
import type { IdentityService } from '@snowluma/protocol/identity-service';
import type { MsgPushContext } from '@snowluma/protocol/msg-push/context';
import { PkgType } from '@snowluma/protocol/msg-push/enums';
import { MsgPushRegistry } from '@snowluma/protocol/msg-push/registry';
import { afterEach, describe, expect, it } from 'vitest';
const previousLogLevel = getLogLevel();
afterEach(() => {
setLogLevel(previousLogLevel);
});
function context(msgType: number): MsgPushContext {
return {
head: {
msgType,
subType: 7,
c2cCmd: 0,
sequence: 88,
ntMsgSeq: 0,
timestamp: 1,
msgId: 2,
},
fromUin: 20002,
fromUid: 'u_peer',
selfUin: 10001,
content: new Uint8Array(0),
body: undefined,
responseHead: undefined,
identity: {} as IdentityService,
isHistorical: false,
};
}
function traceMessages(entries: LogEntry[]): string[] {
return entries.filter((entry) => entry.level === 'trace').map((entry) => entry.message);
}
describe('MsgPushRegistry TRACE branches', () => {
it('records an unregistered decoder with its message identity', () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
try {
const registry = new MsgPushRegistry();
expect(registry.decode(context(9999))).toEqual([]);
expect(traceMessages(entries)).toContain(
'packet_branch branch=decoder_unregistered msgType=9999 subType=7 messageSeq=88',
);
} finally {
unsubscribe();
}
});
it('records a decoder exception before failing open', () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
try {
const registry = new MsgPushRegistry();
registry.register(PkgType.PrivateMessage, () => {
throw new Error('fixture decode failed');
});
expect(registry.decode(context(PkgType.PrivateMessage))).toEqual([]);
expect(traceMessages(entries)).toContain(
'packet_branch branch=decoder_exception msgType=166 subType=7 messageSeq=88 error="fixture decode failed"',
);
} finally {
unsubscribe();
}
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,328 @@
// 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 routes in QQ NT's static system-
// message table, but only when given a dedup tracker (the live path). Ordinary
// chat routes remain outside that table even when their outer msgType matches.
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
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, OnlineDeviceNotify } from '@snowluma/proto-defs/notify';
import { IdentityService } from '../../src/identity-service';
import {
deriveSysMsgDedupIdentity,
parseMsgPush,
SysMsgDedup,
type SysMsgDedupIdentity,
} from '../../src/msg-push';
const identity = IdentityService.memory('2000000001');
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 Identity.findUinByUid returns it without a map lookup.
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),
}),
},
});
}
function c2cPacket(msgType: number, subType: number): PacketInfo {
return pushPacket({
responseHead: { fromUin: 10001, fromUid: 'u_x' },
contentHead: { msgType, subType, sequence: 900, timestamp: 1781540572, msgId: 7 },
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
});
}
function onlineDevicesPush(deviceName: string): PacketInfo {
return pushPacket({
responseHead: { fromUin: 2000000001, fromUid: '' },
contentHead: {
msgType: 528,
subType: 349,
sequence: 800,
timestamp: 1781540572,
msgId: 555,
},
body: {
msgContent: protobuf_encode<OnlineDeviceNotify>({
devices: [
{ appId: 537242075, instanceId: 202, clientType: 1, platform: 3, deviceName },
],
}),
},
});
}
function dedupIdentity(overrides: Partial<SysMsgDedupIdentity> = {}): SysMsgDedupIdentity {
return {
peerUid: '700',
chatType: 2,
sequence: 500,
random: 12345,
...overrides,
};
}
function deriveIdentity(opts: {
msgType: number;
subType: number;
sequence?: number;
msgId?: number;
fromUin?: number;
fromUid?: string;
}) {
return deriveSysMsgDedupIdentity({
head: {
msgType: opts.msgType,
subType: opts.subType,
c2cCmd: 0,
sequence: opts.sequence ?? 500,
ntMsgSeq: 0,
timestamp: 1781540572,
msgId: opts.msgId ?? 12345,
},
fromUin: opts.fromUin ?? 700,
fromUid: opts.fromUid ?? 'u_peer',
});
}
describe('deriveSysMsgDedupIdentity', () => {
it.each([
[528, 290],
[166, 75],
[166, 129],
[166, 131],
[166, 133],
[166, 135],
[167, 133],
])('maps native C2C route %d/%d to fromUid and chatType 1', (msgType, subType) => {
expect(deriveIdentity({ msgType, subType, fromUin: 700, fromUid: 'u_peer' })).toEqual({
peerUid: 'u_peer',
chatType: 1,
sequence: 500,
random: 12345,
});
});
it.each([
[732, 12],
[732, 16],
[732, 20],
[33, 0],
[34, 0],
[85, 0],
])('maps native group route %d/%d to decimal fromUin and chatType 2', (msgType, subType) => {
expect(deriveIdentity({ msgType, subType, fromUin: 700, fromUid: 'ignored' })).toEqual({
peerUid: '700',
chatType: 2,
sequence: 500,
random: 12345,
});
});
it('[#266] fails open for an outer package pair absent from QQ NT’s static table', () => {
expect(deriveIdentity({ msgType: 528, subType: 99 })).toBeNull();
});
it('fails open when the native sequence, random, or route-specific peer is missing', () => {
expect(deriveIdentity({ msgType: 33, subType: 0, sequence: 0 })).toBeNull();
expect(deriveIdentity({ msgType: 33, subType: 0, msgId: 0 })).toBeNull();
expect(deriveIdentity({ msgType: 33, subType: 0, fromUin: 0 })).toBeNull();
expect(deriveIdentity({ msgType: 528, subType: 290, fromUid: '' })).toBeNull();
});
});
describe('SysMsgDedup', () => {
it('flags the second push with the same native global key', () => {
const d = new SysMsgDedup();
expect(d.seenDuplicate(dedupIdentity())).toBe(false);
expect(d.seenDuplicate(dedupIdentity())).toBe(true);
});
it('treats a different sequence, random, peer, or chat type as distinct', () => {
const d = new SysMsgDedup();
expect(d.seenDuplicate(dedupIdentity())).toBe(false);
expect(d.seenDuplicate(dedupIdentity({ sequence: 501 }))).toBe(false);
expect(d.seenDuplicate(dedupIdentity({ random: 99999 }))).toBe(false);
expect(d.seenDuplicate(dedupIdentity({ peerUid: '701' }))).toBe(false);
expect(d.seenDuplicate(dedupIdentity({ chatType: 1 }))).toBe(false);
});
it('[#266] dedups different listed outer routes that derive the same native identity', () => {
const d = new SysMsgDedup();
const first = deriveIdentity({ msgType: 166, subType: 133 });
const alternateRoute = deriveIdentity({ msgType: 167, subType: 133 });
expect(first).not.toBeNull();
expect(alternateRoute).toEqual(first);
expect(d.seenDuplicate(first!)).toBe(false);
expect(d.seenDuplicate(alternateRoute!)).toBe(true);
});
it('[#266] does not record an unlisted outer route', () => {
const d = new SysMsgDedup();
const unlisted = deriveIdentity({ msgType: 528, subType: 99 });
const listed = deriveIdentity({ msgType: 528, subType: 290 });
expect(unlisted).toBeNull();
expect(listed).not.toBeNull();
expect(d.seenDuplicate(listed!)).toBe(false);
});
it('never dedups a manually supplied identity without a server discriminator or peer', () => {
const d = new SysMsgDedup();
expect(d.seenDuplicate(dedupIdentity({ sequence: 0 }))).toBe(false);
expect(d.seenDuplicate(dedupIdentity({ sequence: 0 }))).toBe(false);
expect(d.seenDuplicate(dedupIdentity({ random: 0 }))).toBe(false);
expect(d.seenDuplicate(dedupIdentity({ peerUid: '' }))).toBe(false);
});
it('evicts the oldest key once capacity is exceeded (bounded memory)', () => {
const d = new SysMsgDedup(2);
expect(d.seenDuplicate(dedupIdentity({ sequence: 1, random: 1 }))).toBe(false);
expect(d.seenDuplicate(dedupIdentity({ sequence: 2, random: 1 }))).toBe(false);
expect(d.seenDuplicate(dedupIdentity({ sequence: 3, random: 1 }))).toBe(false);
expect(d.seenDuplicate(dedupIdentity({ sequence: 1, random: 1 }))).toBe(false);
expect(d.seenDuplicate(dedupIdentity({ sequence: 3, random: 1 }))).toBe(true);
});
});
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('traces the actual duplicate branch before returning zero events', () => {
const previousLevel = getLogLevel();
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
try {
const dedup = new SysMsgDedup();
const push = () =>
memberIncreasePush({
groupId: 700,
memberUin: 2854207029,
sequence: 800,
msgId: 555,
});
expect(parseMsgPush(push(), identity, dedup)).toHaveLength(1);
expect(parseMsgPush(push(), identity, dedup)).toEqual([]);
expect(
entries.filter((entry) => entry.scope === 'MsgPush' && entry.level === 'trace'),
).toEqual([
expect.objectContaining({
message:
'packet_branch serviceCmd="trpc.msg.olpush.OlPushService.MsgPush" seqId=0 branch=duplicate_system_push peer="700" chatType=2 messageSeq=800 messageRandom=555',
}),
]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
});
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('[#266] dedups a listed C2C route before its outer type is decoded as a chat message', () => {
const dedup = new SysMsgDedup();
expect(parseMsgPush(c2cPacket(166, 133), identity, dedup)).toHaveLength(1);
expect(parseMsgPush(c2cPacket(166, 133), identity, dedup)).toEqual([]);
});
it('[#266] records a listed undecoded route before a matching listed route is dispatched', () => {
const dedup = new SysMsgDedup();
expect(parseMsgPush(c2cPacket(167, 133), identity, dedup)).toEqual([]);
expect(parseMsgPush(c2cPacket(166, 133), identity, dedup)).toEqual([]);
});
it('does not dedup ordinary chat messages outside the native system-message table', () => {
const dedup = new SysMsgDedup();
expect(parseMsgPush(c2cPacket(166, 0), identity, dedup)).toHaveLength(1);
expect(parseMsgPush(c2cPacket(166, 0), identity, dedup)).toHaveLength(1);
});
it('does not dedup replacement state snapshots with the same server identity', () => {
const dedup = new SysMsgDedup();
expect(parseMsgPush(onlineDevicesPush('DESKTOP-A'), identity, dedup)).toMatchObject([
{ kind: 'online_devices_changed', devices: [{ deviceName: 'DESKTOP-A' }] },
]);
expect(parseMsgPush(onlineDevicesPush('DESKTOP-B'), identity, dedup)).toMatchObject([
{ kind: 'online_devices_changed', devices: [{ deviceName: 'DESKTOP-B' }] },
]);
});
});
@@ -0,0 +1,94 @@
import type { PacketInfo } from '@snowluma/common/protocol-types';
import { protobuf_encode, type pb, type pb_repeated, type uint_32 } from '@snowluma/proton';
import { describe, expect, it } from 'vitest';
import type { IdentityService } from '../../src/identity-service';
import { parseMsgPush } from '../../src/msg-push';
interface TextFixture {
str?: pb<1, string>;
}
interface ElemFixture {
text?: pb<1, TextFixture>;
}
interface RichTextFixture {
elems?: pb_repeated<2, ElemFixture>;
}
interface MessageBodyFixture {
richText?: pb<1, RichTextFixture>;
}
interface TempSessionFixture {
previousGroup?: pb<4, uint_32>;
sourceGroup?: pb<5, uint_32>;
}
interface ResponseHeadFixture {
fromUin?: pb<1, uint_32>;
fromUid?: pb<2, string>;
tempSession?: pb<7, TempSessionFixture>;
}
interface ContentHeadFixture {
msgType?: pb<1, uint_32>;
sequence?: pb<5, uint_32>;
timestamp?: pb<6, uint_32>;
ntMsgSeq?: pb<11, uint_32>;
}
interface PushBodyFixture {
responseHead?: pb<1, ResponseHeadFixture>;
contentHead?: pb<2, ContentHeadFixture>;
body?: pb<3, MessageBodyFixture>;
}
interface PushFixture {
message?: pb<1, PushBodyFixture>;
}
const identity = { findFriend: () => undefined } as unknown as IdentityService;
function tempMessagePacket(previousGroup: number, sourceGroup: number): PacketInfo {
return {
pid: 1,
uin: '2000000001',
serviceCmd: 'trpc.msg.olpush.OlPushService.MsgPush',
seqId: 1,
retCode: 0,
fromClient: false,
body: protobuf_encode<PushFixture>({
message: {
responseHead: {
fromUin: 10001,
fromUid: 'u_peer',
tempSession: { previousGroup, sourceGroup },
},
contentHead: {
msgType: 141,
sequence: 7,
timestamp: 1_710_000_000,
ntMsgSeq: 8,
},
body: { richText: { elems: [{ text: { str: 'hello' } }] } },
},
}),
};
}
describe('parseMsgPush — temporary-session source (#308)', () => {
it('keeps a passive reply bound to the group that opened the current session', () => {
const events = parseMsgPush(tempMessagePacket(11111, 22222), identity);
expect(events).toMatchObject([
{
kind: 'temp_message',
senderUin: 10001,
groupId: 22222,
elements: [{ type: 'text', text: 'hello' }],
},
]);
});
});