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

778 lines
27 KiB
TypeScript

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 { SendPacketResult } from '@snowluma/common/packet-sender';
import type {
OidbBase,
OidbEmpty,
OidbFriend,
OidbFriendCategory,
OidbRobotUinRangeResponse,
OidbSvcTrpcTcp0x88D_0Response,
OidbSvcTrpcTcp0xFD4_1Response,
OidbSvcTrpcTcp0xFE5_2Response,
OidbSvcTrpcTcp0xFE7_3Response,
} from '@snowluma/proto-defs/oidb';
import type {
OidbFriendListRequest,
OidbRobotUinRangeRequest,
OidbSetFriendCategoryRequest,
} from '@snowluma/proto-defs/oidb-actions/base';
import { ContactsApi } from '../../src/bridge/apis/contacts';
interface ObservedGroupRequestUserByUin {
uin?: pb<1, uint_32>;
name?: pb<2, string>;
}
interface ObservedGroupRequestUserByUid {
uid?: pb<1, string>;
name?: pb<2, string>;
}
interface ObservedGroupRequestGroup {
groupUin?: pb<1, uint_32>;
groupName?: pb<2, string>;
}
interface ObservedGroupRequest<TUser> {
sequence?: pb<1, uint_64>;
eventType?: pb<2, uint_32>;
state?: pb<3, uint_32>;
group?: pb<4, ObservedGroupRequestGroup>;
target?: pb<5, TUser>;
invitor?: pb<6, TUser>;
operatorUser?: pb<7, TUser>;
comment?: pb<10, string>;
}
interface ObservedGroupRequestResponse<TUser> {
requests?: pb_repeated<1, ObservedGroupRequest<TUser>>;
}
function friend(
uin: number,
categoryId: number,
uid: string,
nickname: string,
remark = '',
): OidbFriend {
return {
uin,
uid,
customGroup: categoryId,
additional: [
{
type: 1,
layer1: {
properties: [
{ code: 20002, value: Buffer.from(nickname) },
{ code: 103, value: Buffer.from(remark) },
],
},
},
],
};
}
function category(
categoryId: number,
categoryName: string,
memberCount: number,
sortId: number,
): OidbFriendCategory {
return { categoryId, categoryName, memberCount, sortId };
}
function packet(body: OidbSvcTrpcTcp0xFD4_1Response): SendPacketResult {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({ body }),
),
};
}
function robotRangePacket(body: OidbRobotUinRangeResponse): SendPacketResult {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(protobuf_encode<OidbBase<OidbRobotUinRangeResponse>>({ body })),
};
}
function memberListPacket(body: OidbSvcTrpcTcp0xFE7_3Response): SendPacketResult {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE7_3Response>>({ body }),
),
};
}
function groupListPacket(body: OidbSvcTrpcTcp0xFE5_2Response): SendPacketResult {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE5_2Response>>({ body }),
),
};
}
function groupDetailPacket(body: OidbSvcTrpcTcp0x88D_0Response): SendPacketResult {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({ body }),
),
};
}
function groupRequestPacketByUin(
body: ObservedGroupRequestResponse<ObservedGroupRequestUserByUin>,
): SendPacketResult {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<OidbBase<ObservedGroupRequestResponse<ObservedGroupRequestUserByUin>>>({
body,
}),
),
};
}
function groupRequestPacketByUid(
body: ObservedGroupRequestResponse<ObservedGroupRequestUserByUid>,
): SendPacketResult {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<OidbBase<ObservedGroupRequestResponse<ObservedGroupRequestUserByUid>>>({
body,
}),
),
};
}
function apiForPages(pages: OidbSvcTrpcTcp0xFD4_1Response[]) {
let index = 0;
const sendRawPacket = vi.fn(
async (_cmd: string, _body: Uint8Array): Promise<SendPacketResult> => {
const page = pages[index++];
if (!page) {
throw new Error(`unexpected friend-list page ${index}`);
}
return packet(page);
},
);
const rememberFriends = vi.fn();
const api = new ContactsApi({
sendRawPacket,
identity: { rememberFriends },
} as any);
return { api, sendRawPacket, rememberFriends };
}
function apiForCategoryChange(roster: OidbSvcTrpcTcp0xFD4_1Response) {
const sendRawPacket = vi.fn(
async (command: string, _body: Uint8Array): Promise<SendPacketResult> => {
if (command === 'OidbSvcTrpcTcp.0xfd4_1') {
return packet(roster);
}
if (command === 'OidbSvcTrpcTcp.0x1255_0') {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<OidbBase<OidbEmpty>>({
command: 0x1255,
subCommand: 0,
}),
),
};
}
throw new Error(`unexpected command: ${command}`);
},
);
const api = new ContactsApi({
sendRawPacket,
identity: { uin: '10001', rememberFriends: vi.fn() },
} as any);
return { api, sendRawPacket };
}
describe('apis/contacts / group roster', () => {
it('maps QQ group remarks into the returned and remembered roster', async () => {
const rememberGroups = vi.fn();
const sendRawPacket = vi.fn(async () =>
groupListPacket({
groups: [
{
groupUin: 123456789,
info: {
groupName: 'Project',
memberCount: 42,
memberMax: 500,
shutUpAllTimestamp: 4_294_967_295,
},
customInfo: { remark: ' My Project ' },
},
],
}),
);
const api = new ContactsApi({
sendRawPacket,
identity: { rememberGroups },
} as any);
const groups = await api.fetchGroupList();
expect(groups).toEqual([
expect.objectContaining({
groupId: 123456789,
groupName: 'Project',
remark: ' My Project ',
memberCount: 42,
memberMax: 500,
allMuted: true,
}),
]);
expect(rememberGroups).toHaveBeenCalledWith(groups);
});
// #490: `memo` prefers the announcement, so a group with both fields set used
// to lose its description entirely. The description must stay readable.
it('keeps the group description readable when an announcement is also set (#490)', async () => {
const sendRawPacket = vi.fn(async () =>
groupListPacket({
groups: [
{
groupUin: 123456789,
info: {
groupName: 'Both Set',
description: '简介正文',
announcement: '公告正文',
},
},
],
}),
);
const api = new ContactsApi({
sendRawPacket,
identity: { rememberGroups: vi.fn() },
} as any);
const [group] = await api.fetchGroupList();
expect(group?.memo).toBe('公告正文');
expect(group?.description).toBe('简介正文');
});
it('reports the description for a group that has no announcement (#490)', async () => {
const sendRawPacket = vi.fn(async () =>
groupListPacket({
groups: [
{
groupUin: 123456789,
info: { groupName: 'Description Only', description: '只有简介' },
},
],
}),
);
const api = new ContactsApi({
sendRawPacket,
identity: { rememberGroups: vi.fn() },
} as any);
const [group] = await api.fetchGroupList();
// memo still falls back to the description (unchanged pre-#490 behaviour).
expect(group?.memo).toBe('只有简介');
expect(group?.description).toBe('只有简介');
});
it('leaves the description unset on a non-member detail lookup (#490)', async () => {
const sendRawPacket = vi.fn(async () =>
groupDetailPacket({
groupInfo: {
uin: 123456789n,
results: { name: 'Not Joined', noticePreview: '公告预览' },
},
}),
);
const api = new ContactsApi({ sendRawPacket, identity: {} } as any);
const detail = await api.fetchGroupDetail(123456789);
// 0x88D_0 has no description tag, so there is nothing to expose — the field
// must stay absent rather than echo the announcement preview.
expect(detail?.memo).toBe('公告预览');
expect(detail?.description).toBeUndefined();
});
it('maps the group-wide mute timestamp from a single-group detail', async () => {
const sendRawPacket = vi.fn(async () =>
groupDetailPacket({
groupInfo: {
uin: 123456789n,
results: {
name: 'Muted Group',
shutUpAllTimestamp: 4_294_967_295,
},
},
}),
);
const api = new ContactsApi({ sendRawPacket, identity: {} } as any);
const detail = await api.fetchGroupDetail(123456789);
expect(detail).toEqual(
expect.objectContaining({
groupId: 123456789,
groupName: 'Muted Group',
allMuted: true,
}),
);
});
it('treats a past group-mute expire as unmuted (#356)', async () => {
const sendRawPacket = vi.fn(async () =>
groupListPacket({
groups: [
{
groupUin: 123456789,
info: {
groupName: 'Was Muted',
shutUpAllTimestamp: 1_700_000_000,
},
},
],
}),
);
const api = new ContactsApi({
sendRawPacket,
identity: { rememberGroups: vi.fn() },
} as any);
const groups = await api.fetchGroupList();
expect(groups[0]?.allMuted).toBe(false);
});
});
describe('apis/contacts / group requests', () => {
it('maps the native numeric-account response into directly actionable requests', async () => {
const rememberGroupRequests = vi.fn();
const findUidByUin = vi.fn((uin: number) =>
uin === 1_234_567_890 ? 'cached_target_uid' : null,
);
const sendRawPacket = vi.fn(async () =>
groupRequestPacketByUin({
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',
},
],
}),
);
const api = new ContactsApi({
sendRawPacket,
identity: { findUidByUin, rememberGroupRequests },
} as any);
const requests = await api.fetchGroupRequests(false);
expect(requests).toEqual([
expect.objectContaining({
groupId: 1_095_186_374,
targetUid: 'cached_target_uid',
targetUin: 1_234_567_890,
invitorUin: 2_345_678_901,
operatorUin: 3_456_789_012,
sequence: 1_785_525_232_784_291,
notifyType: 7,
eventType: 1,
filtered: false,
}),
]);
expect(rememberGroupRequests).toHaveBeenCalledWith(requests);
});
it('retains the UID-form response for correlating real-time request pushes', async () => {
const rememberGroupRequests = vi.fn();
const findUinByUid = vi.fn((uid: string) => (uid === 'target_uid' ? 1_234_567_890 : null));
const sendRawPacket = vi.fn(async () =>
groupRequestPacketByUid({
requests: [
{
sequence: 42n,
eventType: 1,
state: 1,
group: { groupUin: 999, groupName: 'group' },
target: { uid: 'target_uid', name: 'requester' },
invitor: { uid: 'inviter_uid', name: 'inviter' },
operatorUser: { uid: 'operator_uid', name: 'operator' },
},
],
}),
);
const api = new ContactsApi({
sendRawPacket,
identity: { findUinByUid, rememberGroupRequests },
} as any);
const requests = await api.fetchGroupRequestsByUid(true);
expect(requests).toEqual([
expect.objectContaining({
targetUid: 'target_uid',
targetUin: 1_234_567_890,
invitorUid: 'inviter_uid',
operatorUid: 'operator_uid',
notifyType: 7,
eventType: 1,
filtered: true,
}),
]);
expect(rememberGroupRequests).toHaveBeenCalledWith(requests);
});
});
describe('apis/contacts / categorized friend roster', () => {
it('keeps fetchFriendList flat while traversing cookie pages', async () => {
const cookie = Uint8Array.from([0x01]);
const { api, sendRawPacket } = apiForPages([
{ cookie, friends: [friend(10001, 0, 'u1', 'Default')] },
{ friends: [friend(10002, 7, 'u2', 'Alice', 'A')] },
]);
await expect(api.fetchFriendList()).resolves.toEqual([
{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 },
{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 },
]);
expect(sendRawPacket).toHaveBeenCalledTimes(2);
});
it('groups a complete multi-page roster and preserves empty categories', async () => {
const cookie = Uint8Array.from([0xaa, 0xbb]);
const { api, sendRawPacket, rememberFriends } = apiForPages([
{
cookie,
friends: [friend(10001, 0, 'u1', 'Default')],
categories: [
category(0, '我的好友', 1, 0),
category(7, 'Work', 1, 1),
category(9, 'Empty', 0, 2),
],
},
{
friends: [friend(10002, 7, 'u2', 'Alice', 'A')],
categories: [category(7, 'Work', 1, 1)],
},
]);
await expect(api.fetchFriendCategories()).resolves.toEqual([
{
categoryId: 0,
categoryName: '我的好友',
memberCount: 1,
sortId: 0,
friends: [
{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 },
],
},
{
categoryId: 7,
categoryName: 'Work',
memberCount: 1,
sortId: 1,
friends: [{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 }],
},
{
categoryId: 9,
categoryName: 'Empty',
memberCount: 0,
sortId: 2,
friends: [],
},
]);
expect(sendRawPacket).toHaveBeenCalledTimes(2);
const secondRequest = protobuf_decode<OidbBase<OidbFriendListRequest>>(
sendRawPacket.mock.calls[1]![1],
);
expect(secondRequest.body?.cookie).toEqual(cookie);
expect(rememberFriends).toHaveBeenCalledOnce();
expect(rememberFriends).toHaveBeenCalledWith([
{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 },
{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 },
]);
});
it('rejects a friend whose category metadata never arrives', async () => {
const { api } = apiForPages([
{
friends: [friend(10001, 99, 'u1', 'Orphan')],
categories: [],
},
]);
await expect(api.fetchFriendCategories()).rejects.toThrow('missing category 99');
});
it('rejects a repeated non-empty pagination cookie', async () => {
const cookie = Uint8Array.from([0xaa]);
const { api } = apiForPages([{ cookie }, { cookie }]);
await expect(api.fetchFriendCategories()).rejects.toThrow('repeated friend-list cookie aa');
});
it('moves a live friend to a category selected by ID', async () => {
const { api, sendRawPacket } = apiForCategoryChange({
friends: [friend(10001, 0, 'u_friend', 'Alice')],
categories: [category(0, '我的好友', 1, 0), category(7, 'Work', 0, 1)],
});
await api.setFriendCategory({ uin: 10001, categoryId: 7 });
expect(sendRawPacket).toHaveBeenCalledTimes(2);
const [command, bytes] = sendRawPacket.mock.calls[1]!;
expect(command).toBe('OidbSvcTrpcTcp.0x1255_0');
const envelope = protobuf_decode<OidbBase<OidbSetFriendCategoryRequest>>(bytes);
expect(envelope.body).toEqual({ uid: 'u_friend', categoryId: 7 });
});
it('resolves an exact unique category name before moving the friend', async () => {
const { api, sendRawPacket } = apiForCategoryChange({
friends: [friend(10001, 0, 'u_friend', 'Alice')],
categories: [category(0, '我的好友', 1, 0), category(7, 'Work', 0, 1)],
});
await api.setFriendCategory({ uin: 10001, categoryName: 'Work' });
const envelope = protobuf_decode<OidbBase<OidbSetFriendCategoryRequest>>(
sendRawPacket.mock.calls[1]![1],
);
expect(envelope.body).toEqual({ uid: 'u_friend', categoryId: 7 });
});
it('rejects unknown friends and categories without sending a mutation', async () => {
const unknownFriend = apiForCategoryChange({
friends: [friend(10001, 0, 'u_friend', 'Alice')],
categories: [category(0, '我的好友', 1, 0)],
});
await expect(
unknownFriend.api.setFriendCategory({
uin: 10002,
categoryId: 0,
}),
).rejects.toThrow('friend 10002 is not in the live roster');
expect(unknownFriend.sendRawPacket).toHaveBeenCalledTimes(1);
const unknownCategory = apiForCategoryChange({
friends: [friend(10001, 0, 'u_friend', 'Alice')],
categories: [category(0, '我的好友', 1, 0)],
});
await expect(
unknownCategory.api.setFriendCategory({
uin: 10001,
categoryId: 7,
}),
).rejects.toThrow('friend category 7 does not exist');
expect(unknownCategory.sendRawPacket).toHaveBeenCalledTimes(1);
});
it('rejects duplicate category names instead of selecting one arbitrarily', async () => {
const { api, sendRawPacket } = apiForCategoryChange({
friends: [friend(10001, 0, 'u_friend', 'Alice')],
categories: [category(0, 'Work', 1, 0), category(7, 'Work', 0, 1)],
});
await expect(
api.setFriendCategory({
uin: 10001,
categoryName: 'Work',
}),
).rejects.toThrow('friend category name "Work" is ambiguous');
expect(sendRawPacket).toHaveBeenCalledTimes(1);
});
it('rejects missing or conflicting selectors before reading the roster', async () => {
const { api, sendRawPacket } = apiForCategoryChange({
friends: [],
categories: [],
});
await expect(api.setFriendCategory({ uin: 10001 })).rejects.toThrow(
'exactly one of categoryId or categoryName is required',
);
await expect(
api.setFriendCategory({
uin: 10001,
categoryId: 7,
categoryName: 'Work',
}),
).rejects.toThrow('exactly one of categoryId or categoryName is required');
expect(sendRawPacket).not.toHaveBeenCalled();
});
});
describe('apis/contacts / robot group-member classification', () => {
it('loads QQ robot ranges once and marks every fetched member', async () => {
const rememberGroupMembers = vi.fn();
const sendRawPacket = vi.fn(
async (cmd: string, _data: Uint8Array): Promise<SendPacketResult> => {
if (cmd === 'OidbSvcTrpcTcp.0x496_0') {
return robotRangePacket({
robotConfig: {
version: 206,
ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }],
},
});
}
if (cmd === 'OidbSvcTrpcTcp.0xfe7_3') {
return memberListPacket({
groupUin: 42,
members: [
{ uin: { uid: 'u_robot', uin: 3_889_054_356 }, memberName: 'robot' },
{ uin: { uid: 'u_person', uin: 1_234_567_890 }, memberName: 'person' },
],
});
}
throw new Error(`unexpected command: ${cmd}`);
},
);
const api = new ContactsApi({
sendRawPacket,
identity: { uin: '10001', rememberGroupMembers },
} as any);
const first = await api.fetchGroupMemberList(42);
const second = await api.fetchGroupMemberList(42, { force: true });
expect(first).toEqual([
expect.objectContaining({ uin: 3_889_054_356, isRobot: true }),
expect.objectContaining({ uin: 1_234_567_890, isRobot: false }),
]);
expect(second).toEqual(first);
expect(rememberGroupMembers).toHaveBeenCalledWith(42, first);
expect(sendRawPacket.mock.calls.map(([cmd]) => cmd).sort()).toEqual([
'OidbSvcTrpcTcp.0x496_0',
'OidbSvcTrpcTcp.0xfe7_3',
'OidbSvcTrpcTcp.0xfe7_3',
]);
expect(
sendRawPacket.mock.calls.filter(([cmd]) => cmd === 'OidbSvcTrpcTcp.0x496_0'),
).toHaveLength(1);
const rangeCall = sendRawPacket.mock.calls.find(
([cmd]) => cmd === 'OidbSvcTrpcTcp.0x496_0',
)!;
const request = protobuf_decode<OidbBase<OidbRobotUinRangeRequest>>(rangeCall[1]);
expect(request.body).toMatchObject({ justFetchMsgConfig: 1, type: 1 });
});
it('propagates an invalid range snapshot and retries it on the next fetch', async () => {
let rangeAttempts = 0;
const sendRawPacket = vi.fn(
async (cmd: string, _data: Uint8Array): Promise<SendPacketResult> => {
if (cmd === 'OidbSvcTrpcTcp.0x496_0') {
rangeAttempts += 1;
return rangeAttempts === 1
? robotRangePacket({})
: robotRangePacket({
robotConfig: {
version: 206,
ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }],
},
});
}
if (cmd === 'OidbSvcTrpcTcp.0xfe7_3') {
return memberListPacket({
groupUin: 42,
members: [
{ uin: { uid: 'u_robot', uin: 3_889_054_356 }, memberName: 'robot' },
],
});
}
throw new Error(`unexpected command: ${cmd}`);
},
);
const rememberGroupMembers = vi.fn();
const api = new ContactsApi({
sendRawPacket,
identity: { uin: '10001', rememberGroupMembers },
} as any);
await expect(api.fetchGroupMemberList(42)).rejects.toThrow(
'0x496_0 response missing robot range config',
);
await expect(api.fetchGroupMemberList(42)).resolves.toEqual([
expect.objectContaining({ isRobot: true }),
]);
expect(rangeAttempts).toBe(2);
expect(rememberGroupMembers).toHaveBeenCalledOnce();
});
});
describe('apis/contacts / invite-card pending application', () => {
it('remembers, reads, and reverse-looks-up a card sequence', () => {
const api = new ContactsApi({ identity: { uin: '10001' } } as any);
expect(api.getGroupInviteCardSequence(12345)).toBeUndefined();
expect(api.rememberGroupInviteCardSequence(12345, 778899)).toBe(true);
expect(api.rememberGroupInviteCardSequence(12345, 778899)).toBe(false);
expect(api.getGroupInviteCardSequence(12345)).toBe(778899);
expect(api.findGroupInviteCardGroupBySequence(778899)).toBe(12345);
expect(api.findGroupInviteCardGroupBySequence(1)).toBeUndefined();
});
it('requires a group when different groups have the same card sequence', () => {
const api = new ContactsApi({ identity: { uin: '10001' } } as any);
api.rememberGroupInviteCardSequence(12345, 778899);
api.rememberGroupInviteCardSequence(67890, 778899);
expect(() => api.findGroupInviteCardGroupBySequence(778899)).toThrow('multiple groups');
expect(api.findGroupInviteCardGroupBySequence(778899, 67890)).toBe(67890);
});
});