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
250 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|