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
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:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user