feat(onebot): support moving friends between categories
Add the set_friends_category action with either an existing category ID or an exact unique category name. Validate the current friend and category roster before applying a change, reject missing or ambiguous targets, and surface failed updates instead of reporting false success. Expose the action through the SDK types and cover validation, successful updates, repeat requests, service rejection, and registry completeness. Closes #297
This commit is contained in:
@@ -13,6 +13,7 @@ import { FetchUserProfile } from '@snowluma/protocol/oidb-services/contacts/fetc
|
||||
import { FetchUserProfileByUid } from '@snowluma/protocol/oidb-services/contacts/fetch-user-profile-by-uid';
|
||||
import { GetBuddyRecommendArk } from '@snowluma/protocol/oidb-services/contacts/get-buddy-recommend-ark';
|
||||
import { GetGroupRecommendArk } from '@snowluma/protocol/oidb-services/contacts/get-group-recommend-ark';
|
||||
import { SetFriendCategory } from '@snowluma/protocol/oidb-services/contacts/set-friend-category';
|
||||
import { toHex } from '@snowluma/common/hex';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import { createSingleFlightCache, type SingleFlightCache } from '@snowluma/common/single-flight-cache';
|
||||
@@ -60,6 +61,12 @@ interface FriendRoster {
|
||||
categories: FriendCategoryMeta[];
|
||||
}
|
||||
|
||||
export interface SetFriendCategoryParams {
|
||||
uin: number;
|
||||
categoryId?: number;
|
||||
categoryName?: string;
|
||||
}
|
||||
|
||||
export function buildFriendProperties(raw: FriendPropertySource): Map<number, string> {
|
||||
const props = new Map<number, string>();
|
||||
for (const additional of raw.additional ?? []) {
|
||||
@@ -225,6 +232,52 @@ export class ContactsApi {
|
||||
});
|
||||
}
|
||||
|
||||
async setFriendCategory(params: SetFriendCategoryParams): Promise<void> {
|
||||
const selectorCount = Number(params.categoryId !== undefined)
|
||||
+ Number(params.categoryName !== undefined);
|
||||
if (selectorCount !== 1) {
|
||||
throw new Error('exactly one of categoryId or categoryName is required');
|
||||
}
|
||||
const categories = await this.fetchFriendCategories();
|
||||
const friendCategory = categories.find(category =>
|
||||
category.friends.some(friend => friend.uin === params.uin));
|
||||
const friend = friendCategory?.friends.find(entry => entry.uin === params.uin);
|
||||
if (!friend) {
|
||||
throw new Error(`friend ${params.uin} is not in the live roster`);
|
||||
}
|
||||
if (!friend.uid) {
|
||||
throw new Error(`friend ${params.uin} has no UID in the live roster`);
|
||||
}
|
||||
|
||||
let target: FriendCategoryInfo | undefined;
|
||||
if (params.categoryId !== undefined) {
|
||||
target = categories.find(category => category.categoryId === params.categoryId);
|
||||
if (!target) {
|
||||
throw new Error(`friend category ${params.categoryId} does not exist`);
|
||||
}
|
||||
} else {
|
||||
const matches = categories.filter(category => category.categoryName === params.categoryName);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`friend category name "${params.categoryName}" does not exist`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`friend category name "${params.categoryName}" is ambiguous`);
|
||||
}
|
||||
target = matches[0]!;
|
||||
}
|
||||
|
||||
await SetFriendCategory.invoke(this.ctx, {
|
||||
uid: friend.uid,
|
||||
categoryId: target.categoryId,
|
||||
});
|
||||
log.info(
|
||||
'friend category updated: uin=%s friend=%d category=%d',
|
||||
this.ctx.identity.uin,
|
||||
params.uin,
|
||||
target.categoryId,
|
||||
);
|
||||
}
|
||||
|
||||
async fetchGroupList(): Promise<QQGroupInfo[]> {
|
||||
const resp = await FetchGroupList.invoke(this.ctx);
|
||||
const groups: QQGroupInfo[] = [];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type {
|
||||
OidbBase,
|
||||
OidbEmpty,
|
||||
OidbFriend,
|
||||
OidbFriendCategory,
|
||||
OidbRobotUinRangeResponse,
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
import type {
|
||||
OidbFriendListRequest,
|
||||
OidbRobotUinRangeRequest,
|
||||
OidbSetFriendCategoryRequest,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import { ContactsApi } from '../../src/bridge/apis/contacts';
|
||||
@@ -102,6 +104,33 @@ function apiForPages(pages: OidbSvcTrpcTcp0xFD4_1Response[]) {
|
||||
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 / categorized friend roster', () => {
|
||||
it('keeps fetchFriendList flat while traversing cookie pages', async () => {
|
||||
const cookie = Uint8Array.from([0x01]);
|
||||
@@ -188,6 +217,95 @@ describe('apis/contacts / categorized friend roster', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -1189,6 +1189,26 @@ export const actions = [
|
||||
},
|
||||
}),
|
||||
|
||||
defineAction({
|
||||
name: 'set_friends_category',
|
||||
summary: '移动好友到指定分组',
|
||||
returns: '成功时返回空数据。',
|
||||
params: {
|
||||
uin: f.userId().describe('要移动的好友 QQ 号'),
|
||||
categoryId: f.int({ min: 0 }).optional().describe('目标分组 ID'),
|
||||
categoryName: f.string({ allowEmpty: false }).optional().describe('目标分组名称(必须唯一且完全匹配)'),
|
||||
},
|
||||
rules: (rules) => [rules.exactlyOneOf('categoryId', 'categoryName')],
|
||||
run: async (params, ctx) => {
|
||||
await ctx.bridge.apis.contacts.setFriendCategory({
|
||||
uin: params.uin,
|
||||
categoryId: params.categoryId,
|
||||
categoryName: params.categoryName,
|
||||
});
|
||||
return okResponse();
|
||||
},
|
||||
}),
|
||||
|
||||
// get_recent_contact — 最近会话列表(占位)。QQ 原生该接口是内核本地快照
|
||||
// (getRecentContactListSnapShot),返回带 peerName/remark/lastestMsg 等丰富
|
||||
// 元信息;SnowLuma 既无对应 SSO/packet wire,自有 message store 也只覆盖
|
||||
|
||||
@@ -90,9 +90,9 @@ describe('compileActionRegistry namespace conflicts', () => {
|
||||
});
|
||||
|
||||
describe('compiled production Action registry', () => {
|
||||
it('preserves all 180 canonical Action docs', () => {
|
||||
expect(ACTION_REGISTRY.actions).toHaveLength(180);
|
||||
expect(collectActionDocs()).toHaveLength(180);
|
||||
it('preserves all 181 canonical Action docs', () => {
|
||||
expect(ACTION_REGISTRY.actions).toHaveLength(181);
|
||||
expect(collectActionDocs()).toHaveLength(181);
|
||||
});
|
||||
|
||||
it('resolves every executable name to exactly one canonical doc', () => {
|
||||
|
||||
@@ -38,6 +38,7 @@ function fakeMeta(overrides: Partial<MessageMeta> = {}): MessageMeta {
|
||||
const APIS_ROUTING: Record<string, [string, string]> = {
|
||||
fetchFriendList: ['contacts', 'fetchFriendList'],
|
||||
fetchFriendCategories: ['contacts', 'fetchFriendCategories'],
|
||||
setFriendCategory: ['contacts', 'setFriendCategory'],
|
||||
fetchGroupList: ['contacts', 'fetchGroupList'],
|
||||
fetchGroupMemberList: ['contacts', 'fetchGroupMemberList'],
|
||||
fetchUserProfile: ['contacts', 'fetchUserProfile'],
|
||||
@@ -265,6 +266,72 @@ describe('extended-actions / get_friends_with_category', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extended-actions / set_friends_category', () => {
|
||||
it('moves a friend by category ID', async () => {
|
||||
const setFriendCategory = vi.fn(async () => undefined);
|
||||
const bridge = fakeBridge({ setFriendCategory });
|
||||
|
||||
const response = await makeHandler(fakeCtx(bridge))
|
||||
.handle('set_friends_category', { uin: 10001, categoryId: 7 });
|
||||
|
||||
expect(response).toMatchObject({ status: 'ok', retcode: 0, data: null });
|
||||
expect(setFriendCategory).toHaveBeenCalledWith({
|
||||
uin: 10001,
|
||||
categoryId: 7,
|
||||
categoryName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('moves a friend by an exact category name', async () => {
|
||||
const setFriendCategory = vi.fn(async () => undefined);
|
||||
const bridge = fakeBridge({ setFriendCategory });
|
||||
|
||||
const response = await makeHandler(fakeCtx(bridge))
|
||||
.handle('set_friends_category', { uin: 10001, categoryName: 'Work' });
|
||||
|
||||
expect(response).toMatchObject({ status: 'ok', retcode: 0, data: null });
|
||||
expect(setFriendCategory).toHaveBeenCalledWith({
|
||||
uin: 10001,
|
||||
categoryId: undefined,
|
||||
categoryName: 'Work',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects missing or conflicting category selectors before calling QQ', async () => {
|
||||
const setFriendCategory = vi.fn(async () => undefined);
|
||||
const bridge = fakeBridge({ setFriendCategory });
|
||||
const handler = makeHandler(fakeCtx(bridge));
|
||||
|
||||
const missing = await handler.handle('set_friends_category', { uin: 10001 });
|
||||
const conflicting = await handler.handle('set_friends_category', {
|
||||
uin: 10001,
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
});
|
||||
|
||||
expect(missing).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
expect(conflicting).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
expect(setFriendCategory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces roster or server failures instead of reporting success', async () => {
|
||||
const bridge = fakeBridge({
|
||||
setFriendCategory: vi.fn(async () => {
|
||||
throw new Error('friend 10001 is not in the live roster');
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await makeHandler(fakeCtx(bridge))
|
||||
.handle('set_friends_category', { uin: 10001, categoryId: 7 });
|
||||
|
||||
expect(response).toMatchObject({
|
||||
status: 'failed',
|
||||
retcode: 100,
|
||||
wording: 'friend 10001 is not in the live roster',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tier 1: send_packet / .send_packet ───
|
||||
|
||||
describe('extended-actions / send_packet', () => {
|
||||
|
||||
@@ -69,6 +69,11 @@ export interface OidbFriendRequestAction {
|
||||
accept?: pb<1, uint_32>;
|
||||
targetUid?: pb<2, string>;
|
||||
}
|
||||
/** 0x1255_0 — move one friend into a numbered category. */
|
||||
export interface OidbSetFriendCategoryRequest {
|
||||
uid?: pb<1, string>;
|
||||
categoryId?: pb<2, uint_32>;
|
||||
}
|
||||
export interface OidbDeleteFriendField2Field3 {
|
||||
field1?: pb<1, uint_32>;
|
||||
field2?: pb<2, uint_32>;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase, OidbBaseMeta } from '@snowluma/proto-defs/oidb';
|
||||
import type { OidbSetFriendCategoryRequest } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import { makeOidbEnvelope } from '../../bridge-oidb';
|
||||
import { OidbError, type OidbSender } from '../../oidb-service';
|
||||
|
||||
/**
|
||||
* Move one friend into a category.
|
||||
*
|
||||
* The desktop client treats 2001002 as an idempotent success result, so this
|
||||
* call intentionally validates the response itself instead of using the
|
||||
* generic OIDB helper that rejects every non-zero application code.
|
||||
*/
|
||||
export namespace SetFriendCategory {
|
||||
export const command = 0x1255;
|
||||
export const subCommand = 0;
|
||||
export const uinForm = true;
|
||||
export const alreadyAppliedCode = 2_001_002;
|
||||
|
||||
export interface Params {
|
||||
uid: string;
|
||||
categoryId: number;
|
||||
}
|
||||
|
||||
export type Deps = OidbSender;
|
||||
|
||||
export const serialize = (_ctx: Deps, params: Params): OidbSetFriendCategoryRequest => ({
|
||||
uid: params.uid,
|
||||
categoryId: params.categoryId,
|
||||
});
|
||||
|
||||
function accepted(code: number | null | undefined): boolean {
|
||||
return code == null || code === 0 || code === alreadyAppliedCode;
|
||||
}
|
||||
|
||||
export async function invoke(deps: Deps, params: Params): Promise<void> {
|
||||
const body = serialize(deps, params);
|
||||
const bytes = protobuf_encode<OidbBase<OidbSetFriendCategoryRequest>>(
|
||||
makeOidbEnvelope(command, subCommand, body, uinForm),
|
||||
);
|
||||
const result = await deps.sendRawPacket('OidbSvcTrpcTcp.0x1255_0', bytes);
|
||||
|
||||
if (!result.gotResponse) {
|
||||
throw new Error(result.errorMessage || 'no response');
|
||||
}
|
||||
if (!result.success && result.errorCode !== alreadyAppliedCode) {
|
||||
if (result.errorCode) {
|
||||
throw new OidbError(result.errorCode, result.errorMessage, command, subCommand);
|
||||
}
|
||||
throw new Error(result.errorMessage || 'packet send failed');
|
||||
}
|
||||
if (!accepted(result.errorCode)) {
|
||||
throw new OidbError(result.errorCode, result.errorMessage, command, subCommand);
|
||||
}
|
||||
|
||||
const response = result.responseData ?? new Uint8Array(0);
|
||||
if (response.length === 0) return;
|
||||
const meta = protobuf_decode<OidbBaseMeta>(response);
|
||||
if (!accepted(meta.errorCode)) {
|
||||
throw new OidbError(meta.errorCode!, meta.errorMsg ?? '', command, subCommand);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
SendPrivateMsgParams,
|
||||
SetFriendAddRequestParams,
|
||||
SetFriendRemarkParams,
|
||||
SetFriendsCategoryParams,
|
||||
SetGroupAddRequestParams,
|
||||
SetGroupAddOptionParams,
|
||||
SetGroupAdminParams,
|
||||
@@ -202,6 +203,7 @@ export interface SnowLumaActionMap {
|
||||
get_emoji_likes: { params: GetEmojiLikesParams; data: JsonObject };
|
||||
fetch_emoji_like: { params: FetchEmojiLikeParams; data: JsonObject };
|
||||
get_friends_with_category: { params: JsonObject; data: FriendCategoryResult[] };
|
||||
set_friends_category: { params: SetFriendsCategoryParams; data: EmptyData };
|
||||
get_online_clients: { params: GetOnlineClientsParams; data: OnlineClientsInfo };
|
||||
_get_model_show: { params: JsonObject; data: { variants: JsonArray } };
|
||||
_set_model_show: { params: JsonObject; data: EmptyData };
|
||||
|
||||
@@ -103,6 +103,11 @@ export interface SetGroupSpecialTitleParams extends GroupIdParams {
|
||||
special_title?: string;
|
||||
}
|
||||
|
||||
export type SetFriendsCategoryParams = JsonObject & (
|
||||
| { uin: number; categoryId: number; categoryName?: never }
|
||||
| { uin: number; categoryId?: never; categoryName: string }
|
||||
);
|
||||
|
||||
export interface UploadGroupFileParams extends GroupIdParams {
|
||||
file: string;
|
||||
name?: string;
|
||||
|
||||
Reference in New Issue
Block a user