Files
SnowLuma/packages/protocol/tests/msg-push/fetch-c2c-history.test.ts
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

496 lines
18 KiB
TypeScript

// 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 },
]);
});
});