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

250 lines
10 KiB
TypeScript

// 0x88D_0 — fetch a single group's detail by uin (resolves non-member groups,
// e.g. to name a group invite). Verifies the request mask + envelope (reserved=0,
// matching Lagrange's GetGroupInfoService) and the response decode.
import { describe, expect, it, vi } from 'vitest';
import {
protobuf_decode,
protobuf_encode,
type pb,
type uint_32,
type uint_64,
} from '@snowluma/proton';
import type { OidbBase, OidbSvcTrpcTcp0x88D_0Response } from '@snowluma/proto-defs/oidb';
import type { OidbGroupDetailRequest } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchGroupDetail } from '../../../src/oidb-services/contacts/fetch-group-detail';
interface GroupDetailResultsFixture {
name?: pb<15, string>;
shutUpAllTimestamp?: pb<45, uint_32>;
leftover59?: pb<59, uint_32>;
privilegeFlag?: pb<56, uint_32>;
groupFlagExt4?: pb<101, uint_32>;
}
interface GroupDetailInfoFixture {
uin?: pb<1, uint_64>;
results?: pb<3, GroupDetailResultsFixture>;
}
interface GroupDetailResponseFixture {
groupInfo?: pb<1, GroupDetailInfoFixture>;
}
function makeSender(body?: OidbSvcTrpcTcp0x88D_0Response) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FetchGroupDetail namespace', () => {
it('declares 0x88D_0 with no uinForm (reserved stays 0, per Lagrange)', () => {
expect(FetchGroupDetail.command).toBe(0x88d);
expect(FetchGroupDetail.subCommand).toBe(0);
expect((FetchGroupDetail as { uinForm?: boolean }).uinForm).toBeUndefined();
});
describe('serialize', () => {
it('builds the field1 + uin + request-mask flags', () => {
const out = FetchGroupDetail.serialize({} as any, { groupUin: 601692726 });
expect(out.field1).toBe(537099973);
expect(out.config?.uin).toBe(BigInt(601692726));
expect(out.config?.flags?.name).toBe(''); // string mask → request the name
expect(out.config?.flags?.memberCount).toBe(true);
expect(out.config?.flags?.maxMemberCount).toBe(true);
expect(
(out.config?.flags as { shutUpAllTimestamp?: boolean })?.shutUpAllTimestamp,
).toBe(true);
expect(out.config?.flags?.privilegeFlag).toBe(true);
expect(out.config?.flags?.groupFlagExt4).toBe(true);
expect(out.config?.flags?.addType).toBe(true);
expect(out.config?.flags?.noFingerOpen).toBe(true);
expect(out.config?.flags?.noCodeFingerOpen).toBe(true);
expect(out.config?.flags?.question).toBe('');
expect(out.config?.flags?.answer).toBe('');
});
});
describe('deserialize', () => {
it('passes the response body through', () => {
const body = { groupInfo: { uin: 1n, results: { name: 'X' } } } as any;
expect(FetchGroupDetail.deserialize({} as any, body)).toBe(body);
});
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0x88d_0 and round-trips the uin in the request', async () => {
const sender = makeSender({ groupInfo: { uin: 601692726n, results: { name: 'g' } } });
await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x88d_0');
const env = protobuf_decode<OidbBase<OidbGroupDetailRequest>>(bytes);
expect(env.command).toBe(0x88d);
expect(env.subCommand ?? 0).toBe(0); // proto3 omits the 0 default on the wire
expect(env.reserved ?? 0).toBe(0); // NOT uin-form
expect(env.body?.config?.uin).toBe(BigInt(601692726));
expect(env.body?.config?.flags?.question).toBe('');
expect(env.body?.config?.flags?.answer).toBe('');
});
it('keeps empty question/answer masks on the wire (#393)', async () => {
const sender = makeSender({ groupInfo: { uin: 1n, results: { name: 'g' } } });
await FetchGroupDetail.invoke(sender, { groupUin: 1 });
const bytes = sender.sendRawPacket.mock.calls[0]![1] as Uint8Array;
// length-delimited empty string: field 24/25, wire type 2, length 0.
const hex = Buffer.from(bytes).toString('hex');
expect(hex).toContain('c20100');
expect(hex).toContain('ca0100');
});
it('returns the decoded group detail (name + counts)', async () => {
const sender = makeSender({
groupInfo: {
uin: 601692726n,
results: { name: '测试群', memberCount: 42n, maxMemberCount: 500n },
},
});
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
expect(out.groupInfo?.results?.name).toBe('测试群');
expect(out.groupInfo?.results?.memberCount).toBe(42n);
expect(out.groupInfo?.results?.maxMemberCount).toBe(500n);
});
it('decodes the group-wide mute timestamp from a detail response', async () => {
const responseData = Buffer.from(
protobuf_encode<OidbBase<GroupDetailResponseFixture>>({
body: {
groupInfo: {
uin: 601692726n,
results: { name: 'Muted Group', shutUpAllTimestamp: 4_294_967_295 },
},
},
}),
);
const sender = makeSender();
sender.sendRawPacket.mockResolvedValue({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
});
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
expect(out.groupInfo?.results?.shutUpAllTimestamp).toBe(4_294_967_295);
});
it('does not treat 0x88D results tag 59 as the mute expire (#356)', async () => {
const responseData = Buffer.from(
protobuf_encode<OidbBase<GroupDetailResponseFixture>>({
body: {
groupInfo: {
uin: 601692726n,
results: { name: 'Unmuted Group', leftover59: 4_294_967_295 },
},
},
}),
);
const sender = makeSender();
sender.sendRawPacket.mockResolvedValue({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
});
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
expect(out.groupInfo?.results?.shutUpAllTimestamp ?? undefined).toBeUndefined();
});
it('decodes the complete privilege flag used by masked group mutations', async () => {
const responseData = Buffer.from(
protobuf_encode<OidbBase<GroupDetailResponseFixture>>({
body: {
groupInfo: {
uin: 601692726n,
results: { privilegeFlag: 0x82100001 },
},
},
}),
);
const sender = makeSender();
sender.sendRawPacket.mockResolvedValue({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
});
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
expect(out.groupInfo?.results?.privilegeFlag).toBe(0x82100001);
});
it('decodes the complete extended group flag used by history visibility', async () => {
const responseData = Buffer.from(
protobuf_encode<OidbBase<GroupDetailResponseFixture>>({
body: {
groupInfo: {
uin: 601692726n,
results: { groupFlagExt4: 0x80000005 },
},
},
}),
);
const sender = makeSender();
sender.sendRawPacket.mockResolvedValue({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
});
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
expect(out.groupInfo?.results?.groupFlagExt4).toBe(0x80000005);
});
it('decodes add-option and search flags from the detail response', async () => {
const responseData = Buffer.from(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
body: {
groupInfo: {
uin: 601692726n,
results: { addType: 2, noFingerOpen: 1, noCodeFingerOpen: 0 },
},
},
}),
);
const sender = makeSender();
sender.sendRawPacket.mockResolvedValue({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
});
const out = await FetchGroupDetail.invoke(sender, { groupUin: 601692726 });
expect(out.groupInfo?.results?.addType).toBe(2);
expect(out.groupInfo?.results?.noFingerOpen).toBe(1);
expect(out.groupInfo?.results?.noCodeFingerOpen ?? 0).toBe(0);
});
});
});