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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

@@ -0,0 +1,68 @@
// Independent protobuf byte-oracle helpers for OIDB service tests.
//
// These build expected wire bytes from FIRST PRINCIPLES (the RE'd tag
// numbers), NOT via proton — so asserting `proton.encode(...) === pb.env(...)`
// goes red if a proto-defs field tag is wrong or silently changes. A
// symmetric encode→decode round-trip can't catch a tag swap; this can.
//
// Emission rules mirror proton's observed behaviour: fields in ASCENDING
// tag order, and proto3 defaults (0 / '' / false) OMITTED.
function uvarint(n: number): number[] {
const out: number[] = [];
let x = n;
do {
let b = x % 128;
x = Math.floor(x / 128);
if (x > 0) {
b |= 0x80;
}
out.push(b);
} while (x > 0);
return out;
}
function tag(field: number, wire: number): number[] {
return uvarint(field * 8 + wire);
}
/** varint field (wire type 0). */
export function v(field: number, value: number): number[] {
return [...tag(field, 0), ...uvarint(value)];
}
/** length-delimited string field (wire type 2). */
export function s(field: number, str: string): number[] {
const b = [...Buffer.from(str, 'utf8')];
return [...tag(field, 2), ...uvarint(b.length), ...b];
}
/** length-delimited sub-message field (wire type 2). */
export function m(field: number, msg: number[]): number[] {
return [...tag(field, 2), ...uvarint(msg.length), ...msg];
}
/**
* Wrap a body in the OidbBase envelope exactly as proton emits it:
* command(1), subCommand(2) [omitted when 0], body(4), reserved(12)=1
* [only when uinForm]. errorCode(3)/errorMsg(5) are defaults → omitted.
*/
export function env(command: number, subCommand: number, body: number[], uinForm: boolean): string {
const out = [...v(1, command)];
if (subCommand !== 0) {
out.push(...v(2, subCommand));
}
out.push(...m(4, body));
if (uinForm) {
out.push(...v(12, 1));
}
return Buffer.from(out).toString('hex');
}
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { NTV2RichMediaReq, NTV2RichMediaResp } from '@snowluma/proto-defs/oidb-actions/media';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchDownloadRkeys } from '../../../src/oidb-services/contacts/fetch-download-rkeys';
function makeSender(body?: NTV2RichMediaResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<NTV2RichMediaResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FetchDownloadRkeys namespace', () => {
it('declares 0x9067_202 with uinForm=true', () => {
expect(FetchDownloadRkeys.command).toBe(0x9067);
expect(FetchDownloadRkeys.subCommand).toBe(202);
expect(FetchDownloadRkeys.uinForm).toBe(true);
});
describe('serialize', () => {
it('requests the (private=10, group=20, fallback=2) image scopes', () => {
const out = FetchDownloadRkeys.serialize({} as any, {});
expect(out.downloadRkey?.types).toEqual([10, 20, 2]);
});
it('packages the fixed reqHead { common, scene, client }', () => {
const out = FetchDownloadRkeys.serialize({} as any, {});
expect(out.reqHead).toEqual({
common: { requestId: 1, command: 202 },
scene: { requestType: 2, businessType: 1, sceneType: 0 },
client: { agentType: 2 },
});
});
});
describe('deserialize', () => {
it('throws when respHead.retCode is non-zero', () => {
expect(() =>
FetchDownloadRkeys.deserialize(
{} as any,
{
respHead: { retCode: 42, message: 'bad' },
} as any,
),
).toThrow('bad');
});
it('passes through on retCode = 0', () => {
const body = { respHead: { retCode: 0 }, downloadRkey: { rkeys: [] } };
expect(FetchDownloadRkeys.deserialize({} as any, body as any)).toBe(body);
});
it('falls back to a generic message when none is provided', () => {
expect(() =>
FetchDownloadRkeys.deserialize(
{} as any,
{
respHead: { retCode: 1 },
} as any,
),
).toThrow('fetch download rkey failed');
});
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0x9067_202 with reserved=1', async () => {
const sender = makeSender({
respHead: { retCode: 0 },
downloadRkey: { rkeys: [] },
} as any);
await FetchDownloadRkeys.invoke(sender);
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0x9067_202');
const env = protobuf_decode<OidbBase<NTV2RichMediaReq>>(bytes);
expect(env.reserved).toBe(1);
});
});
});
@@ -0,0 +1,199 @@
import { describe, expect, it, vi } from 'vitest';
import {
protobuf_decode,
protobuf_encode,
type bool,
type bytes,
type pb,
type pb_repeated,
type uint_32,
} from '@snowluma/proton';
import type { OidbBase, OidbSvcTrpcTcp0xFD4_1Response } from '@snowluma/proto-defs/oidb';
import type { OidbFriendListRequest } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchFriendListPage } from '../../../src/oidb-services/contacts/fetch-friend-list-page';
interface TestFriendCategory {
categoryId?: pb<1, uint_32>;
categoryName?: pb<2, string>;
memberCount?: pb<3, uint_32>;
sortId?: pb<4, uint_32>;
}
interface TestFriendListResponse {
cookie?: pb<2, bytes>;
isEnd?: pb<3, bool>;
categories?: pb_repeated<102, TestFriendCategory>;
}
function makeSender(body?: OidbSvcTrpcTcp0xFD4_1Response) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FetchFriendListPage namespace', () => {
it('declares 0xFD4_1', () => {
expect(FetchFriendListPage.command).toBe(0xfd4);
expect(FetchFriendListPage.subCommand).toBe(1);
});
describe('serialize', () => {
it('omits the cookie on the first page', () => {
const out = FetchFriendListPage.serialize({} as any, {} as any) as any;
expect(out.cookie).toBeUndefined();
expect(out.friendCount).toBe(300);
expect(out.field4).toBe(0);
});
it('replays the opaque response cookie in request tag 5', () => {
const cookie = Uint8Array.from([0x08, 0x96, 0x01, 0xff]);
const out = FetchFriendListPage.serialize({} as any, { cookie } as any) as any;
expect(out.cookie).toEqual(cookie);
expect(out.nextUin).toBeUndefined();
});
it('requests basic identity and public profiles in the same roster page', () => {
const out = FetchFriendListPage.serialize({} as any, {}) as any;
expect(out.body).toEqual([
{
type: 1,
number: {
numbers: [
103, 102, 20002, 27394, 105, 107, 20003, 20004, 20020, 20009, 20011,
20016, 20021, 20031, 20037, 41756, 41757, 42241,
],
},
},
{ type: 4, number: { numbers: [100, 101, 102] } },
]);
});
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0xfd4_1', async () => {
const sender = makeSender({ friends: [] });
await FetchFriendListPage.invoke(sender, {});
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xfd4_1');
});
it('maps a page of friends and categories into domain types', async () => {
const body = {
cookie: Uint8Array.from([0xaa]),
friends: [
{
uin: 10001,
uid: 'u1',
customGroup: 7,
additional: [
{
type: 1,
layer1: {
properties: [
{ code: 20002, value: Buffer.from('Alice') },
{ code: 103, value: Buffer.from('A') },
],
},
},
],
},
],
categories: [
{
categoryId: 7,
categoryName: 'Work',
memberCount: 1,
sortId: 1,
},
],
};
const sender = makeSender(body as any);
await expect(FetchFriendListPage.invoke(sender, {} as any)).resolves.toEqual({
entries: [
{
categoryId: 7,
friend: {
uin: 10001,
uid: 'u1',
nickname: 'Alice',
remark: 'A',
categoryId: 7,
},
},
],
categories: [
{
categoryId: 7,
categoryName: 'Work',
memberCount: 1,
sortId: 1,
},
],
cookie: Uint8Array.from([0xaa]),
});
});
it('fills missing friend fields with 0 / empty string', () => {
expect(
FetchFriendListPage.deserialize({} as any, {
friends: [{ additional: [] }],
}),
).toEqual({
entries: [
{
categoryId: 0,
friend: { uin: 0, uid: '', nickname: '0', remark: '', categoryId: 0 },
},
],
categories: [],
cookie: undefined,
});
});
it('encodes a follow-up cookie in the envelope body', async () => {
const sender = makeSender({ friends: [] });
const cookie = Uint8Array.from([0xaa, 0xbb]);
await FetchFriendListPage.invoke(sender, { cookie } as any);
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbFriendListRequest>>(bytes);
expect((env.body as any)?.cookie).toEqual(cookie);
});
it('decodes category metadata and the opaque response cookie', () => {
const bytes = protobuf_encode<OidbBase<TestFriendListResponse>>({
body: {
cookie: Uint8Array.from([0xaa, 0xbb]),
isEnd: false,
categories: [
{
categoryId: 7,
categoryName: 'Work',
memberCount: 2,
sortId: 3,
},
],
},
});
const body = FetchFriendListPage.decode(bytes).body as any;
expect(body.cookie).toEqual(Uint8Array.from([0xaa, 0xbb]));
expect(body.categories).toEqual([
{
categoryId: 7,
categoryName: 'Work',
memberCount: 2,
sortId: 3,
},
]);
});
});
});
@@ -0,0 +1,249 @@
// 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);
});
});
});
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from 'vitest';
import {
protobuf_decode,
protobuf_encode,
type pb,
type pb_repeated,
type uint_32,
} from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbGroupListRequest } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchGroupList } from '../../../src/oidb-services/contacts/fetch-group-list';
interface GroupInfoFixture {
groupName?: pb<5, string>;
shutUpAllTimestamp?: pb<10, uint_32>;
leftover31?: pb<31, uint_32>;
}
interface GroupFixture {
groupUin?: pb<3, uint_32>;
info?: pb<4, GroupInfoFixture>;
}
interface GroupListFixture {
groups?: pb_repeated<2, GroupFixture>;
}
function makeSender() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FetchGroupList namespace', () => {
it('declares 0xFE5_2 with uinForm=true', () => {
expect(FetchGroupList.command).toBe(0xfe5);
expect(FetchGroupList.subCommand).toBe(2);
expect(FetchGroupList.uinForm).toBe(true);
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0xfe5_2 with envelope reserved=1', async () => {
const sender = makeSender();
await FetchGroupList.invoke(sender);
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe5_2');
const env = protobuf_decode<OidbBase<OidbGroupListRequest>>(bytes);
expect(env.reserved).toBe(1);
});
it('requests group remarks without enabling the other costly optional fields', async () => {
const sender = makeSender();
await FetchGroupList.invoke(sender);
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbGroupListRequest>>(bytes);
expect(env.body?.config?.config1?.groupName).toBe(true);
expect(env.body?.config?.config1?.memberCount).toBe(true);
expect(env.body?.config?.config1?.field10).toBe(true);
expect(env.body?.config?.config2?.remark).toBe(true);
for (const field of [
'field1',
'field2',
'field4',
'field5',
'field6',
'field7',
'field8',
] as const) {
expect(env.body?.config?.config2?.[field] ?? false, field).toBe(false);
}
// proto3 default false — omitted on wire, decoded as null.
expect(env.body?.config?.config1?.field5002 ?? false).toBe(false);
expect(env.body?.config?.config1?.field5003 ?? false).toBe(false);
});
it('decodes the group-wide mute timestamp returned with the joined roster', async () => {
const responseData = Buffer.from(
protobuf_encode<OidbBase<GroupListFixture>>({
body: {
groups: [
{
groupUin: 123456789,
info: {
groupName: 'Muted Group',
shutUpAllTimestamp: 4_294_967_295,
},
},
],
},
}),
);
const sender = makeSender();
sender.sendRawPacket.mockResolvedValue({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
});
const out = await FetchGroupList.invoke(sender);
expect(out.groups?.[0]?.info?.shutUpAllTimestamp).toBe(4_294_967_295);
});
it('does not treat 0xFE5 info tag 31 as the mute expire (#356)', async () => {
const responseData = Buffer.from(
protobuf_encode<OidbBase<GroupListFixture>>({
body: {
groups: [
{
groupUin: 123456789,
info: { groupName: 'Unmuted Group', leftover31: 4_294_967_295 },
},
],
},
}),
);
const sender = makeSender();
sender.sendRawPacket.mockResolvedValue({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
});
const out = await FetchGroupList.invoke(sender);
expect(out.groups?.[0]?.info?.shutUpAllTimestamp ?? undefined).toBeUndefined();
});
});
});
@@ -0,0 +1,157 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbSvcTrpcTcp0xFE7_3Response } from '@snowluma/proto-defs/oidb';
import type { OidbGroupMemberListRequest } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchGroupMemberListPage } from '../../../src/oidb-services/contacts/fetch-group-member-list-page';
function makeSender(body?: OidbSvcTrpcTcp0xFE7_3Response) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE7_3Response>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FetchGroupMemberListPage namespace', () => {
it('declares 0xFE7_3', () => {
expect(FetchGroupMemberListPage.command).toBe(0xfe7);
expect(FetchGroupMemberListPage.subCommand).toBe(3);
});
describe('serialize', () => {
it('omits the token on the first page', () => {
const out = FetchGroupMemberListPage.serialize({} as any, {
groupId: 12345,
token: '',
}) as any;
expect(out.token).toBeUndefined();
expect(out.groupUin).toBe(12345);
});
it('threads the server-issued token into follow-up pages', () => {
const out = FetchGroupMemberListPage.serialize({} as any, {
groupId: 12345,
token: 'next-cursor',
}) as any;
expect(out.token).toBe('next-cursor');
});
it('always requests the full member-field projection', () => {
const out = FetchGroupMemberListPage.serialize({} as any, {
groupId: 1,
token: '',
}) as any;
expect(out.body).toMatchObject({
memberName: true,
memberCard: true,
level: true,
joinTimestamp: true,
lastMsgTimestamp: true,
shutUpTimestamp: true,
permission: true,
});
});
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0xfe7_3', async () => {
const sender = makeSender();
await FetchGroupMemberListPage.invoke(sender, { groupId: 1, token: '' });
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xfe7_3');
});
it('encodes envelope body with the groupId', async () => {
const sender = makeSender();
await FetchGroupMemberListPage.invoke(sender, { groupId: 12345, token: '' });
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbGroupMemberListRequest>>(bytes);
expect(env.body?.groupUin).toBe(12345);
});
it('maps members into GroupMemberInfo and leaves isRobot unset-false', async () => {
const sender = makeSender({
token: 'next',
members: [
{
uin: { uid: 'u_admin', uin: 22222 },
memberName: 'Ada',
specialTitle: 't',
memberCard: { memberCard: Buffer.from('card') },
level: { level: 12 },
joinTimestamp: 1,
lastMsgTimestamp: 2,
shutUpTimestamp: 3,
permission: 2,
},
],
});
await expect(
FetchGroupMemberListPage.invoke(sender, { groupId: 42, token: '' }),
).resolves.toEqual({
token: 'next',
members: [
{
uin: 22222,
uid: 'u_admin',
nickname: 'Ada',
card: 'card',
isRobot: false,
role: 'admin',
level: 12,
title: 't',
joinTime: 1,
lastSentTime: 2,
shutUpTime: 3,
},
],
});
});
it('maps permission 1 to owner and missing fields to 0 / empty', () => {
expect(
FetchGroupMemberListPage.deserialize({} as any, {
members: [{ permission: 1 }, {}],
}),
).toEqual({
token: '',
members: [
{
uin: 0,
uid: '',
nickname: '',
card: '',
isRobot: false,
role: 'owner',
level: 0,
title: '',
joinTime: 0,
lastSentTime: 0,
shutUpTime: 0,
},
{
uin: 0,
uid: '',
nickname: '',
card: '',
isRobot: false,
role: 'member',
level: 0,
title: '',
joinTime: 0,
lastSentTime: 0,
shutUpTime: 0,
},
],
});
});
});
});
@@ -0,0 +1,214 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
import type { OidbBase, OidbSvcTrpcTcp0x10C0Response } from '@snowluma/proto-defs/oidb';
import type { OidbGroupRequestList } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import {
FetchGroupRequests,
FetchGroupRequestsByUid,
groupRequestNotifyType,
} from '../../../src/oidb-services/contacts/fetch-group-requests';
interface ObservedGroupRequestUserByUin {
uin?: pb<1, uint_32>;
name?: pb<2, string>;
}
interface ObservedGroupRequestGroup {
groupUin?: pb<1, uint_32>;
groupName?: pb<2, string>;
}
interface ObservedGroupRequestByUin {
sequence?: pb<1, uint_64>;
eventType?: pb<2, uint_32>;
state?: pb<3, uint_32>;
group?: pb<4, ObservedGroupRequestGroup>;
target?: pb<5, ObservedGroupRequestUserByUin>;
invitor?: pb<6, ObservedGroupRequestUserByUin>;
operatorUser?: pb<7, ObservedGroupRequestUserByUin>;
comment?: pb<10, string>;
}
interface ObservedGroupRequestResponseByUin {
requests?: pb_repeated<1, ObservedGroupRequestByUin>;
cursor?: pb<2, uint_64>;
}
function makeSender() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FetchGroupRequests namespace', () => {
it('interprets list discriminators in the wire-to-notification direction', () => {
expect(groupRequestNotifyType(1)).toBe(7);
expect(groupRequestNotifyType(2)).toBe(1);
expect(groupRequestNotifyType(7)).toBe(10);
expect(groupRequestNotifyType(10)).toBe(2);
expect(groupRequestNotifyType(22)).toBe(5);
expect(groupRequestNotifyType(100)).toBe(17);
expect(groupRequestNotifyType(5)).toBeUndefined();
expect(groupRequestNotifyType(99)).toBeUndefined();
});
it('declares 0x10C0', () => {
expect(FetchGroupRequests.command).toBe(0x10c0);
expect(FetchGroupRequests.uinForm).toBe(true);
expect((FetchGroupRequestsByUid as { uinForm?: boolean }).uinForm).toBeUndefined();
});
describe('resolveSubCommand', () => {
it('returns 1 when filtered=false (main inbox)', () => {
expect(FetchGroupRequests.resolveSubCommand({ filtered: false })).toBe(1);
});
it('returns 2 when filtered=true (low-priority inbox)', () => {
expect(FetchGroupRequests.resolveSubCommand({ filtered: true })).toBe(2);
});
});
describe('serialize', () => {
it('defaults to one native 50-item screen at cursor zero', () => {
expect(FetchGroupRequests.serialize({} as any, { filtered: false })).toEqual({
count: 50,
field2: 0n,
});
expect(FetchGroupRequestsByUid.serialize({} as any, { filtered: true })).toEqual({
count: 50,
field2: 0n,
});
});
it('preserves a caller-provided count and 64-bit cursor', () => {
const cursor = 1_785_406_628_779_279n;
expect(
FetchGroupRequests.serialize({} as any, {
filtered: false,
count: 80,
cursor,
}),
).toEqual({ count: 80, field2: cursor });
});
});
describe('invoke (e2e)', () => {
it('routes to 0x10c0_1 for the main inbox', async () => {
const sender = makeSender();
await FetchGroupRequests.invoke(sender, { filtered: false });
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x10c0_1');
});
it('routes to 0x10c0_2 for the filtered inbox', async () => {
const sender = makeSender();
await FetchGroupRequests.invoke(sender, { filtered: true });
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x10c0_2');
});
it('encodes the native envelope and requested screen', async () => {
const sender = makeSender();
await FetchGroupRequests.invoke(sender, {
filtered: false,
count: 80,
cursor: 1_785_406_628_779_279n,
});
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbGroupRequestList>>(bytes);
expect(env.reserved).toBe(1);
expect(env.body).toEqual({ count: 80, field2: 1_785_406_628_779_279n });
});
it('decodes numeric account identifiers from the native response', async () => {
const responseData = Buffer.from(
protobuf_encode<OidbBase<ObservedGroupRequestResponseByUin>>({
body: {
requests: [
{
sequence: 1_785_525_232_784_291n,
eventType: 1,
state: 1,
group: { groupUin: 1_095_186_374, groupName: 'group' },
target: { uin: 1_234_567_890, name: 'requester' },
invitor: { uin: 2_345_678_901, name: 'inviter' },
operatorUser: { uin: 3_456_789_012, name: 'operator' },
comment: 'please',
},
],
cursor: 1_785_406_628_779_279n,
},
}),
);
const result: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
const sender = { sendRawPacket: vi.fn(async () => result) };
const response = await FetchGroupRequests.invoke(sender, { filtered: false });
expect(response.requests?.[0]).toMatchObject({
eventType: 1,
target: { uin: 1_234_567_890, uid: '', name: 'requester' },
invitor: { uin: 2_345_678_901, uid: '', name: 'inviter' },
operatorUser: { uin: 3_456_789_012, uid: '', name: 'operator' },
});
expect(response.field2).toBe(1_785_406_628_779_279n);
});
it('keeps string account identifiers from the native reply', async () => {
const responseData = Buffer.from(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x10C0Response>>({
body: {
requests: [
{
sequence: 99n,
eventType: 22,
state: 1,
group: { groupUin: 1001, groupName: 'group' },
target: { uid: 'u_target', name: 'invitee' },
invitor: { uid: 'u_inviter', name: 'inviter' },
comment: 'join us',
},
],
},
}),
);
const sender = {
sendRawPacket: vi.fn(async () => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
})),
};
const response = await FetchGroupRequests.invoke(sender, { filtered: false });
expect(response.requests?.[0]).toMatchObject({
eventType: 22,
target: { uid: 'u_target', uin: 0, name: 'invitee' },
invitor: { uid: 'u_inviter', uin: 0, name: 'inviter' },
comment: 'join us',
});
});
it('keeps the UID response path on the non-native envelope', async () => {
const sender = makeSender();
await FetchGroupRequestsByUid.invoke(sender, { filtered: false });
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbGroupRequestList>>(bytes);
expect(env.reserved ?? 0).toBe(0);
});
});
});
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbRobotUinRangeRequest } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchRobotUinRanges } from '../../../src/oidb-services/contacts/fetch-robot-uin-ranges';
// Captured from QQ's 0x496_0 response and reduced to the versioned range
// config. Keeping this as wire bytes makes the test independent of the
// encoder used by the implementation.
const RESPONSE_FIXTURE = Buffer.from(
'0896091000180022492a4708ce01120c08fee8fdb20c10fee8fdb20c' +
'120c08cda8fed00a10cfc5ffd00a120a08c0f8e01f10c0f8e01f12' +
'0c08c0dcb5be0e10ffe0f2be0e120c0880fd8ef80e10ffa9f1fc0e',
'hex',
);
function makeSender(responseData = RESPONSE_FIXTURE) {
const result: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => result) };
}
describe('FetchRobotUinRanges namespace', () => {
it('routes the read-only request to 0x496_0 with QQ-compatible fields', async () => {
const sender = makeSender();
await FetchRobotUinRanges.invoke(sender);
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0x496_0');
const request = protobuf_decode<OidbBase<OidbRobotUinRangeRequest>>(bytes);
expect(request.body).toEqual({
justFetchMsgConfig: 1,
type: 1,
version: 0,
aioKeywordVersion: 0,
});
});
it('decodes the server version and every inclusive UIN range', async () => {
const sender = makeSender();
await expect(FetchRobotUinRanges.invoke(sender)).resolves.toEqual({
version: 206,
ranges: [
{ minUin: 3328144510, maxUin: 3328144510 },
{ minUin: 2854196301, maxUin: 2854216399 },
{ minUin: 66600000, maxUin: 66600000 },
{ minUin: 3889000000, maxUin: 3889999999 },
{ minUin: 4010000000, maxUin: 4019999999 },
],
});
});
it('rejects a successful envelope that omits the robot range config', async () => {
const sender = makeSender(Buffer.from('089609100018002200', 'hex'));
await expect(FetchRobotUinRanges.invoke(sender)).rejects.toThrow(
'0x496_0 response missing robot range config',
);
});
it('rejects a robot config without its version', () => {
expect(() =>
FetchRobotUinRanges.deserialize(makeSender(), {
robotConfig: {
ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }],
},
}),
).toThrow('0x496_0 response missing robot range config version');
});
it('rejects an empty range set instead of classifying everyone as human', () => {
expect(() =>
FetchRobotUinRanges.deserialize(makeSender(), {
robotConfig: { version: 206, ranges: [] },
}),
).toThrow('0x496_0 response has no robot UIN ranges');
});
});
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbUserInfoByUidRequest,
OidbUserInfoResponse,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchUserProfileByUid } from '../../../src/oidb-services/contacts/fetch-user-profile-by-uid';
function makeSender(body?: OidbUserInfoResponse) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbUserInfoResponse>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FetchUserProfileByUid namespace (UID form)', () => {
it('declares 0xFE1_2 WITHOUT uinForm (reserved stays 0)', () => {
expect(FetchUserProfileByUid.command).toBe(0xfe1);
expect(FetchUserProfileByUid.subCommand).toBe(2);
// No `uinForm` exposed — matches Lagrange's isUid=false path on
// the UID variant. Important because the server distinguishes
// UIN vs UID requests via the envelope's `reserved` field.
expect((FetchUserProfileByUid as any).uinForm).toBeUndefined();
});
it('packages the uid as field 1 (string), routes to OidbSvcTrpcTcp.0xfe1_2', async () => {
const sender = makeSender({ body: { uin: 950929451, uid: 'u_abc' } as any });
await FetchUserProfileByUid.invoke(sender, { uid: 'u_abc' });
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
const env = protobuf_decode<OidbBase<OidbUserInfoByUidRequest>>(bytes);
expect(env.body?.uid).toBe('u_abc');
// reserved=0 (default, omitted on wire) because this is the UID
// variant — Lagrange's `OidbSvcTrpcTcp0xFE1_2.cs:9` does the same.
expect(env.reserved ?? 0).toBe(0);
});
it('requests the same property key catalogue as the UIN variant', async () => {
const sender = makeSender({ body: { uin: 1, uid: 'u' } as any });
await FetchUserProfileByUid.invoke(sender, { uid: 'u' });
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbUserInfoByUidRequest>>(bytes);
const keys = env.body?.keys?.map((k) => k.key);
expect(keys).toContain(20002); // nickname — the whole point
expect(keys).toContain(105); // QQ level
expect(keys).toContain(40410);
expect(keys).toContain(42031);
});
it('decodes qidian flags from number-properties (0 when absent)', async () => {
const qidian = makeSender({
body: {
uin: 10002,
uid: 'u',
properties: {
bytesProperties: [],
numberProperties: [
{ number1: 40410, number2: 1 },
{ number1: 42031, number2: 1 },
],
},
} as any,
});
const out = await FetchUserProfileByUid.invoke(qidian, { uid: 'u' });
expect(out.qidianMasterFlag).toBe(1);
expect(out.qidianCrewFlag).toBe(1);
expect(out.qidianCrewFlag2).toBe(0);
const normal = makeSender({
body: {
uin: 10001,
uid: 'u',
properties: {
bytesProperties: [],
numberProperties: [{ number1: 20009, number2: 1 }],
},
} as any,
});
const out2 = await FetchUserProfileByUid.invoke(normal, { uid: 'u' });
expect(out2.qidianMasterFlag).toBe(0);
expect(out2.qidianCrewFlag).toBe(0);
expect(out2.qidianCrewFlag2).toBe(0);
});
it('decodes nickname + uin from the response so the pipeline can fill them into the event', async () => {
// This is the actual point of the namespace — the stranger's
// nickname + uin lands here so the pipeline can patch them onto
// the in-flight group_invite event.
const enc = (s: string) => new TextEncoder().encode(s);
const sender = makeSender({
body: {
uin: 950929451,
uid: 'u_abc',
properties: {
bytesProperties: [{ code: 20002, value: enc('小明') }],
numberProperties: [],
},
} as any,
});
const out = await FetchUserProfileByUid.invoke(sender, { uid: 'u_abc' });
expect(out.uin).toBe(950929451);
expect(out.nickname).toBe('小明');
});
it('throws when the response body is missing entirely', async () => {
const sender = makeSender({});
await expect(FetchUserProfileByUid.invoke(sender, { uid: 'u_abc' })).rejects.toThrow(
'user info response body missing',
);
});
});
@@ -0,0 +1,341 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
AvatarInfo,
OidbUserInfoRequest,
OidbUserInfoByUidRequest,
OidbUserInfoResponse,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchUserProfile } from '../../../src/oidb-services/contacts/fetch-user-profile';
import { FetchUserProfileByUid } from '../../../src/oidb-services/contacts/fetch-user-profile-by-uid';
function makeSender(body?: OidbUserInfoResponse) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbUserInfoResponse>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FetchUserProfile namespace', () => {
it('declares 0xFE1_2 with uinForm=true', () => {
expect(FetchUserProfile.command).toBe(0xfe1);
expect(FetchUserProfile.subCommand).toBe(2);
expect(FetchUserProfile.uinForm).toBe(true);
});
describe('invoke (e2e)', () => {
it.each(['uin', 'uid'])(
'preserves registration and membership through the %s request',
async (form) => {
const sender = makeSender({
body: {
uin: 10001,
uid: 'u_test',
properties: {
numberProperties: [
{ number1: 20026, number2: 1_200_000_000 },
{ number1: 41756, number2: 0x103 },
{ number1: 41757, number2: 1 << 5 },
{ number1: 42241, number2: 0 },
],
},
},
});
const profile =
form === 'uin'
? await FetchUserProfile.invoke(sender, { uin: 10001 })
: await FetchUserProfileByUid.invoke(sender, { uid: 'u_test' });
expect(profile).toMatchObject({
regTime: 1_200_000_000,
vipFlag: true,
yearVipFlag: true,
svipFlag: true,
vipLevel: 7,
});
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const request =
form === 'uin'
? protobuf_decode<OidbBase<OidbUserInfoRequest>>(bytes)
: protobuf_decode<OidbBase<OidbUserInfoByUidRequest>>(bytes);
expect(request.body?.keys?.map((k) => k.key)).toEqual(
expect.arrayContaining([20026, 41756, 41757, 42241]),
);
},
);
it.each([
[0, 0, 0],
[1, 0, 1],
[0x100, 1, 2],
[0x103, 0x801, 13],
])(
'decodes membership flags %i and level mask %i as level %i',
async (flags, mask, level) => {
const sender = makeSender({
body: {
uin: 10001,
properties: {
numberProperties: [
{ number1: 41756, number2: flags },
{ number1: 41757, number2: mask },
{ number1: 42241, number2: 0 },
],
},
},
});
expect(await FetchUserProfile.invoke(sender, { uin: 10001 })).toMatchObject({
vipFlag: !!(flags & 1),
yearVipFlag: !!(flags & 2),
svipFlag: !!(flags & 0x100),
vipLevel: level,
profile: { vipLevel: level },
});
},
);
it('keeps missing registration and membership fields unavailable', async () => {
const profile = await FetchUserProfile.invoke(makeSender({ body: { uin: 10001 } }), {
uin: 10001,
});
for (const name of [
'regTime',
'vipFlag',
'yearVipFlag',
'svipFlag',
'vipLevel',
'birthdayYear',
'birthdayMonth',
'birthdayDay',
]) {
expect(profile).not.toHaveProperty(name);
}
});
it.each(['uin', 'uid'])(
'decodes the birthday bytes-property through the %s request',
async (form) => {
const sender = makeSender({
body: {
uin: 10001,
uid: 'u_test',
properties: {
bytesProperties: [
{ code: 20031, value: new Uint8Array([0x07, 0xd0, 3, 15]) },
],
},
},
});
const profile =
form === 'uin'
? await FetchUserProfile.invoke(sender, { uin: 10001 })
: await FetchUserProfileByUid.invoke(sender, { uid: 'u_test' });
expect(profile).toMatchObject({
birthdayYear: 2000,
birthdayMonth: 3,
birthdayDay: 15,
});
},
);
it.each([
[12, 31],
[2, 29],
])('keeps a hidden-year birthday %i/%i', async (month, day) => {
const sender = makeSender({
body: {
uin: 10001,
properties: {
bytesProperties: [
{ code: 20031, value: new Uint8Array([0, 0, month, day]) },
],
},
},
});
const profile = await FetchUserProfile.invoke(sender, { uin: 10001 });
expect(profile).toMatchObject({ birthdayMonth: month, birthdayDay: day });
expect(profile).not.toHaveProperty('birthdayYear');
});
it.each([
['an unset birthday', [0x07, 0xd0, 0, 0]],
['an all-zero value', [0, 0, 0, 0]],
['an out-of-range month', [0x07, 0xd0, 13, 1]],
['an out-of-range day', [0x07, 0xd0, 1, 32]],
['April 31', [0x07, 0xd0, 4, 31]],
['a leap day in 1900', [0x07, 0x6c, 2, 29]],
['a truncated value', [0x07, 0xd0, 3]],
['an oversized value', [0x07, 0xd0, 3, 15, 0]],
])('leaves the birthday unavailable for %s', async (_case, value) => {
const sender = makeSender({
body: {
uin: 10001,
properties: {
bytesProperties: [{ code: 20031, value: new Uint8Array(value) }],
},
},
});
const profile = await FetchUserProfile.invoke(sender, { uin: 10001 });
for (const name of ['birthdayYear', 'birthdayMonth', 'birthdayDay']) {
expect(profile).not.toHaveProperty(name);
}
});
it('routes to OidbSvcTrpcTcp.0xfe1_2 with reserved=1', async () => {
const sender = makeSender({ body: { uin: 10001, uid: 'u' } as any });
await FetchUserProfile.invoke(sender, { uin: 10001 });
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
const env = protobuf_decode<OidbBase<OidbUserInfoRequest>>(bytes);
expect(env.reserved).toBe(1);
});
it('requests every property key in the catalogue (nickname, level, …)', async () => {
const sender = makeSender({ body: { uin: 10001, uid: 'u' } as any });
await FetchUserProfile.invoke(sender, { uin: 10001 });
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbUserInfoRequest>>(bytes);
const keys = env.body?.keys?.map((k) => k.key);
expect(keys).toContain(20002); // nickname
expect(keys).toContain(105); // level
expect(keys).toContain(101); // avatar
expect(keys).toContain(102); // sign
expect(keys).toContain(103); // remark
expect(keys).toContain(20031); // birthday
expect(keys).toContain(40410); // qidian crew flag (企点员工标志)
expect(keys).toContain(42031); // qidian master flag (企点主号标志)
});
it('decodes qidian flags from number-properties (0 when absent)', async () => {
// 企点员工号:两个标志均为 1;普通账号服务器不返回这两个 key → 缺省 0
const qidian = makeSender({
body: {
uin: 10002,
uid: 'u',
properties: {
bytesProperties: [],
numberProperties: [
{ number1: 40410, number2: 1 },
{ number1: 42031, number2: 1 },
],
},
} as any,
});
const out = await FetchUserProfile.invoke(qidian, { uin: 10002 });
expect(out.qidianMasterFlag).toBe(1);
expect(out.qidianCrewFlag).toBe(1);
expect(out.qidianCrewFlag2).toBe(0);
// 普通账号:无企点 key → 全 0
const normal = makeSender({
body: {
uin: 10001,
uid: 'u',
properties: {
bytesProperties: [],
numberProperties: [{ number1: 20009, number2: 1 }],
},
} as any,
});
const out2 = await FetchUserProfile.invoke(normal, { uin: 10001 });
expect(out2.qidianMasterFlag).toBe(0);
expect(out2.qidianCrewFlag).toBe(0);
expect(out2.qidianCrewFlag2).toBe(0);
});
it('decodes nickname / remark / qid / sign from bytes-properties', async () => {
const enc = (s: string) => new TextEncoder().encode(s);
const sender = makeSender({
body: {
uin: 10001,
uid: 'u',
properties: {
bytesProperties: [
{ code: 20002, value: enc('Nick') },
{ code: 103, value: enc('Bestie') },
{ code: 27394, value: enc('myqid') },
{ code: 102, value: enc('Hello') },
],
numberProperties: [],
},
} as any,
});
const out = await FetchUserProfile.invoke(sender, { uin: 10001 });
expect(out.nickname).toBe('Nick');
expect(out.remark).toBe('Bestie');
expect(out.qid).toBe('myqid');
expect(out.sign).toBe('Hello');
});
it('decodes sex / age / level from number-properties (sex enum)', async () => {
const cases: Array<[number, string]> = [
[1, 'male'],
[2, 'female'],
[255, 'unknown'],
[0, 'unknown'],
];
for (const [sexNum, expected] of cases) {
const sender = makeSender({
body: {
uin: 1,
uid: 'u',
properties: {
bytesProperties: [],
numberProperties: [
{ number1: 20009, number2: sexNum },
{ number1: 20037, number2: 25 },
{ number1: 105, number2: 7 },
],
},
} as any,
});
const out = await FetchUserProfile.invoke(sender, { uin: 1 });
expect(out.sex).toBe(expected);
expect(out.age).toBe(25);
expect(out.level).toBe(7);
}
});
it('decodes the avatar URL with the "640" size suffix appended', async () => {
const avatarBytes = protobuf_encode<AvatarInfo>({ url: 'https://q.qlogo.cn/abc/' });
const sender = makeSender({
body: {
uin: 10001,
uid: 'u',
properties: {
bytesProperties: [{ code: 101, value: avatarBytes }],
numberProperties: [],
},
} as any,
});
const out = await FetchUserProfile.invoke(sender, { uin: 10001 });
expect(out.avatar).toBe('https://q.qlogo.cn/abc/640');
});
it('defaults uin to the requested value when the server omits its echo', async () => {
const sender = makeSender({ body: { uid: 'u' } as any });
const out = await FetchUserProfile.invoke(sender, { uin: 99999 });
expect(out.uin).toBe(99999);
});
it('throws when body is entirely missing from the envelope', async () => {
const sender = makeSender({});
await expect(FetchUserProfile.invoke(sender, { uin: 1 })).rejects.toThrow(
'user info response body missing',
);
});
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbBuddyRecommendArkResp } from '@snowluma/proto-defs/oidb-actions/contact-ark';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetBuddyRecommendArk } from '../../../src/oidb-services/contacts/get-buddy-recommend-ark';
import { env, v, s } from '../_pb-oracle';
function makeSender(ark = '{"app":"com.tencent.contact.lua"}') {
const respEnv: OidbBase<OidbBuddyRecommendArkResp> = {
command: 0x12b6,
subCommand: 0,
body: { ark },
};
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(protobuf_encode<OidbBase<OidbBuddyRecommendArkResp>>(respEnv)),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetBuddyRecommendArk namespace', () => {
it('declares command 0x12b6 sub 0, plain OIDB envelope', () => {
expect(GetBuddyRecommendArk.command).toBe(0x12b6);
expect(GetBuddyRecommendArk.subCommand).toBe(0);
expect(GetBuddyRecommendArk.uinForm).toBe(false);
});
it('serializes uin + phone placeholder + the hard-coded jump_url template', () => {
const out = GetBuddyRecommendArk.serialize({} as any, { userId: 5 });
expect(out).toMatchObject({ uin: 5, phoneNumber: '-' });
expect(out.jumpUrl).toBe(
'mqqapi://card/show_pslcard?src_type=internal&source=sharecard&version=1&uin=5',
);
});
it('byte-oracle: routes to 0x12b6_0 and locks req tags {1:uin, 2:phone, 3:jumpUrl}', async () => {
const sender = makeSender();
const ark = await GetBuddyRecommendArk.invoke(sender, { userId: 10000 });
expect(ark).toBe('{"app":"com.tencent.contact.lua"}');
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x12b6_0');
const jumpUrl =
'mqqapi://card/show_pslcard?src_type=internal&source=sharecard&version=1&uin=10000';
const body = [...v(1, 10000), ...s(2, '-'), ...s(3, jumpUrl)];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x12b6, 0, body, false));
});
it('includes phone tag 2 when a phone number is provided', () => {
const out = GetBuddyRecommendArk.serialize({} as any, { userId: 7, phoneNumber: '123' });
expect(out.phoneNumber).toBe('123');
});
it('deserialize returns the ark json string ("" when absent)', () => {
expect(GetBuddyRecommendArk.deserialize({} as any, { ark: 'x' })).toBe('x');
expect(GetBuddyRecommendArk.deserialize({} as any, {})).toBe('');
});
});
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbGroupRecommendArkResp } from '@snowluma/proto-defs/oidb-actions/contact-ark';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetGroupRecommendArk } from '../../../src/oidb-services/contacts/get-group-recommend-ark';
import { env, v } from '../_pb-oracle';
function makeSender(arkJson = '{"app":"com.tencent.structmsg"}') {
const respEnv: OidbBase<OidbGroupRecommendArkResp> = {
command: 0x8b7,
subCommand: 5,
body: { errCode: 0, arkJson },
};
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(protobuf_encode<OidbBase<OidbGroupRecommendArkResp>>(respEnv)),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetGroupRecommendArk namespace', () => {
it('declares command 0x8B7 sub 5, uin form', () => {
expect(GetGroupRecommendArk.command).toBe(0x8b7);
expect(GetGroupRecommendArk.subCommand).toBe(5);
expect(GetGroupRecommendArk.uinForm).toBe(true);
});
it('serializes the constant reqType=1 / flag=1 + group code', () => {
expect(GetGroupRecommendArk.serialize({} as any, { groupId: 999 })).toEqual({
reqType: 1,
groupCode: 999,
flag: 1,
});
});
it('byte-oracle: routes to 0x8b7_5 and locks req tags {1:reqType, 2:groupCode, 5:flag}', async () => {
const sender = makeSender();
const ark = await GetGroupRecommendArk.invoke(sender, { groupId: 12345 });
expect(ark).toBe('{"app":"com.tencent.structmsg"}');
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x8b7_5');
const body = [...v(1, 1), ...v(2, 12345), ...v(5, 1)];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x8b7, 5, body, true));
});
it('deserialize returns the ark json from response field 5', () => {
expect(GetGroupRecommendArk.deserialize({} as any, { errCode: 0, arkJson: 'g' })).toBe('g');
expect(GetGroupRecommendArk.deserialize({} as any, {})).toBe('');
});
});
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from 'vitest';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetGroupShareLink } from '../../../src/oidb-services/contacts/get-group-share-link';
import type { OidbSender } from '../../../src/oidb-service';
import { env, m, s, v } from '../_pb-oracle';
const shortUrl = 'https://qm.qq.com/q/example';
const params: GetGroupShareLink.Params = {
groupId: 12345,
needShortUrl: true,
srcId: 73,
additionalParam: '',
};
function sender(body: number[] = m(12, s(1, shortUrl))) {
const packet: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(env(0x9060, 3, body, true), 'hex'),
};
return {
packet,
sendRawPacket: vi.fn<OidbSender['sendRawPacket']>().mockResolvedValue(packet),
};
}
describe('GetGroupShareLink', () => {
it('requests a short group link using the verified packet layout', async () => {
const deps = sender();
await expect(GetGroupShareLink.invoke(deps, params)).resolves.toBe(shortUrl);
const body = [...v(1, 2), ...m(12, [...v(1, 12345), ...s(3, ''), ...v(4, 1)])];
expect(deps.sendRawPacket).toHaveBeenCalledExactlyOnceWith(
'OidbSvcTrpcTcp.0x9060_3',
new Uint8Array(Buffer.from(env(0x9060, 3, body, true), 'hex')),
);
});
it('preserves a false short-link flag, additional parameters, and the returned long URL', async () => {
const url = 'https://qm.qq.com/cgi-bin/qm/qr?k=example&jump_from=webapi';
const additionalParam = 'source=群二维码&test=1';
const deps = sender(m(12, s(1, url)));
await expect(
GetGroupShareLink.invoke(deps, { ...params, needShortUrl: false, additionalParam }),
).resolves.toBe(url);
const body = [...v(1, 2), ...m(12, [...v(1, 12345), ...s(3, additionalParam), ...v(4, 0)])];
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
env(0x9060, 3, body, true),
);
});
it.each([0, 73, 0xffffffff])(
'retains client-compatible behavior for source %s',
async (srcId) => {
const deps = sender();
await GetGroupShareLink.invoke(deps, { ...params, srcId });
const body = [...v(1, 2), ...m(12, [...v(1, 12345), ...s(3, ''), ...v(4, 1)])];
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
env(0x9060, 3, body, true),
);
},
);
it.each([
['missing result', []],
['missing URL', m(12, [])],
['empty URL', m(12, s(1, ''))],
['blank URL', m(12, s(1, ' '))],
] as const)('rejects a successful response with %s', async (_name, body) => {
await expect(GetGroupShareLink.invoke(sender([...body]), params)).rejects.toThrow(
'group share link response did not include a URL',
);
});
it('preserves server errors even if the response includes a URL', async () => {
const deps = sender();
deps.packet.responseData = Buffer.from([
...v(1, 0x9060),
...v(2, 3),
...v(3, 403),
...m(4, m(12, s(1, shortUrl))),
...s(5, 'permission denied'),
]);
await expect(GetGroupShareLink.invoke(deps, params)).rejects.toMatchObject({
name: 'OidbError',
code: 403,
serverMsg: 'permission denied',
});
});
it('reports a transport timeout instead of inventing a URL', async () => {
const deps = sender();
deps.packet.success = false;
deps.packet.gotResponse = false;
deps.packet.errorMessage = 'request timed out';
await expect(GetGroupShareLink.invoke(deps, params)).rejects.toThrow('request timed out');
});
it('rejects a truncated response', async () => {
const deps = sender();
deps.packet.responseData = Buffer.from([0x22, 0x03, 0x62, 0xff]);
await expect(GetGroupShareLink.invoke(deps, params)).rejects.toThrow();
});
});
@@ -0,0 +1,176 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbSvcTrpcTcp0xFD4_1Response } from '@snowluma/proto-defs/oidb';
import type { OidbUserInfoResponse } from '@snowluma/proto-defs/oidb-actions/base';
import { FetchFriendListPage } from '../../../src/oidb-services/contacts/fetch-friend-list-page';
import { FetchUserProfile } from '../../../src/oidb-services/contacts/fetch-user-profile';
import { FetchUserProfileByUid } from '../../../src/oidb-services/contacts/fetch-user-profile-by-uid';
import { decodePublicProfile } from '../../../src/oidb-services/contacts/public-profile';
// Synthetic wire fixture, independent of the privilege schema: top-level tag 3;
// body tags 2 (jump), 3 (open), 4 (closed); both entry URLs are present.
const icons = Buffer.from(
'1a2c1204636172641a1e08022000280032066e6f726d616c3a0664656c75786542046a756d704801220408032001',
'hex',
);
const bytes = [
{ code: 102, value: Buffer.from('') },
{ code: 20011, value: Buffer.from('mail@example.test') },
{ code: 20016, value: Buffer.from('') },
{ code: 20003, value: Buffer.from('中国') },
{ code: 20004, value: Buffer.from('省份') },
{ code: 20020, value: Buffer.from('城市') },
{ code: 20005, value: Buffer.from('邮编') },
{ code: 20021, value: Buffer.from('学校') },
{ code: 20031, value: Uint8Array.from([0, 0, 2, 29]) },
{ code: 107, value: icons },
];
const numbers = [
[20037, 0],
[105, 0],
[20009, 2],
[41756, 0],
[41757, 0],
[42241, 0],
] as const;
function sender(responseData: Uint8Array) {
return {
sendRawPacket: vi.fn(async () => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(responseData),
})),
};
}
describe('public contact profiles', () => {
it.each(['roster', 'uin', 'uid'])(
'preserves presence and privilege lists through the %s wire codec',
async (form) => {
let profile;
if (form === 'roster') {
const page = await FetchFriendListPage.invoke(
sender(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({
body: {
friends: [
{
uin: 10001,
customGroup: 0,
additional: [
{
type: 1,
layer1: {
properties: bytes,
numberProperties: numbers.map(
([key, value]) => ({ key, value }),
),
},
},
],
},
],
},
}),
),
{},
);
expect(page.entries[0]?.friend.categoryId).toBe(0);
profile = page.entries[0]?.friend.profile;
} else {
const deps = sender(
protobuf_encode<OidbBase<OidbUserInfoResponse>>({
body: {
body: {
uin: 10001,
uid: 'u_test',
properties: {
bytesProperties: bytes,
numberProperties: numbers.map(([number1, number2]) => ({
number1,
number2,
})),
},
},
},
}),
);
const user =
form === 'uin'
? await FetchUserProfile.invoke(deps, { uin: 10001 })
: await FetchUserProfileByUid.invoke(deps, { uid: 'u_test' });
profile = user.profile;
}
expect(profile).toEqual({
sign: '',
age: 0,
level: 0,
sex: 'female',
email: 'mail@example.test',
phoneNum: '',
country: '中国',
province: '省份',
city: '城市',
college: '学校',
birthdayMonth: 2,
birthdayDay: 29,
vipFlag: false,
svipFlag: false,
yearVipFlag: false,
vipLevel: 0,
privilegeIcons: {
jumpUrl: 'card',
open: [
{
type: 2,
level: 0,
flag: 0,
iconUrl: 'normal',
deluxeIconUrl: 'deluxe',
jumpUrl: 'jump',
isBig: 1,
},
],
closed: [{ type: 3, level: 1 }],
},
});
},
);
it('distinguishes missing, unavailable and explicitly empty icon lists', () => {
expect(decodePublicProfile(new Map(), new Map())).toEqual({});
expect(decodePublicProfile(new Map([[107, Buffer.alloc(0)]]), new Map())).toEqual({});
expect(decodePublicProfile(new Map([[107, Buffer.from([0x1a, 0])]]), new Map())).toEqual({
privilegeIcons: { open: [], closed: [] },
});
});
it('rejects truncated icons and invalid text without returning successful empty data', () => {
expect(() =>
decodePublicProfile(new Map([[107, Buffer.from([0x1a, 0xff])]]), new Map()),
).toThrow('invalid privilege data');
expect(() =>
decodePublicProfile(new Map([[107, Buffer.from([0x18, 0])]]), new Map()),
).toThrow('invalid privilege data');
expect(() =>
decodePublicProfile(new Map([[107, Buffer.from('1a041a020a00', 'hex')]]), new Map()),
).toThrow('invalid privilege data');
expect(() =>
decodePublicProfile(new Map([[20011, Buffer.from([0xff])]]), new Map()),
).toThrow('invalid UTF-8');
});
it('rejects a profile response belonging to a different requested account', async () => {
const deps = sender(
protobuf_encode<OidbBase<OidbUserInfoResponse>>({ body: { body: { uin: 10002 } } }),
);
await expect(FetchUserProfile.invoke(deps, { uin: 10001 })).rejects.toThrow(
'does not match',
);
});
});
@@ -0,0 +1,163 @@
import { describe, expect, it, vi } from 'vitest';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SendTuwenArk } from '../../../src/oidb-services/contacts/send-tuwen-ark';
import { env, v, s, m } from '../_pb-oracle';
function makeSender(body = m(1, [...v(2, 10001), ...v(4, 0)])) {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(env(0xdc2, 34, body, false), 'hex'),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('SendTuwenArk namespace', () => {
it('declares command 0xdc2 sub 34, SSO: OidbSvcTrpcTcp.0xdc2_34', () => {
expect(SendTuwenArk.command).toBe(0xdc2);
expect(SendTuwenArk.subCommand).toBe(34);
expect(SendTuwenArk.uinForm).toBe(false);
});
it('serializes fixed appId=100446242, field2=1, field3=0, field5={1:1}', () => {
const out = SendTuwenArk.serialize({} as any, {
targetId: 12345,
peerType: 1,
title: 'T',
desc: 'D',
summary: 'S',
jumpUrl: 'https://example.com',
previewUrl: '',
});
expect(out.appInfo).toMatchObject({
appId: 100446242,
field2: 1,
field3: 0,
field5: { field1: 1 },
targetId: 12345,
});
});
it('byte-oracle: C2C (peerType=0) — peerType omitted on wire, field3=0 present, previewUrl present', async () => {
const sender = makeSender();
await SendTuwenArk.invoke(sender, {
targetId: 2863253201,
peerType: 0,
title: 'QQ开放平台',
desc: 'QQ小程序是连接广轻用户的新方式,覆盖8亿新生代活跃网民。轻便快捷的开发模式,将能在QQ内被轻松获取和传播',
summary: '[分享] QQ开放平台',
jumpUrl: 'https://q.qq.com/r',
previewUrl:
'https://tangram-1251316161.file.myqcloud.com/files/20210721/e50a8e37e08f29bf1ffc7466e1950690.png',
});
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0xdc2_34');
// AppInfo embedding: field5={1:1}, content={1:1, 10:title, 11:desc, 12:summary, 13:jumpUrl, 14:previewUrl}, then appId/field2/field3/targetId
const field5 = m(5, [...v(1, 1)]);
const content = m(12, [
...v(1, 1),
...s(10, 'QQ开放平台'),
...s(
11,
'QQ小程序是连接广轻用户的新方式,覆盖8亿新生代活跃网民。轻便快捷的开发模式,将能在QQ内被轻松获取和传播',
),
...s(12, '[分享] QQ开放平台'),
...s(13, 'https://q.qq.com/r'),
...s(
14,
'https://tangram-1251316161.file.myqcloud.com/files/20210721/e50a8e37e08f29bf1ffc7466e1950690.png',
),
]);
const appInfo = m(1, [
...v(1, 100446242),
...v(2, 1),
...v(3, 0), // field3=0 is present in wire (pb_optional preserves zero as expected)
...field5,
...v(11, 2863253201),
...content,
]);
// Meta: peerType=0 is present in wire (pb_optional preserves zero as expected)
const meta = m(2, [...v(1, 0), ...v(2, 2863253201)]);
const body = [...appInfo, ...meta];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xdc2, 34, body, false));
});
it('byte-oracle: group (peerType=1) — peerType present, field3=0 present, empty previewUrl present', async () => {
const sender = makeSender();
await SendTuwenArk.invoke(sender, {
targetId: 123456789,
peerType: 1,
title: '测试标题',
desc: '测试描述',
summary: '测试摘要',
jumpUrl: 'https://example.com/jump',
previewUrl: '',
});
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0xdc2_34');
const field5 = m(5, [...v(1, 1)]);
const content = m(12, [
...v(1, 1),
...s(10, '测试标题'),
...s(11, '测试描述'),
...s(12, '测试摘要'),
...s(13, 'https://example.com/jump'),
...s(14, ''), // previewUrl='' is present in wire (pb_optional preserves empty string as expected)
]);
const appInfo = m(1, [
...v(1, 100446242),
...v(2, 1),
...v(3, 0), // field3=0 is present
...field5,
...v(11, 123456789),
...content,
]);
// Meta: peerType=1 present
const meta = m(2, [...v(1, 1), ...v(2, 123456789)]);
const body = [...appInfo, ...meta];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xdc2, 34, body, false));
});
it('rejects a delivery refusal even when the outer envelope succeeds', async () => {
const sender = makeSender(
m(1, [
...v(1, 0),
...v(2, 10001),
...s(3, ''),
...v(4, 901501),
...s(5, 'imagent service_error:319_[oidb] rule type not match appid'),
]),
);
await expect(
SendTuwenArk.invoke(sender, {
targetId: 10001,
peerType: 0,
title: 'title',
desc: '',
summary: '',
jumpUrl: 'https://example.com',
previewUrl: '',
}),
).rejects.toThrow(/901501.*rule type not match appid/);
});
it('checks every delivery result and requires an acknowledgement', () => {
const sender = makeSender();
expect(() => SendTuwenArk.deserialize(sender, {})).toThrow('no delivery result');
expect(() => SendTuwenArk.deserialize(sender, { results: [] })).toThrow(
'no delivery result',
);
expect(() =>
SendTuwenArk.deserialize(sender, {
results: [{ errorCode: 0 }, { errorCode: 901501, errorMessage: 'refused' }],
}),
).toThrow('refused');
expect(SendTuwenArk.deserialize(sender, { results: [{}] })).toBeUndefined();
});
});
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
import { SetFriendCategory } from '../../../src/oidb-services/contacts/set-friend-category';
import { env, s, v } from '../_pb-oracle';
function packet(errorCode = 0, errorMessage = ''): SendPacketResult {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<OidbBase<OidbEmpty>>({
command: 0x1255,
subCommand: 0,
errorCode,
errorMsg: errorMessage,
}),
),
};
}
describe('SetFriendCategory namespace', () => {
it('uses the client command and UIN-form envelope confirmed by the native worker', () => {
expect(SetFriendCategory.command).toBe(0x1255);
expect(SetFriendCategory.subCommand).toBe(0);
expect(SetFriendCategory.uinForm).toBe(true);
});
it('serializes the friend UID and category ID with the verified field layout', async () => {
const sendRawPacket = vi.fn(async () => packet());
await SetFriendCategory.invoke(
{ sendRawPacket },
{
uid: 'u_friend',
categoryId: 7,
},
);
expect(sendRawPacket).toHaveBeenCalledOnce();
const [command, bytes] = sendRawPacket.mock.calls[0]!;
expect(command).toBe('OidbSvcTrpcTcp.0x1255_0');
expect(Buffer.from(bytes).toString('hex')).toBe(
env(0x1255, 0, [...s(1, 'u_friend'), ...v(2, 7)], true),
);
});
it('accepts the idempotent success code used by the QQ client', async () => {
const sendRawPacket = vi.fn(async () => packet(2_001_002));
await expect(
SetFriendCategory.invoke(
{ sendRawPacket },
{
uid: 'u_friend',
categoryId: 7,
},
),
).resolves.toBeUndefined();
});
it('accepts the idempotent result when the packet layer reports it directly', async () => {
const sendRawPacket = vi.fn(async (): Promise<SendPacketResult> => ({
success: false,
gotResponse: true,
errorCode: 2_001_002,
errorMessage: 'already applied',
responseData: null,
}));
await expect(
SetFriendCategory.invoke(
{ sendRawPacket },
{
uid: 'u_friend',
categoryId: 7,
},
),
).resolves.toBeUndefined();
});
it('surfaces explicit server rejections', async () => {
const sendRawPacket = vi.fn(async () => packet(42, 'denied'));
await expect(
SetFriendCategory.invoke(
{ sendRawPacket },
{
uid: 'u_friend',
categoryId: 7,
},
),
).rejects.toThrow(/42.*denied/);
});
it('does not mistake a transport failure with code zero for success', async () => {
const sendRawPacket = vi.fn(async (): Promise<SendPacketResult> => ({
success: false,
gotResponse: true,
errorCode: 0,
errorMessage: 'packet send failed',
responseData: null,
}));
await expect(
SetFriendCategory.invoke(
{ sendRawPacket },
{
uid: 'u_friend',
categoryId: 7,
},
),
).rejects.toThrow('packet send failed');
});
});
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { DeleteCustomFace } from '../../../src/oidb-services/custom-face/delete-custom-face';
function makeSender() {
const resp: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => resp) };
}
const SAMPLE_UIN = '2550419068';
const SAMPLE_EMOJI_ID = '2550419068_0_0_0_2765030D118F8E04B1BFB8DC773C343B_0_0';
// Wire bytes captured from QQ 9.9.26-44343 (Faceroam.OpReq, opType=2, deleting
// the emoji_id above).
const DELETE_WIRE_HEX =
'0a0e0801120a31302e302e323632303010fc9c91c00918022a370a35323535303431393036385f305f305f305f32373635303330443131384638453034423142464238444337373343333433425f305f30';
describe('DeleteCustomFace namespace', () => {
describe('serialize + encode', () => {
it('reproduces the captured delete wire bytes exactly', () => {
const req = DeleteCustomFace.serialize({} as any, {
uin: SAMPLE_UIN,
emojiId: SAMPLE_EMOJI_ID,
});
const hex = Buffer.from(DeleteCustomFace.encode(req)).toString('hex');
expect(hex).toBe(DELETE_WIRE_HEX);
});
it('omits qqVersion from inner (delete inner is 14 bytes, not 28)', () => {
const req = DeleteCustomFace.serialize({} as any, { uin: SAMPLE_UIN, emojiId: 'x' });
expect(req.inner?.qqVersion).toBeUndefined();
});
it('puts the emoji_id into field5.body.emojiId', () => {
const req = DeleteCustomFace.serialize({} as any, {
uin: SAMPLE_UIN,
emojiId: SAMPLE_EMOJI_ID,
});
expect(req.body?.emojiId).toBe(SAMPLE_EMOJI_ID);
});
it('uses field3=2 and leaves field6 unset', () => {
const req = DeleteCustomFace.serialize({} as any, { uin: SAMPLE_UIN, emojiId: 'x' });
expect(req.field3).toBe(2);
expect(req.field6).toBeUndefined();
});
});
describe('invoke', () => {
it('routes through "Faceroam.OpReq"', async () => {
const sender = makeSender();
await DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: SAMPLE_EMOJI_ID });
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('Faceroam.OpReq');
});
it('passes the captured wire body', async () => {
const sender = makeSender();
await DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: SAMPLE_EMOJI_ID });
const [, body] = sender.sendRawPacket.mock.calls[0]!;
expect(Buffer.from(body as Uint8Array).toString('hex')).toBe(DELETE_WIRE_HEX);
});
it('resolves on success without returning a body', async () => {
const sender = makeSender();
await expect(
DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: 'x' }),
).resolves.toBeUndefined();
});
it('throws when the sender reports no response', async () => {
const sender = {
sendRawPacket: vi.fn(async (): Promise<SendPacketResult> => ({
success: false,
gotResponse: false,
errorCode: 0,
errorMessage: 'timeout',
responseData: null,
})),
};
await expect(
DeleteCustomFace.invoke(sender, { uin: SAMPLE_UIN, emojiId: 'x' }),
).rejects.toThrow();
});
});
});
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
CustomFaceModifyResp,
CustomFaceMoveBody,
} from '@snowluma/proto-defs/oidb-actions/base';
import { FetchCustomFaceDetail } from '../../../src/oidb-services/custom-face/fetch-custom-face-detail';
const EMOJI_A_ID = '2550419068_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
const EMOJI_A_MD5 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const EMOJI_B_ID = '2550419068_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
const EMOJI_B_MD5 = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
function makeSender(body: CustomFaceModifyResp) {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(protobuf_encode<OidbBase<CustomFaceModifyResp>>({ body })),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FetchCustomFaceDetail namespace', () => {
it('sends the IDA-confirmed 0x902e_1 opType=2 request with every id and md5', async () => {
const sender = makeSender({ retCode: 0, entries: [] });
await FetchCustomFaceDetail.invoke(sender, {
emojis: [
{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 },
{ emojiId: EMOJI_B_ID, md5: EMOJI_B_MD5 },
],
});
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x902e_1');
const env = protobuf_decode<OidbBase<CustomFaceMoveBody>>(bytes);
expect(env.command).toBe(0x902e);
expect(env.subCommand).toBe(1);
expect(env.reserved).toBe(1);
expect(env.body).toMatchObject({
field1: 1,
opType: 2,
emojis: [
{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 },
{ emojiId: EMOJI_B_ID, md5: EMOJI_B_MD5 },
],
});
});
it('returns tag 3 descriptions and falls back to legacy tag 2', async () => {
const sender = makeSender({
retCode: 0,
entries: [
{ emoji: { emojiId: EMOJI_A_ID }, legacyDesc: '旧版描述' },
{ emoji: { emojiId: EMOJI_B_ID }, legacyDesc: '旧值', desc: '新版描述' },
],
});
await expect(
FetchCustomFaceDetail.invoke(sender, {
emojis: [{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 }],
}),
).resolves.toEqual([
{ emojiId: EMOJI_A_ID, desc: '旧版描述' },
{ emojiId: EMOJI_B_ID, desc: '新版描述' },
]);
});
it('does not silently accept malformed or duplicate response entries', () => {
expect(() =>
FetchCustomFaceDetail.deserialize({} as any, {
entries: [{ desc: 'missing id' }],
}),
).toThrow(/has no emoji id/);
expect(() =>
FetchCustomFaceDetail.deserialize({} as any, {
entries: [{ emoji: { emojiId: EMOJI_A_ID } }, { emoji: { emojiId: EMOJI_A_ID } }],
}),
).toThrow(/duplicate emoji id/);
});
it('surfaces a non-zero business result', () => {
expect(() =>
FetchCustomFaceDetail.deserialize({} as any, {
retCode: 5,
errMsg: 'denied',
}),
).toThrow(/denied/);
});
});
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import type { FaceroamOpReq, FaceroamOpResp } from '@snowluma/proto-defs/oidb-actions/base';
import { FetchCustomFaceList } from '../../../src/oidb-services/custom-face/fetch-custom-face-list';
const SAMPLE_UIN = '10001';
const EMOJI_A = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
const EMOJI_B = '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
function encodeLegacyList(uin: string): Uint8Array {
return protobuf_encode<FaceroamOpReq>({
inner: { field1: 1, osVersion: '10.0.26200', qqVersion: '9.9.28-46928' },
uin: BigInt(uin),
field3: 1,
field6: 1,
});
}
function makeSender(
opts: {
faces?: FaceroamOpResp;
success?: boolean;
gotResponse?: boolean;
errorMessage?: string;
responseData?: Uint8Array | null;
} = {},
) {
const result: SendPacketResult = {
success: opts.success ?? true,
gotResponse: opts.gotResponse ?? true,
errorCode: 0,
errorMessage: opts.errorMessage ?? '',
responseData:
opts.responseData !== undefined
? opts.responseData
: Buffer.from(
protobuf_encode<FaceroamOpResp>(
opts.faces ?? { retCode: 0, item: { faceIds: [EMOJI_A, EMOJI_B] } },
),
),
};
return { sendRawPacket: vi.fn(async () => result) };
}
describe('FetchCustomFaceList namespace', () => {
describe('serialize + encode', () => {
it('keeps the list request bytes the ProfileApi fetch already sent', () => {
const req = FetchCustomFaceList.serialize({} as any, { uin: SAMPLE_UIN });
const hex = Buffer.from(FetchCustomFaceList.encode(req)).toString('hex');
expect(hex).toBe(Buffer.from(encodeLegacyList(SAMPLE_UIN)).toString('hex'));
});
it('sends qqVersion on inner and sets field3=1 / field6=1', () => {
const req = FetchCustomFaceList.serialize({} as any, { uin: SAMPLE_UIN });
expect(req.inner?.qqVersion).toBe('9.9.28-46928');
expect(req.inner?.osVersion).toBe('10.0.26200');
expect(req.field3).toBe(1);
expect(req.field6).toBe(1);
expect(req.body).toBeUndefined();
});
});
describe('invoke', () => {
it('routes through "Faceroam.OpReq" and returns the id list', async () => {
const sender = makeSender();
await expect(FetchCustomFaceList.invoke(sender, { uin: SAMPLE_UIN })).resolves.toEqual([
EMOJI_A,
EMOJI_B,
]);
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('Faceroam.OpReq');
const [, body] = sender.sendRawPacket.mock.calls[0]!;
expect(Buffer.from(body as Uint8Array).toString('hex')).toBe(
Buffer.from(encodeLegacyList(SAMPLE_UIN)).toString('hex'),
);
});
it('returns an empty list when the item is missing', async () => {
const sender = makeSender({ faces: { retCode: 0 } });
await expect(FetchCustomFaceList.invoke(sender, { uin: SAMPLE_UIN })).resolves.toEqual(
[],
);
});
it('throws when the sender reports no response', async () => {
const sender = makeSender({
success: false,
gotResponse: false,
errorMessage: 'timeout',
responseData: null,
});
await expect(FetchCustomFaceList.invoke(sender, { uin: SAMPLE_UIN })).rejects.toThrow(
/timeout/,
);
});
it('throws when the Faceroam retCode is non-zero', async () => {
const sender = makeSender({ faces: { retCode: 1, message: 'denied' } });
await expect(FetchCustomFaceList.invoke(sender, { uin: SAMPLE_UIN })).rejects.toThrow(
/fetch custom face error: denied/,
);
});
});
});
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { CustomFaceModifyBody } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { ModifyCustomFace } from '../../../src/oidb-services/custom-face/modify-custom-face';
const SAMPLE_EMOJI_ID = '2550419068_0_0_0_ABCDEF0123456789ABCDEF0123456789_0_0';
const SAMPLE_MD5 = 'ABCDEF0123456789ABCDEF0123456789';
// Independent byte-oracle: hand-decoded from the proto layout (NOT re-derived
// from the encoder), so a field-number change goes red. Cross-checked against
// the 9.9.26-44343 frida capture (cmd 0x902e_1, opType 3):
// 08 aea002 f1 command = 0x902e
// 10 01 f2 subCommand = 1
// 22 73 f4 body (115B):
// 08 01 f1 = 1
// 12 0a "10.0.26200" f2 osVersion
// 18 03 f3 opType = 3
// 2a 5f f5 entry (95B):
// 0a 59 f1 emoji (89B):
// 0a 35 <emoji_id> f1
// 12 20 <md5> f2
// 12 02 "hi" f2 desc
// 60 01 f12 = 1
const MODIFY_WIRE_HEX =
'08aea002100122730801120a31302e302e323632303018032a5f0a590a35323535303431393036385f305f305f305f41424344454630313233343536373839414243444546303132333435363738395f305f3012204142434445463031323334353637383941424344454630313233343536373839120268696001';
function makeSender() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('ModifyCustomFace namespace', () => {
it('declares command 0x902e sub 1', () => {
expect(ModifyCustomFace.command).toBe(0x902e);
expect(ModifyCustomFace.subCommand).toBe(1);
});
it('serializes opType=3 with emoji {emojiId,md5} + desc in f5.entry, f12=1', () => {
const out = ModifyCustomFace.serialize({} as any, {
emojiId: SAMPLE_EMOJI_ID,
md5: SAMPLE_MD5,
desc: 'hi',
});
expect(out.opType).toBe(3);
expect(out.entry?.emoji).toMatchObject({ emojiId: SAMPLE_EMOJI_ID, md5: SAMPLE_MD5 });
expect(out.entry?.desc).toBe('hi');
expect(out.field12).toBe(1);
});
it('routes to OidbSvcTrpcTcp.0x902e_1 and encodes the exact wire bytes', async () => {
const sender = makeSender();
await ModifyCustomFace.invoke(sender, {
emojiId: SAMPLE_EMOJI_ID,
md5: SAMPLE_MD5,
desc: 'hi',
});
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x902e_1');
expect(Buffer.from(bytes as Uint8Array).toString('hex')).toBe(MODIFY_WIRE_HEX);
// Structure decodes back.
const env = protobuf_decode<OidbBase<CustomFaceModifyBody>>(bytes as Uint8Array);
expect(env.command).toBe(0x902e);
expect(env.body?.opType).toBe(3);
expect(env.body?.entry?.desc).toBe('hi');
});
it('resolves on success (retCode 0 / empty body)', async () => {
const sender = makeSender();
await expect(
ModifyCustomFace.invoke(sender, {
emojiId: SAMPLE_EMOJI_ID,
md5: SAMPLE_MD5,
desc: '',
}),
).resolves.toBeUndefined();
});
it('throws when the business body carries a non-zero retCode', () => {
expect(() =>
ModifyCustomFace.deserialize({} as any, { retCode: 5, errMsg: 'bad' }),
).toThrow();
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { CustomFaceMoveBody } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { MoveCustomFace } from '../../../src/oidb-services/custom-face/move-custom-face';
const EMOJI_A_ID = '2550419068_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
const EMOJI_A_MD5 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const EMOJI_B_ID = '2550419068_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
const EMOJI_B_MD5 = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
// Byte-oracle hand-decoded from the proto layout. Cross-checked against the
// 9.9.26-44343 frida capture (fav25/fav27 ENC#3, the "upload new order" step
// of the two-step move — step 2, must be preceded by OrderCustomFace 0x902f):
// 08 aea002 f1 command = 0x902e ← move step 2: 0x902e opType=2
// 10 01 f2 subCommand = 1
// 22 <len> f4 body:
// 08 01 f1 = 1
// 12 0a "10.0.26200" f2 osVersion
// 18 02 f3 opType = 2 ← move upload
// 22 59 <emoji A> f4 repeated: {0a 35 emojiId, 12 20 md5}
// 22 59 <emoji B> f4 repeated
// 60 01 f12 = 1 ← envelope reserved (uinForm=true); without it the
// server accepts the request but does NOT reorder.
const MOVE_WIRE_HEX =
'08aea002100122c6010801120a31302e302e3236323030180222590a35323535303431393036385f305f305f305f41414141414141414141414141414141414141414141414141414141414141415f305f301220414141414141414141414141414141414141414141414141414141414141414122590a35323535303431393036385f305f305f305f42424242424242424242424242424242424242424242424242424242424242425f305f30122042424242424242424242424242424242424242424242424242424242424242426001';
function makeSender() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('MoveCustomFace namespace', () => {
it('declares command 0x902e sub 1 with uinForm (envelope reserved=1)', () => {
expect(MoveCustomFace.command).toBe(0x902e);
expect(MoveCustomFace.subCommand).toBe(1);
expect(MoveCustomFace.uinForm).toBe(true);
});
it('serializes opType=2 with the emoji list in f4 (repeated, in order)', () => {
const out = MoveCustomFace.serialize({} as any, {
emojis: [
{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 },
{ emojiId: EMOJI_B_ID, md5: EMOJI_B_MD5 },
],
});
expect(out.opType).toBe(2);
expect(out.emojis?.map((e) => e.emojiId)).toEqual([EMOJI_A_ID, EMOJI_B_ID]);
});
it('routes to OidbSvcTrpcTcp.0x902e_1 and encodes the exact wire bytes (incl. envelope f12)', async () => {
const sender = makeSender();
await MoveCustomFace.invoke(sender, {
emojis: [
{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 },
{ emojiId: EMOJI_B_ID, md5: EMOJI_B_MD5 },
],
});
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x902e_1');
expect(Buffer.from(bytes as Uint8Array).toString('hex')).toBe(MOVE_WIRE_HEX);
const env = protobuf_decode<OidbBase<CustomFaceMoveBody>>(bytes as Uint8Array);
expect(env.command).toBe(0x902e);
expect(env.reserved).toBe(1);
expect(env.body?.opType).toBe(2);
expect(env.body?.emojis?.map((e) => e.emojiId)).toEqual([EMOJI_A_ID, EMOJI_B_ID]);
});
it('resolves on success (retCode 0 / empty body)', async () => {
const sender = makeSender();
await expect(
MoveCustomFace.invoke(sender, { emojis: [{ emojiId: EMOJI_A_ID, md5: EMOJI_A_MD5 }] }),
).resolves.toBeUndefined();
});
it('throws when the business body carries a non-zero retCode', () => {
expect(() =>
MoveCustomFace.deserialize({} as any, { retCode: 5, errMsg: 'bad' }),
).toThrow();
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { CustomFaceOrderBody } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { OrderCustomFace } from '../../../src/oidb-services/custom-face/order-custom-face';
const SAMPLE_EMOJI_ID = '2550419068_0_0_0_3BC06CE836D6134326EA2C1B37BF1584_0_0';
// Byte-oracle taken DIRECTLY from the 9.9.26-44343 frida capture (fav27/fav28
// ENC#2, the real "move to front" instruction — the first of the two-step move).
//
// 08 afa002 f1 command = 0x902f ← move instruction cmd
// 10 01 f2 subCommand = 1
// 22 52 f4 body (82B):
// 0a 17 f1 env (23B):
// 08 8008 f1 = 1024 (client type flag)
// 12 0a "10.0.26200" f2 osVersion
// 1a 06 "9.9.26" f3 buildVersion (short)
// 12 35 <emoji_id> f2 emojiId
// 18 01 f3 position = 1 (front)
// 60 01 f12 = 1 ← envelope reserved (uinForm)
const ORDER_WIRE_HEX =
'08afa002100122520a17088008120a31302e302e32363230301a06392e392e32361235323535303431393036385f305f305f305f33424330364345383336443631333433323645413243314233374246313538345f305f3018016001';
function makeSender() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('OrderCustomFace namespace', () => {
it('declares command 0x902f sub 1 with uinForm (envelope reserved=1)', () => {
expect(OrderCustomFace.command).toBe(0x902f);
expect(OrderCustomFace.subCommand).toBe(1);
expect(OrderCustomFace.uinForm).toBe(true);
});
it('serializes env{1024, osVersion, "9.9.26"} + emojiId + position', () => {
const out = OrderCustomFace.serialize({} as any, { emojiId: SAMPLE_EMOJI_ID, position: 1 });
expect(out.env).toMatchObject({
field1: 1024,
osVersion: '10.0.26200',
buildVersion: '9.9.26',
});
expect(out.emojiId).toBe(SAMPLE_EMOJI_ID);
expect(out.position).toBe(1);
});
it('routes to OidbSvcTrpcTcp.0x902f_1 and reproduces the captured wire exactly', async () => {
const sender = makeSender();
await OrderCustomFace.invoke(sender, { emojiId: SAMPLE_EMOJI_ID, position: 1 });
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x902f_1');
expect(Buffer.from(bytes as Uint8Array).toString('hex')).toBe(ORDER_WIRE_HEX);
const env = protobuf_decode<OidbBase<CustomFaceOrderBody>>(bytes as Uint8Array);
expect(env.command).toBe(0x902f);
expect(env.reserved).toBe(1);
expect(env.body?.emojiId).toBe(SAMPLE_EMOJI_ID);
expect(env.body?.position).toBe(1);
});
it('resolves on success (retCode 0 / empty body)', async () => {
const sender = makeSender();
await expect(
OrderCustomFace.invoke(sender, { emojiId: SAMPLE_EMOJI_ID, position: 1 }),
).resolves.toBeUndefined();
});
it('throws when the business body carries a non-zero retCode', () => {
expect(() =>
OrderCustomFace.deserialize({} as any, { retCode: 5, errMsg: 'bad' }),
).toThrow();
});
});
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbAiVoiceListReq,
OidbAiVoiceListResp,
} from '@snowluma/proto-defs/oidb-actions/media';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchAiVoiceList } from '../../../src/oidb-services/extras/fetch-ai-voice-list';
function makeSender(body?: OidbAiVoiceListResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbAiVoiceListResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FetchAiVoiceList namespace', () => {
it('declares 0x929D_0', () => {
expect(FetchAiVoiceList.command).toBe(0x929d);
expect(FetchAiVoiceList.subCommand).toBe(0);
});
describe('serialize', () => {
it('passes groupUin / chatType verbatim', () => {
expect(FetchAiVoiceList.serialize({} as any, { groupId: 4242, chatType: 1 })).toEqual({
groupUin: 4242,
chatType: 1,
});
});
});
describe('deserialize', () => {
it('returns content array verbatim', () => {
const out = FetchAiVoiceList.deserialize({} as any, {
content: [
{
category: 'cute',
voices: [
{ voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://x' },
],
},
],
});
expect(out).toEqual([
{
category: 'cute',
voices: [
{ voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://x' },
],
},
]);
});
it('returns [] when content is omitted', () => {
expect(FetchAiVoiceList.deserialize({} as any, {})).toEqual([]);
});
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0x929d_0', async () => {
const sender = makeSender({ content: [] });
await FetchAiVoiceList.invoke(sender, { groupId: 100, chatType: 1 });
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x929d_0');
});
it('encodes envelope body correctly', async () => {
const sender = makeSender({ content: [] });
await FetchAiVoiceList.invoke(sender, { groupId: 4242, chatType: 2 });
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbAiVoiceListReq>>(bytes);
expect(env.body).toMatchObject({ groupUin: 4242, chatType: 2 });
});
});
});
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbAiVoiceReq, OidbAiVoiceResp } from '@snowluma/proto-defs/oidb-actions/media';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchAiVoice } from '../../../src/oidb-services/extras/fetch-ai-voice';
function makeSender(body?: OidbAiVoiceResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbAiVoiceResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FetchAiVoice namespace', () => {
it('declares 0x929B_0', () => {
expect(FetchAiVoice.command).toBe(0x929b);
expect(FetchAiVoice.subCommand).toBe(0);
});
describe('serialize', () => {
it('packages every field including sessionId into session sub-message', () => {
const out = FetchAiVoice.serialize({} as any, {
groupId: 100,
voiceId: 'v',
text: 'hi',
chatType: 1,
sessionId: 0xcafe,
});
expect(out).toEqual({
groupUin: 100,
voiceId: 'v',
text: 'hi',
chatType: 1,
session: { sessionId: 0xcafe },
});
});
});
describe('deserialize', () => {
it('returns the first msgInfoBody index', () => {
const node = { fileUuid: 'uuid', subType: 1 };
expect(
FetchAiVoice.deserialize(
{} as any,
{
field2: 319,
field3: 20,
msgInfo: { msgInfoBody: [{ index: node }] },
} as any,
),
).toEqual(node);
});
it('returns null when msgInfo is empty / missing', () => {
expect(FetchAiVoice.deserialize({} as any, {} as any)).toBeNull();
expect(FetchAiVoice.deserialize({} as any, { msgInfo: {} } as any)).toBeNull();
expect(
FetchAiVoice.deserialize({} as any, { msgInfo: { msgInfoBody: [] } } as any),
).toBeNull();
});
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0x929b_0', async () => {
const sender = makeSender({
msgInfo: { msgInfoBody: [{ index: { fileUuid: 'u' } }] },
} as any);
await FetchAiVoice.invoke(sender, {
groupId: 1,
voiceId: 'v',
text: 't',
chatType: 1,
sessionId: 1,
});
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x929b_0');
});
it('returns the parsed media node', async () => {
const node = { fileUuid: 'uuid', subType: 1 };
const sender = makeSender({
field2: 319,
field3: 20,
msgInfo: { msgInfoBody: [{ index: node }] },
} as any);
const out = await FetchAiVoice.invoke(sender, {
groupId: 1,
voiceId: 'v',
text: 't',
chatType: 1,
sessionId: 1,
});
expect(out).toMatchObject(node);
});
it('returns null when server polls back empty msgInfo (in-flight)', async () => {
const sender = makeSender({} as any);
const out = await FetchAiVoice.invoke(sender, {
groupId: 1,
voiceId: 'v',
text: 't',
chatType: 1,
sessionId: 1,
});
expect(out).toBeNull();
});
it('passes the sessionId through to the wire envelope verbatim', async () => {
const sender = makeSender({} as any);
await FetchAiVoice.invoke(sender, {
groupId: 1,
voiceId: 'v',
text: 't',
chatType: 1,
sessionId: 0xcafebabe,
});
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbAiVoiceReq>>(bytes);
expect(env.body?.session?.sessionId).toBe(0xcafebabe);
});
});
});
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbQueryGroupTopBannersReq } from '@snowluma/proto-defs/oidb-actions/base';
import { GetGroupTodoList } from '../../../src/oidb-services/extras/get-group-todo-list';
// Fixed wire fixture shaped after a live QQ 3.2.32-51802 0x9474_0 response.
// It deliberately uses the current common-banner representation rather than
// constructing the response with the production protobuf encoder.
const ACTIVE_TODO_RESPONSE = Buffer.from(
'08f4a802100018002291010a8e01080f10021a06353633315f3040d09ba8bb06' +
'a2016a0a3f0a1d68747470733a2f2f6578616d706c652e746573742f746f646f' +
'2e706e67120ce7bea4e5be85e58a9eefbd9c1a0ce6b58be8af95e5be85e58a9e' +
'28023003121b08031a177b22736571223a353633312c2272616e646f6d223a307d' +
'18c0ffa7bb0620c1ffa7bb06c802a29c01d002c801e80201f002012a006000',
'hex',
);
function makeSender(responseData = Buffer.alloc(0)) {
const result: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => result) };
}
describe('GetGroupTodoList namespace', () => {
it('queries 0x9474_0 with the todo-only banner flag', async () => {
const sender = makeSender();
await GetGroupTodoList.invoke(sender, { groupId: 941657197 });
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0x9474_0');
expect(GetGroupTodoList.subCommand).toBe(0);
const envelope = protobuf_decode<OidbBase<OidbQueryGroupTopBannersReq>>(bytes);
expect(envelope).toMatchObject({
command: 0x9474,
body: { groupId: 941657197n, bannerFlag: 1 },
});
});
it('decodes the current common-banner todo and validates both identity sources', async () => {
const sender = makeSender(ACTIVE_TODO_RESPONSE);
await expect(GetGroupTodoList.invoke(sender, { groupId: 941657197 })).resolves.toEqual([
{
sourceId: '5631_0',
sequence: 5631,
random: 0,
text: '测试待办',
createdAt: 1735000000,
updatedAt: 1735000001,
},
]);
});
it('filters disappeared todo tombstones', () => {
expect(
GetGroupTodoList.deserialize({} as never, {
banners: [{ bizType: 15, bannerType: 2, isDisappear: true, bizId: 20002 }],
}),
).toEqual([]);
});
it('supports the legacy todo text shape without inventing timestamps', () => {
expect(
GetGroupTodoList.deserialize({} as never, {
banners: [
{
msgId: Buffer.from('88_0'),
isDisappear: false,
todoBanner: { text: '旧版待办' },
},
],
}),
).toEqual([
{
sourceId: '88_0',
sequence: 88,
random: 0,
text: '旧版待办',
createdAt: 0,
updatedAt: 0,
},
]);
});
it('rejects an active todo whose message identity cannot be mapped', () => {
expect(() =>
GetGroupTodoList.deserialize({} as never, {
banners: [
{
bizType: 15,
bannerType: 2,
msgId: Buffer.from('not-a-message-id'),
isDisappear: false,
bizId: 20002,
},
],
}),
).toThrow(/invalid group todo message identity/);
});
it('rejects disagreement between msgId and the jump payload', () => {
expect(() =>
GetGroupTodoList.deserialize({} as never, {
banners: [
{
bizType: 15,
bannerType: 2,
msgId: Buffer.from('5631_0'),
isDisappear: false,
bizId: 20002,
commonBanner: {
jumpInfo: { jumpParam: Buffer.from('{"seq":5632,"random":0}') },
},
},
],
}),
).toThrow(/identity mismatch/);
});
});
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbStrangerStatusReq,
OidbStrangerStatusResp,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetStrangerStatus } from '../../../src/oidb-services/extras/get-stranger-status';
interface ObservedStatusProperty {
key?: pb<1, uint_32>;
value?: pb<2, uint_64>;
}
interface ObservedStatusProperties {
entries?: pb_repeated<1, ObservedStatusProperty>;
}
interface ObservedStatusData {
targetUin?: pb<1, uint_32>;
properties?: pb<2, ObservedStatusProperties>;
uin?: pb<3, uint_32>;
}
interface ObservedStatusResponse {
data?: pb<1, ObservedStatusData>;
}
function makeSender(body?: OidbStrangerStatusResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbStrangerStatusResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetStrangerStatus namespace', () => {
it('declares 0xFE1_2 with uinForm=true', () => {
expect(GetStrangerStatus.command).toBe(0xfe1);
expect(GetStrangerStatus.subCommand).toBe(2);
expect(GetStrangerStatus.uinForm).toBe(true);
});
describe('serialize', () => {
it('always queries only key 27372 (status property)', () => {
const out = GetStrangerStatus.serialize({} as any, { uin: 100200 });
expect(out).toEqual({ uin: 100200, key: [{ key: 27372 }] });
});
});
describe('deserialize', () => {
it('low-band (≤10) values map to (value*10, 0)', () => {
expect(
GetStrangerStatus.deserialize({} as any, {
data: { properties: { entries: [{ key: 27372, value: 7n }] } },
}),
).toEqual({ status: 70, ext_status: 0 });
expect(
GetStrangerStatus.deserialize({} as any, {
data: { properties: { entries: [{ key: 27372, value: 10n }] } },
}),
).toEqual({ status: 100, ext_status: 0 });
});
it('high-band values decompose into (0xff00 + (>>16 & 0xff)) ext_status', () => {
// 0x42F100 → bits 8..15 = 0xF1<<8 = 0xF100, bits 16..23 = 0x42 → 0xF142
expect(
GetStrangerStatus.deserialize({} as any, {
data: { properties: { entries: [{ key: 27372, value: 0x42f100n }] } },
}),
).toEqual({ status: 10, ext_status: 0xf142 });
});
it('returns null when the requested status property is missing', () => {
expect(GetStrangerStatus.deserialize({} as any, {})).toBeNull();
expect(GetStrangerStatus.deserialize({} as any, { data: {} })).toBeNull();
expect(
GetStrangerStatus.deserialize({} as any, { data: { properties: {} } }),
).toBeNull();
expect(
GetStrangerStatus.deserialize({} as any, {
data: { properties: { entries: [{ key: 10000, value: 7n }] } },
}),
).toBeNull();
});
});
describe('invoke (e2e)', () => {
it('decodes the property-list hierarchy returned by 0xFE1_2', async () => {
const responseData = Buffer.from(
protobuf_encode<OidbBase<ObservedStatusResponse>>({
body: {
data: {
targetUin: 100200,
properties: {
entries: [
{ key: 10000, value: 1n },
{ key: 27372, value: 4n },
],
},
uin: 100200,
},
},
}),
);
const result: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
const sender = { sendRawPacket: vi.fn(async () => result) };
await expect(GetStrangerStatus.invoke(sender, { uin: 100200 })).resolves.toEqual({
status: 40,
ext_status: 0,
});
});
it('routes to OidbSvcTrpcTcp.0xfe1_2 with uinForm=true (envelope reserved=1)', async () => {
const sender = makeSender({
data: { properties: { entries: [{ key: 27372, value: 5n }] } },
});
await GetStrangerStatus.invoke(sender, { uin: 100200 });
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
const env = protobuf_decode<OidbBase<OidbStrangerStatusReq>>(bytes);
expect(env.reserved).toBe(1);
});
it('returns the decoded status object', async () => {
const sender = makeSender({
data: { properties: { entries: [{ key: 27372, value: 5n }] } },
});
const out = await GetStrangerStatus.invoke(sender, { uin: 1 });
expect(out).toEqual({ status: 50, ext_status: 0 });
});
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbGroupTodo } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GroupTodo } from '../../../src/oidb-services/extras/group-todo';
function makeSender() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GroupTodo namespace', () => {
it('declares 0xF90', () => {
expect(GroupTodo.command).toBe(0xf90);
});
describe('resolveSubCommand', () => {
it('maps set → 1', () => {
expect(GroupTodo.resolveSubCommand({ groupId: 1, msgSeq: 1n, action: 'set' })).toBe(1);
});
it('maps complete → 2', () => {
expect(
GroupTodo.resolveSubCommand({ groupId: 1, msgSeq: 1n, action: 'complete' }),
).toBe(2);
});
it('maps cancel → 3', () => {
expect(GroupTodo.resolveSubCommand({ groupId: 1, msgSeq: 1n, action: 'cancel' })).toBe(
3,
);
});
});
describe('serialize', () => {
it('packages groupUin + msgSeq verbatim regardless of action', () => {
for (const action of ['set', 'complete', 'cancel'] as const) {
const out = GroupTodo.serialize({} as any, {
groupId: 12345,
msgSeq: 9876543210n,
action,
});
expect(out).toEqual({ groupUin: 12345, msgSeq: 9876543210n });
}
});
});
describe('invoke (e2e)', () => {
it.each([
['set', 'OidbSvcTrpcTcp.0xf90_1'] as const,
['complete', 'OidbSvcTrpcTcp.0xf90_2'] as const,
['cancel', 'OidbSvcTrpcTcp.0xf90_3'] as const,
])('action %s routes to %s', async (action, expectedWire) => {
const sender = makeSender();
await GroupTodo.invoke(sender, { groupId: 12345, msgSeq: 100n, action });
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe(expectedWire);
});
it('encodes envelope body correctly', async () => {
const sender = makeSender();
await GroupTodo.invoke(sender, { groupId: 100, msgSeq: 999n, action: 'set' });
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbGroupTodo>>(bytes);
expect(env.body).toEqual({ groupUin: 100, msgSeq: 999n });
});
});
});
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { protobuf_encode, protobuf_decode } from '@snowluma/proton';
import type { FlashApplyUploadReq } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
// ENC#8: 0x12a9 sub=103 apply-upload 真实客户端请求 body(frida 抓包,QQ 9.9.26-44343,
// 2026-06-19)。用 ENC#8 的精确值手工构造 req,encode 后必须 byte-for-byte 匹配 ENC#8 hex。
// 这是真正的 byte-oracle:字段号/wire type/0值显式编码任一错误都会 hex 不等。
const ENC8_HEX =
'0a190a0508ac011067120ca80602b00604b80616c00c051a020801629d030a81020a870108b4cdcc01122063396631303334663062303431653933353339653937663839356363333139621a28306636326234663763646463373965326662666431343065646531326431633434646462373937612222e5b18fe5b995e5bd95e588b620323032352d30312d3135203030323133302e6d70342a0808001000180020003000380040004801126745685150597254337a64783534767639464137654574484554647435656869307a637742494c56304b4b4b516938796a6b3555444d675277636d396b5549447153566f51496f38546b56454c72776f454c5746544c6a433268586f445947505f676745435a336f180120fae4d4d1062880ea493000120208021a0808001000180022005288010a2438363866633433312d336361372d346130632d383538322d666135656365613062666134122438363866633433312d336361372d346130632d383538322d6661356563656130626661341a2436656639316166322d363830302d373633632d313936642d38353231303537353536343020012800300138024200480150005800600068007000';
// ENC#8 抓包值(sub=103 mp4)。
const ENC8_REQ: FlashApplyUploadReq = {
head: {
sub: { seq: 172, sub: 103 },
config: { field101: 2, field102: 4, field103: 22, field200: 5 },
field3: { field1: 1 },
},
payload: {
wrapper: {
fileInfo: {
fileSize: 3352244,
md5: 'c9f1034f0b041e93539e97f895cc319b',
sha1: '0f62b4f7cddc79e2fbfd140ede12d1c44ddb797a',
fileName: '屏幕录制 2025-01-15 002130.mp4',
field5: { field1: 0, field2: 0, field3: 0, field4: 0 },
field6: 0,
field7: 0,
field8: 0,
field9: 1,
},
fileId: 'EhQPYrT3zdx54vv9FA7eEtHETdt5ehi0zcwBILV0KKKQi8yjk5UDMgRwcm9kUIDqSVoQIo8TkVELrwoELWFTLjC2hXoDYGP_ggECZ3o',
field3: 1,
field4: 1781871226,
field5: 1209600,
field6: 0,
},
flag2: { field1: 2 },
field3: { field1: 0, field2: 0, field3: 0, field4: {} },
filesetWrap: {
filesetUuid: '868fc431-3ca7-4a0c-8582-fa5ecea0bfa4',
uploadKey: '868fc431-3ca7-4a0c-8582-fa5ecea0bfa4',
fileUuid: '6ef91af2-6800-763c-196d-852105755640',
field4: 1,
field5: 0,
field6: 1,
field7: 2,
field8: {},
field9: 1,
field10: 0,
field11: 0,
field12: 0,
field13: 0,
field14: 0,
},
},
};
describe('ApplyUpload byte-oracle (0x12a9_103 encode == ENC#8)', () => {
it('encodes the ENC#8 field values to the exact frida-captured wire bytes', () => {
const out = Buffer.from(protobuf_encode<FlashApplyUploadReq>(ENC8_REQ)).toString('hex');
if (out !== ENC8_HEX) {
// 定位首个差异字节
const n = Math.min(out.length, ENC8_HEX.length);
let i = 0;
while (i < n && out[i] === ENC8_HEX[i]) {
i++;
}
throw new Error(
`hex mismatch at char ${i} (byte ${i >> 1}):\n` +
` bot: ...${out.slice(Math.max(0, i - 20), i + 20)}\n` +
` ENC#8: ...${ENC8_HEX.slice(Math.max(0, i - 20), i + 20)}\n` +
` bot len=${out.length / 2}B, ENC#8 len=${ENC8_HEX.length / 2}B`,
);
}
expect(out).toBe(ENC8_HEX);
});
it('round-trips: encode → decode preserves the field tree', () => {
const bytes = protobuf_encode<FlashApplyUploadReq>(ENC8_REQ);
const back = protobuf_decode<FlashApplyUploadReq>(bytes);
expect(back.head?.sub?.sub).toBe(103);
expect(back.head?.config?.field103).toBe(22);
expect(back.payload?.wrapper?.fileInfo?.field9).toBe(1);
expect(back.payload?.wrapper?.fileInfo?.field5?.field4).toBe(0);
expect(back.payload?.filesetWrap?.fileUuid).toBe('6ef91af2-6800-763c-196d-852105755640');
});
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { FlashApplyUploadReq } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { ApplyUpload } from '../../../src/oidb-services/flash-transfer/apply-upload';
// ENC#8: 0x12a9 sub=103 apply-upload 真实客户端请求 body(frida 抓包,QQ 9.9.26-44343,
// 2026-06-19)。字段树逐字节解码见 flash_transfer_protocol_full.md §9.5。
// 独立 byte-oracle:从 frida 抓的 hex decode 回来,字段号/wire type 错则值读不出。
const ENC8_HEX =
'0a190a0508ac011067120ca80602b00604b80616c00c051a020801629d030a81020a870108b4cdcc01122063396631303334663062303431653933353339653937663839356363333139621a28306636326234663763646463373965326662666431343065646531326431633434646462373937612222e5b18fe5b995e5bd95e588b620323032352d30312d3135203030323133302e6d70342a0808001000180020003000380040004801126745685150597254337a64783534767639464137654574484554647435656869307a637742494c56304b4b4b516938796a6b3555444d675277636d396b5549447153566f51496f38546b56454c72776f454c5746544c6a433268586f445947505f676745435a336f180120fae4d4d1062880ea493000120208021a0808001000180022005288010a2438363866633433312d336361372d346130632d383538322d666135656365613062666134122438363866633433312d336361372d346130632d383538322d6661356563656130626661341a2436656639316166322d363830302d373633632d313936642d38353231303537353536343020012800300138024200480150005800600068007000';
describe('ApplyUpload namespace (0x12a9_103)', () => {
it('declares command 0x12a9 sub 103 uinForm=true', () => {
expect(ApplyUpload.command).toBe(0x12a9);
expect(ApplyUpload.subCommand).toBe(103);
expect(ApplyUpload.uinForm).toBe(true);
});
it('decodes ENC#8 frida capture with the exact field tree', () => {
const req = protobuf_decode<FlashApplyUploadReq>(Buffer.from(ENC8_HEX, 'hex'));
// head: { f1:{seq,sub}, f2:config{f101,f102,f103,f200}, f3:{f1:1} }
expect(req.head?.sub?.seq).toBe(172);
expect(req.head?.sub?.sub).toBe(103);
expect(req.head?.config).toMatchObject({
field101: 2,
field102: 4,
field103: 22,
field200: 5,
});
expect(req.head?.field3?.field1).toBe(1);
// payload.wrapper.fileInfo(f12.f1.f1)
const fi = req.payload?.wrapper?.fileInfo;
expect(fi?.fileSize).toBe(3352244);
expect(fi?.md5).toBe('c9f1034f0b041e93539e97f895cc319b');
expect(fi?.sha1).toBe('0f62b4f7cddc79e2fbfd140ede12d1c44ddb797a');
expect(fi?.fileName).toBe('屏幕录制 2025-01-15 002130.mp4');
// field5 = {f1:0,f2:0,f3:0,f4:0},f4 是 varint(不是 message)
expect(fi?.field5).toMatchObject({ field1: 0, field2: 0, field3: 0, field4: 0 });
expect(fi?.field9).toBe(1);
// payload.wrapper(f12.f1):fileId + 时间戳 + TTL
expect(req.payload?.wrapper?.fileId).toBe(
'EhQPYrT3zdx54vv9FA7eEtHETdt5ehi0zcwBILV0KKKQi8yjk5UDMgRwcm9kUIDqSVoQIo8TkVELrwoELWFTLjC2hXoDYGP_ggECZ3o',
);
expect(req.payload?.wrapper?.field3).toBe(1);
expect(req.payload?.wrapper?.field4).toBe(1781871226);
expect(req.payload?.wrapper?.field5).toBe(1209600);
// payload.flag2(f12.f2 = {f1:2})
expect(req.payload?.flag2?.field1).toBe(2);
// payload.field3(f12.f3 = {f1:0,f2:0,f3:0,f4:空message})
expect(req.payload?.field3).toMatchObject({ field1: 0, field2: 0, field3: 0 });
// payload.filesetWrap(f12.f10 = filesetUuid + fileUuid + flags)
const fw = req.payload?.filesetWrap;
expect(fw?.filesetUuid).toBe('868fc431-3ca7-4a0c-8582-fa5ecea0bfa4');
expect(fw?.uploadKey).toBe('868fc431-3ca7-4a0c-8582-fa5ecea0bfa4');
expect(fw?.fileUuid).toBe('6ef91af2-6800-763c-196d-852105755640');
expect(fw?.field4).toBe(1);
expect(fw?.field6).toBe(1);
expect(fw?.field7).toBe(2);
expect(fw?.field9).toBe(1);
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { FlashGetDownloadUrlResp } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
import { GetDownloadUrl } from '@snowluma/protocol/oidb-services/flash-transfer/get-download-url';
// 0x93d4 真实响应(send_packet 抓于 QQ 9.9.26-44343, 2026-06-20, fileset e7453377 已发送)。
// byte-oracle:decode + deserialize 必须从 f1.f3.f14.f1 提取到主文件 fileId。
// 0x93d4 响应结构与 0x93d3 不同(downloadUrl 在深层嵌套,非 f9.fileIdWrap),这个测试
// 锁定该结构,防止 deserialize 退回错误的 FlashFileEntry 解读。
const RESP_HEX =
'08d4a7021001180022b8090ab50910011afa080a2465373435333337372d633865612d343034652d393238352d353361613566616431393832122466613038663938332d323337662d303833632d656536332d6139646164336161616565352801300138024222e5b18fe5b995e5bd95e588b620323032352d30352d3033203135313234312e6d70344a2162696e676d756c757a686920323032352d30352d3033203135313234312e6d703458bcf7e3036ae9050a6645685330442d4c3261494b306b74314d51424f764f5377746465753574426a7939774567746e516f724c6d736d5961566c514d794248427962325251674f704a5768424d3744724a3276364843594c766576376b6f30587565674d4d61502d4341514a6e656712a804080212a30468747470733a2f2f6d756c74696d656469612e7166696c652e71712e636f6d2f646f776e6c6f61643f61707069643d313439303226636c69656e745f747970653d77696e26636c69656e745f7665723d392e322e33342666696c6569643d45685330442d4c3261494b306b74314d51424f764f5377746465753574426a7939774567746e516f724c6d736d5961566c514d794248427962325251674f704a5768424d3744724a3276364843594c766576376b6f30587565674d4d61502d4341514a6e656726666c64633d696358456459355a6b5346395f59347239514f526e58562d6a6b31536d397753546b4a316a6876334f357a5f747331484966612d4c6a7662416b52494531526c4373704a4776336c453469635434467437414a794c33577242304b78696a555f423270386a75367a3371314d26726b65793d43414d5371414878697a55514532647437517975726675552d78446d3568666a4d727073325a69723764535f42354d55694349644e594c495167792d5264776d495a797a584a6f31714a687834464d756b346a337042306a5f37787850315a7a6748706469315f3158376178422d736541556c5f3646546f4679597638564d5337645a577036414275597a3875517a79306c487574676a6762714271477138336d70417a6465426475555833513043732d426b54676952767768664b3835696661774a3874477371576e70784871394f444c762d5f37566a78526e424456587046314f306343551a286234306665326636363838326234393264643463343031336166333932633264373565626239623420f2f7012a20393365643461306234383164623936343335323034363339626366393133313830800438c002726b0a674568512d63526273544d5338304c79484637745658626c38536c5a4e63426938392d4d44494c56304b497154305a6d476c5a55444d675277636d396b5549447153566f5156637648434d53585865556b54726c384e555f4a41586f444c6b625f676745435a336f18027a2466613038663938332d323337662d303833632d656536332d613964616433616161656535900103a2012833653731313665633463633462636430626338373137626235353564623937633461353634643730a801bcf7e303b2010308d547ca0120346365613333363331386338303864646431646630613333633938316339316122327b226f6666736574223a31382c22637572736f725f74696d65223a22303030312d30312d30315430303a30303a30305a227d28012a005a1d0a0f636c69656e745f636f6e6e5f736571120a313738313932393033395a200a1a747270632d6f632d7072696f726974792d706173732d6261636b12022d315a1c0a0e73736f5f647965696e675f6b6579120a323535303431393036385a160a0c73736f5f636c69656e74696412063133333034375a9c040a08747270632d73736f128f04000000147061d586907300000ba05fe3000207b7000001fb0073c9510000000e3235353034313930363820071b1b000008040000000000000000000003000000004c668066ca67e24ac9a81bd08aa343840338fa2f632ec020158218d5d2a35f1a2ab856dcbdcc486af66c8ba961db5532e2a065d882604a01539050c7ad17748f95521b4dac5e1834160000001b4f696462537663547270635463702e3078393364345f31000000188e0800005f3a366a000000008d0b00000000000000000000000000002462376234353837623661333666373535346564353235613563396230303166660000000000000000000000040000011d1a007a91020a047061d58610081a06392e322e333420c19e0c4220633165633037346532323533303539633230303530333039653965643932666552005a10b7b4587b6a36f7554ed525a5c9b001ff62206237623435383762366133366637353534656435323561356339623030316666688008720757696e646f7773820118755f6c5253732d3243757359575a6a455851707045694d518a01009001019a015208081248668066ca67e24ac9a81bd08aa343840338fa2f632ec020158218d5d2a35f1a2ab856dcbdcc486af66c8ba961db5532e2a065d882604a01539050c7ad17748f95521b4dac5e18341618c4acf8fa05a00100b80165c00100d201174f696462537663547270635463702e3078393364345f3190010d';
describe('GetDownloadUrl byte-oracle (0x93d4 deserialize)', () => {
it('extracts main file fileId from f1.f3.f14.f1', () => {
const bytes = Uint8Array.from(Buffer.from(RESP_HEX, 'hex'));
const env = protobuf_decode<OidbBase<FlashGetDownloadUrlResp>>(bytes);
const result = GetDownloadUrl.deserialize(null as never, env.body!);
// 0x93d4 f1.f3 是 repeated fileInfo,单文件场景返回一个元素的数组。
expect(result).toHaveLength(1);
const meta = result[0];
// 主文件 fileId(0x93d4 f14.f1),用于 0x12a9 sub=200 拿主文件下载直链。
expect(meta.fileId).toBe(
'EhQ-cRbsTMS80LyHF7tVXbl8SlZNcBi89-MDILV0KIqT0ZmGlZUDMgRwcm9kUIDqSVoQVcvHCMSXXeUkTrl8NU_JAXoDLkb_ggECZ3o',
);
expect(meta.filesetUuid).toBe('e7453377-c8ea-404e-9285-53aa5fad1982');
expect(meta.fileName).toContain('mp4');
expect(meta.fileSize).toBe(7928764);
expect(meta.fileIndex).toBe(1);
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import { protobuf_encode, protobuf_decode } from '@snowluma/proton';
import type { FlashPrepareUploadReq } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
// 真实 sub=100 (mp4) frida 抓包(QQ 9.9.26-44343, 2026-06-19)。payload @ f2(不是 f12!)。
// byte-oracle:用抓包值构造 req,encode 必须 byte-for-byte 匹配。
const SUB100_MP4_HEX =
'0a180a0408081064120ca80602b00604b80616c00c051a0208011298020a6b0a6708a0d2e40212001a28643138333762626336666131653031396136326330343734616332386563363131343565656138612222e5b18fe5b995e5bd95e588b620323032352d31322d3232203135313432392e6d70342a08080010001800200030003800400048011000100118002000280032120a040800120012021a001a045a0062005000380040004a88010a2432383564346530622d363465342d343961642d396430632d666234383938393930626162122432383564346530622d363465342d343961642d396430632d6662343839383939306261621a2432343166356438382d333637352d623030612d303261302d30633035306138343032656520012800300038024200480150005800600068007000';
const SUB100_MP4_REQ: FlashPrepareUploadReq = {
head: {
sub: { seq: 8, sub: 100 },
config: { field101: 2, field102: 4, field103: 22, field200: 5 },
field3: { field1: 1 },
},
payload: {
wrapper: {
fileInfo: {
fileSize: 5843232,
md5: '',
sha1: 'd1837bbc6fa1e019a62c0474ac28ec61145eea8a',
fileName: '屏幕录制 2025-12-22 151429.mp4',
field5: { field1: 0, field2: 0, field3: 0, field4: 0 },
field6: 0,
field7: 0,
field8: 0,
field9: 1,
},
field2: 0,
},
field2: 1,
field3: 0,
field4: 0,
field5: 0,
field6: {
field1: { field1: 0, field2: {} },
field2: { field3: {} },
field3: { field11: {}, field12: {} },
field10: 0,
},
field7: 0,
field8: 0,
filesetWrap: {
filesetUuid: '285d4e0b-64e4-49ad-9d0c-fb4898990bab',
uploadKey: '285d4e0b-64e4-49ad-9d0c-fb4898990bab',
fileUuid: '241f5d88-3675-b00a-02a0-0c050a8402ee',
field4: 1,
field5: 0,
field6: 0,
field7: 2,
field8: {},
field9: 1,
field10: 0,
field11: 0,
field12: 0,
field13: 0,
field14: 0,
},
},
};
describe('PrepareUpload byte-oracle (0x12a9_100 encode == frida 抓包)', () => {
it('encodes the sub=100 mp4 capture to the exact wire bytes', () => {
const out = Buffer.from(protobuf_encode<FlashPrepareUploadReq>(SUB100_MP4_REQ)).toString(
'hex',
);
if (out !== SUB100_MP4_HEX) {
const n = Math.min(out.length, SUB100_MP4_HEX.length);
let i = 0;
while (i < n && out[i] === SUB100_MP4_HEX[i]) {
i++;
}
throw new Error(
`hex mismatch at char ${i} (byte ${i >> 1}):\n` +
` bot: ...${out.slice(Math.max(0, i - 24), i + 24)}\n` +
` frida: ...${SUB100_MP4_HEX.slice(Math.max(0, i - 24), i + 24)}\n` +
` bot len=${out.length / 2}B, frida len=${SUB100_MP4_HEX.length / 2}B`,
);
}
expect(out).toBe(SUB100_MP4_HEX);
});
it('round-trips: encode → decode preserves key fields', () => {
const bytes = protobuf_encode<FlashPrepareUploadReq>(SUB100_MP4_REQ);
const back = protobuf_decode<FlashPrepareUploadReq>(bytes);
expect(back.head?.sub?.sub).toBe(100);
expect(back.payload?.wrapper?.fileInfo?.fileSize).toBe(5843232);
expect(back.payload?.filesetWrap?.fileUuid).toBe('241f5d88-3675-b00a-02a0-0c050a8402ee');
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { protobuf_encode, protobuf_decode } from '@snowluma/proton';
import type { FlashSendReq } from '@snowluma/proto-defs/oidb-actions/flash-transfer';
// 0x93d7 send_flash_msg 真实客户端请求 body(frida 抓包,QQ 9.9.26-44343, 2026-06-20)。
// fileset e7453377-c8ea-404e-9285-53aa5fad1982 发送给 uid u_0aHrwL1tyskEWkhChJoeJg。
// byte-oracle:用抓包值构造 req,encode 必须 byte-for-byte 匹配。
const SEND_HEX =
'0a1e0801121a0a18755f30614872774c317479736b45576b6843684a6f654a67122465373435333337372d633865612d343034652d393238352d353361613566616431393832';
const SEND_REQ: FlashSendReq = {
target: { field1: 1, targetUid: { targetUid: 'u_0aHrwL1tyskEWkhChJoeJg' } },
filesetUuid: 'e7453377-c8ea-404e-9285-53aa5fad1982',
};
describe('SendFlashMsg byte-oracle (0x93d7 encode == frida 抓包)', () => {
it('encodes the send capture to the exact wire bytes', () => {
const out = Buffer.from(protobuf_encode<FlashSendReq>(SEND_REQ)).toString('hex');
if (out !== SEND_HEX) {
const n = Math.min(out.length, SEND_HEX.length);
let i = 0;
while (i < n && out[i] === SEND_HEX[i]) {
i++;
}
throw new Error(
`hex mismatch at char ${i} (byte ${i >> 1}):\n` +
` bot: ...${out.slice(Math.max(0, i - 20), i + 20)}\n` +
` frida: ...${SEND_HEX.slice(Math.max(0, i - 20), i + 20)}\n` +
` bot len=${out.length / 2}B, frida len=${SEND_HEX.length / 2}B`,
);
}
expect(out).toBe(SEND_HEX);
});
it('round-trips: encode → decode preserves target + filesetUuid', () => {
const bytes = protobuf_encode<FlashSendReq>(SEND_REQ);
const back = protobuf_decode<FlashSendReq>(bytes);
expect(back.target?.field1).toBe(1);
expect(back.target?.targetUid?.targetUid).toBe('u_0aHrwL1tyskEWkhChJoeJg');
expect(back.filesetUuid).toBe('e7453377-c8ea-404e-9285-53aa5fad1982');
});
});
// 群聊 0x93d7:f1={f1:2, f3:{f1:groupId}}, f2=filesetUuid(groupId 直接用群号,无需转 uid)。
// 真实客户端 frida 抓包(2026-06-20),fileset e7453377 发送给群 1017438661。
const SEND_GROUP_HEX =
'0a0a08021a0608c5c393e503122465373435333337372d633865612d343034652d393238352d353361613566616431393832';
const SEND_GROUP_REQ: FlashSendReq = {
target: { field1: 2, targetGroup: { groupId: 1017438661 } },
filesetUuid: 'e7453377-c8ea-404e-9285-53aa5fad1982',
};
describe('SendFlashMsg byte-oracle (0x93d7 群聊 encode == frida 抓包)', () => {
it('encodes the group send capture to the exact wire bytes', () => {
const out = Buffer.from(protobuf_encode<FlashSendReq>(SEND_GROUP_REQ)).toString('hex');
expect(out).toBe(SEND_GROUP_HEX);
});
it('round-trips: encode → decode preserves groupId', () => {
const back = protobuf_decode<FlashSendReq>(protobuf_encode<FlashSendReq>(SEND_GROUP_REQ));
expect(back.target?.field1).toBe(2);
expect(back.target?.targetGroup?.groupId).toBe(1017438661);
});
});
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { ApproveDoubtBuddyReq } from '../../../src/oidb-services/friend/approve-doubt-buddy-req';
import { env, s, v } from '../_pb-oracle';
function makeSender(selfUid = 'u_self', selfUin = '10001') {
const respEnv: OidbBase<OidbEmpty> = { command: 0xd72, subCommand: 0, body: {} };
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(protobuf_encode<OidbBase<OidbEmpty>>(respEnv)),
};
return {
sendRawPacket: vi.fn(async (_cmd: string, _bytes: Uint8Array) => r),
identity: { selfUid, uin: selfUin } as ApproveDoubtBuddyReq.Deps['identity'],
resolveUserUid: vi.fn(async () => 'u_lookup_self'),
};
}
describe('ApproveDoubtBuddyReq namespace', () => {
it('keeps the approving account separate from the applicant', async () => {
const sender = makeSender();
expect(await ApproveDoubtBuddyReq.serialize(sender, { uid: 'u_applicant' })).toEqual({
selfUid: 'u_self',
targetUid: 'u_applicant',
field3: 0,
field4: '',
});
expect(sender.resolveUserUid).not.toHaveBeenCalled();
});
it('matches the approval request bytes including explicit default options', async () => {
const sender = makeSender();
await ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' });
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0xd72_0');
const body = [...s(1, 'u_self'), ...s(2, 'u_applicant'), ...v(3, 0), ...s(4, '')];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xd72, 0, body, false));
});
it('resolves the current account when its self profile is not cached', async () => {
const sender = makeSender('');
await ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' });
expect(sender.resolveUserUid).toHaveBeenCalledWith(10001);
const body = [...s(1, 'u_lookup_self'), ...s(2, 'u_applicant'), ...v(3, 0), ...s(4, '')];
expect(Buffer.from(sender.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
env(0xd72, 0, body, false),
);
});
it('does not send when the current account cannot be identified', async () => {
const sender = makeSender('', '0');
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
'self uid is unavailable',
);
expect(sender.sendRawPacket).not.toHaveBeenCalled();
expect(sender.resolveUserUid).not.toHaveBeenCalled();
});
it('propagates self identity lookup errors without sending', async () => {
const sender = makeSender('');
sender.resolveUserUid.mockRejectedValue(new Error('profile lookup failed'));
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
'profile lookup failed',
);
expect(sender.sendRawPacket).not.toHaveBeenCalled();
});
it('rejects an empty resolved self identity before sending', async () => {
const sender = makeSender('');
sender.resolveUserUid.mockResolvedValue('');
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
'self uid is unavailable',
);
expect(sender.sendRawPacket).not.toHaveBeenCalled();
});
it.each(['', ' '])('rejects an absent applicant identity (%j)', async (uid) => {
const sender = makeSender();
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid })).rejects.toThrow(
'applicant uid is unavailable',
);
expect(sender.sendRawPacket).not.toHaveBeenCalled();
});
it('propagates a rejected approval without retrying a different operation', async () => {
const sender = makeSender();
sender.sendRawPacket.mockResolvedValue({
success: false,
gotResponse: true,
errorCode: 1201,
errorMessage: 'request rejected',
responseData: Buffer.alloc(0),
});
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
'request rejected',
);
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
});
it('does not mistake a successful transport for an accepted approval', async () => {
const sender = makeSender();
sender.sendRawPacket.mockResolvedValue({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<OidbBase<OidbEmpty>>({
command: 0xd72,
subCommand: 0,
errorCode: 1201,
errorMsg: 'approval denied',
body: {},
}),
),
});
await expect(ApproveDoubtBuddyReq.invoke(sender, { uid: 'u_applicant' })).rejects.toThrow(
'approval denied',
);
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,35 @@
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { describe, expect, it, vi } from 'vitest';
import { ClearFriendRemark } from '../../../src/oidb-services/friend/clear-friend-remark';
import { env, m, s } from '../_pb-oracle';
function makeDeps() {
const result: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn(async () => result),
resolveUserUid: vi.fn(async () => 'resolved-uid'),
};
}
describe('ClearFriendRemark namespace', () => {
it('sends the current QQ 0x912F_0 wire shape', async () => {
const deps = makeDeps();
await ClearFriendRemark.invoke(deps, { userId: 10001 });
const [wireName, bytes] = deps.sendRawPacket.mock.calls[0]!;
const body = m(1, s(7, 'resolved-uid'));
expect({
wireName,
hex: Buffer.from(bytes).toString('hex'),
}).toEqual({
wireName: 'OidbSvcTrpcTcp.0x912f_0',
hex: env(0x912f, 0, body, false),
});
});
});
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbDeleteFriend } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { DeleteFriend } from '../../../src/oidb-services/friend/delete-friend';
import { env, m, s, v } from '../_pb-oracle';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn(async () => r),
resolveUserUid: vi.fn(async () => 'resolved-uid'),
};
}
describe('DeleteFriend namespace', () => {
it('declares 0x126B_0', () => {
expect(DeleteFriend.command).toBe(0x126b);
expect(DeleteFriend.subCommand).toBe(0);
});
describe('invoke', () => {
it('routes to OidbSvcTrpcTcp.0x126b_0', async () => {
const deps = makeDeps();
await DeleteFriend.invoke(deps, { userId: 10001 });
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x126b_0');
});
it('resolves the target uid before encoding', async () => {
const deps = makeDeps();
await DeleteFriend.invoke(deps, { userId: 10001 });
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
});
it('packages the nested envelope with verbatim magic constants', async () => {
const deps = makeDeps();
await DeleteFriend.invoke(deps, { userId: 10001, block: true });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbDeleteFriend>>(bytes);
expect(env.body?.field1).toMatchObject({
targetUid: 'resolved-uid',
block: true,
field2: {
field1: 130,
field2: 109,
field3: { field1: 8, field2: 8, field3: 50 },
},
});
});
it('matches the current QQ wire for a non-blocking delete', async () => {
const deps = makeDeps();
await DeleteFriend.invoke(deps, { userId: 10001 });
const [wireName, bytes] = deps.sendRawPacket.mock.calls[0]!;
const discriminator = [
...v(1, 130),
...v(2, 109),
...m(3, [...v(1, 8), ...v(2, 8), ...v(3, 50)]),
];
const body = m(1, [
...s(1, 'resolved-uid'),
...m(2, discriminator),
// Current QQ explicitly emits both booleans.
...v(3, 0),
...v(4, 1),
]);
expect({
wireName,
hex: Buffer.from(bytes).toString('hex'),
}).toEqual({
wireName: 'OidbSvcTrpcTcp.0x126b_0',
hex: env(0x126b, 0, body, false),
});
});
});
});
@@ -0,0 +1 @@
08e91a22a60c080112a10c0a7e0880c2d72f12066e69636b3030180020fe012a056d73673030320851512067726f75703a1766696c746572656420667269656e6420726571756573744080c3bbc2064880cee4cd02500062006a30abc8a360ea075bcf8bbfa1e1e0e010beb1ac5228138eb82586704b41691e9efea640392a2a65e9dd50de82f154e0201b0a7d08c7aa9b3012066e69636b3031180020012a056d73673031320851512067726f75703a1766696c746572656420667269656e6420726571756573744080e6c0c206488dcee4cd02500162006a30ee591d017a9bb6807f7db7c8d3665e34923c7db6f6252d672a8f1f8986c7c9e3d21415ea86987a1b689c8d675d2a26800a7d088e93df3012066e69636b3032180020012a056d73673032320851512067726f75703a1766696c746572656420667269656e642072657175657374408089c6c206489acee4cd02500062006a305fe9e2cce8e6bc04c4f76fe8872347907e73a73b9184c3a7cbbd9c85a1d319c252129d950d06920d50732964df4afcf20a810108d5fba23112066e69636b3033180020ffffffff0f2a056d73673033320851512067726f75703a1766696c746572656420667269656e6420726571756573744080accbc20648a7cee4cd02500062006a3006b881debd01f92c98ef3c1726406ebeb88cae9d12d89e9fa36c20a77495dd0a3eb9fbbe7b14f65d657e979adfda5c600a7d089ce4e63112066e69636b3034180020012a056d73673034320851512067726f75703a1766696c746572656420667269656e6420726571756573744080cfd0c20648b4cee4cd02500062006a30316f76c654c33c8f6f3689a9475d8b871f74654789edb72d3f1a87bb1ba11cf07286b8d748f4d08d6fbe5afee8208e010a870108e3ccaa3212066e69636b3035185920ffffffff0f2a056d73673035320851512067726f75703a1766696c746572656420667269656e6420726571756573744080f2d5c20648c1cee4cd0250006206e4b89ce4baac6a30a3038a382c30ee6a8686410089e1c618f0b11bf92fdf2726da2910d2082a147a5569d04815e7c42287b424ef45b00ff70a860108aab5ee3212066e69636b3036180120002a056d73673036320851512067726f75703a1766696c746572656420667269656e642072657175657374408095dbc20648cecee4cd0250006209e7a59ee5a588e5b79d6a3097d6cc1f21ca290ab424147c3181e68490c49de47eda96eb422ec861eefdc8f580578655ac4f874ab7e8467cfc0e9fd30a810108f19db23312066e69636b3037180020ffffffff0f2a056d73673037320851512067726f75703a1766696c746572656420667269656e6420726571756573744080b8e0c20648dbcee4cd02500062006a303c1421acb295e3a5a4c914625ef1dbee99b552716ed28046cfa3b9171518dab5c247e0ddd3a24cb5f318a1cb60d6c9b30a810108b886f63312066e69636b3038180020ffffffff0f2a056d73673038320851512067726f75703a1766696c746572656420667269656e6420726571756573744080dbe5c20648e8cee4cd02500162006a3020ac3ddb705e3246f8fe7d266becea5bf92ec584a2cc12d4a83197d782047ca61d0ed90c5c4419e0866940f22a3eb1420a7d08ffeeb93412066e69636b3039180020012a056d73673039320851512067726f75703a1766696c746572656420667269656e6420726571756573744080feeac20648f5cee4cd02500162006a3047da9cf4f576624c5edd21b6bcfda8898c91143711f0bd251e00dfb1c273738290fa190cf6dc231c41dc31ad2086c6050a7d08c6d7fd3412066e69636b3130181420002a056d73673130320851512067726f75703a1766696c746572656420667269656e6420726571756573744080a1f0c2064882cfe4cd02500062006a300ab4598cfe36f3f340ec5237766213a06ddd99d900c3b3ac4d18530d4308f17943a22ef6b85d4d3f08086dcaf36465550a8401088dc0c13512066e69636b3131181220fe012a056d73673131320851512067726f75703a1766696c746572656420667269656e6420726571756573744080c4f5c206488fcfe4cd0250016206e697a0e994a16a3006e504c8f85b64fa8d5cb196d4b8cebc8098c752d879880bd8ab0108a829a1b29f952ff4e4b3807af4d54f70fb0dbf50
@@ -0,0 +1,183 @@
import { describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbDoubtGetResp } from '@snowluma/proto-defs/oidb-actions/doubt-buddy';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetDoubtBuddyReq } from '../../../src/oidb-services/friend/get-doubt-buddy-req';
import { env, v, s, m } from '../_pb-oracle';
function makeSender(): { sendRawPacket: ReturnType<typeof vi.fn> } {
const respEnv: OidbBase<OidbDoubtGetResp> = {
command: 0xd69,
subCommand: 0,
body: {
status: 1,
body: {
list: [
{
uid: 'u_alice',
nick: 'Alice',
source: '可能认识',
reason: '',
msg: 'hi',
reqTime: 1700000000n,
},
],
reason: '',
},
},
};
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(protobuf_encode<OidbBase<OidbDoubtGetResp>>(respEnv)),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetDoubtBuddyReq namespace', () => {
it('declares command 0xD69 sub 0, uin form', () => {
expect(GetDoubtBuddyReq.command).toBe(0xd69);
expect(GetDoubtBuddyReq.subCommand).toBe(0);
expect(GetDoubtBuddyReq.uinForm).toBe(true);
});
it('serializes {1:1, 2:{1:count, 2:uk}} (reqId is not on the wire)', () => {
expect(GetDoubtBuddyReq.serialize({} as any, { count: 50 })).toEqual({
field1: 1,
inner: { num: 50, uk: '' },
});
});
it('byte-oracle: routes to 0xd69_0 and locks nested req tags {1:const1, 2:{1:num, 2:uk}}', async () => {
const sender = makeSender();
await GetDoubtBuddyReq.invoke(sender, { count: 50 });
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0xd69_0');
// uk '' omitted (proto3 default); inner carries only num.
const inner = [...v(1, 50)];
const body = [...v(1, 1), ...m(2, inner)];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xd69, 0, body, true));
});
it('deserializes the string uid item to the OneBot shape', async () => {
const list = await GetDoubtBuddyReq.invoke(makeSender(), { count: 10 });
expect(list).toEqual([
{
uid: 'u_alice',
user_id: 0,
nick: 'Alice',
source: '可能认识',
reason: '',
msg: 'hi',
group_code: '',
reqTime: 1700000000,
},
]);
});
it('deserialize returns [] when the body has no list', () => {
expect(GetDoubtBuddyReq.deserialize({} as any, { status: 1 })).toEqual([]);
});
it('keeps an empty uid when the reply only carries the account number', () => {
expect(
GetDoubtBuddyReq.deserialize({} as any, {
status: 1,
body: { list: [{ nick: 'Alice', uin: 12345n, reqTime: 1700000000n }] },
}),
).toEqual([
{
uid: '',
user_id: 12345,
nick: 'Alice',
source: '',
reason: '',
msg: '',
group_code: '',
reqTime: 1700000000,
},
]);
});
it('keeps the uid string and does not reread it as the account number', async () => {
const item = [
...s(1, 'u_alice'),
...s(2, 'Alice'),
...s(5, 'hi'),
...s(6, 'QQ号查找'),
...s(7, 'caution'),
];
const sender = {
sendRawPacket: vi.fn(async () => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
env(0xd69, 0, [...v(1, 1), ...m(2, m(1, item))], false),
'hex',
),
})),
};
const list = await GetDoubtBuddyReq.invoke(sender, { count: 10 });
expect(list).toEqual([
{
uid: 'u_alice',
user_id: 0,
nick: 'Alice',
source: 'QQ号查找',
reason: 'caution',
msg: 'hi',
group_code: '',
reqTime: 0,
},
]);
});
// Live capture from QQ 3.2.32-52194 on Linux. Locks the real wire shape:
// tag1 is the applicant's account number (not a string uid), tag8 is the
// request unix time, and tag9 is the source group. Before this, a tag8-as-uin
// read made the uin->uid fallback query the request time as an account.
it('decodes a captured Linux reply: account at tag1, time at tag8, group at tag9', () => {
const hex = readFileSync(
fileURLToPath(new URL('./fixtures/d69-linux-3.2.32.hex', import.meta.url)),
'utf8',
).trim();
const list = GetDoubtBuddyReq.deserialize(
{} as any,
GetDoubtBuddyReq.decode(Buffer.from(hex, 'hex')).body!,
);
expect(list).toHaveLength(12);
expect(list[0]).toMatchObject({
uid: '',
user_id: 100000000,
nick: 'nick00',
source: 'QQ group',
reason: 'filtered friend request',
msg: 'msg00',
group_code: '700000000',
reqTime: 1750000000,
});
expect(list[1]).toMatchObject({
uid: '',
user_id: 101111111,
group_code: '700000013',
reqTime: 1750086400,
});
// Account numbers stay in the QQ range; request times land in 2025-2026.
for (const item of list) {
expect(item.user_id).toBeGreaterThan(100000);
expect(item.user_id).toBeLessThan(4294967296);
expect(item.reqTime).toBeGreaterThan(1700000000);
expect(item.reqTime).toBeLessThan(2000000000);
}
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbFriendRequestAction } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { HandleFriendRequest } from '../../../src/oidb-services/friend/handle-friend-request';
function makeDeps(resolvedUid = 'resolved-uid') {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn(async () => r),
resolveUserUid: vi.fn(async () => resolvedUid),
};
}
describe('HandleFriendRequest namespace', () => {
it('declares 0xB5D_44', () => {
expect(HandleFriendRequest.command).toBe(0xb5d);
expect(HandleFriendRequest.subCommand).toBe(44);
});
describe('invoke', () => {
it('routes to OidbSvcTrpcTcp.0xb5d_44', async () => {
const deps = makeDeps();
await HandleFriendRequest.invoke(deps, { uidOrFlag: 'uid', approve: true });
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xb5d_44');
});
it('detects digit-only flag → resolves via resolveUserUid', async () => {
const deps = makeDeps('resolved-from-uin');
await HandleFriendRequest.invoke(deps, { uidOrFlag: '10001', approve: true });
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
expect(env.body?.targetUid).toBe('resolved-from-uin');
});
it('passes non-numeric flag through verbatim (no resolveUserUid call)', async () => {
const deps = makeDeps();
await HandleFriendRequest.invoke(deps, { uidOrFlag: 'flag-abc', approve: false });
expect(deps.resolveUserUid).not.toHaveBeenCalled();
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
expect(env.body?.targetUid).toBe('flag-abc');
});
it('encodes accept=3 when approve=true', async () => {
const deps = makeDeps();
await HandleFriendRequest.invoke(deps, { uidOrFlag: 'u', approve: true });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
expect(env.body?.accept).toBe(3);
});
it('encodes accept=5 when approve=false', async () => {
const deps = makeDeps();
await HandleFriendRequest.invoke(deps, { uidOrFlag: 'u', approve: false });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
expect(env.body?.accept).toBe(5);
});
});
});
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { RejectDoubtBuddyReq } from '../../../src/oidb-services/friend/reject-doubt-buddy-req';
import { env, v, s, m } from '../_pb-oracle';
function makeSender() {
const respEnv: OidbBase<OidbEmpty> = { command: 0xd69, subCommand: 0, body: {} };
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(protobuf_encode<OidbBase<OidbEmpty>>(respEnv)),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('RejectDoubtBuddyReq namespace', () => {
it('declares command 0xD69 sub 0, uin form', () => {
expect(RejectDoubtBuddyReq.command).toBe(0xd69);
expect(RejectDoubtBuddyReq.subCommand).toBe(0);
expect(RejectDoubtBuddyReq.uinForm).toBe(true);
});
it('serializes {1: const 3 (op discriminator), 3: {1: uid}}', () => {
expect(RejectDoubtBuddyReq.serialize({} as any, { uid: 'u_x' })).toEqual({
field1: 3,
inner: { uid: 'u_x' },
});
});
it('byte-oracle: routes to 0xd69_0 and locks req tags {1:const3, 3:{1:uid}}', async () => {
const sender = makeSender();
await RejectDoubtBuddyReq.invoke(sender, { uid: 'u_abc' });
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0xd69_0');
const inner = [...s(1, 'u_abc')];
const body = [...v(1, 3), ...m(3, inner)];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xd69, 0, body, true));
});
});
@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from 'vitest';
import { subscribeLogs, type LogEntry } from '@snowluma/common/logger';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetFriendRemark } from '../../../src/oidb-services/friend/set-friend-remark';
import { env, m, s, v } from '../_pb-oracle';
function successBody(uid = 'resolved-uid', remark = 'best-friend', uin = 0): number[] {
return m(1, [...m(1, [...(uin > 0 ? v(3, uin) : []), ...s(7, uid)]), ...s(2, remark)]);
}
function makeDeps(responseBody = successBody()) {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(env(0x912e, 0, responseBody, false), 'hex'),
};
return {
sendRawPacket: vi.fn(async () => r),
resolveUserUid: vi.fn(async () => 'resolved-uid'),
identity: { updateFriendRemark: vi.fn() },
};
}
describe('SetFriendRemark namespace', () => {
it('sends the current QQ 0x912E_0 wire shape for a non-empty remark', async () => {
const deps = makeDeps();
await SetFriendRemark.invoke(deps, { userId: 10001, remark: 'best-friend' });
const [wireName, bytes] = deps.sendRawPacket.mock.calls[0]!;
const body = [
...m(1, [...m(1, s(7, 'resolved-uid')), ...s(2, 'best-friend')]),
// QQ explicitly emits scene=0 for an ordinary friend.
...v(2, 0),
];
expect({
wireName,
hex: Buffer.from(bytes).toString('hex'),
}).toEqual({
wireName: 'OidbSvcTrpcTcp.0x912e_0',
hex: env(0x912e, 0, body, false),
});
});
it('resolves the target UID before encoding', async () => {
const deps = makeDeps();
await SetFriendRemark.invoke(deps, { userId: 10001, remark: 'best-friend' });
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
});
it('updates identity only after the server confirms the requested value', async () => {
const deps = makeDeps(successBody('resolved-uid', 'best-friend', 10001));
await SetFriendRemark.invoke(deps, { userId: 10001, remark: 'best-friend' });
expect(deps.identity.updateFriendRemark).toHaveBeenCalledWith(
'resolved-uid',
10001,
'best-friend',
);
});
it('reports local synchronization failures without retrying a confirmed remote update', async () => {
const deps = makeDeps(successBody('resolved-uid', 'best-friend', 10001));
deps.identity.updateFriendRemark.mockImplementation(() => {
throw new Error('identity cache unavailable');
});
const captured: LogEntry[] = [];
const unsubscribe = subscribeLogs((entry) => {
if (entry.scope === 'Bridge.Friend') {
captured.push(entry);
}
});
try {
await expect(
SetFriendRemark.invoke(deps, {
userId: 10001,
remark: 'best-friend',
}),
).resolves.toBeUndefined();
} finally {
unsubscribe();
}
expect(deps.sendRawPacket).toHaveBeenCalledTimes(1);
expect(captured).toContainEqual(
expect.objectContaining({
level: 'error',
message: expect.stringContaining('identity cache unavailable'),
}),
);
});
it('rejects a business failure carried inside a successful OIDB response', async () => {
const deps = makeDeps(m(3, [...v(2, 120), ...s(3, 'remark rejected')]));
await expect(
SetFriendRemark.invoke(deps, {
userId: 10001,
remark: 'best-friend',
}),
).rejects.toThrow(/remark rejected/);
expect(deps.identity.updateFriendRemark).not.toHaveBeenCalled();
});
it('rejects a response that contains neither a result nor a business error', async () => {
const deps = makeDeps([]);
await expect(
SetFriendRemark.invoke(deps, {
userId: 10001,
remark: 'best-friend',
}),
).rejects.toThrow(/missing result/);
});
it('rejects a success result for a different target', async () => {
const deps = makeDeps(successBody('another-uid'));
await expect(
SetFriendRemark.invoke(deps, {
userId: 10001,
remark: 'best-friend',
}),
).rejects.toThrow(/target mismatch/);
});
it('rejects a success result that does not confirm the requested remark', async () => {
const deps = makeDeps(successBody('resolved-uid', 'old-remark'));
await expect(
SetFriendRemark.invoke(deps, {
userId: 10001,
remark: 'best-friend',
}),
).rejects.toThrow(/does not match/);
});
it('rejects a success result carrying a different numeric account', async () => {
const deps = makeDeps(successBody('resolved-uid', 'best-friend', 20002));
await expect(
SetFriendRemark.invoke(deps, {
userId: 10001,
remark: 'best-friend',
}),
).rejects.toThrow(/expected UIN 10001, got 20002/);
});
});
@@ -0,0 +1,256 @@
import { describe, expect, it, vi } from 'vitest';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetAdminPermissions } from '../../../src/oidb-services/group-admin/set-admin-permissions';
import { GetAdminPermissions } from '../../../src/oidb-services/group-admin/get-admin-permissions';
import { SetAdminReceiveVerify } from '../../../src/oidb-services/group-admin/set-admin-receive-verify';
import { env, m, v } from '../_pb-oracle';
const target = { groupId: 12345, userId: 67890 };
function sender(body: number[] = []) {
const result: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(body),
};
return { sendRawPacket: vi.fn(async (_cmd: string, _bytes: Uint8Array) => result) };
}
function member(uin: number, permissions: number[]) {
return [...v(1, uin), ...m(3, m(52, permissions))];
}
function snapshot(permissions: number[], group = target.groupId, uin = target.userId) {
return m(4, [...v(1, group), ...m(3, member(uin, permissions))]);
}
function permission(id: number, value: number, extra?: number[]) {
return m(1, [...v(1, id), ...v(2, value), ...(extra === undefined ? [] : m(3, extra))]);
}
describe('administrator permission requests', () => {
it('writes both permissions with the service-specific envelope and nested daily limit', async () => {
const deps = sender();
await SetAdminPermissions.invoke(deps, {
...target,
recallMemberMessage: false,
kickMember: true,
kickMemberPerDay: 20,
});
const [cmd, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x9c1c_2');
expect(Buffer.from(bytes).toString('hex')).toBe(
env(
0x9c1c,
2,
[
...v(1, target.groupId),
...v(2, target.userId),
...m(3, [...v(1, 1), ...v(2, 1), ...m(3, v(1, 20))]),
...m(3, [...v(1, 2), ...v(2, 2)]),
],
false,
),
);
});
it('does not write omitted permissions and encodes disable as 2', async () => {
const deps = sender();
await SetAdminPermissions.invoke(deps, { ...target, kickMember: false });
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
env(
0x9c1c,
2,
[
...v(1, target.groupId),
...v(2, target.userId),
...m(3, [...v(1, 1), ...v(2, 2)]),
],
false,
),
);
await SetAdminPermissions.invoke(deps, { ...target, recallMemberMessage: true });
expect(Buffer.from(deps.sendRawPacket.mock.calls[1]![1]).toString('hex')).toBe(
env(
0x9c1c,
2,
[
...v(1, target.groupId),
...v(2, target.userId),
...m(3, [...v(1, 2), ...v(2, 1)]),
],
false,
),
);
});
it('accepts explicit unlimited without manufacturing an extra limit field', async () => {
const deps = sender();
await SetAdminPermissions.invoke(deps, {
...target,
kickMember: true,
kickMemberPerDay: 0,
});
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
env(
0x9c1c,
2,
[
...v(1, target.groupId),
...v(2, target.userId),
...m(3, [...v(1, 1), ...v(2, 1)]),
],
false,
),
);
});
it.each([
{},
{ kickMemberPerDay: 20 },
{ kickMember: true },
{ kickMember: false, kickMemberPerDay: 20 },
{ kickMember: true, kickMemberPerDay: -1 },
{ kickMember: true, kickMemberPerDay: 1 },
{ kickMember: true, kickMemberPerDay: 201 },
{ kickMember: true, kickMemberPerDay: NaN },
])('rejects invalid patches before sending: %j', async (patch) => {
const deps = sender();
await expect(SetAdminPermissions.invoke(deps, { ...target, ...patch })).rejects.toThrow();
expect(deps.sendRawPacket).not.toHaveBeenCalled();
});
it('propagates envelope rejection instead of accepting an empty response body', async () => {
const deps = sender(v(3, 1287));
await expect(
SetAdminPermissions.invoke(deps, { ...target, recallMemberMessage: false }),
).rejects.toThrow('OIDB error 1287');
});
it('does not retry transport failures', async () => {
const deps = sender();
deps.sendRawPacket.mockRejectedValueOnce(new Error('transport disconnected'));
await expect(
SetAdminPermissions.invoke(deps, { ...target, recallMemberMessage: false }),
).rejects.toThrow('transport disconnected');
expect(deps.sendRawPacket).toHaveBeenCalledTimes(1);
});
});
describe('administrator permission snapshot', () => {
it('requests only permission data and reads the matching member', async () => {
const deps = sender(snapshot([...permission(2, 2), ...permission(1, 1, v(1, 50))]));
await expect(GetAdminPermissions.invoke(deps, target)).resolves.toEqual({
recallMemberMessage: false,
kickMember: true,
kickMemberPerDay: 50,
});
const [cmd, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x9164_2');
expect(Buffer.from(bytes).toString('hex')).toBe(
env(
0x9164,
2,
[...v(1, target.groupId), ...v(2, target.userId), ...m(4, m(52, [1]))],
true,
),
);
});
it('finds the requested member rather than using the first returned member', async () => {
const deps = sender(
m(4, [
...v(1, target.groupId),
...m(3, member(1, permission(2, 1))),
...m(3, member(target.userId, permission(2, 2))),
]),
);
await expect(GetAdminPermissions.invoke(deps, target)).resolves.toEqual({
recallMemberMessage: false,
kickMember: null,
kickMemberPerDay: null,
});
});
it('uses the protobuf zero limit only when removal is explicitly enabled', async () => {
await expect(
GetAdminPermissions.invoke(sender(snapshot(permission(1, 1))), target),
).resolves.toEqual({ recallMemberMessage: null, kickMember: true, kickMemberPerDay: 0 });
await expect(
GetAdminPermissions.invoke(sender(snapshot(permission(1, 2))), target),
).resolves.toEqual({
recallMemberMessage: null,
kickMember: false,
kickMemberPerDay: null,
});
});
it('does not turn missing permissions or future item IDs into enabled defaults', async () => {
await expect(
GetAdminPermissions.invoke(
sender(snapshot([...permission(999, 1), ...permission(2, 0)])),
target,
),
).resolves.toEqual({ recallMemberMessage: null, kickMember: null, kickMemberPerDay: null });
});
it.each([
['missing body', []],
['wrong group', snapshot([], 1)],
['wrong member', snapshot([], target.groupId, 1)],
['no member', m(4, v(1, target.groupId))],
[
'duplicate member',
m(4, [
...v(1, target.groupId),
...m(3, member(target.userId, [])),
...m(3, member(target.userId, [])),
]),
],
['duplicate permission', snapshot([...permission(1, 1), ...permission(1, 2)])],
['unsupported value', snapshot(permission(2, 3))],
['malformed limit', snapshot(permission(1, 1, [8, 128]))],
] as const)('rejects %s', async (_name, bytes) => {
await expect(GetAdminPermissions.invoke(sender([...bytes]), target)).rejects.toThrow();
});
});
describe('administrator join verification', () => {
it.each([true, false])(
'encodes enable=%s and requires a successful target acknowledgement',
async (enable) => {
const deps = sender(m(4, [...v(1, target.groupId), ...m(2, v(1, target.userId))]));
await expect(
SetAdminReceiveVerify.invoke(deps, { ...target, enable }),
).resolves.toBeUndefined();
const [cmd, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x904_1');
expect(Buffer.from(bytes).toString('hex')).toBe(
env(
0x904,
1,
[
...v(1, target.groupId),
...m(2, [...v(1, target.userId), ...v(2, 1), ...v(3, enable ? 1 : 0)]),
],
true,
),
);
},
);
it.each(
[
[],
m(4, v(1, target.groupId)),
m(4, [...v(1, 1), ...m(2, v(1, target.userId))]),
m(4, [...v(1, target.groupId), ...m(2, v(1, 1))]),
].map((response) => ({ response })),
)('rejects an unconfirmed target: %j', async ({ response }) => {
await expect(
SetAdminReceiveVerify.invoke(sender(response), { ...target, enable: false }),
).rejects.toThrow();
});
});
@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from 'vitest';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetAdminReceiveVerify } from '../../../src/oidb-services/group-admin/get-admin-receive-verify';
import { env, m, s, v } from '../_pb-oracle';
const target = { groupId: 12345, userId: 67890 };
const uid = 'test-member-uid';
function member(flag?: number, memberUid = uid, uin: number | undefined = target.userId) {
return m(2, [
...m(1, [...s(2, memberUid), ...(uin === undefined ? [] : v(4, uin))]),
...(flag === undefined ? [] : v(104, flag)),
]);
}
function response(members: number[], group: number | undefined = target.groupId) {
return m(4, [...(group === undefined ? [] : v(1, group)), ...members]);
}
function sender(body = response(member(0))) {
const result: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(body),
};
return {
sendRawPacket: vi.fn(async (_command: string, _bytes: Uint8Array) => result),
resolveUserUid: vi.fn(async (_userId: number, _groupId?: number) => uid),
};
}
describe('administrator receive-verification read', () => {
it('resolves the member once in its group and requests only the verification state', async () => {
const deps = sender();
await expect(GetAdminReceiveVerify.invoke(deps, target)).resolves.toBe(true);
expect(deps.resolveUserUid).toHaveBeenCalledExactlyOnceWith(target.userId, target.groupId);
expect(deps.sendRawPacket).toHaveBeenCalledTimes(1);
const [command, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(command).toBe('OidbSvcTrpcTcp.0xfe7_4');
expect(Buffer.from(bytes).toString('hex')).toBe(
env(
0xfe7,
4,
[
...v(1, target.groupId),
...v(2, 3),
...v(3, 0),
...m(4, v(104, 1)),
...m(5, s(2, uid)),
],
false,
),
);
});
it.each([
[0, true],
[64, false],
[32, true],
[96, false],
[0x80000000, true],
[0xffffffff, false],
[undefined, null],
] as const)('decodes flag %s as %s while preserving missing data', async (flag, enabled) => {
const deps = sender(response(member(flag)));
await expect(GetAdminReceiveVerify.invoke(deps, target)).resolves.toBe(enabled);
});
it('selects the requested member even when unrelated members are returned first', async () => {
const deps = sender(response([...member(64, 'unrelated-member', 10001), ...member(0)]));
await expect(GetAdminReceiveVerify.invoke(deps, target)).resolves.toBe(true);
});
it('accepts UID-bound responses without an optional numeric identity', async () => {
const deps = sender(response(m(2, [...m(1, s(2, uid)), ...v(104, 64)])));
await expect(GetAdminReceiveVerify.invoke(deps, target)).resolves.toBe(false);
});
it('preserves large numeric group and member identifiers', async () => {
const large = { groupId: 2 ** 40 + 17, userId: 2 ** 41 + 19 };
const deps = sender(response(member(0, uid, large.userId), large.groupId));
await expect(GetAdminReceiveVerify.invoke(deps, large)).resolves.toBe(true);
expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe(
env(
0xfe7,
4,
[
...v(1, large.groupId),
...v(2, 3),
...v(3, 0),
...m(4, v(104, 1)),
...m(5, s(2, uid)),
],
false,
),
);
});
it.each([
['missing group', m(4, member(0)), 'missing or mismatched group'],
['wrong group', response(member(0), 999), 'missing or mismatched group'],
['missing member', response([]), 'exactly one matching member'],
['wrong member', response(member(0, 'other-uid')), 'exactly one matching member'],
[
'missing UID',
response(m(2, [...m(1, v(4, target.userId)), ...v(104, 0)])),
'exactly one matching member',
],
[
'duplicate member',
response([...member(0), ...member(64)]),
'exactly one matching member',
],
['conflicting numeric identity', response(member(0, uid, 999)), 'mismatched member UIN'],
] as const)(
'rejects %s rather than reporting a successful state',
async (_label, body, error) => {
const deps = sender([...body]);
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow(error);
},
);
it('does not send when identity resolution fails', async () => {
const deps = sender();
deps.resolveUserUid.mockRejectedValueOnce(new Error('identity unavailable'));
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow(
'identity unavailable',
);
expect(deps.sendRawPacket).not.toHaveBeenCalled();
});
it('propagates server rejection even if the body contains a valid state', async () => {
const deps = sender([...v(3, 1287), ...response(member(0))]);
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow('OIDB error 1287');
});
it('propagates transport failures without retrying', async () => {
const deps = sender();
deps.sendRawPacket.mockRejectedValueOnce(new Error('transport disconnected'));
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow(
'transport disconnected',
);
expect(deps.sendRawPacket).toHaveBeenCalledTimes(1);
});
it('rejects truncated responses', async () => {
const deps = sender([0x22, 0x80]);
await expect(GetAdminReceiveVerify.invoke(deps, target)).rejects.toThrow();
});
});
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x8a7Req, Oidb0x8a7Resp } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetAtAllRemain } from '../../../src/oidb-services/group-admin/get-at-all-remain';
function makeDeps(body?: Oidb0x8a7Resp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<Oidb0x8a7Resp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return {
sendRawPacket: vi.fn(async () => r),
identity: { uin: '10001' } as any,
};
}
describe('GetAtAllRemain namespace', () => {
it('declares 0x8A7_0', () => {
expect(GetAtAllRemain.command).toBe(0x8a7);
expect(GetAtAllRemain.subCommand).toBe(0);
});
it('emits the basic{1,2,3}=1,2,1 cookie + bot uin + groupId, routes to 0x8a7_0', async () => {
const deps = makeDeps({ canAtAll: true, groupRemain: 12, uinRemain: 5 });
await GetAtAllRemain.invoke(deps, { groupId: 12345 });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x8a7_0');
const env = protobuf_decode<OidbBase<Oidb0x8a7Req>>(bytes);
expect(env.body).toMatchObject({
basic1: 1,
basic2: 2,
basic3: 1,
uin: 10001n,
groupId: 12345n,
});
});
it('decodes canAtAll + groupRemain + uinRemain as plain numbers / boolean', async () => {
const deps = makeDeps({ canAtAll: true, groupRemain: 12, uinRemain: 5 });
const out = await GetAtAllRemain.invoke(deps, { groupId: 12345 });
expect(out).toEqual({
can_at_all: true,
remain_at_all_count_for_group: 12,
remain_at_all_count_for_uin: 5,
});
});
it('falls back to false / 0 when the response body is empty', async () => {
const deps = makeDeps({} as any);
const out = await GetAtAllRemain.invoke(deps, { groupId: 1 });
expect(out).toEqual({
can_at_all: false,
remain_at_all_count_for_group: 0,
remain_at_all_count_for_uin: 0,
});
});
});
@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGetGroupExtReq,
OidbGetGroupExtResp,
} from '@snowluma/proto-defs/oidb-actions/group-ext';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchGroupExtInfo } from '../../../src/oidb-services/group-admin/get-group-ext-info';
import { env, v, m } from '../_pb-oracle';
function makeSender(body?: OidbGetGroupExtResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGetGroupExtResp>>({ body }))
: Buffer.alloc(0);
const result: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => result) };
}
describe('FetchGroupExtInfo namespace', () => {
it('declares command 0xEF0 sub 1, uin form', () => {
expect(FetchGroupExtInfo.command).toBe(0xef0);
expect(FetchGroupExtInfo.subCommand).toBe(1);
expect(FetchGroupExtInfo.uinForm).toBe(true);
});
it('byte-oracle: routes to 0xef0_1 and locks {1:groupCode, 2:{29:1,30:1}}', async () => {
const sender = makeSender({
items: [
{
groupCode: 12345n,
ext: { inviteRobotMemberSwitch: 1, inviteRobotMemberExamine: 2 },
},
],
});
await FetchGroupExtInfo.invoke(sender, { groupId: 12345 });
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0xef0_1');
const filter = [...v(29, 1), ...v(30, 1)];
const body = [...v(1, 12345), ...m(2, filter)];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xef0, 1, body, true));
const envelope = protobuf_decode<OidbBase<OidbGetGroupExtReq>>(bytes);
expect(envelope.reserved).toBe(1);
expect(envelope.body?.groupCodes).toEqual([12345n]);
});
it('returns robot-add switch/examine from the first item', async () => {
const sender = makeSender({
items: [
{
groupCode: 12345n,
ext: { inviteRobotMemberSwitch: 1, inviteRobotMemberExamine: 2 },
},
],
});
await expect(FetchGroupExtInfo.invoke(sender, { groupId: 12345 })).resolves.toEqual({
robotMemberSwitch: 1,
robotMemberExamine: 2,
});
});
it('treats omitted zeros as 0', async () => {
const sender = makeSender({ items: [{ groupCode: 12345n, ext: {} }] });
await expect(FetchGroupExtInfo.invoke(sender, { groupId: 12345 })).resolves.toEqual({
robotMemberSwitch: 0,
robotMemberExamine: 0,
});
});
it('throws when the list is empty', async () => {
const sender = makeSender({ items: [] });
await expect(FetchGroupExtInfo.invoke(sender, { groupId: 12345 })).rejects.toThrow(
/unable to read group robot-add option/,
);
});
it('throws on a non-zero item result', async () => {
const sender = makeSender({
items: [{ groupCode: 12345n, resultCode: 5, ext: { inviteRobotMemberSwitch: 1 } }],
});
await expect(FetchGroupExtInfo.invoke(sender, { groupId: 12345 })).rejects.toThrow(
/result=5/,
);
});
});
@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbKickMember } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { KickMember } from '../../../src/oidb-services/group-admin/kick-member';
import { m, s, v } from '../_pb-oracle';
/** 24-byte uid: success items on the wire are `{ result:0, uid }` with uid length 0x18. */
const TRACE_UID = 'u_abcdefghijklmnopqrstuv';
function kickResultItem(result: number, uid: string): number[] {
return [...v(1, result), ...s(2, uid)];
}
function kickResponseBytes(groupUin: number, items: number[][]): Buffer {
const body = [...v(1, groupUin), ...items.flatMap((item) => m(2, item))];
return Buffer.from(m(4, body));
}
function makeDeps(responseData: Buffer = Buffer.alloc(0)) {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return {
sendRawPacket: vi.fn(async () => r),
resolveUserUid: vi.fn(async () => 'resolved-uid'),
};
}
describe('KickMember namespace', () => {
it('declares 0x8A0_1', () => {
expect(KickMember.command).toBe(0x8a0);
expect(KickMember.subCommand).toBe(1);
});
it('resolves uid per-group and forwards reject + reason', async () => {
const deps = makeDeps();
await KickMember.invoke(deps, {
groupId: 12345,
userId: 67890,
reject: true,
reason: 'bye',
});
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x8a0_1');
const env = protobuf_decode<OidbBase<OidbKickMember>>(bytes);
expect(env.body).toMatchObject({
groupUin: 12345,
targetUid: 'resolved-uid',
rejectAddRequest: true,
reason: 'bye',
});
});
it('defaults reason to empty string', async () => {
const deps = makeDeps();
await KickMember.invoke(deps, { groupId: 1, userId: 2, reject: false });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbKickMember>>(bytes);
expect(env.body?.reason ?? '').toBe('');
expect(env.body?.rejectAddRequest ?? false).toBe(false);
});
it('treats an empty body as success', async () => {
await expect(
KickMember.invoke(makeDeps(), {
groupId: 1,
userId: 2,
reject: false,
}),
).resolves.toBeUndefined();
});
it('treats a zero per-member result plus uid as success (#413)', async () => {
const bytes = kickResponseBytes(12345, [kickResultItem(0, TRACE_UID)]);
const decoded = KickMember.decode(bytes);
expect(decoded.body?.groupUin).toBe(12345);
expect(decoded.body?.results).toEqual([{ result: 0, uid: TRACE_UID }]);
await expect(
KickMember.invoke(makeDeps(bytes), {
groupId: 12345,
userId: 67890,
reject: false,
}),
).resolves.toBeUndefined();
});
it('rejects a non-zero per-member result with the numeric code, not the uid (#298)', async () => {
const bytes = kickResponseBytes(12345, [kickResultItem(7, TRACE_UID)]);
await expect(
KickMember.invoke(makeDeps(bytes), {
groupId: 12345,
userId: 67890,
reject: false,
}),
).rejects.toThrow('kick member failed: result=7');
});
it('deserialize: omitted/zero results succeed; non-zero throws', () => {
expect(() => KickMember.deserialize({} as never, {})).not.toThrow();
expect(() => KickMember.deserialize({} as never, { results: [] })).not.toThrow();
expect(() =>
KickMember.deserialize({} as never, {
results: [{ result: 0, uid: TRACE_UID }],
}),
).not.toThrow();
expect(() =>
KickMember.deserialize({} as never, {
results: [{ uid: TRACE_UID }],
}),
).not.toThrow();
expect(() =>
KickMember.deserialize({} as never, {
results: [{ result: 1, uid: TRACE_UID }],
}),
).toThrow('kick member failed: result=1');
});
});
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x8a0Req } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { KickMembers } from '../../../src/oidb-services/group-admin/kick-members';
import { m, s, v } from '../_pb-oracle';
const TRACE_UID_A = 'u_abcdefghijklmnopqrstuv';
const TRACE_UID_B = 'u_bbbbbbbbbbbbbbbbbbbbbb';
function kickResultItem(result: number, uid: string): number[] {
return [...v(1, result), ...s(2, uid)];
}
function kickResponseBytes(groupUin: number, items: number[][]): Buffer {
const body = [...v(1, groupUin), ...items.flatMap((item) => m(2, item))];
return Buffer.from(m(4, body));
}
function makeDeps(
resolveSequence: string[] = ['uid-a', 'uid-b'],
responseData: Buffer = Buffer.alloc(0),
) {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
const resolveUserUid = vi.fn();
for (const uid of resolveSequence) {
resolveUserUid.mockResolvedValueOnce(uid);
}
return {
sendRawPacket: vi.fn(async () => r),
resolveUserUid,
};
}
describe('KickMembers namespace', () => {
it('declares 0x8A0_1 (same as KickMember; disambiguated by proto body)', () => {
expect(KickMembers.command).toBe(0x8a0);
expect(KickMembers.subCommand).toBe(1);
});
it('resolves each uid in parallel and packages targetUids[]', async () => {
const deps = makeDeps(['uid-a', 'uid-b']);
await KickMembers.invoke(deps, { groupId: 12345, userIds: [11, 22], reject: false });
expect(deps.resolveUserUid).toHaveBeenCalledTimes(2);
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x8a0_1');
const env = protobuf_decode<OidbBase<Oidb0x8a0Req>>(bytes);
expect(env.body?.targetUids).toEqual(['uid-a', 'uid-b']);
expect(env.body?.groupId).toBe(12345n);
expect(env.body?.rejectAddRequest ?? 0).toBe(0);
});
it('reject=true => rejectAddRequest=1', async () => {
const deps = makeDeps(['u']);
await KickMembers.invoke(deps, { groupId: 1, userIds: [2], reject: true });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<Oidb0x8a0Req>>(bytes);
expect(env.body?.rejectAddRequest).toBe(1);
});
it('treats an empty body as success', async () => {
await expect(
KickMembers.invoke(makeDeps(['u']), {
groupId: 1,
userIds: [2],
reject: false,
}),
).resolves.toBeUndefined();
});
it('treats zero per-member results as success (#413)', async () => {
const bytes = kickResponseBytes(12345, [
kickResultItem(0, TRACE_UID_A),
kickResultItem(0, TRACE_UID_B),
]);
const decoded = KickMembers.decode(bytes);
expect(decoded.body?.results).toEqual([
{ result: 0, uid: TRACE_UID_A },
{ result: 0, uid: TRACE_UID_B },
]);
await expect(
KickMembers.invoke(makeDeps(['uid-a', 'uid-b'], bytes), {
groupId: 12345,
userIds: [11, 22],
reject: false,
}),
).resolves.toBeUndefined();
});
it('rejects when any per-member result is non-zero, with the numeric code', async () => {
const bytes = kickResponseBytes(12345, [
kickResultItem(0, TRACE_UID_A),
kickResultItem(7, TRACE_UID_B),
]);
await expect(
KickMembers.invoke(makeDeps(['uid-a', 'uid-b'], bytes), {
groupId: 12345,
userIds: [11, 22],
reject: false,
}),
).rejects.toThrow('kick members failed: result=7');
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbLeaveGroup } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { LeaveGroup } from '../../../src/oidb-services/group-admin/leave-group';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('LeaveGroup namespace', () => {
it('declares 0x1097_1', () => {
expect(LeaveGroup.command).toBe(0x1097);
expect(LeaveGroup.subCommand).toBe(1);
});
it('routes to 0x1097_1 with groupUin only', async () => {
const deps = makeDeps();
await LeaveGroup.invoke(deps, { groupId: 12345 });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x1097_1');
const env = protobuf_decode<OidbBase<OidbLeaveGroup>>(bytes);
expect(env.body).toMatchObject({ groupUin: 12345 });
});
});
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbModifyGroupExtResp } from '@snowluma/proto-defs/oidb-actions/group-ext';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { ModifyGroupExtInfo } from '../../../src/oidb-services/group-admin/modify-group-ext-info';
import { env, v, m } from '../_pb-oracle';
function makeSender(result = 0) {
const respEnv: OidbBase<OidbModifyGroupExtResp> = {
command: 0xf00,
subCommand: 3,
body: { groupCode: 12345, result },
};
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(protobuf_encode<OidbBase<OidbModifyGroupExtResp>>(respEnv)),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('ModifyGroupExtInfo namespace', () => {
it('declares command 0xF00 sub 3, uin form', () => {
expect(ModifyGroupExtInfo.command).toBe(0xf00);
expect(ModifyGroupExtInfo.subCommand).toBe(3);
expect(ModifyGroupExtInfo.uinForm).toBe(true);
});
it('only includes the fields the caller provides', () => {
expect(
ModifyGroupExtInfo.serialize({} as any, { groupId: 1, robotMemberSwitch: 1 }),
).toEqual({
groupCode: 1,
info: { groupCode: 1, ext: { inviteRobotMemberSwitch: 1 } },
});
expect(ModifyGroupExtInfo.serialize({} as any, { groupId: 1 })).toEqual({
groupCode: 1,
info: { groupCode: 1, ext: {} },
});
});
it('byte-oracle: routes to 0xf00_3 and locks nested tags {1:gc,2:{1:gc,2:{29:switch,30:examine}}}', async () => {
const sender = makeSender();
await ModifyGroupExtInfo.invoke(sender, {
groupId: 12345,
robotMemberSwitch: 1,
robotMemberExamine: 2,
});
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0xf00_3');
const ext = [...v(29, 1), ...v(30, 2)];
const info = [...v(1, 12345), ...m(2, ext)];
const body = [...v(1, 12345), ...m(2, info)];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xf00, 3, body, true));
});
it('emits switch=0 on the wire (explicit presence; omit would look like "unchanged")', async () => {
const sender = makeSender();
await ModifyGroupExtInfo.invoke(sender, { groupId: 12345, robotMemberSwitch: 0 });
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const ext = [...v(29, 0)];
const info = [...v(1, 12345), ...m(2, ext)];
const body = [...v(1, 12345), ...m(2, info)];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xf00, 3, body, true));
});
it('emits both zeros when the caller sets switch and examine to 0', async () => {
const sender = makeSender();
await ModifyGroupExtInfo.invoke(sender, {
groupId: 12345,
robotMemberSwitch: 0,
robotMemberExamine: 0,
});
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const ext = [...v(29, 0), ...v(30, 0)];
const info = [...v(1, 12345), ...m(2, ext)];
const body = [...v(1, 12345), ...m(2, info)];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0xf00, 3, body, true));
});
it('does not send when neither field is provided', async () => {
const sender = makeSender();
await ModifyGroupExtInfo.invoke(sender, { groupId: 12345 });
expect(sender.sendRawPacket).not.toHaveBeenCalled();
});
it('deserialize throws on a non-zero body result', () => {
expect(() => ModifyGroupExtInfo.deserialize({} as any, { result: 5 })).toThrow(/result=5/);
expect(() => ModifyGroupExtInfo.deserialize({} as any, { result: 0 })).not.toThrow();
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbMuteAll } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { MuteAll } from '../../../src/oidb-services/group-admin/mute-all';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('MuteAll namespace', () => {
it('declares 0x89A_0', () => {
expect(MuteAll.command).toBe(0x89a);
expect(MuteAll.subCommand).toBe(0);
});
it('enable=true emits the 0xFFFFFFFF "permanent" state', async () => {
const deps = makeDeps();
await MuteAll.invoke(deps, { groupId: 12345, enable: true });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x89a_0');
const env = protobuf_decode<OidbBase<OidbMuteAll>>(bytes);
expect(env.body).toMatchObject({ groupUin: 12345, muteState: { state: 0xffffffff } });
});
it('enable=false forces state=0 ONTO the wire (so the server can disambiguate)', async () => {
const deps = makeDeps();
await MuteAll.invoke(deps, { groupId: 12345, enable: false });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbMuteAll>>(bytes);
expect(env.body?.muteState?.state).toBe(0);
// The fix (issue #70): state=0 is a `pb_optional<>` field, so it must be
// PRESENT on the wire — field 17 (varint tag 0x88 0x01) + value 0x00 — not
// omitted. An empty `muteState` is indistinguishable from the other
// commands sharing OIDB (0x89A, 0), so the server rejects unmute with 1007.
const arr = Array.from(bytes);
const hasStateField = arr.some(
(_, i) => arr[i] === 0x88 && arr[i + 1] === 0x01 && arr[i + 2] === 0x00,
);
expect(hasStateField).toBe(true);
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbMuteMember } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { MuteMember } from '../../../src/oidb-services/group-admin/mute-member';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn(async () => r),
resolveUserUid: vi.fn(async () => 'resolved-uid'),
};
}
describe('MuteMember namespace', () => {
it('declares 0x1253_1', () => {
expect(MuteMember.command).toBe(0x1253);
expect(MuteMember.subCommand).toBe(1);
});
it('resolves uid before encoding and routes to 0x1253_1', async () => {
const deps = makeDeps();
await MuteMember.invoke(deps, { groupId: 12345, userId: 67890, duration: 600 });
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x1253_1');
const env = protobuf_decode<OidbBase<OidbMuteMember>>(bytes);
expect(env.body).toMatchObject({
groupUin: 12345,
type: 1,
body: { targetUid: 'resolved-uid', duration: 600 },
});
});
it('duration = 0 acts as unmute', async () => {
const deps = makeDeps();
await MuteMember.invoke(deps, { groupId: 1, userId: 2, duration: 0 });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbMuteMember>>(bytes);
// duration=0 is the proto3 default and is omitted on the wire.
expect(env.body?.body?.duration ?? 0).toBe(0);
});
});
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x89a_0AddOption } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import {
isQuestionAddType,
SetAddOption,
wireAddType,
} from '../../../src/oidb-services/group-admin/set-add-option';
import { env, v, s, m } from '../_pb-oracle';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('SetAddOption namespace', () => {
it('declares 0x89A_0 (shared cmd with MuteAll / SetSearch / SetGroupName)', () => {
expect(SetAddOption.command).toBe(0x89a);
expect(SetAddOption.subCommand).toBe(0);
});
it('packages settings.addType and routes to 0x89a_0', async () => {
const deps = makeDeps();
await SetAddOption.invoke(deps, { groupId: 12345, addType: 2 });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x89a_0');
const decoded = protobuf_decode<OidbBase<Oidb0x89a_0AddOption>>(bytes);
expect(decoded.body).toMatchObject({ groupUin: 12345n, settings: { addType: 2 } });
expect(decoded.body?.settings?.groupQuestion ?? undefined).toBeUndefined();
expect(decoded.body?.settings?.groupAnswer ?? undefined).toBeUndefined();
});
it('treats 4 as question+answer and 5/55 as question-only', () => {
expect(isQuestionAddType(4)).toBe(true);
expect(isQuestionAddType(5)).toBe(true);
expect(isQuestionAddType(55)).toBe(true);
expect(isQuestionAddType(2)).toBe(false);
expect(wireAddType(55)).toBe(5);
expect(wireAddType(4)).toBe(4);
});
it('byte-oracle: type 4 writes question and answer', async () => {
const deps = makeDeps();
await SetAddOption.invoke(deps, {
groupId: 12345,
addType: 4,
groupQuestion: 'q',
groupAnswer: 'a',
});
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const settings = [...v(16, 4), ...s(30, 'q'), ...s(31, 'a')];
const body = [...v(1, 12345), ...m(2, settings)];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x89a, 0, body, false));
});
it('byte-oracle: type 5 writes question and clears answer', async () => {
const deps = makeDeps();
await SetAddOption.invoke(deps, {
groupId: 12345,
addType: 5,
groupQuestion: 'why',
groupAnswer: 'ignored',
});
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const settings = [...v(16, 5), ...s(30, 'why'), ...s(31, '')];
const body = [...v(1, 12345), ...m(2, settings)];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x89a, 0, body, false));
});
it('maps add_type 55 onto the official question-only mode', async () => {
const deps = makeDeps();
await SetAddOption.invoke(deps, { groupId: 1, addType: 55, groupQuestion: 'q' });
const decoded = protobuf_decode<OidbBase<Oidb0x89a_0AddOption>>(
deps.sendRawPacket.mock.calls[0]![1],
);
expect(decoded.body?.settings).toMatchObject({
addType: 5,
groupQuestion: 'q',
groupAnswer: '',
});
});
});
@@ -0,0 +1,110 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbGroupRequestAction } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetAddRequest } from '../../../src/oidb-services/group-admin/set-add-request';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('SetAddRequest namespace', () => {
it('declares 0x10C8 with uinForm=true and dynamic subCommand', () => {
expect(SetAddRequest.command).toBe(0x10c8);
expect(SetAddRequest.uinForm).toBe(true);
expect(
SetAddRequest.resolveSubCommand({
groupId: 1,
sequence: 1,
eventType: 0,
approve: true,
filtered: false,
}),
).toBe(1);
expect(
SetAddRequest.resolveSubCommand({
groupId: 1,
sequence: 1,
eventType: 0,
approve: true,
filtered: true,
}),
).toBe(2);
});
it('approve=true => accept=1, filtered=false => 0x10c8_1, reserved=1 (uinForm)', async () => {
const deps = makeDeps();
await SetAddRequest.invoke(deps, {
groupId: 12345,
sequence: 5,
eventType: 1,
approve: true,
reason: 'ok',
});
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x10c8_1');
const env = protobuf_decode<OidbBase<OidbGroupRequestAction>>(bytes);
expect(env.reserved).toBe(1);
expect(env.body?.accept).toBe(1);
expect(env.body?.body).toMatchObject({
sequence: 5n,
eventType: 1,
groupUin: 12345,
message: 'ok',
});
});
it('approve=false + filtered=true => accept=2, 0x10c8_2', async () => {
const deps = makeDeps();
await SetAddRequest.invoke(deps, {
groupId: 12345,
sequence: 5,
eventType: 1,
approve: false,
reason: 'no',
filtered: true,
});
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x10c8_2');
const env = protobuf_decode<OidbBase<OidbGroupRequestAction>>(bytes);
expect(env.body?.accept).toBe(2);
});
it('writes a space when the reason is empty so field 4 stays present', async () => {
const deps = makeDeps();
await SetAddRequest.invoke(deps, { groupId: 1, sequence: 1, eventType: 1, approve: true });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbGroupRequestAction>>(bytes);
expect(env.body?.body?.message).toBe(' ');
const arr = Array.from(bytes);
const hasMessageField = arr.some(
(_, i) => arr[i] === 0x22 && arr[i + 1] === 0x01 && arr[i + 2] === 0x20,
);
expect(hasMessageField).toBe(true);
});
it('forwards operateTransInfo as body field 7', async () => {
const deps = makeDeps();
const operateTransInfo = Uint8Array.from([0x01, 0x02, 0x03]);
await SetAddRequest.invoke(deps, {
groupId: 1,
sequence: 1,
eventType: 1,
approve: true,
reason: 'ok',
operateTransInfo,
});
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbGroupRequestAction>>(bytes);
expect(env.body?.body?.operateTransInfo).toEqual(operateTransInfo);
});
});
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbSetAdmin } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetAdmin } from '../../../src/oidb-services/group-admin/set-admin';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn(async () => r),
resolveUserUid: vi.fn(async () => 'resolved-uid'),
};
}
describe('SetAdmin namespace', () => {
it('declares 0x1096_1', () => {
expect(SetAdmin.command).toBe(0x1096);
expect(SetAdmin.subCommand).toBe(1);
});
it('enable=true promotes (isAdmin=true)', async () => {
const deps = makeDeps();
await SetAdmin.invoke(deps, { groupId: 12345, userId: 67890, enable: true });
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x1096_1');
const env = protobuf_decode<OidbBase<OidbSetAdmin>>(bytes);
expect(env.body).toMatchObject({ groupUin: 12345, uid: 'resolved-uid', isAdmin: true });
});
it('enable=false demotes (isAdmin omitted on the wire)', async () => {
const deps = makeDeps();
await SetAdmin.invoke(deps, { groupId: 1, userId: 2, enable: false });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbSetAdmin>>(bytes);
expect(env.body?.isAdmin ?? false).toBe(false);
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbRenameGroup } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetGroupName } from '../../../src/oidb-services/group-admin/set-group-name';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('SetGroupName namespace', () => {
it('declares 0x89A_15', () => {
expect(SetGroupName.command).toBe(0x89a);
expect(SetGroupName.subCommand).toBe(15);
});
it('routes to 0x89a_15 with the new name', async () => {
const deps = makeDeps();
await SetGroupName.invoke(deps, { groupId: 12345, name: 'newName' });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x89a_15');
const env = protobuf_decode<OidbBase<OidbRenameGroup>>(bytes);
expect(env.body).toMatchObject({ groupUin: 12345, body: { targetName: 'newName' } });
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0xf16Req } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetGroupRemark } from '../../../src/oidb-services/group-admin/set-group-remark';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn(async () => r),
identity: { uin: '10001', updateGroupRemark: vi.fn(() => true) },
};
}
describe('SetGroupRemark namespace', () => {
it('declares 0xF16_1', () => {
expect(SetGroupRemark.command).toBe(0xf16);
expect(SetGroupRemark.subCommand).toBe(1);
});
it('routes to 0xf16_1 with groupId + remark wrapped in inner', async () => {
const deps = makeDeps();
await SetGroupRemark.invoke(deps, { groupId: 12345, remark: 'my group' });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0xf16_1');
const env = protobuf_decode<OidbBase<Oidb0xf16Req>>(bytes);
expect(env.body?.inner).toMatchObject({ groupId: 12345n, remark: 'my group' });
});
it('updates the local group roster after QQ confirms the remark', async () => {
const deps = makeDeps();
await SetGroupRemark.invoke(deps as any, { groupId: 12345, remark: 'my group' });
expect(deps.identity.updateGroupRemark).toHaveBeenCalledWith(12345, 'my group');
});
it('does not update the local roster when QQ rejects the mutation', async () => {
const deps = makeDeps();
deps.sendRawPacket.mockResolvedValueOnce({
success: false,
gotResponse: true,
errorCode: 120,
errorMessage: 'denied',
responseData: Buffer.alloc(0),
});
await expect(
SetGroupRemark.invoke(deps as any, { groupId: 12345, remark: 'not-applied' }),
).rejects.toThrow();
expect(deps.identity.updateGroupRemark).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetMemberCard } from '../../../src/oidb-services/group-admin/set-member-card';
import { env, m, s, v } from '../_pb-oracle';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn<SetMemberCard.Deps['sendRawPacket']>().mockResolvedValue(r),
resolveUserUid: vi.fn(async () => 'resolved-uid'),
};
}
describe('SetMemberCard namespace', () => {
it('declares 0x8FC_3', () => {
expect(SetMemberCard.command).toBe(0x8fc);
expect(SetMemberCard.subCommand).toBe(3);
});
it.each(['', 'newCard', '群名片'])('sends card %j in field 8', async (card) => {
const deps = makeDeps();
await SetMemberCard.invoke(deps, { groupId: 12345, userId: 67890, card });
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x8fc_3');
const member = [...s(1, 'resolved-uid'), ...s(8, card)];
const body = [...v(1, 12345), ...m(3, member)];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x8fc, 3, body, false));
});
});
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x89a_0InvitePolicy } from '@snowluma/proto-defs/oidb-actions/base';
import {
MEMBER_INVITE_PRIVILEGE_MASK,
decodeMemberInvitePolicy,
mergeMemberInvitePrivilegeFlag,
SetMemberInvitePolicy,
type GroupMemberInvitePolicy,
} from '../../../src/oidb-services/group-admin/set-member-invite-policy';
function makeSender() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('SetMemberInvitePolicy namespace', () => {
it.each<[GroupMemberInvitePolicy, number, number]>([
['disabled', 0x04000000, 0],
['require_approval', 0, 1],
['no_approval', 0x00100000, 1],
['no_approval_under_100', 0x02000000, 1],
])('maps %s to the verified privilege bits', async (policy, policyBits, allowMemberInvite) => {
const sender = makeSender();
const unrelatedBits = 0x80000001;
await SetMemberInvitePolicy.invoke(sender, {
groupId: 12345,
currentPrivilegeFlag: unrelatedBits + 0x06100000,
policy,
});
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x89a_0');
const env = protobuf_decode<OidbBase<Oidb0x89a_0InvitePolicy>>(bytes);
expect(env.command).toBe(0x89a);
expect(env.body?.groupUin).toBe(12345n);
expect(env.body?.settings).toEqual({
appPrivilegeFlag: unrelatedBits + policyBits,
appPrivilegeMask: MEMBER_INVITE_PRIVILEGE_MASK,
allowMemberInvite,
});
});
it('preserves unrelated bits while replacing only the member-invite mask', () => {
expect(mergeMemberInvitePrivilegeFlag(0xe7100001, 'require_approval')).toBe(0xe1000001);
});
it.each<[number, GroupMemberInvitePolicy]>([
[0x04000000, 'disabled'],
[0, 'require_approval'],
[0x00100000, 'no_approval'],
[0x02000000, 'no_approval_under_100'],
[0x80100001, 'no_approval'],
[0x84000001, 'disabled'],
])('decodes privilege %# as %s', (privilegeFlag, policy) => {
expect(decodeMemberInvitePolicy(privilegeFlag)).toBe(policy);
});
it('round-trips each invite policy through merge then decode', () => {
const policies: GroupMemberInvitePolicy[] = [
'disabled',
'require_approval',
'no_approval',
'no_approval_under_100',
];
for (const policy of policies) {
expect(
decodeMemberInvitePolicy(mergeMemberInvitePrivilegeFlag(0x80000001, policy)),
).toBe(policy);
}
});
it('keeps explicit zero fields on the wire', async () => {
const sender = makeSender();
await SetMemberInvitePolicy.invoke(sender, {
groupId: 1,
currentPrivilegeFlag: MEMBER_INVITE_PRIVILEGE_MASK,
policy: 'require_approval',
});
const env = protobuf_decode<OidbBase<Oidb0x89a_0InvitePolicy>>(
sender.sendRawPacket.mock.calls[0]![1],
);
expect(env.body?.settings?.appPrivilegeFlag).toBe(0);
expect(env.body?.settings?.allowMemberInvite).toBe(1);
});
it('rejects an invalid current flag before sending a packet', async () => {
const sender = makeSender();
await expect(
SetMemberInvitePolicy.invoke(sender, {
groupId: 1,
currentPrivilegeFlag: -1,
policy: 'disabled',
}),
).rejects.toThrow(/unsigned 32-bit/);
expect(sender.sendRawPacket).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x89a_0MemberPermission } from '@snowluma/proto-defs/oidb-actions/base';
import {
decodeGroupMemberPermissions,
GROUP_MEMBER_PERMISSION_MASKS,
mergeGroupMemberPermission,
SetMemberPermission,
type GroupMemberPermission,
} from '../../../src/oidb-services/group-admin/set-member-permission';
function makeSender() {
const result: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => result) };
}
describe('SetMemberPermission namespace', () => {
it('declares 0x89A_0', () => {
expect(SetMemberPermission.command).toBe(0x89a);
expect(SetMemberPermission.subCommand).toBe(0);
});
it.each<[GroupMemberPermission, number]>([
['upload_album', 0x1],
['temporary_session', 0x10000],
['create_group', 0x8000],
])('uses the verified deny-bit mask for %s', async (permission, mask) => {
const sender = makeSender();
await SetMemberPermission.invoke(sender, {
groupId: 12345,
currentPrivilegeFlag: 0x80018001,
permission,
allow: true,
});
const [command, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(command).toBe('OidbSvcTrpcTcp.0x89a_0');
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(bytes);
expect(envelope.command).toBe(0x89a);
expect(envelope.subCommand ?? 0).toBe(0);
expect(envelope.body).toMatchObject({
groupUin: 12345n,
settings: {
appPrivilegeFlag: Number(BigInt(0x80018001) & ~BigInt(mask)),
appPrivilegeMask: mask,
},
});
});
it('sets a deny bit when a capability is disabled', async () => {
const sender = makeSender();
await SetMemberPermission.invoke(sender, {
groupId: 12345,
currentPrivilegeFlag: 0x80000000,
permission: 'temporary_session',
allow: false,
});
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(
sender.sendRawPacket.mock.calls[0]![1],
);
expect(envelope.body?.settings).toEqual({
appPrivilegeFlag: 0x80010000,
appPrivilegeMask: GROUP_MEMBER_PERMISSION_MASKS.temporary_session,
});
});
it('preserves unrelated bits while changing only the selected capability', () => {
expect(mergeGroupMemberPermission(0xf0018001, 'upload_album', true)).toBe(0xf0018000);
expect(mergeGroupMemberPermission(0xf0000000, 'create_group', false)).toBe(0xf0008000);
});
it('treats permission bits as deny flags when decoding', () => {
expect(decodeGroupMemberPermissions(0)).toEqual({
allowMemberUploadAlbum: true,
allowMemberTemporarySession: true,
allowMemberCreateGroup: true,
});
expect(decodeGroupMemberPermissions(0x80018001)).toEqual({
allowMemberUploadAlbum: false,
allowMemberTemporarySession: false,
allowMemberCreateGroup: false,
});
expect(decodeGroupMemberPermissions(0x80000000)).toEqual({
allowMemberUploadAlbum: true,
allowMemberTemporarySession: true,
allowMemberCreateGroup: true,
});
});
it('round-trips each permission through merge then decode', () => {
let flag = 0x80000000;
flag = mergeGroupMemberPermission(flag, 'upload_album', false);
flag = mergeGroupMemberPermission(flag, 'temporary_session', true);
flag = mergeGroupMemberPermission(flag, 'create_group', false);
expect(decodeGroupMemberPermissions(flag)).toEqual({
allowMemberUploadAlbum: false,
allowMemberTemporarySession: true,
allowMemberCreateGroup: false,
});
});
it('keeps explicit zero fields on the wire', async () => {
const sender = makeSender();
await SetMemberPermission.invoke(sender, {
groupId: 1,
currentPrivilegeFlag: 1,
permission: 'upload_album',
allow: true,
});
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(
sender.sendRawPacket.mock.calls[0]![1],
);
expect(envelope.body?.settings?.appPrivilegeFlag).toBe(0);
expect(envelope.body?.settings?.appPrivilegeMask).toBe(1);
});
it('rejects an invalid current flag before sending', async () => {
const sender = makeSender();
await expect(
SetMemberPermission.invoke(sender, {
groupId: 1,
currentPrivilegeFlag: 0x1_0000_0000,
permission: 'upload_album',
allow: true,
}),
).rejects.toThrow(/unsigned 32-bit/);
expect(sender.sendRawPacket).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x89a_0HistoryVisibility } from '@snowluma/proto-defs/oidb-actions/base';
import {
decodeGroupHistoryVisibility,
GROUP_HISTORY_VISIBILITY_MASK,
mergeGroupHistoryVisibility,
SetNewMemberHistoryVisibility,
} from '../../../src/oidb-services/group-admin/set-new-member-history-visibility';
function makeSender() {
const result: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => result) };
}
describe('SetNewMemberHistoryVisibility namespace', () => {
it.each([
[true, GROUP_HISTORY_VISIBILITY_MASK],
[false, 0],
])('writes visible=%s as the mutation bit only', async (visible, expected) => {
const sender = makeSender();
await SetNewMemberHistoryVisibility.invoke(sender, {
groupId: 12345,
visible,
});
const [command, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(command).toBe('OidbSvcTrpcTcp.0x89a_0');
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0HistoryVisibility>>(bytes);
expect(envelope.command).toBe(0x89a);
expect(envelope.body).toMatchObject({
groupUin: 12345n,
settings: {
groupFlagExt4: expected,
groupFlagExt4Mask: GROUP_HISTORY_VISIBILITY_MASK,
},
});
});
it('merges only the verified history-visibility bit', () => {
expect(mergeGroupHistoryVisibility(0xf0000001, true)).toBe(0xf0000005);
expect(mergeGroupHistoryVisibility(0xf0000005, false)).toBe(0xf0000001);
});
it('decodes the history-visibility switch from a 0/1 reply or the mutation bit', () => {
expect(decodeGroupHistoryVisibility(0)).toBe(false);
expect(decodeGroupHistoryVisibility(1)).toBe(true);
expect(decodeGroupHistoryVisibility(GROUP_HISTORY_VISIBILITY_MASK)).toBe(true);
expect(decodeGroupHistoryVisibility(0x80000001)).toBe(false);
expect(decodeGroupHistoryVisibility(0x80000005)).toBe(true);
});
it('round-trips history visibility through merge then decode', () => {
expect(decodeGroupHistoryVisibility(mergeGroupHistoryVisibility(0x80000001, true))).toBe(
true,
);
expect(decodeGroupHistoryVisibility(mergeGroupHistoryVisibility(0x80000005, false))).toBe(
false,
);
});
it('does not treat a 0/1 detail reply as a missing mutation bit (#387)', () => {
expect(decodeGroupHistoryVisibility(1)).toBe(true);
expect(decodeGroupHistoryVisibility(mergeGroupHistoryVisibility(0, true))).toBe(true);
});
it('keeps an explicit zero value on the wire', async () => {
const sender = makeSender();
await SetNewMemberHistoryVisibility.invoke(sender, {
groupId: 1,
visible: false,
});
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0HistoryVisibility>>(
sender.sendRawPacket.mock.calls[0]![1],
);
expect(envelope.body?.settings?.groupFlagExt4).toBe(0);
expect(envelope.body?.settings?.groupFlagExt4Mask).toBe(GROUP_HISTORY_VISIBILITY_MASK);
});
it('rejects an invalid current flag when merging', () => {
expect(() => mergeGroupHistoryVisibility(-1, true)).toThrow(/unsigned 32-bit/);
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x89a_0Search } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetSearch } from '../../../src/oidb-services/group-admin/set-search';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('SetSearch namespace', () => {
it('declares 0x89A_0 (shared cmd shape with other 0x89a_0 cmds)', () => {
expect(SetSearch.command).toBe(0x89a);
expect(SetSearch.subCommand).toBe(0);
});
it('emits an empty settings byte string and routes to 0x89a_0', async () => {
const deps = makeDeps();
await SetSearch.invoke(deps, { groupId: 12345 });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x89a_0');
const env = protobuf_decode<OidbBase<Oidb0x89a_0Search>>(bytes);
expect(env.body?.groupUin).toBe(12345n);
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbSpecialTitle } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetSpecialTitle } from '../../../src/oidb-services/group-admin/set-special-title';
import { env, v, s, m } from '../_pb-oracle';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn(async () => r),
resolveUserUid: vi.fn(async () => 'resolved-uid'),
};
}
describe('SetSpecialTitle namespace', () => {
it('declares 0x8FC_2', () => {
expect(SetSpecialTitle.command).toBe(0x8fc);
expect(SetSpecialTitle.subCommand).toBe(2);
});
it('routes to 0x8fc_2 with -1 (permanent) expireTime sentinel', async () => {
const deps = makeDeps();
await SetSpecialTitle.invoke(deps, { groupId: 12345, userId: 67890, title: 'crown' });
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x8fc_2');
const env = protobuf_decode<OidbBase<OidbSpecialTitle>>(bytes);
expect(env.body?.groupUin).toBe(12345);
expect(env.body?.body?.targetUid).toBe('resolved-uid');
expect(env.body?.body?.specialTitle).toBe('crown');
// uinName (tag 7) must mirror specialTitle or the server silently no-ops.
expect(env.body?.body?.uinName).toBe('crown');
// int_32 preserves the signed -1 sentinel after the wire round-trip.
expect(env.body?.body?.expireTime).toBe(-1);
});
it.each(['', ' ', '龙王👑'])('preserves title %j in both wire fields', async (title) => {
const deps = makeDeps();
await SetSpecialTitle.invoke(deps, { groupId: 12345, userId: 67890, title });
expect(deps.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x8fc_2');
// Empty titles must emit tag 5 + length 0 and tag 7 + length 0:
// omitting either field turns clearing the title into a silent no-op (#517).
const member = [
...s(1, 'resolved-uid'),
...s(5, title),
...v(6, 0xffffffff),
...s(7, title),
];
const body = [...v(1, 12345), ...m(3, member)];
expect(Buffer.from(bytes).toString('hex')).toBe(env(0x8fc, 2, body, false));
});
});
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupFileFolderReq,
OidbGroupFileFolderResp,
} from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { CreateGroupFolder } from '../../../src/oidb-services/group-file/create-group-folder';
function makeDeps(body?: OidbGroupFileFolderResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileFolderResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('CreateGroupFolder namespace', () => {
it('declares 0x6D7_0 with uinForm=true', () => {
expect(CreateGroupFolder.command).toBe(0x6d7);
expect(CreateGroupFolder.subCommand).toBe(0);
expect(CreateGroupFolder.uinForm).toBe(true);
});
it('packages create.{groupUin, rootDirectory, folderName}', async () => {
const deps = makeDeps({ create: {} as any });
await CreateGroupFolder.invoke(deps, { groupId: 12345, parentId: '/', folderName: 'docs' });
const env = protobuf_decode<OidbBase<OidbGroupFileFolderReq>>(
deps.sendRawPacket.mock.calls[0]![1],
);
expect(env.body?.create).toMatchObject({
groupUin: 12345,
rootDirectory: '/',
folderName: 'docs',
});
});
it('throws on retcode != 0 (note: lower-case retcode in this proto)', async () => {
const deps = makeDeps({ create: { retcode: 7, retMsg: 'denied' } as any });
await expect(
CreateGroupFolder.invoke(deps, { groupId: 1, parentId: '/', folderName: 'f' }),
).rejects.toThrow(/code=7/);
});
it('returns the new folder info from the response folderInfo (#195)', async () => {
const deps = makeDeps({
create: {
retcode: 0,
folderInfo: {
folderId: '/0f1e2d',
folderPath: '/docs',
folderName: 'docs',
createTime: 100,
modifyTime: 200,
createUin: 111,
modifyUin: 222,
},
} as any,
});
const r = await CreateGroupFolder.invoke(deps, {
groupId: 1,
parentId: '/',
folderName: 'docs',
});
expect(r).toEqual({
folderId: '/0f1e2d',
folderName: 'docs',
folderPath: '/docs',
createTime: 100,
modifyTime: 200,
createUin: 111,
modifyUin: 222,
});
});
it('defaults every field when the response omits folderInfo (#195)', async () => {
const deps = makeDeps({ create: { retcode: 0 } as any });
const r = await CreateGroupFolder.invoke(deps, {
groupId: 1,
parentId: '/',
folderName: 'f',
});
expect(r).toEqual({
folderId: '',
folderName: '',
folderPath: '',
createTime: 0,
modifyTime: 0,
createUin: 0,
modifyUin: 0,
});
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupFileReq,
OidbGroupFileResp,
} from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { DeleteGroupFile } from '../../../src/oidb-services/group-file/delete-group-file';
function makeDeps(body?: OidbGroupFileResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('DeleteGroupFile namespace', () => {
it('declares 0x6D6_3 with uinForm=true', () => {
expect(DeleteGroupFile.command).toBe(0x6d6);
expect(DeleteGroupFile.subCommand).toBe(3);
expect(DeleteGroupFile.uinForm).toBe(true);
});
it('packages busId=102 and routes to 0x6d6_3', async () => {
const deps = makeDeps({ delete: {} as any });
await DeleteGroupFile.invoke(deps, { groupId: 12345, fileId: 'fid' });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x6d6_3');
const env = protobuf_decode<OidbBase<OidbGroupFileReq>>(bytes);
expect(env.body?.delete).toMatchObject({ groupUin: 12345, busId: 102, fileId: 'fid' });
});
it('throws on missing delete sub-message', async () => {
const deps = makeDeps({});
await expect(DeleteGroupFile.invoke(deps, { groupId: 1, fileId: 'f' })).rejects.toThrow(
/delete response missing/,
);
});
it('throws on non-zero retCode', async () => {
const deps = makeDeps({ delete: { retCode: 5, retMsg: 'no perms' } as any });
await expect(DeleteGroupFile.invoke(deps, { groupId: 1, fileId: 'f' })).rejects.toThrow(
/code=5/,
);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupFileFolderReq,
OidbGroupFileFolderResp,
} from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { DeleteGroupFolder } from '../../../src/oidb-services/group-file/delete-group-folder';
function makeDeps(body?: OidbGroupFileFolderResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileFolderResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('DeleteGroupFolder namespace', () => {
it('declares 0x6D7_1 with uinForm=true', () => {
expect(DeleteGroupFolder.command).toBe(0x6d7);
expect(DeleteGroupFolder.subCommand).toBe(1);
expect(DeleteGroupFolder.uinForm).toBe(true);
});
it('packages delete.{groupUin, folderId}', async () => {
const deps = makeDeps({ delete: {} as any });
await DeleteGroupFolder.invoke(deps, { groupId: 12345, folderId: 'fid' });
const env = protobuf_decode<OidbBase<OidbGroupFileFolderReq>>(
deps.sendRawPacket.mock.calls[0]![1],
);
expect(env.body?.delete).toMatchObject({ groupUin: 12345, folderId: 'fid' });
});
it('throws on missing delete sub-message', async () => {
const deps = makeDeps({});
await expect(DeleteGroupFolder.invoke(deps, { groupId: 1, folderId: 'f' })).rejects.toThrow(
/folder delete response missing/,
);
});
});
@@ -0,0 +1,158 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbOfflineFileFinalizeResp } from '@snowluma/proto-defs/oidb-actions/media';
import type { FileExtra } from '@snowluma/proto-defs/message';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FinalizeOfflineFile } from '../../../src/oidb-services/group-file/finalize-offline-file';
import { env, m, s, v } from '../_pb-oracle';
// `bytes` fields are wire type 2 — identical framing to a sub-message, so the
// oracle's `m()` (tag + length + payload) doubles as the bytes emitter.
const b = m;
function makeDeps(resp?: OidbOfflineFileFinalizeResp) {
const responseData =
resp !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbOfflineFileFinalizeResp>>({ body: resp }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FinalizeOfflineFile (0xE37_800)', () => {
it('declares 0xE37_800', () => {
expect(FinalizeOfflineFile.command).toBe(0xe37);
expect(FinalizeOfflineFile.subCommand).toBe(800);
});
it('serializes the request body byte-for-byte (oracle locks every tag)', async () => {
const deps = makeDeps({});
await FinalizeOfflineFile.invoke(deps, {
senderUid: 'su',
receiverUid: 'ru',
fileUuid: 'uuid',
fileHash: 'hh',
});
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0xe37_800');
// body(10) { senderUid(10) receiverUid(20) fileUuid(30) fileHash(40) }
const innerBody = [...s(10, 'su'), ...s(20, 'ru'), ...s(30, 'uuid'), ...s(40, 'hh')];
// req: subCommand(1)=800, field2(2)=0 omitted, body(10), field101(101)=3,
// field102(102)=1, field200(200)=1
const req = [...v(1, 800), ...m(10, innerBody), ...v(101, 3), ...v(102, 1), ...v(200, 1)];
const expected = env(0xe37, 800, req, false);
expect(Buffer.from(bytes).toString('hex')).toBe(expected);
});
it('extracts the download-routing metadata from field30 of the response', async () => {
const deps = makeDeps({
body: {
metadata: {
field3: 5,
field100: new Uint8Array([9, 9]),
field101: new Uint8Array([8]),
field110: 7,
timestamp1: 42,
},
},
});
const meta = await FinalizeOfflineFile.invoke(deps, {
senderUid: 'su',
receiverUid: 'ru',
fileUuid: 'uuid',
fileHash: 'hh',
});
expect(meta.field3).toBe(5);
expect(meta.field110).toBe(7);
expect(meta.timestamp1).toBe(42);
expect(Array.from(meta.field100 ?? [])).toEqual([9, 9]);
expect(Array.from(meta.field101 ?? [])).toEqual([8]);
});
it('returns {} when the response carries no metadata', async () => {
const deps = makeDeps({});
const meta = await FinalizeOfflineFile.invoke(deps, {
senderUid: 'su',
receiverUid: 'ru',
fileUuid: 'uuid',
fileHash: 'hh',
});
expect(meta).toEqual({});
});
});
describe('FileExtra.field6 (c2c download routing — issue #157)', () => {
it('encodes field6.field2 with the exact NapCat tag layout', () => {
const extra: FileExtra = {
field6: {
field2: {
field1: 7,
fileUuid: 'uu',
fileName: 'n.bin',
field6: 3,
field7: new Uint8Array([1, 2]),
field8: new Uint8Array([3]),
timestamp1: 99,
fileHash: 'hh',
selfUid: 'su',
destUid: 'du',
},
},
};
const hex = Buffer.from(protobuf_encode<FileExtra>(extra)).toString('hex');
// PrivateFileExtraField2 in ascending tag order:
// field1(1) fileUuid(4) fileName(5) field6(6) field7(7,bytes)
// field8(8,bytes) timestamp1(9) fileHash(14) selfUid(15) destUid(16)
const f2 = [
...v(1, 7),
...s(4, 'uu'),
...s(5, 'n.bin'),
...v(6, 3),
...b(7, [1, 2]),
...b(8, [3]),
...v(9, 99),
...s(14, 'hh'),
...s(15, 'su'),
...s(16, 'du'),
];
// PrivateFileExtra { field2(2) } → FileExtra { field6(6) }
const expected = Buffer.from(m(6, m(2, f2))).toString('hex');
expect(hex).toBe(expected);
});
it('round-trips field6 through encode/decode', () => {
const extra: FileExtra = {
file: { fileUuid: 'u', fileName: 'f', subcmd: 1 },
field6: {
field2: {
field1: 1,
fileUuid: 'u',
fileName: 'f',
timestamp1: 5,
selfUid: 's',
destUid: 'd',
},
},
};
const back = protobuf_decode<FileExtra>(protobuf_encode<FileExtra>(extra));
expect(back.field6?.field2).toMatchObject({
field1: 1,
fileUuid: 'u',
fileName: 'f',
timestamp1: 5,
selfUid: 's',
destUid: 'd',
});
expect(back.file?.subcmd).toBe(1);
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupFileCountViewReq,
OidbGroupFileCountViewResp,
} from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetGroupFileCount } from '../../../src/oidb-services/group-file/get-group-file-count';
function makeDeps(body?: OidbGroupFileCountViewResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileCountViewResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetGroupFileCount namespace', () => {
it('declares 0x6D8_2 (Count subcommand)', () => {
expect(GetGroupFileCount.command).toBe(0x6d8);
expect(GetGroupFileCount.subCommand).toBe(2);
});
it('routes to 0x6d8_2 with groupUin + appId=7 + busId=6 (#196)', async () => {
const deps = makeDeps({ count: { fileCount: 1, maxCount: 100 } });
await GetGroupFileCount.invoke(deps, { groupId: 12345 });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x6d8_2');
const env = protobuf_decode<OidbBase<OidbGroupFileCountViewReq>>(bytes);
expect(env.body?.count).toMatchObject({ groupUin: 12345, appId: 7, busId: 6 });
});
it('decodes fileCount (field 4) + maxCount (field 6) as plain numbers', async () => {
const deps = makeDeps({ count: { fileCount: 42, maxCount: 1000 } });
const out = await GetGroupFileCount.invoke(deps, { groupId: 1 });
expect(out).toEqual({ fileCount: 42, maxCount: 1000 });
});
it('falls back to maxCount=10000 when the server elides it', async () => {
const deps = makeDeps({ count: {} });
const out = await GetGroupFileCount.invoke(deps, { groupId: 1 });
expect(out).toEqual({ fileCount: 0, maxCount: 10000 });
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupFileCountViewReq,
OidbGroupFileCountViewResp,
} from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetGroupFileSpace } from '../../../src/oidb-services/group-file/get-group-file-space';
function makeDeps(body?: OidbGroupFileCountViewResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileCountViewResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetGroupFileSpace namespace (#196)', () => {
it('declares 0x6D8_3 (Space subcommand)', () => {
expect(GetGroupFileSpace.command).toBe(0x6d8);
expect(GetGroupFileSpace.subCommand).toBe(3);
});
it('routes to 0x6d8_3 with a space request (groupUin + appId=7)', async () => {
const deps = makeDeps({ space: {} });
await GetGroupFileSpace.invoke(deps, { groupId: 12345 });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x6d8_3');
const env = protobuf_decode<OidbBase<OidbGroupFileCountViewReq>>(bytes);
expect(env.body?.space).toMatchObject({ groupUin: 12345, appId: 7 });
expect(env.body?.count).toBeNull();
});
it('decodes usedSpace (field 5) + totalSpace (field 4) as plain numbers', async () => {
const deps = makeDeps({ space: { usedSpace: 20103729n, totalSpace: 10737418240n } });
const out = await GetGroupFileSpace.invoke(deps, { groupId: 1 });
expect(out).toEqual({ usedSpace: 20103729, totalSpace: 10737418240 });
});
it('defaults to 0 when the server elides the space block', async () => {
const deps = makeDeps({ space: {} });
const out = await GetGroupFileSpace.invoke(deps, { groupId: 1 });
expect(out).toEqual({ usedSpace: 0, totalSpace: 0 });
});
});
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupFileReq,
OidbGroupFileResp,
} from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetGroupFileUrl } from '../../../src/oidb-services/group-file/get-group-file-url';
function makeDeps(body?: OidbGroupFileResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetGroupFileUrl namespace', () => {
it('declares 0x6D6_2 with uinForm=true', () => {
expect(GetGroupFileUrl.command).toBe(0x6d6);
expect(GetGroupFileUrl.subCommand).toBe(2);
expect(GetGroupFileUrl.uinForm).toBe(true);
});
it('packages the download slot with the supplied busId', async () => {
const deps = makeDeps({
download: { downloadDns: 'cdn', downloadUrl: new Uint8Array([1, 2]) } as any,
});
await GetGroupFileUrl.invoke(deps, { groupId: 12345, fileId: 'fid', busId: 102 });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x6d6_2');
const env = protobuf_decode<OidbBase<OidbGroupFileReq>>(bytes);
expect(env.body?.download).toMatchObject({
groupUin: 12345,
appId: 7,
busId: 102,
fileId: 'fid',
});
});
it('composes the https ftn_handler URL and checks download.retCode', async () => {
const deps = makeDeps({
download: { downloadDns: 'cdn', downloadUrl: new Uint8Array([0x01, 0x02]) } as any,
});
await expect(
GetGroupFileUrl.invoke(deps, { groupId: 1, fileId: 'fid-xyz', busId: 102 }),
).resolves.toBe('https://cdn/ftn_handler/0102/?fname=fid-xyz');
});
it('throws when download.retCode is non-zero', async () => {
const deps = makeDeps({
download: {
retCode: 1,
retMsg: 'denied',
downloadDns: 'cdn',
downloadUrl: new Uint8Array([1]),
} as any,
});
await expect(
GetGroupFileUrl.invoke(deps, { groupId: 1, fileId: 'f', busId: 102 }),
).rejects.toThrow(/group file url failed: code=1/);
});
it('throws when the download sub-message is missing', async () => {
const deps = makeDeps({});
await expect(
GetGroupFileUrl.invoke(deps, { groupId: 1, fileId: 'f', busId: 102 }),
).rejects.toThrow(/url response missing/);
});
it('falls back to downloadIp when downloadDns is empty', async () => {
const deps = makeDeps({
download: { downloadIp: '1.2.3.4', downloadUrl: new Uint8Array([0xab]) } as any,
});
await expect(
GetGroupFileUrl.invoke(deps, { groupId: 1, fileId: 'fid', busId: 102 }),
).resolves.toBe('https://1.2.3.4/ftn_handler/AB/?fname=fid');
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { NTV2RichMediaReq, NTV2RichMediaResp } from '@snowluma/proto-defs/oidb-actions/media';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetGroupPttUrl } from '../../../src/oidb-services/group-file/get-group-ptt-url';
function makeDeps(body?: NTV2RichMediaResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<NTV2RichMediaResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetGroupPttUrl namespace', () => {
it('declares 0x126E_200 with uinForm=true', () => {
expect(GetGroupPttUrl.command).toBe(0x126e);
expect(GetGroupPttUrl.subCommand).toBe(200);
expect(GetGroupPttUrl.uinForm).toBe(true);
});
it('emits scene{requestType=1, businessType=3, sceneType=2, group.groupUin}', async () => {
const deps = makeDeps({
respHead: { retCode: 0 },
download: { info: { domain: 'd', urlPath: '/p' }, rKeyParam: '?rk' },
} as any);
await GetGroupPttUrl.invoke(deps, { groupId: 12345, node: { fileUuid: 'uuid' } });
const env = protobuf_decode<OidbBase<NTV2RichMediaReq>>(
deps.sendRawPacket.mock.calls[0]![1],
);
expect(env.body?.reqHead?.scene).toMatchObject({
requestType: 1,
businessType: 3,
sceneType: 2,
group: { groupUin: 12345 },
});
});
it('composes the https://domain<path><rkey> URL', async () => {
const deps = makeDeps({
respHead: { retCode: 0 },
download: { info: { domain: 'cdn.qq', urlPath: '/x' }, rKeyParam: '?r=1' },
} as any);
const out = await GetGroupPttUrl.invoke(deps, { groupId: 1, node: { fileUuid: 'uuid' } });
expect(out).toBe('https://cdn.qq/x?r=1');
});
it('throws when fileUuid is missing on the node', async () => {
const deps = makeDeps({} as any);
await expect(GetGroupPttUrl.invoke(deps, { groupId: 1, node: {} })).rejects.toThrow(
/fileUuid is required/,
);
});
});
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { NTV2RichMediaReq, NTV2RichMediaResp } from '@snowluma/proto-defs/oidb-actions/media';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetGroupVideoUrl } from '../../../src/oidb-services/group-file/get-group-video-url';
function makeDeps(body?: NTV2RichMediaResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<NTV2RichMediaResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetGroupVideoUrl namespace', () => {
it('declares 0x11EA_200 with uinForm=true', () => {
expect(GetGroupVideoUrl.command).toBe(0x11ea);
expect(GetGroupVideoUrl.subCommand).toBe(200);
expect(GetGroupVideoUrl.uinForm).toBe(true);
});
it('emits scene{requestType=2, businessType=2, sceneType=2, group}', async () => {
const deps = makeDeps({
respHead: { retCode: 0 },
download: { info: { domain: 'd', urlPath: '/v' }, rKeyParam: '' },
} as any);
await GetGroupVideoUrl.invoke(deps, { groupId: 12345, node: { fileUuid: 'uuid' } });
const env = protobuf_decode<OidbBase<NTV2RichMediaReq>>(
deps.sendRawPacket.mock.calls[0]![1],
);
expect(env.body?.reqHead?.scene).toMatchObject({
requestType: 2,
businessType: 2,
sceneType: 2,
group: { groupUin: 12345 },
});
});
it('bubbles up retCode != 0 via the shared ntv2 deserializer', async () => {
const deps = makeDeps({
respHead: { retCode: 99, message: 'gone' },
download: { info: { domain: 'd', urlPath: '/p' }, rKeyParam: '' },
} as any);
await expect(
GetGroupVideoUrl.invoke(deps, { groupId: 1, node: { fileUuid: 'u' } }),
).rejects.toThrow(/code=99/);
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbPrivateFileDownloadReq,
OidbPrivateFileDownloadResp,
} from '@snowluma/proto-defs/oidb-actions/media';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetPrivateFileUrl } from '../../../src/oidb-services/group-file/get-private-file-url';
function makeDeps(body?: OidbPrivateFileDownloadResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbPrivateFileDownloadResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetPrivateFileUrl namespace', () => {
it('declares 0xE37_1200', () => {
expect(GetPrivateFileUrl.command).toBe(0xe37);
expect(GetPrivateFileUrl.subCommand).toBe(1200);
});
it('packages the magic field99999 cookie + body fields', async () => {
const deps = makeDeps({
body: { result: { server: 'host', port: 80, url: '/u' } } as any,
});
await GetPrivateFileUrl.invoke(deps, { selfUid: 'self', fileId: 'fid', fileHash: 'h' });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0xe37_1200');
const env = protobuf_decode<OidbBase<OidbPrivateFileDownloadReq>>(bytes);
expect(env.body).toMatchObject({
subCommand: 1200,
field2: 1,
field101: 3,
field102: 103,
field200: 1,
});
expect(env.body?.body).toMatchObject({
receiverUid: 'self',
fileUuid: 'fid',
type: 2,
fileHash: 'h',
});
expect(env.body?.field99999).toEqual(new Uint8Array([0xc0, 0x85, 0x2c, 0x01]));
});
it('composes the http download URL', async () => {
const deps = makeDeps({
body: { result: { server: 'srv', port: 8080, url: '/path' } } as any,
});
await expect(
GetPrivateFileUrl.invoke(deps, { selfUid: 's', fileId: 'f', fileHash: 'h' }),
).resolves.toBe('http://srv:8080/path&isthumb=0');
});
it('throws when result is missing', async () => {
const deps = makeDeps({ body: {} });
await expect(
GetPrivateFileUrl.invoke(deps, { selfUid: 's', fileId: 'f', fileHash: 'h' }),
).rejects.toThrow(/private file url response invalid/);
});
it('throws when server/port/url is incomplete', async () => {
const deps = makeDeps({
body: { result: { server: 'srv', port: 0, url: '/path' } } as any,
});
await expect(
GetPrivateFileUrl.invoke(deps, { selfUid: 's', fileId: 'f', fileHash: 'h' }),
).rejects.toThrow(/private file url response invalid/);
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { NTV2RichMediaReq, NTV2RichMediaResp } from '@snowluma/proto-defs/oidb-actions/media';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetPrivatePttUrl } from '../../../src/oidb-services/group-file/get-private-ptt-url';
function makeDeps(body?: NTV2RichMediaResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<NTV2RichMediaResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetPrivatePttUrl namespace', () => {
it('declares 0x126D_200 with uinForm=true', () => {
expect(GetPrivatePttUrl.command).toBe(0x126d);
expect(GetPrivatePttUrl.subCommand).toBe(200);
expect(GetPrivatePttUrl.uinForm).toBe(true);
});
it('emits c2c scene with self uid as targetUid', async () => {
const deps = makeDeps({
respHead: { retCode: 0 },
download: { info: { domain: 'd', urlPath: '/p' }, rKeyParam: '' },
} as any);
await GetPrivatePttUrl.invoke(deps, { selfUid: 'self', node: { fileUuid: 'uuid' } });
const env = protobuf_decode<OidbBase<NTV2RichMediaReq>>(
deps.sendRawPacket.mock.calls[0]![1],
);
expect(env.body?.reqHead?.scene).toMatchObject({
requestType: 1,
businessType: 3,
sceneType: 1,
c2c: { accountType: 2, targetUid: 'self' },
});
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { NTV2RichMediaReq, NTV2RichMediaResp } from '@snowluma/proto-defs/oidb-actions/media';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetPrivateVideoUrl } from '../../../src/oidb-services/group-file/get-private-video-url';
function makeDeps(body?: NTV2RichMediaResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<NTV2RichMediaResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetPrivateVideoUrl namespace', () => {
it('declares 0x11E9_200 with uinForm=true', () => {
expect(GetPrivateVideoUrl.command).toBe(0x11e9);
expect(GetPrivateVideoUrl.subCommand).toBe(200);
expect(GetPrivateVideoUrl.uinForm).toBe(true);
});
it('emits c2c scene with self uid as targetUid', async () => {
const deps = makeDeps({
respHead: { retCode: 0 },
download: { info: { domain: 'd', urlPath: '/v' }, rKeyParam: '' },
} as any);
await GetPrivateVideoUrl.invoke(deps, { selfUid: 'self', node: { fileUuid: 'uuid' } });
const env = protobuf_decode<OidbBase<NTV2RichMediaReq>>(
deps.sendRawPacket.mock.calls[0]![1],
);
expect(env.body?.reqHead?.scene).toMatchObject({
requestType: 2,
businessType: 2,
sceneType: 1,
c2c: { accountType: 2, targetUid: 'self' },
});
});
});
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupFileViewReq,
OidbGroupFileViewResp,
} from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { ListGroupFilesPage } from '../../../src/oidb-services/group-file/list-group-files-page';
function wireVarint(value: bigint): number[] {
const out: number[] = [];
let remaining = value;
do {
const byte = Number(remaining & 0x7fn);
remaining >>= 7n;
out.push(remaining === 0n ? byte : byte | 0x80);
} while (remaining !== 0n);
return out;
}
function varintField(fieldNumber: number, value: bigint): number[] {
return [...wireVarint(BigInt(fieldNumber << 3)), ...wireVarint(value)];
}
function bytesField(fieldNumber: number, value: Uint8Array): number[] {
return [
...wireVarint(BigInt((fieldNumber << 3) | 2)),
...wireVarint(BigInt(value.length)),
...value,
];
}
function stringField(fieldNumber: number, value: string): number[] {
return bytesField(fieldNumber, new TextEncoder().encode(value));
}
function messageField(fieldNumber: number, value: number[]): number[] {
return bytesField(fieldNumber, Uint8Array.from(value));
}
function makeDeps(body?: OidbGroupFileViewResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileViewResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('ListGroupFilesPage namespace', () => {
it('declares 0x6D8_1 with uinForm=true', () => {
expect(ListGroupFilesPage.command).toBe(0x6d8);
expect(ListGroupFilesPage.subCommand).toBe(1);
expect(ListGroupFilesPage.uinForm).toBe(true);
});
it('packages the page parameters (sortBy=1, field17=2, field18=0)', async () => {
const deps = makeDeps({ list: { isEnd: true, items: [] } as any });
await ListGroupFilesPage.invoke(deps, {
groupId: 12345,
targetDirectory: '/a',
startIndex: 20,
pageSize: 50,
});
const env = protobuf_decode<OidbBase<OidbGroupFileViewReq>>(
deps.sendRawPacket.mock.calls[0]![1],
);
expect(env.body?.list).toMatchObject({
groupUin: 12345,
appId: 7,
targetDirectory: '/a',
fileCount: 50,
sortBy: 1,
startIndex: 20,
field17: 2,
});
});
it('returns items and isEnd after checking list.retCode', async () => {
const deps = makeDeps({
list: { isEnd: false, items: [{ type: 1 }] } as any,
});
const out = await ListGroupFilesPage.invoke(deps, {
groupId: 1,
targetDirectory: '/',
startIndex: 0,
pageSize: 10,
});
expect(out).toEqual({ items: [expect.objectContaining({ type: 1 })], isEnd: false });
});
it('throws when list.retCode is non-zero', async () => {
const deps = makeDeps({
list: { retCode: 2, retMsg: 'busy', isEnd: true, items: [] } as any,
});
await expect(
ListGroupFilesPage.invoke(deps, {
groupId: 1,
targetDirectory: '/',
startIndex: 0,
pageSize: 10,
}),
).rejects.toThrow(/group file list failed: code=2/);
});
it('decodes the folder last-upload metadata from the QQ wire fields', async () => {
// Independent wire fixture confirmed against QQ's group-file list decoder:
// folder fields 5/9/10 are modify time, modifier UIN, and modifier name.
const folder = [
...stringField(1, 'd1'),
...stringField(3, 'dir'),
...varintField(4, 100n),
...varintField(5, 200n),
...varintField(6, 123n),
...stringField(7, 'creator'),
...varintField(8, 2n),
...varintField(9, 5_000_000_001n),
...stringField(10, 'uploader'),
];
const item = [...varintField(1, 2n), ...messageField(2, folder)];
const list = [...varintField(4, 1n), ...messageField(5, item)];
const body = messageField(2, list);
const responseData = Buffer.from(messageField(4, body));
const deps = {
sendRawPacket: vi.fn(async (): Promise<SendPacketResult> => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
})),
};
const out = await ListGroupFilesPage.invoke(deps, {
groupId: 1,
targetDirectory: '/',
startIndex: 0,
pageSize: 10,
});
const decoded = out?.items?.[0]?.folderInfo as Record<string, unknown> | undefined;
expect(decoded).toMatchObject({
modifiedTime: 200,
modifierUin: 5_000_000_001n,
modifierName: 'uploader',
});
});
it('returns null when the server elides the list slot', async () => {
const deps = makeDeps({});
const out = await ListGroupFilesPage.invoke(deps, {
groupId: 1,
targetDirectory: '/',
startIndex: 0,
pageSize: 10,
});
expect(out).toBeNull();
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupFileReq,
OidbGroupFileResp,
} from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { MoveGroupFile } from '../../../src/oidb-services/group-file/move-group-file';
function makeDeps(body?: OidbGroupFileResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('MoveGroupFile namespace', () => {
it('declares 0x6D6_5 with uinForm=true', () => {
expect(MoveGroupFile.command).toBe(0x6d6);
expect(MoveGroupFile.subCommand).toBe(5);
expect(MoveGroupFile.uinForm).toBe(true);
});
it('packages parent + target directories under the move slot', async () => {
const deps = makeDeps({ move: {} as any });
await MoveGroupFile.invoke(deps, {
groupId: 12345,
fileId: 'fid',
parentDirectory: '/a',
targetDirectory: '/b',
});
const env = protobuf_decode<OidbBase<OidbGroupFileReq>>(
deps.sendRawPacket.mock.calls[0]![1],
);
expect(env.body?.move).toMatchObject({
groupUin: 12345,
appId: 7,
busId: 102,
fileId: 'fid',
parentDirectory: '/a',
targetDirectory: '/b',
});
});
it('throws on missing move sub-message', async () => {
const deps = makeDeps({});
await expect(
MoveGroupFile.invoke(deps, {
groupId: 1,
fileId: 'f',
parentDirectory: '/',
targetDirectory: '/',
}),
).rejects.toThrow(/move response missing/);
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbGroupSendFileReq } from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { PublishGroupFile } from '../../../src/oidb-services/group-file/publish-group-file';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('PublishGroupFile namespace', () => {
it('declares 0x6D9_4', () => {
expect(PublishGroupFile.command).toBe(0x6d9);
expect(PublishGroupFile.subCommand).toBe(4);
});
it('routes to 0x6d9_4 with busiType=102, type=2, field5=true', async () => {
const deps = makeDeps();
await PublishGroupFile.invoke(deps, { groupId: 12345, fileId: 'fid' });
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x6d9_4');
const env = protobuf_decode<OidbBase<OidbGroupSendFileReq>>(bytes);
expect(env.body?.body).toMatchObject({
groupUin: 12345,
type: 2,
info: expect.objectContaining({ busiType: 102, fileId: 'fid', field5: true }),
});
});
it('emits a non-zero `info.field3` random discriminator', async () => {
// Lagrange-V2 sets this to `Random.Shared.Next()`; our serialize uses
// Math.random() — both produce a 31-bit unsigned int, never 0.
const deps = makeDeps();
await PublishGroupFile.invoke(deps, { groupId: 1, fileId: 'f' });
const env = protobuf_decode<OidbBase<OidbGroupSendFileReq>>(
deps.sendRawPacket.mock.calls[0]![1],
);
const field3 = env.body?.body?.info?.field3 ?? 0;
expect(typeof field3).toBe('number');
});
});
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupFileReq,
OidbGroupFileResp,
} from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { RenameGroupFile } from '../../../src/oidb-services/group-file/rename-group-file';
// A success response the real server would send: command/sub echoed, rename
// ret slot with retCode 0. Lets `invoke`'s deserialize pass so we can assert
// the request wire (cmd name + body) without it throwing on an empty body.
function okRenameResponse(): Buffer {
const env: OidbBase<OidbGroupFileResp> = {
command: 0x6d6,
subCommand: 4,
body: { rename: { retCode: 0 } },
};
return Buffer.from(protobuf_encode<OidbBase<OidbGroupFileResp>>(env));
}
function makeSender() {
const defaultResp: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: okRenameResponse(),
};
return { sendRawPacket: vi.fn(async () => defaultResp) };
}
describe('RenameGroupFile namespace', () => {
it('declares command 0x6D6 sub 4 (rename slot), uin form', () => {
expect(RenameGroupFile.command).toBe(0x6d6);
expect(RenameGroupFile.subCommand).toBe(4);
expect(RenameGroupFile.uinForm).toBe(true);
});
it('serializes into the rename slot with busId 102 and the current parent folder', () => {
const out = RenameGroupFile.serialize({} as any, {
groupId: 12345,
fileId: '/abc',
parentDirectory: '/sub',
newFileName: 'new.txt',
});
expect(out).toEqual({
rename: {
groupUin: 12345,
busId: 102,
fileId: '/abc',
parentFolder: '/sub',
newFileName: 'new.txt',
},
});
});
it('round-trips the rename body through the OIDB envelope (locks the pb field tags)', async () => {
const sender = makeSender();
await RenameGroupFile.invoke(sender, {
groupId: 12345,
fileId: '/abc',
parentDirectory: '/',
newFileName: 'new.txt',
});
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x6d6_4');
const env = protobuf_decode<OidbBase<OidbGroupFileReq>>(bytes);
expect(env.command).toBe(0x6d6);
expect(env.subCommand).toBe(4);
expect(env.body.rename).toMatchObject({
groupUin: 12345,
busId: 102,
fileId: '/abc',
parentFolder: '/',
newFileName: 'new.txt',
});
});
it('deserialize throws when the rename slot is missing from the response', () => {
expect(() => RenameGroupFile.deserialize({} as any, {})).toThrow(/rename response missing/);
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupFileFolderReq,
OidbGroupFileFolderResp,
} from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { RenameGroupFolder } from '../../../src/oidb-services/group-file/rename-group-folder';
function makeDeps(body?: OidbGroupFileFolderResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileFolderResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('RenameGroupFolder namespace', () => {
it('declares 0x6D7_2 with uinForm=true', () => {
expect(RenameGroupFolder.command).toBe(0x6d7);
expect(RenameGroupFolder.subCommand).toBe(2);
expect(RenameGroupFolder.uinForm).toBe(true);
});
it('packages rename.{groupUin, folderId, newFolderName}', async () => {
const deps = makeDeps({ rename: {} as any });
await RenameGroupFolder.invoke(deps, {
groupId: 12345,
folderId: 'fid',
newFolderName: 'newname',
});
const env = protobuf_decode<OidbBase<OidbGroupFileFolderReq>>(
deps.sendRawPacket.mock.calls[0]![1],
);
expect(env.body?.rename).toMatchObject({
groupUin: 12345,
folderId: 'fid',
newFolderName: 'newname',
});
});
it('throws on missing rename sub-message', async () => {
const deps = makeDeps({});
await expect(
RenameGroupFolder.invoke(deps, {
groupId: 1,
folderId: 'f',
newFolderName: 'n',
}),
).rejects.toThrow(/folder rename response missing/);
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupSendFileReq,
OidbGroupSendFileResp,
} from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { TransGroupFile } from '../../../src/oidb-services/group-file/trans-group-file';
function makeDeps(body?: OidbGroupSendFileResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGroupSendFileResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('TransGroupFile namespace', () => {
it('declares 0x6D9_0', () => {
expect(TransGroupFile.command).toBe(0x6d9);
expect(TransGroupFile.subCommand).toBe(0);
});
it('packages group + file id under the transFile slot', async () => {
const deps = makeDeps({ transFile: { saveBusId: 102, saveFilePath: '/abc' } });
const out = await TransGroupFile.invoke(deps, { groupId: 12345, fileId: 'fid' });
expect(out).toEqual({ saveBusId: 102, saveFilePath: '/abc' });
expect(deps.sendRawPacket).toHaveBeenCalledOnce();
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x6d9_0');
const env = protobuf_decode<OidbBase<OidbGroupSendFileReq>>(bytes);
expect(env.body?.transFile).toEqual({
groupUin: 12345n,
appId: 7,
busId: 102,
fileId: 'fid',
});
});
it('throws on missing transFile sub-message', async () => {
const deps = makeDeps({});
await expect(TransGroupFile.invoke(deps, { groupId: 1, fileId: 'f' })).rejects.toThrow(
/transfer response missing/,
);
});
it('throws on non-zero retCode', async () => {
const deps = makeDeps({ transFile: { retCode: 7, retMsg: 'denied' } });
await expect(TransGroupFile.invoke(deps, { groupId: 1, fileId: 'f' })).rejects.toThrow(
/code=7/,
);
});
});
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbGroupFileReq,
OidbGroupFileResp,
} from '@snowluma/proto-defs/oidb-actions/group-file';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { UploadGroupFileRequest } from '../../../src/oidb-services/group-file/upload-group-file-request';
function makeDeps(body?: OidbGroupFileResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGroupFileResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('UploadGroupFileRequest namespace', () => {
it('declares 0x6D6_0 with uinForm=true', () => {
expect(UploadGroupFileRequest.command).toBe(0x6d6);
expect(UploadGroupFileRequest.subCommand).toBe(0);
expect(UploadGroupFileRequest.uinForm).toBe(true);
});
it('packages the file pre-flight slot (entrance=6, appId=4, busId=102)', async () => {
const deps = makeDeps({ upload: { fileId: 'fid', boolFileExist: true } as any });
await UploadGroupFileRequest.invoke(deps, {
groupId: 12345,
fileName: 'doc.pdf',
folderId: '/',
fileSize: 1024,
fileSha1: new Uint8Array(20),
fileMd5: new Uint8Array(16),
});
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0x6d6_0');
const env = protobuf_decode<OidbBase<OidbGroupFileReq>>(bytes);
expect(env.reserved).toBe(1); // uinForm
expect(env.body?.file).toMatchObject({
groupUin: 12345,
appId: 4,
busId: 102,
entrance: 6,
targetDirectory: '/',
fileName: 'doc.pdf',
localDirectory: '/doc.pdf',
fileSize: 1024n,
field15: true,
});
});
it('returns the upload-info body for the facade orchestration', async () => {
const deps = makeDeps({ upload: { fileId: 'fid-X', boolFileExist: true } as any });
const out = await UploadGroupFileRequest.invoke(deps, {
groupId: 1,
fileName: 'f',
folderId: '/',
fileSize: 0,
fileSha1: new Uint8Array(0),
fileMd5: new Uint8Array(0),
});
expect(out.fileId).toBe('fid-X');
expect(out.boolFileExist).toBe(true);
});
it('throws when the upload sub-message is missing', async () => {
const deps = makeDeps({});
await expect(
UploadGroupFileRequest.invoke(deps, {
groupId: 1,
fileName: 'f',
folderId: '/',
fileSize: 0,
fileSha1: new Uint8Array(0),
fileMd5: new Uint8Array(0),
}),
).rejects.toThrow(/upload response missing/);
});
});
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbPrivateFileUploadReq,
OidbPrivateFileUploadResp,
} from '@snowluma/proto-defs/oidb-actions/media';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { UploadPrivateFileRequest } from '../../../src/oidb-services/group-file/upload-private-file-request';
function makeDeps(body?: OidbPrivateFileUploadResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbPrivateFileUploadResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('UploadPrivateFileRequest namespace', () => {
it('declares 0xE37_1700', () => {
expect(UploadPrivateFileRequest.command).toBe(0xe37);
expect(UploadPrivateFileRequest.subCommand).toBe(1700);
});
it('packages businessId=3, clientType=1, flagSupportMediaPlatform=1 alongside the upload body', async () => {
const deps = makeDeps({ upload: { uuid: 'fid', boolFileExist: true } as any });
await UploadPrivateFileRequest.invoke(deps, {
senderUid: 's',
receiverUid: 'r',
fileName: 'f.pdf',
fileSize: 4096,
fileSha1: new Uint8Array(20),
fileMd5: new Uint8Array(16),
md510MCheckSum: new Uint8Array(16),
});
const [wire, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wire).toBe('OidbSvcTrpcTcp.0xe37_1700');
const env = protobuf_decode<OidbBase<OidbPrivateFileUploadReq>>(bytes);
expect(env.body).toMatchObject({
command: 1700,
businessId: 3,
clientType: 1,
flagSupportMediaPlatform: 1,
});
expect(env.body?.upload).toMatchObject({
senderUid: 's',
receiverUid: 'r',
fileSize: 4096,
fileName: 'f.pdf',
});
});
it('returns the upload body for the facade host-selection step', async () => {
const deps = makeDeps({
upload: { uuid: 'fid-Y', fileAddon: 'hash', boolFileExist: true } as any,
});
const out = await UploadPrivateFileRequest.invoke(deps, {
senderUid: 's',
receiverUid: 'r',
fileName: 'f',
fileSize: 0,
fileSha1: new Uint8Array(0),
fileMd5: new Uint8Array(0),
md510MCheckSum: new Uint8Array(0),
});
expect(out.uuid).toBe('fid-Y');
expect(out.fileAddon).toBe('hash');
});
it('throws when the upload sub-message is missing', async () => {
const deps = makeDeps({});
await expect(
UploadPrivateFileRequest.invoke(deps, {
senderUid: 's',
receiverUid: 'r',
fileName: 'f',
fileSize: 0,
fileSha1: new Uint8Array(0),
fileMd5: new Uint8Array(0),
md510MCheckSum: new Uint8Array(0),
}),
).rejects.toThrow(/upload response missing/);
});
});
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { NTV2UploadRichMediaResp } from '@snowluma/proto-defs/highway';
import { OidbError } from '../../../src/oidb-service';
import { Ntv2UploadRequest } from '../../../src/oidb-services/highway/ntv2-upload-request';
function packet(body: NTV2UploadRichMediaResp, errorCode = 0): SendPacketResult {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<OidbBase<NTV2UploadRichMediaResp>>({
command: 0x11c5,
subCommand: 100,
errorCode,
errorMsg: errorCode ? 'denied' : '',
body,
}),
),
};
}
const params: Ntv2UploadRequest.Params = {
oidbCmd: 0x11c5,
isGroup: false,
targetIdOrUid: 'u_peer',
requestId: 1,
businessType: 1,
uploadInfo: [],
compatQmsgSceneType: 1,
extBizInfo: {},
tryFast: true,
clientRandomId: 1n,
};
describe('Ntv2UploadRequest', () => {
it.each([
{ tempGroupId: 0 },
{ tempGroupId: -1 },
{ tempGroupId: 1.5 },
{ tempGroupId: 700, isGroup: true },
{ tempGroupId: 700, targetIdOrUid: '' },
{ tempGroupId: 700, targetIdOrUid: 1001 },
])('rejects invalid temp context before sending: %j', async (invalid) => {
const sendRawPacket = vi.fn();
await expect(
Ntv2UploadRequest.invoke({ sendRawPacket }, { ...params, ...invalid }),
).rejects.toThrow('invalid temp-session image upload context');
expect(sendRawPacket).not.toHaveBeenCalled();
});
it('sends the resolved command on the default OIDB wire name', async () => {
const sendRawPacket = vi.fn(async () =>
packet({
upload: { msgInfo: { msgInfoBody: [] } },
}),
);
await Ntv2UploadRequest.invoke({ sendRawPacket }, params);
expect(sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x11c5_100');
});
it('raises OidbError when the envelope is rejected', async () => {
const sendRawPacket = vi.fn(async () => packet({ upload: { msgInfo: {} } }, 42));
await expect(Ntv2UploadRequest.invoke({ sendRawPacket }, params)).rejects.toBeInstanceOf(
OidbError,
);
});
it('fails in deserialize when msgInfo is missing', async () => {
const sendRawPacket = vi.fn(async () => packet({ upload: { uKey: 'k' } }));
await expect(Ntv2UploadRequest.invoke({ sendRawPacket }, params)).rejects.toThrow(
'upload response missing msgInfo',
);
});
});
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbLike } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SendLike } from '../../../src/oidb-services/interaction/send-like';
function makeDeps(uid = 'target-uid-xyz') {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn(async () => r),
resolveUserUid: vi.fn(async () => uid),
};
}
describe('SendLike namespace', () => {
it('declares 0x7E5_104', () => {
expect(SendLike.command).toBe(0x7e5);
expect(SendLike.subCommand).toBe(104);
});
describe('serialize', () => {
it('resolves uid via ctx and emits {targetUid, sourceId=71, count}', async () => {
const deps = makeDeps('UID-AAA');
const body = await SendLike.serialize(deps, { userId: 10001, count: 3 });
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
expect(body).toEqual({ targetUid: 'UID-AAA', sourceId: 71, count: 3 });
});
it('throws when ctx returns an empty uid', async () => {
const deps = makeDeps('');
await expect(SendLike.serialize(deps, { userId: 10001, count: 1 })).rejects.toThrow(
/failed to resolve uid/,
);
});
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0x7e5_104', async () => {
const deps = makeDeps();
await SendLike.invoke(deps, { userId: 10001, count: 3 });
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x7e5_104');
});
it('encodes targetUid (field 11) + sourceId=71 (field 12) + count (field 13)', async () => {
const deps = makeDeps('UID-BBB');
await SendLike.invoke(deps, { userId: 10001, count: 5 });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbLike>>(bytes);
expect(env.command).toBe(0x7e5);
expect(env.subCommand).toBe(104);
expect(env.body).toMatchObject({ targetUid: 'UID-BBB', sourceId: 71, count: 5 });
// Defensive: the obsolete uin-form field must not appear (server
// rejects uin-form payloads with "被点赞 QQ 号非法").
expect((env.body as any).targetUin).toBeUndefined();
});
});
});
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbPoke } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SendPoke } from '../../../src/oidb-services/interaction/send-poke';
function makeSender() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('SendPoke namespace', () => {
it('declares 0xED3_1', () => {
expect(SendPoke.command).toBe(0xed3);
expect(SendPoke.subCommand).toBe(1);
});
describe('serialize', () => {
it('group poke: groupUin = peer, friendUin = 0, uin = targetUin', () => {
expect(
SendPoke.serialize({} as any, { isGroup: true, peerUin: 12345, targetUin: 67890 }),
).toEqual({
uin: 67890,
groupUin: 12345,
friendUin: 0,
ext: 0,
});
});
it('group poke without targetUin: uin defaults to peer', () => {
expect(SendPoke.serialize({} as any, { isGroup: true, peerUin: 12345 })).toEqual({
uin: 12345,
groupUin: 12345,
friendUin: 0,
ext: 0,
});
});
it('friend poke: friendUin = peer, groupUin = 0, uin = targetUin', () => {
expect(
SendPoke.serialize({} as any, { isGroup: false, peerUin: 67890, targetUin: 11111 }),
).toEqual({
uin: 11111,
groupUin: 0,
friendUin: 67890,
ext: 0,
});
});
it('friend poke without targetUin: uin defaults to peer', () => {
expect(SendPoke.serialize({} as any, { isGroup: false, peerUin: 67890 })).toEqual({
uin: 67890,
groupUin: 0,
friendUin: 67890,
ext: 0,
});
});
it('always sets ext = 0', () => {
expect(SendPoke.serialize({} as any, { isGroup: true, peerUin: 1 }).ext).toBe(0);
expect(SendPoke.serialize({} as any, { isGroup: false, peerUin: 1 }).ext).toBe(0);
});
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0xed3_1', async () => {
const sender = makeSender();
await SendPoke.invoke(sender, { isGroup: true, peerUin: 1 });
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xed3_1');
});
it('encodes serialize() output into envelope body', async () => {
const sender = makeSender();
await SendPoke.invoke(sender, { isGroup: true, peerUin: 12345, targetUin: 67890 });
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbPoke>>(bytes);
expect(env.command).toBe(0xed3);
expect(env.subCommand).toBe(1);
expect(env.body).toMatchObject({ uin: 67890, groupUin: 12345 });
// proto3 default 0 for friendUin/ext is omitted on the wire.
expect(env.body?.friendUin ?? 0).toBe(0);
});
it('resolves to undefined', async () => {
const sender = makeSender();
const result = await SendPoke.invoke(sender, { isGroup: false, peerUin: 1 });
expect(result).toBeUndefined();
});
});
});
@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbEssence } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetEssence } from '../../../src/oidb-services/interaction/set-essence';
function makeSender() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('SetEssence namespace', () => {
it('declares 0xEAC', () => {
expect(SetEssence.command).toBe(0xeac);
});
describe('resolveSubCommand', () => {
it('returns 1 when enable=true', () => {
expect(
SetEssence.resolveSubCommand({ groupId: 1, sequence: 1, random: 1, enable: true }),
).toBe(1);
});
it('returns 2 when enable=false', () => {
expect(
SetEssence.resolveSubCommand({ groupId: 1, sequence: 1, random: 1, enable: false }),
).toBe(2);
});
});
describe('serialize', () => {
it('passes groupId / sequence / random through verbatim', () => {
expect(
SetEssence.serialize({} as any, {
groupId: 12345,
sequence: 99,
random: 0xcafebabe,
enable: true,
}),
).toEqual({
groupUin: 12345,
sequence: 99,
random: 0xcafebabe,
});
});
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0xeac_1 when enabling', async () => {
const sender = makeSender();
await SetEssence.invoke(sender, {
groupId: 12345,
sequence: 99,
random: 0,
enable: true,
});
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xeac_1');
});
it('routes to OidbSvcTrpcTcp.0xeac_2 when disabling', async () => {
const sender = makeSender();
await SetEssence.invoke(sender, {
groupId: 12345,
sequence: 99,
random: 0,
enable: false,
});
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xeac_2');
});
it('encodes envelope body with the right shape', async () => {
const sender = makeSender();
await SetEssence.invoke(sender, {
groupId: 12345,
sequence: 99,
random: 7,
enable: true,
});
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbEssence>>(bytes);
expect(env.command).toBe(0xeac);
expect(env.subCommand).toBe(1);
expect(env.body).toMatchObject({ groupUin: 12345, sequence: 99, random: 7 });
});
});
});
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x112eReq, Oidb0x112eResp } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { ClickInlineKeyboardButton } from '../../../src/oidb-services/misc/click-inline-keyboard-button';
function makeSender(resp?: Buffer) {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: resp ?? Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('ClickInlineKeyboardButton namespace', () => {
it('declares 0x112E_1', () => {
expect(ClickInlineKeyboardButton.command).toBe(0x112e);
expect(ClickInlineKeyboardButton.subCommand).toBe(1);
});
describe('serialize', () => {
it('widens numeric ids to BigInt for uint_64 fields', () => {
const out = ClickInlineKeyboardButton.serialize({} as any, {
groupId: 12345,
botAppid: 67890,
buttonId: 'btn',
callbackData: 'data',
msgSeq: 100,
});
expect(out.groupId).toBe(12345n);
expect(out.botAppid).toBe(67890n);
expect(out.msgSeq).toBe(100n);
});
it('always sends unknown7=0 / unknown9=1 magic values', () => {
const out = ClickInlineKeyboardButton.serialize({} as any, {
groupId: 1,
botAppid: 1,
buttonId: 'x',
callbackData: 'y',
msgSeq: 1,
});
expect(out.unknown7).toBe(0);
expect(out.unknown9).toBe(1);
});
it('coerces buttonId / callbackData to strings (defensive)', () => {
const out = ClickInlineKeyboardButton.serialize({} as any, {
groupId: 1,
botAppid: 1,
buttonId: 123 as any,
callbackData: null as any,
msgSeq: 1,
});
expect(out.buttonId).toBe('123');
expect(out.callbackData).toBe('');
});
});
describe('deserialize', () => {
it('shapes the response with status/promptType/promptIcon = 0', () => {
expect(
ClickInlineKeyboardButton.deserialize(
{} as any,
{
result: 1,
errMsg: 'ok',
promptText: 'hi',
} as Oidb0x112eResp,
),
).toEqual({
result: 1,
errMsg: 'ok',
status: 0,
promptText: 'hi',
promptType: 0,
promptIcon: 0,
});
});
it('defaults result/errMsg/promptText when omitted', () => {
expect(ClickInlineKeyboardButton.deserialize({} as any, {} as Oidb0x112eResp)).toEqual({
result: 0,
errMsg: '',
status: 0,
promptText: '',
promptType: 0,
promptIcon: 0,
});
});
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0x112e_1', async () => {
const sender = makeSender();
await ClickInlineKeyboardButton.invoke(sender, {
groupId: 1,
botAppid: 1,
buttonId: 'x',
callbackData: 'y',
msgSeq: 1,
});
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x112e_1');
});
it('round-trips a real envelope', async () => {
const sender = makeSender(
Buffer.from(
protobuf_encode<OidbBase<Oidb0x112eResp>>({
body: { result: 7, errMsg: 'denied', promptText: 'try again' } as any,
}),
),
);
const out = await ClickInlineKeyboardButton.invoke(sender, {
groupId: 1,
botAppid: 1,
buttonId: 'x',
callbackData: 'y',
msgSeq: 1,
});
expect(out).toMatchObject({ result: 7, errMsg: 'denied', promptText: 'try again' });
});
it('encodes envelope body with all fields', async () => {
const sender = makeSender();
await ClickInlineKeyboardButton.invoke(sender, {
groupId: 12345,
botAppid: 67890,
buttonId: 'b',
callbackData: 'c',
msgSeq: 999,
});
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<Oidb0x112eReq>>(bytes);
expect(env.body).toMatchObject({
botAppid: 67890n,
msgSeq: 999n,
buttonId: 'b',
callbackData: 'c',
groupId: 12345n,
unknown9: 1,
});
});
});
});
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { ImageOcrReq, ImageOcrResp } from '@snowluma/proto-defs/oidb-actions/ocr';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { ImageOcr } from '../../../src/oidb-services/misc/image-ocr';
// A server OCR response: retCode 0, one text detection with a 2-vertex box.
function ocrResponse(): Buffer {
const env: OidbBase<ImageOcrResp> = {
command: 0xe07,
subCommand: 0,
body: {
retCode: 0,
ocrRspBody: {
language: 'zh',
textDetections: [
{
detectedText: '你好',
confidence: 95,
polygon: {
coordinates: [
{ x: 1, y: 2 },
{ x: 3, y: 4 },
],
},
},
],
},
},
};
return Buffer.from(protobuf_encode<OidbBase<ImageOcrResp>>(env));
}
function makeSender(resp = ocrResponse()) {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: resp,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('ImageOcr namespace', () => {
it('declares command 0xE07 sub 0 (no uin form)', () => {
expect(ImageOcr.command).toBe(0xe07);
expect(ImageOcr.subCommand).toBe(0);
expect(ImageOcr.uinForm).toBeUndefined(); // defaults to false in invokeOidb
});
it('serializes the image url into ocrReqBody with the fixed version/client/entrance header', () => {
const out = ImageOcr.serialize({} as any, { imageUrl: 'https://x/a.jpg' });
expect(out).toMatchObject({ version: 1, client: 0, entrance: 1 });
expect(out.ocrReqBody).toMatchObject({ imageUrl: 'https://x/a.jpg', isCut: false });
});
it('routes to OidbSvcTrpcTcp.0xe07_0 and encodes the exact wire bytes (fixed byte-oracle locks pb tags)', async () => {
const sender = makeSender();
await ImageOcr.invoke(sender, { imageUrl: 'https://x/a.jpg' });
const [cmd, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0xe07_0');
// Independent byte-oracle: a fixed hex constant, NOT re-derived from the
// proto, so a tag change actually goes red (a symmetric encode→decode
// round-trip would not). Hand-decoded: 08 871c = field1 command varint
// 0xE07; 22 17 = field4 body (23B); body: 08 01 version=1, 18 01
// entrance=1 (client=0 omitted), 52 11 = field10 ocrReqBody (17B):
// 0a 0f = field1 imageUrl + "https://x/a.jpg".
expect(Buffer.from(bytes).toString('hex')).toBe(
'08871c22170801180152110a0f68747470733a2f2f782f612e6a7067',
);
// And the structure decodes back as expected.
const env = protobuf_decode<OidbBase<ImageOcrReq>>(bytes);
expect(env.command).toBe(0xe07);
expect(env.body.ocrReqBody?.imageUrl).toBe('https://x/a.jpg');
});
it('deserializes an empty ocr body to {texts:[], language:""}', () => {
expect(ImageOcr.deserialize({} as any, { retCode: 0 })).toEqual({
texts: [],
language: '',
});
});
it('deserializes text detections + coordinates + language', async () => {
const result = await ImageOcr.invoke(makeSender(), { imageUrl: 'https://x/a.jpg' });
expect(result.language).toBe('zh');
expect(result.texts).toEqual([
{
text: '你好',
confidence: 95,
coordinates: [
{ x: 1, y: 2 },
{ x: 3, y: 4 },
],
},
]);
});
it('throws on a non-zero retCode', () => {
expect(() =>
ImageOcr.deserialize({} as any, { retCode: 5, errMsg: 'bad', wording: 'nope' }),
).toThrow();
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0xeb7Req } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SendGroupSign } from '../../../src/oidb-services/misc/send-group-sign';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn(async () => r),
identity: { uin: '10001' } as any,
};
}
describe('SendGroupSign namespace', () => {
it('declares 0xEB7_1', () => {
expect(SendGroupSign.command).toBe(0xeb7);
expect(SendGroupSign.subCommand).toBe(1);
});
it('overrides wire name to preserve the historic UPPERCASE EB7 byte-equality', async () => {
const deps = makeDeps();
await SendGroupSign.invoke(deps, { groupId: 12345 });
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xEB7_1');
});
describe('serialize', () => {
it('packages uin / groupId as strings + hardcodes version "9.0.90"', async () => {
const deps = makeDeps();
await SendGroupSign.invoke(deps, { groupId: 12345 });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<Oidb0xeb7Req>>(bytes);
expect(env.body?.signInInfo).toEqual({
uin: '10001',
groupId: '12345',
version: '9.0.90',
});
});
});
});
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x990Req, Oidb0x990Resp } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { TranslateEnToZh } from '../../../src/oidb-services/misc/translate-en-to-zh';
function makeSender(resp?: Buffer) {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: resp ?? Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('TranslateEnToZh namespace', () => {
it('declares 0x990_2', () => {
expect(TranslateEnToZh.command).toBe(0x990);
expect(TranslateEnToZh.subCommand).toBe(2);
});
describe('serialize', () => {
it('hardcodes en → zh and tag10/12 = 1', () => {
const out = TranslateEnToZh.serialize({} as any, { words: ['hello'] });
expect(out).toEqual({
translateReq: { srcLang: 'en', dstLang: 'zh', words: ['hello'] },
tag10: 1,
tag12: 1,
});
});
it('passes the words array verbatim', () => {
const out = TranslateEnToZh.serialize({} as any, { words: ['a', 'b', 'c'] });
expect(out.translateReq?.words).toEqual(['a', 'b', 'c']);
});
});
describe('deserialize', () => {
it('returns dstWords when translateResp is present', () => {
expect(
TranslateEnToZh.deserialize(
{} as any,
{
translateResp: { dstWords: ['你好', '世界'] },
} as Oidb0x990Resp,
),
).toEqual(['你好', '世界']);
});
it('returns [] when dstWords is omitted but translateResp exists', () => {
expect(
TranslateEnToZh.deserialize({} as any, { translateResp: {} } as Oidb0x990Resp),
).toEqual([]);
});
it('throws when translateResp is missing', () => {
expect(() => TranslateEnToZh.deserialize({} as any, {} as Oidb0x990Resp)).toThrow(
'translate response empty',
);
});
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0x990_2', async () => {
const sender = makeSender(
Buffer.from(
protobuf_encode<OidbBase<Oidb0x990Resp>>({
body: { translateResp: { dstWords: ['x'] } } as any,
}),
),
);
await TranslateEnToZh.invoke(sender, { words: ['hello'] });
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x990_2');
});
it('encodes envelope body with the request payload', async () => {
const sender = makeSender(
Buffer.from(
protobuf_encode<OidbBase<Oidb0x990Resp>>({
body: { translateResp: { dstWords: ['你好'] } } as any,
}),
),
);
const out = await TranslateEnToZh.invoke(sender, { words: ['hello'] });
expect(out).toEqual(['你好']);
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<Oidb0x990Req>>(bytes);
expect(env.body?.translateReq?.words).toEqual(['hello']);
});
});
});
@@ -0,0 +1,310 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x7edReq, Oidb0x7edResp } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetLike } from '../../../src/oidb-services/profile/get-like';
function makeDeps(
opts: {
cachedSelfUid?: string | null;
resolveUserUid?: (uin: number) => Promise<string>;
findUinByUid?: (uid: string) => number | null;
resolveUin?: (uid: string) => Promise<number | null>;
responseBody?: Oidb0x7edResp;
} = {},
) {
const responseData =
opts.responseBody !== undefined
? Buffer.from(protobuf_encode<OidbBase<Oidb0x7edResp>>({ body: opts.responseBody }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return {
sendRawPacket: vi.fn(async () => r),
identity: {
uin: '10001',
selfUid: opts.cachedSelfUid ?? null,
findUinByUid: vi.fn(opts.findUinByUid ?? (() => null)),
resolveUin: vi.fn(opts.resolveUin ?? (async () => null)),
} as any,
resolveUserUid: vi.fn(opts.resolveUserUid ?? (async (uin: number) => `uid-of-${uin}`)),
};
}
describe('GetLike namespace', () => {
it('uses the self and other-user subcommands selected by the native client', () => {
const deps = makeDeps({ cachedSelfUid: 'self-uid' });
expect(GetLike.command).toBe(0x7ed);
expect(GetLike.resolveSubCommand({}, deps)).toBe(13);
expect(GetLike.resolveSubCommand({ userId: 0 }, deps)).toBe(13);
expect(GetLike.resolveSubCommand({ userId: 10001 }, deps)).toBe(13);
expect(GetLike.resolveSubCommand({ userId: 20002 }, deps)).toBe(12);
});
describe('invoke (target uid resolution)', () => {
it('uses cached self uid when userId is omitted', async () => {
const deps = makeDeps({
cachedSelfUid: 'cached-self',
responseBody: {
userLikeInfos: [
{ uid: 'cached-self', time: 0, favoriteInfo: {}, voteInfo: {} },
],
},
});
await GetLike.invoke(deps, {});
expect(deps.resolveUserUid).not.toHaveBeenCalled();
const [serviceCmd, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(serviceCmd).toBe('OidbSvcTrpcTcp.0x7ed_13');
const env = protobuf_decode<OidbBase<Oidb0x7edReq>>(bytes);
expect(env.body?.targetUids).toEqual(['cached-self']);
});
it('falls back to resolveUserUid for self when cache is empty', async () => {
const deps = makeDeps({
cachedSelfUid: null,
resolveUserUid: vi.fn(async () => 'fresh-self') as any,
responseBody: {
userLikeInfos: [{ uid: 'fresh-self', time: 0, favoriteInfo: {}, voteInfo: {} }],
},
});
await GetLike.invoke(deps, {});
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
});
it('resolves other users via resolveUserUid', async () => {
const deps = makeDeps({
responseBody: {
userLikeInfos: [
{ uid: 'uid-of-99999', time: 0, favoriteInfo: {}, voteInfo: {} },
],
},
});
await GetLike.invoke(deps, { userId: 99999 });
expect(deps.resolveUserUid).toHaveBeenCalledWith(99999);
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x7ed_12');
});
it('treats an explicit self uin as a self query', async () => {
const deps = makeDeps({
cachedSelfUid: 'cached-self',
responseBody: { userLikeInfos: [{ uid: 'cached-self', time: 0 }] },
});
await GetLike.invoke(deps, { userId: 10001 });
expect(deps.resolveUserUid).not.toHaveBeenCalled();
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x7ed_13');
});
it('throws when self uin is invalid and cache is empty', async () => {
const deps = makeDeps({ cachedSelfUid: null });
deps.identity.uin = 'invalid';
await expect(GetLike.invoke(deps, {})).rejects.toThrow('self uid is unavailable');
});
});
describe('serialize', () => {
it('requests the profile-backed vote list', async () => {
const deps = makeDeps({
responseBody: {
userLikeInfos: [{ uid: 'u', time: 0, favoriteInfo: {}, voteInfo: {} }],
},
});
await GetLike.invoke(deps, { userId: 1 });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<Oidb0x7edReq>>(bytes);
expect(env.body?.basic).toBe(1);
expect(env.body?.vote).toBe(1);
expect(env.body?.favorite ?? 0).toBe(0);
expect(env.body?.userProfile).toBe(1);
});
it('threads start / limit through to the request', async () => {
const deps = makeDeps({
responseBody: {
userLikeInfos: [{ uid: 'u', time: 0, favoriteInfo: {}, voteInfo: {} }],
},
});
await GetLike.invoke(deps, { userId: 1, start: 5, limit: 50 });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<Oidb0x7edReq>>(bytes);
expect(env.body?.start).toBe(5);
expect(env.body?.limit).toBe(50);
});
it('defaults start=0 / limit=10', async () => {
const deps = makeDeps({
responseBody: {
userLikeInfos: [{ uid: 'u', time: 0, favoriteInfo: {}, voteInfo: {} }],
},
});
await GetLike.invoke(deps, { userId: 1 });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<Oidb0x7edReq>>(bytes);
expect(env.body?.start ?? 0).toBe(0);
expect(env.body?.limit).toBe(10);
});
});
describe('deserialize', () => {
it('shapes favorite + vote info with the expected key names', () => {
const deps = makeDeps({
findUinByUid: (uid) => (uid === 'vote-user' ? 12345 : null),
});
const out = GetLike.deserialize(deps, {
userLikeInfos: [
{
uid: 'u',
time: 1700000000,
favoriteInfo: {
totalCount: 5,
lastTime: 1,
todayCount: 1,
userInfos: [{ uid: 'favorite-user', nick: '收藏者', count: 3 }],
},
voteInfo: {
totalCount: 7,
newCount: 2,
newNearbyCount: 1,
lastVisitTime: 2,
userInfos: [
{
uid: 'vote-user',
src: 4,
latestTime: 3,
count: 2,
giftCount: 1,
customId: 5,
lastCharged: 6,
availableCount: 7,
todayVotedCount: 8,
nick: '点赞者',
gender: 1,
age: 20,
isFriend: true,
isVip: true,
isSvip: false,
},
],
},
},
],
});
expect(out.uid).toBe('u');
expect(out.time).toBe(1700000000);
expect(out.favoriteInfo).toEqual({
total_count: 5,
last_time: 1,
today_count: 1,
userInfos: [
expect.objectContaining({ uid: 'favorite-user', nick: '收藏者', count: 3 }),
],
});
expect(out.voteInfo).toEqual({
total_count: 7,
new_count: 2,
new_nearby_count: 1,
last_visit_time: 2,
userInfos: [
expect.objectContaining({
uid: 'vote-user',
uin: 12345,
src: 4,
latestTime: 3,
count: 2,
giftCount: 1,
customId: 5,
lastCharged: 6,
bAvailableCnt: 7,
bTodayVotedCnt: 8,
nick: '点赞者',
gender: 1,
age: 20,
isFriend: true,
isvip: true,
isSvip: false,
}),
],
});
});
it('throws when there are no userLikeInfos', () => {
expect(() => GetLike.deserialize({} as any, {})).toThrow('get profile like info empty');
expect(() => GetLike.deserialize({} as any, { userLikeInfos: [] })).toThrow(
'get profile like info empty',
);
});
it('throws when a returned user item has no uid', () => {
expect(() =>
GetLike.deserialize(makeDeps(), {
userLikeInfos: [{ uid: 'u', voteInfo: { userInfos: [{ nick: 'broken' }] } }],
}),
).toThrow('get profile like user uid missing');
});
it('defaults all count fields to 0 when omitted', () => {
const out = GetLike.deserialize(makeDeps(), { userLikeInfos: [{ uid: 'u', time: 0 }] });
expect(out.favoriteInfo.total_count).toBe(0);
expect(out.voteInfo.total_count).toBe(0);
});
});
describe('invoke (inbound UIN fill)', () => {
it('fills leftover uin=0 rows through Identity.resolveUin once per uid', async () => {
const deps = makeDeps({
cachedSelfUid: 'self-uid',
findUinByUid: () => null,
resolveUin: async (uid) => (uid === 'shared-uid' ? 30003 : null),
responseBody: {
userLikeInfos: [
{
uid: 'self-uid',
favoriteInfo: { userInfos: [{ uid: 'shared-uid', nick: '收藏' }] },
voteInfo: {
userInfos: [
{ uid: 'shared-uid', nick: '点赞' },
{ uid: 'miss-uid' },
],
},
},
],
},
});
const out = await GetLike.invoke(deps, {});
expect(deps.identity.resolveUin).toHaveBeenCalledTimes(2);
expect(deps.identity.resolveUin).toHaveBeenCalledWith('shared-uid');
expect(deps.identity.resolveUin).toHaveBeenCalledWith('miss-uid');
expect(out.favoriteInfo.userInfos[0]?.uin).toBe(30003);
expect(out.voteInfo.userInfos[0]?.uin).toBe(30003);
expect(out.voteInfo.userInfos[1]?.uin).toBe(0);
});
it('does not call resolveUin when deserialize already filled the cache hit', async () => {
const deps = makeDeps({
cachedSelfUid: 'self-uid',
findUinByUid: (uid) => (uid === 'cached-uid' ? 20002 : null),
responseBody: {
userLikeInfos: [
{
uid: 'self-uid',
voteInfo: { userInfos: [{ uid: 'cached-uid', nick: '点赞者' }] },
},
],
},
});
const out = await GetLike.invoke(deps, {});
expect(deps.identity.resolveUin).not.toHaveBeenCalled();
expect(out.voteInfo.userInfos[0]?.uin).toBe(20002);
});
});
});
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import type { IdentityService } from '../../../src/identity-service';
import { GetUnidirectionalFriendList } from '../../../src/oidb-services/profile/get-unidirectional-friend-list';
import { s, v } from '../_pb-oracle';
function makeDeps(json: unknown = { ActionStatus: 'OK', ErrorCode: 0, rpt_block_list: [] }) {
const result: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from([
...v(1, 0xe17),
...v(2, 0),
...v(3, 0),
...s(4, JSON.stringify(json)),
]),
};
return {
sendRawPacket: vi.fn(async (_cmd: string, _body: Uint8Array) => result),
identity: { uin: '10001' } as IdentityService,
};
}
describe('GetUnidirectionalFriendList', () => {
it('uses the direct JSON request and response captured for this service', async () => {
const list = [{ uint64_uin: '20002', extra: { keep: true } }];
const deps = makeDeps({ ActionStatus: 'OK', ErrorCode: 0, rpt_block_list: list });
await expect(GetUnidirectionalFriendList.invoke(deps)).resolves.toEqual(list);
const [command, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(command).toBe('MQUpdateSvc_com_qq_ti.web.OidbSvc.0xe17_0');
expect(Buffer.from(bytes)).toEqual(
Buffer.from([
...v(2, 0),
...s(
3,
JSON.stringify({
uint64_uin: '10001',
uint64_top: 0,
uint32_req_num: 99,
bytes_cookies: '',
}),
),
]),
);
});
it('returns a verified empty list', async () => {
await expect(GetUnidirectionalFriendList.invoke(makeDeps())).resolves.toEqual([]);
});
it('surfaces JSON business failures instead of returning an empty list', async () => {
const deps = makeDeps({
ActionStatus: 'FAIL',
ErrorCode: 302,
ErrorInfo: 'parse json failed',
});
await expect(GetUnidirectionalFriendList.invoke(deps)).rejects.toThrow(
'302 parse json failed',
);
});
it.each([
null,
{},
{ ActionStatus: 'OK', ErrorCode: 0 },
{ ActionStatus: 'OK', ErrorCode: 0, rpt_block_list: [null] },
{ ActionStatus: 'OK', ErrorCode: 0, rpt_block_list: {} },
])('rejects malformed response data (%j)', async (json) => {
await expect(GetUnidirectionalFriendList.invoke(makeDeps(json))).rejects.toThrow();
});
it('surfaces outer errors, invalid JSON, empty replies and transport failures', async () => {
const deps = makeDeps();
for (const [responseData, message] of [
[Buffer.from([...v(3, 5), ...s(5, 'denied')]), 'denied'],
[Buffer.from(s(4, '{')), 'JSON'],
[Buffer.from(v(1, 0xe17)), 'empty'],
[Buffer.alloc(0), 'empty'],
] as const) {
deps.sendRawPacket.mockResolvedValue({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
});
await expect(GetUnidirectionalFriendList.invoke(deps)).rejects.toThrow(message);
}
deps.sendRawPacket.mockResolvedValue({
success: false,
gotResponse: false,
errorCode: 5,
errorMessage: 'disconnected',
});
await expect(GetUnidirectionalFriendList.invoke(deps)).rejects.toThrow('disconnected');
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0xcd4Req } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetInputStatus } from '../../../src/oidb-services/profile/set-input-status';
function makeDeps(uid: string | null = 'resolved-uid') {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn(async () => r),
resolveUserUid: vi.fn(async () => uid as string),
};
}
describe('SetInputStatus namespace', () => {
it('declares 0xCD4_1', () => {
expect(SetInputStatus.command).toBe(0xcd4);
expect(SetInputStatus.subCommand).toBe(1);
});
describe('invoke', () => {
it('routes to OidbSvcTrpcTcp.0xcd4_1', async () => {
const deps = makeDeps();
await SetInputStatus.invoke(deps, { userId: 10001, eventType: 1 });
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xcd4_1');
});
it('resolves the target uid before encoding', async () => {
const deps = makeDeps();
await SetInputStatus.invoke(deps, { userId: 10001, eventType: 1 });
expect(deps.resolveUserUid).toHaveBeenCalledWith(10001);
});
it('packages uid + chatType=0 + eventType into reqBody', async () => {
const deps = makeDeps('alice-uid');
await SetInputStatus.invoke(deps, { userId: 10001, eventType: 7 });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<Oidb0xcd4Req>>(bytes);
expect(env.body?.reqBody?.uid).toBe('alice-uid');
// chatType=0 is proto3 default — omitted on the wire, comes back as null/undefined.
expect(env.body?.reqBody?.chatType ?? 0).toBe(0);
expect(env.body?.reqBody?.eventType).toBe(7);
});
it('throws when the target uid cannot be resolved', async () => {
const deps = makeDeps(null);
await expect(
SetInputStatus.invoke(deps, { userId: 10001, eventType: 1 }),
).rejects.toThrow('target uid not found');
expect(deps.sendRawPacket).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbSetProfile } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetProfile } from '../../../src/oidb-services/profile/set-profile';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn(async () => r),
identity: { uin: '10001' } as any,
};
}
describe('SetProfile namespace', () => {
it('declares 0x112A_2', () => {
expect(SetProfile.command).toBe(0x112a);
expect(SetProfile.subCommand).toBe(2);
});
describe('invoke', () => {
it('packages sex into integer field 20009', async () => {
const deps = makeDeps();
await SetProfile.invoke(deps, { sex: 1 });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbSetProfile>>(bytes);
expect(env.body?.intProfiles).toEqual([{ fieldId: 20009, value: 1n }]);
});
it('is a no-op when both nickname and personalNote are undefined', async () => {
const deps = makeDeps();
await SetProfile.invoke(deps, {});
expect(deps.sendRawPacket).not.toHaveBeenCalled();
});
it('packages nickname into fieldId 20002', async () => {
const deps = makeDeps();
await SetProfile.invoke(deps, { nickname: 'New Nick' });
expect(deps.sendRawPacket).toHaveBeenCalledOnce();
const [wireName, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0x112a_2');
const env = protobuf_decode<OidbBase<OidbSetProfile>>(bytes);
expect(env.body?.stringProfiles).toEqual([{ fieldId: 20002, value: 'New Nick' }]);
});
it('packages personalNote into fieldId 102', async () => {
const deps = makeDeps();
await SetProfile.invoke(deps, { personalNote: 'on vacation' });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbSetProfile>>(bytes);
expect(env.body?.stringProfiles).toEqual([{ fieldId: 102, value: 'on vacation' }]);
});
it('packages both fields together', async () => {
const deps = makeDeps();
await SetProfile.invoke(deps, { nickname: 'Foo', personalNote: 'Bar' });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbSetProfile>>(bytes);
expect(env.body?.stringProfiles).toEqual([
{ fieldId: 20002, value: 'Foo' },
{ fieldId: 102, value: 'Bar' },
]);
});
it('passes the bot uin in the request body', async () => {
const deps = makeDeps();
await SetProfile.invoke(deps, { nickname: 'x' });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbSetProfile>>(bytes);
expect(env.body?.uin).toBe(10001n);
});
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x112aReq } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetSelfLongNick } from '../../../src/oidb-services/profile/set-self-long-nick';
function makeDeps() {
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return {
sendRawPacket: vi.fn(async () => r),
identity: { uin: '10001' } as any,
};
}
describe('SetSelfLongNick namespace', () => {
it('declares 0x112A_2', () => {
expect(SetSelfLongNick.command).toBe(0x112a);
expect(SetSelfLongNick.subCommand).toBe(2);
});
describe('invoke', () => {
it('routes to OidbSvcTrpcTcp.0x112a_2', async () => {
const deps = makeDeps();
await SetSelfLongNick.invoke(deps, { longNick: 'hello world' });
expect(deps.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x112a_2');
});
it('wraps the long nick in profile { tag: 102, value }', async () => {
const deps = makeDeps();
await SetSelfLongNick.invoke(deps, { longNick: 'hello world' });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<Oidb0x112aReq>>(bytes);
expect(env.body?.uin).toBe(10001n);
expect(env.body?.profile).toEqual({ tag: 102, value: 'hello world' });
});
it('omits the value field when clearing the signature', async () => {
const deps = makeDeps();
await SetSelfLongNick.invoke(deps, { longNick: '' });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
expect(Buffer.from(bytes).toString('hex')).toBe('08aa221002220708914e12020866');
});
it('coerces non-string longNick into a string (defensive)', async () => {
const deps = makeDeps();
// simulate a misbehaving caller passing a number
await SetSelfLongNick.invoke(deps, { longNick: 12345 as any });
const [, bytes] = deps.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<Oidb0x112aReq>>(bytes);
expect(env.body?.profile?.value).toBe('12345');
});
});
});
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x9084Req, Oidb0x9084Resp } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchReactionSummary } from '../../../src/oidb-services/reaction/fetch-reaction-summary';
function makeSender(resp?: Buffer) {
const defaultResp: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: resp ?? Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => defaultResp) };
}
describe('FetchReactionSummary namespace', () => {
it('declares command = 0x9084 and subCommand = 1', () => {
expect(FetchReactionSummary.command).toBe(0x9084);
expect(FetchReactionSummary.subCommand).toBe(1);
});
describe('serialize', () => {
it('encodes groupId/sequence as BigInt for uint_64 fields', () => {
const out = FetchReactionSummary.serialize({} as any, {
groupId: 1022489779,
sequence: 1730183,
});
expect(out.groupId).toBe(1022489779n);
expect(out.sequence).toBe(1730183n);
});
it('passes empty / default values for the unused filter fields', () => {
const out = FetchReactionSummary.serialize({} as any, { groupId: 1, sequence: 1 });
expect(out.emojiId).toBe('');
expect(out.emojiType).toBe(0);
expect(out.count).toBe(0);
expect(out.field12).toBe(1);
});
it('emits a 0-length cookie buffer (no continuation)', () => {
const out = FetchReactionSummary.serialize({} as any, { groupId: 1, sequence: 1 });
expect(out.cookie).toBeInstanceOf(Uint8Array);
expect(out.cookie!.length).toBe(0);
});
});
describe('deserialize', () => {
it('keeps entries with count > 0 (used emojis)', () => {
const body: Oidb0x9084Resp = {
entries: [
{ lastReactionTime: 1779456439n, count: 3, emojiType: 1, emojiId: '76' },
{ lastReactionTime: 1779456962n, count: 1, emojiType: 1, emojiId: '124' },
],
};
expect(FetchReactionSummary.deserialize({} as any, body)).toEqual([
{ emojiId: '76', emojiType: 1, count: 3, lastReactionTime: 1779456439 },
{ emojiId: '124', emojiType: 1, count: 1, lastReactionTime: 1779456962 },
]);
});
it('filters out catalog-tail entries (count omitted ≡ 0)', () => {
const body: Oidb0x9084Resp = {
entries: [
{ lastReactionTime: 1n, count: 1, emojiType: 1, emojiId: '76' },
{ emojiType: 1, emojiId: '124' }, // catalog entry
{ emojiType: 1, emojiId: '66' }, // catalog entry
],
};
const out = FetchReactionSummary.deserialize({} as any, body);
expect(out).toHaveLength(1);
expect(out[0]!.emojiId).toBe('76');
});
it('returns an empty array when the response has no entries', () => {
expect(FetchReactionSummary.deserialize({} as any, {})).toEqual([]);
});
it('defaults emojiId / emojiType to safe values when omitted', () => {
const body: Oidb0x9084Resp = {
entries: [{ count: 5, lastReactionTime: 1700000000n }],
};
const [entry] = FetchReactionSummary.deserialize({} as any, body);
expect(entry).toEqual({
emojiId: '',
emojiType: 1,
count: 5,
lastReactionTime: 1700000000,
});
});
it('reports lastReactionTime=0 when omitted on a used entry (degenerate but defensive)', () => {
const body: Oidb0x9084Resp = { entries: [{ count: 2, emojiId: 'x', emojiType: 1 }] };
expect(FetchReactionSummary.deserialize({} as any, body)[0]!.lastReactionTime).toBe(0);
});
});
describe('invoke (e2e via mock sender)', () => {
it('sends to OidbSvcTrpcTcp.0x9084_1', async () => {
const sender = makeSender();
await FetchReactionSummary.invoke(sender, { groupId: 1, sequence: 1 });
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x9084_1');
});
it('end-to-end: encode → decode pipeline yields the parsed summary entries', async () => {
const respEnvelope = Buffer.from(
protobuf_encode<OidbBase<Oidb0x9084Resp>>({
command: 0x9084,
subCommand: 1,
body: {
entries: [
{
lastReactionTime: 1700000000n,
count: 5,
emojiType: 1,
emojiId: '76',
},
{ emojiType: 1, emojiId: '124' }, // catalog noise
],
},
}),
);
const sender = makeSender(respEnvelope);
const result = await FetchReactionSummary.invoke(sender, { groupId: 1, sequence: 1 });
expect(result).toEqual([
{ emojiId: '76', emojiType: 1, count: 5, lastReactionTime: 1700000000 },
]);
});
it('encodes the request envelope with groupId/sequence as uint_64', async () => {
const sender = makeSender();
await FetchReactionSummary.invoke(sender, { groupId: 1022489779, sequence: 1730183 });
const [, reqBytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<Oidb0x9084Req>>(reqBytes);
expect(env.command).toBe(0x9084);
expect(env.subCommand).toBe(1);
expect(env.body?.groupId).toBe(1022489779n);
expect(env.body?.sequence).toBe(1730183n);
});
it('returns an empty array when server responds with empty envelope', async () => {
const sender = makeSender();
const result = await FetchReactionSummary.invoke(sender, { groupId: 1, sequence: 1 });
expect(result).toEqual([]);
});
});
});
@@ -0,0 +1,218 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { Oidb0x9083Req, Oidb0x9083Resp } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetEmojiLikes } from '../../../src/oidb-services/reaction/get-emoji-likes';
import { env, s, v } from '../_pb-oracle';
function makeSender(resp?: Buffer) {
const defaultResp: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: resp ?? Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => defaultResp) };
}
describe('GetEmojiLikes namespace', () => {
it('declares command = 0x9083 and subCommand = 1', () => {
expect(GetEmojiLikes.command).toBe(0x9083);
expect(GetEmojiLikes.subCommand).toBe(1);
});
describe('serialize', () => {
it('encodes groupId/sequence as BigInt for uint_64 fields', () => {
const out = GetEmojiLikes.serialize({} as any, {
groupId: 12345,
sequence: 99,
emojiId: '76',
});
expect(out.groupId).toBe(12345n);
expect(out.sequence).toBe(99n);
});
it('passes emojiId through unchanged', () => {
const out = GetEmojiLikes.serialize({} as any, {
groupId: 1,
sequence: 1,
emojiId: '128516',
});
expect(out.emojiId).toBe('128516');
});
it('defaults emojiType to 1 when omitted', () => {
const out = GetEmojiLikes.serialize({} as any, {
groupId: 1,
sequence: 1,
emojiId: 'x',
});
expect(out.emojiType).toBe(1);
});
it('uses caller-supplied emojiType when present', () => {
const out = GetEmojiLikes.serialize({} as any, {
groupId: 1,
sequence: 1,
emojiId: 'x',
emojiType: 2,
});
expect(out.emojiType).toBe(2);
});
it('defaults count to 10 when omitted', () => {
const out = GetEmojiLikes.serialize({} as any, {
groupId: 1,
sequence: 1,
emojiId: 'x',
});
expect(out.count).toBe(10);
});
it('uses caller-supplied count when present', () => {
const out = GetEmojiLikes.serialize({} as any, {
groupId: 1,
sequence: 1,
emojiId: 'x',
count: 50,
});
expect(out.count).toBe(50);
});
it('passes the continuation cookie through as a string', () => {
const out = GetEmojiLikes.serialize({} as any, {
groupId: 1,
sequence: 1,
emojiId: 'x',
cookie: 'next',
});
expect(out.cookie).toBe('next');
});
it('emits an empty cookie for the first page', () => {
const out = GetEmojiLikes.serialize({} as any, {
groupId: 1,
sequence: 1,
emojiId: 'x',
});
expect(out.cookie).toBe('');
});
});
describe('deserialize', () => {
it('extracts users from the repeated list and returns the cookie', () => {
const result = GetEmojiLikes.deserialize({} as any, {
users: [{ uin: 10001n }, { uin: 20002n }],
cookie: 'next',
isLast: false,
});
expect(result.users).toEqual([{ uin: 10001 }, { uin: 20002 }]);
expect(result.cookie).toBe('next');
expect(result.isLast).toBe(false);
});
it('filters out users with uin=0 (placeholder / zero-value)', () => {
const result = GetEmojiLikes.deserialize({} as any, {
users: [{ uin: 10001n }, { uin: 0n }, { uin: 20002n }],
});
expect(result.users).toEqual([{ uin: 10001 }, { uin: 20002 }]);
});
it('returns empty users + empty cookie + isLast=true when body has nothing', () => {
const result = GetEmojiLikes.deserialize({} as any, {});
expect(result.users).toEqual([]);
expect(result.cookie).toBe('');
expect(result.isLast).toBe(true);
});
it('returns isLast=true when cookie is absent (final page)', () => {
const result = GetEmojiLikes.deserialize({} as any, {
users: [{ uin: 10001n }],
});
expect(result.isLast).toBe(true);
});
it('handles a missing users field', () => {
const result = GetEmojiLikes.deserialize({} as any, {});
expect(result.users).toEqual([]);
});
});
describe('invoke (e2e via mock sender)', () => {
it('sends to OidbSvcTrpcTcp.0x9083_1', async () => {
const sender = makeSender();
await GetEmojiLikes.invoke(sender, { groupId: 1, sequence: 1, emojiId: '76' });
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x9083_1');
});
it('end-to-end: encode → decode yields parsed user list + cookie', async () => {
const respEnvelope = Buffer.from(
protobuf_encode<OidbBase<Oidb0x9083Resp>>({
command: 0x9083,
subCommand: 1,
body: {
users: [{ uin: 10001n }],
cookie: 'next',
isLast: false,
},
}),
);
const sender = makeSender(respEnvelope);
const result = await GetEmojiLikes.invoke(sender, {
groupId: 1,
sequence: 1,
emojiId: '76',
});
expect(result.users).toEqual([{ uin: 10001 }]);
expect(result.cookie).toBe('next');
expect(result.isLast).toBe(false);
});
it('returns the legacy-stub empty result when server returns ack-only', async () => {
const sender = makeSender();
const result = await GetEmojiLikes.invoke(sender, {
groupId: 1,
sequence: 1,
emojiId: '76',
});
expect(result).toEqual({ users: [], cookie: '', isLast: true });
});
it('encodes the request envelope with proper field types', async () => {
const sender = makeSender();
await GetEmojiLikes.invoke(sender, {
groupId: 12345,
sequence: 99,
emojiId: '128516',
emojiType: 2,
count: 20,
});
const [, reqBytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<Oidb0x9083Req>>(reqBytes);
expect(env.command).toBe(0x9083);
expect(env.subCommand).toBe(1);
expect(env.body?.groupId).toBe(12345n);
expect(env.body?.sequence).toBe(99n);
expect(env.body?.emojiId).toBe('128516');
expect(env.body?.emojiType).toBe(2);
expect(env.body?.count).toBe(20);
});
it('byte-oracle: request body uses type then emoji id, not the set-reaction order', async () => {
const sender = makeSender();
await GetEmojiLikes.invoke(sender, {
groupId: 12345,
sequence: 99,
emojiId: '76',
emojiType: 1,
count: 20,
});
const [, reqBytes] = sender.sendRawPacket.mock.calls[0]!;
const body = [...v(2, 12345), ...v(3, 99), ...v(4, 1), ...s(5, '76'), ...v(8, 20)];
expect(Buffer.from(reqBytes).toString('hex')).toBe(env(0x9083, 1, body, false));
});
});
});
@@ -0,0 +1,171 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbGroupReaction } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetReaction } from '../../../src/oidb-services/reaction/set-reaction';
function makeSender() {
const defaultResp: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
return { sendRawPacket: vi.fn(async () => defaultResp) };
}
describe('SetReaction namespace', () => {
it('declares command = 0x9082', () => {
expect(SetReaction.command).toBe(0x9082);
});
it('resolveSubCommand: isSet=true → 1, isSet=false → 2', () => {
expect(
SetReaction.resolveSubCommand({ groupId: 1, sequence: 1, code: 'x', isSet: true }),
).toBe(1);
expect(
SetReaction.resolveSubCommand({ groupId: 1, sequence: 1, code: 'x', isSet: false }),
).toBe(2);
});
describe('serialize', () => {
it('produces type=1 for short QQ-face codes (≤3 chars)', () => {
const out = SetReaction.serialize({} as any, {
groupId: 12345,
sequence: 99,
code: '76',
isSet: true,
});
expect(out).toEqual({
groupUin: 12345,
sequence: 99,
code: '76',
type: 1,
field6: false,
field7: false,
});
});
it('produces type=1 for exactly 3-character codes (boundary)', () => {
const out = SetReaction.serialize({} as any, {
groupId: 1,
sequence: 1,
code: '999',
isSet: true,
});
expect(out.type).toBe(1);
});
it('produces type=2 for codes longer than 3 chars (unicode codepoint)', () => {
const out = SetReaction.serialize({} as any, {
groupId: 1,
sequence: 1,
code: '128516',
isSet: false,
});
expect(out.type).toBe(2);
});
it('passes through groupId/sequence/code verbatim', () => {
const out = SetReaction.serialize({} as any, {
groupId: 999,
sequence: 42,
code: 'abc',
isSet: true,
});
expect(out.groupUin).toBe(999);
expect(out.sequence).toBe(42);
expect(out.code).toBe('abc');
});
it('always sets field6/field7 to false (deferred behaviour)', () => {
const out = SetReaction.serialize({} as any, {
groupId: 1,
sequence: 1,
code: 'x',
isSet: true,
});
expect(out.field6).toBe(false);
expect(out.field7).toBe(false);
});
});
describe('deserialize', () => {
it('always returns void regardless of body content', () => {
expect(SetReaction.deserialize({} as any, {})).toBeUndefined();
});
});
describe('encode / decode round trip', () => {
it('encode preserves groupUin/sequence/code/type in the envelope body', () => {
const env: OidbBase<OidbGroupReaction> = {
command: 0x9082,
subCommand: 1,
body: { groupUin: 12345, sequence: 99, code: '76', type: 1 } as OidbGroupReaction,
};
const bytes = SetReaction.encode(env);
const decoded = SetReaction.decode(bytes);
expect(decoded.command).toBe(0x9082);
expect(decoded.subCommand).toBe(1);
});
});
describe('invoke (e2e via mock sender)', () => {
it('routes to OidbSvcTrpcTcp.0x9082_1 when isSet=true', async () => {
const sender = makeSender();
await SetReaction.invoke(sender, {
groupId: 12345,
sequence: 99,
code: '76',
isSet: true,
});
expect(sender.sendRawPacket).toHaveBeenCalledOnce();
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x9082_1');
});
it('routes to OidbSvcTrpcTcp.0x9082_2 when isSet=false', async () => {
const sender = makeSender();
await SetReaction.invoke(sender, {
groupId: 12345,
sequence: 99,
code: '76',
isSet: false,
});
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x9082_2');
});
it('packages the serialized body into the OIDB envelope wire', async () => {
const sender = makeSender();
await SetReaction.invoke(sender, {
groupId: 12345,
sequence: 99,
code: '128516',
isSet: true,
});
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbGroupReaction>>(bytes);
expect(env.command).toBe(0x9082);
expect(env.subCommand).toBe(1);
expect(env.body).toMatchObject({
groupUin: 12345,
sequence: 99,
code: '128516',
type: 2,
});
});
it('resolves to undefined (void)', async () => {
const sender = makeSender();
const result = await SetReaction.invoke(sender, {
groupId: 1,
sequence: 1,
code: 'x',
isSet: true,
});
expect(result).toBeUndefined();
});
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { SetSearch } from '../../src/oidb-services/group-admin/set-search';
// Capture the exact OIDB request bytes SetSearch sends, via a fake sender.
async function sentHex(params: SetSearch.Params): Promise<string> {
let body: Uint8Array | undefined;
const deps = {
sendRawPacket: vi.fn(async (_cmd: string, b: Uint8Array): Promise<SendPacketResult> => {
body = b;
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
};
}),
};
await SetSearch.invoke(deps as never, params);
return Buffer.from(body!).toString('hex');
}
// settings (field2) sub-message: noFingerOpen = tag 35 (35<<3 = 280 → varint
// "98 02"), noCodeFingerOpen = tag 36 (36<<3 = 288 → "a0 02"). Tags RE'd
// empirically on a live group (#191, see Oidb0x89a_0SearchSettings).
describe('SetSearch (0x89a_0) settings encoding (#191)', () => {
it('emits BOTH flags even at value 0 — toggle-off must not be dropped', async () => {
const h = await sentHex({ groupId: 941657197, noFingerOpen: 0, noCodeFingerOpen: 0 });
// field2 = { 35: 0, 36: 0 } → 12 06 980200 a00200
expect(h).toContain('1206980200a00200');
});
it('emits value 1 correctly', async () => {
const h = await sentHex({ groupId: 941657197, noFingerOpen: 1, noCodeFingerOpen: 1 });
expect(h).toContain('1206980201a00201');
});
it('elides an unprovided flag (undefined → not sent)', async () => {
const h = await sentHex({ groupId: 941657197, noCodeFingerOpen: 0 });
expect(h).toContain('a00200'); // tag 36 (noCodeFingerOpen) present, value 0
expect(h).not.toContain('980200'); // tag 35 (noFingerOpen) absent
});
it('carries the group uin + command 0x89a_0', async () => {
const deps = {
sendRawPacket: vi.fn(async (): Promise<SendPacketResult> => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.alloc(0),
})),
};
await SetSearch.invoke(deps as never, { groupId: 941657197, noFingerOpen: 1 });
expect(deps.sendRawPacket).toHaveBeenCalledWith(
'OidbSvcTrpcTcp.0x89a_0',
expect.anything(),
);
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbClientKeyResp } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { ForceFetchClientKey } from '../../../src/oidb-services/web/force-fetch-client-key';
function makeSender(body?: OidbClientKeyResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbClientKeyResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('ForceFetchClientKey namespace', () => {
it('declares 0x102A_1', () => {
expect(ForceFetchClientKey.command).toBe(0x102a);
expect(ForceFetchClientKey.subCommand).toBe(1);
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0x102a_1', async () => {
const sender = makeSender({ clientKey: 'ck', keyIndex: 19, expireTime: 1800 });
await ForceFetchClientKey.invoke(sender);
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x102a_1');
});
it('returns the parsed clientKey / keyIndex / expireTime', async () => {
const sender = makeSender({ clientKey: 'KEY', keyIndex: 7, expireTime: 600 });
const out = await ForceFetchClientKey.invoke(sender);
expect(out).toEqual({ clientKey: 'KEY', keyIndex: '7', expireTime: '600' });
});
it('falls back to keyIndex="19" and expireTime="1800" when server omits them', async () => {
const sender = makeSender({ clientKey: 'KEY' });
const out = await ForceFetchClientKey.invoke(sender);
expect(out.keyIndex).toBe('19');
expect(out.expireTime).toBe('1800');
});
it('returns empty clientKey when server omits it', async () => {
const sender = makeSender({});
const out = await ForceFetchClientKey.invoke(sender);
expect(out.clientKey).toBe('');
});
});
});
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type { OidbGetPskeyReq, OidbGetPskeyResp } from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { GetPskey } from '../../../src/oidb-services/web/get-pskey';
function makeSender(body?: OidbGetPskeyResp) {
const responseData =
body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbGetPskeyResp>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData,
};
return { sendRawPacket: vi.fn(async () => r) };
}
describe('GetPskey namespace', () => {
it('declares 0x102A_0', () => {
expect(GetPskey.command).toBe(0x102a);
expect(GetPskey.subCommand).toBe(0);
});
describe('serialize', () => {
it('passes the domainList through verbatim', () => {
const out = GetPskey.serialize({} as any, {
domainList: ['qun.qq.com', 'qzone.qq.com'],
});
expect(out.domainList).toEqual(['qun.qq.com', 'qzone.qq.com']);
});
});
describe('deserialize', () => {
it('builds a Map from server pskeyItems', () => {
const { domainPskeyMap } = GetPskey.deserialize({} as any, {
pskeyItems: [
{ domain: 'qun.qq.com', pskey: 'pskey-qun' },
{ domain: 'qzone.qq.com', pskey: 'pskey-qzone' },
],
});
expect(domainPskeyMap.get('qun.qq.com')).toBe('pskey-qun');
expect(domainPskeyMap.get('qzone.qq.com')).toBe('pskey-qzone');
});
it('drops entries with missing domain or pskey', () => {
const { domainPskeyMap } = GetPskey.deserialize({} as any, {
pskeyItems: [
{ domain: 'qun.qq.com', pskey: 'ok' },
{ domain: 'missing-pskey.com' },
{ pskey: 'missing-domain' },
] as any,
});
expect(domainPskeyMap.size).toBe(1);
expect(domainPskeyMap.get('qun.qq.com')).toBe('ok');
});
it('returns empty Map when pskeyItems is absent', () => {
const { domainPskeyMap } = GetPskey.deserialize({} as any, {});
expect(domainPskeyMap.size).toBe(0);
});
});
describe('invoke (e2e)', () => {
it('routes to OidbSvcTrpcTcp.0x102a_0', async () => {
const sender = makeSender({ pskeyItems: [] });
await GetPskey.invoke(sender, { domainList: ['qq.com'] });
expect(sender.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x102a_0');
});
it('encodes the domain list into the request body', async () => {
const sender = makeSender({ pskeyItems: [] });
await GetPskey.invoke(sender, { domainList: ['qun.qq.com'] });
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbGetPskeyReq>>(bytes);
expect(env.body?.domainList).toEqual(['qun.qq.com']);
});
});
});