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,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' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user