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

417 lines
15 KiB
TypeScript

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