diff --git a/packages/core/src/bridge/apis/contacts.ts b/packages/core/src/bridge/apis/contacts.ts index a173d25b..94ae7a0c 100644 --- a/packages/core/src/bridge/apis/contacts.ts +++ b/packages/core/src/bridge/apis/contacts.ts @@ -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 { const props = new Map(); for (const additional of raw.additional ?? []) { @@ -225,6 +232,52 @@ export class ContactsApi { }); } + async setFriendCategory(params: SetFriendCategoryParams): Promise { + 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 { const resp = await FetchGroupList.invoke(this.ctx); const groups: QQGroupInfo[] = []; diff --git a/packages/core/tests/actions/contacts.test.ts b/packages/core/tests/actions/contacts.test.ts index 082dae36..11d31df0 100644 --- a/packages/core/tests/actions/contacts.test.ts +++ b/packages/core/tests/actions/contacts.test.ts @@ -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 => { + 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>({ + 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>(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>( + 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', () => { diff --git a/packages/onebot/src/actions/extended.ts b/packages/onebot/src/actions/extended.ts index a4b19c77..27a5a995 100644 --- a/packages/onebot/src/actions/extended.ts +++ b/packages/onebot/src/actions/extended.ts @@ -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 也只覆盖 diff --git a/packages/onebot/tests/action-registry.test.ts b/packages/onebot/tests/action-registry.test.ts index 86e0d0ce..c0b2a026 100644 --- a/packages/onebot/tests/action-registry.test.ts +++ b/packages/onebot/tests/action-registry.test.ts @@ -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', () => { diff --git a/packages/onebot/tests/extended-actions.test.ts b/packages/onebot/tests/extended-actions.test.ts index 667958ff..56e72ff6 100644 --- a/packages/onebot/tests/extended-actions.test.ts +++ b/packages/onebot/tests/extended-actions.test.ts @@ -38,6 +38,7 @@ function fakeMeta(overrides: Partial = {}): MessageMeta { const APIS_ROUTING: Record = { 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', () => { diff --git a/packages/proto-defs/src/oidb-actions/base.ts b/packages/proto-defs/src/oidb-actions/base.ts index eb9932a9..dc5d7bae 100644 --- a/packages/proto-defs/src/oidb-actions/base.ts +++ b/packages/proto-defs/src/oidb-actions/base.ts @@ -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>; diff --git a/packages/protocol/src/oidb-services/contacts/set-friend-category.ts b/packages/protocol/src/oidb-services/contacts/set-friend-category.ts new file mode 100644 index 00000000..8a91cceb --- /dev/null +++ b/packages/protocol/src/oidb-services/contacts/set-friend-category.ts @@ -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 { + const body = serialize(deps, params); + const bytes = protobuf_encode>( + 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(response); + if (!accepted(meta.errorCode)) { + throw new OidbError(meta.errorCode!, meta.errorMsg ?? '', command, subCommand); + } + } +} diff --git a/packages/protocol/tests/oidb-services/contacts/set-friend-category.test.ts b/packages/protocol/tests/oidb-services/contacts/set-friend-category.test.ts new file mode 100644 index 00000000..e5b3d000 --- /dev/null +++ b/packages/protocol/tests/oidb-services/contacts/set-friend-category.test.ts @@ -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>({ + 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 => ({ + 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 => ({ + 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'); + }); +}); diff --git a/packages/sdk/src/types/actions.ts b/packages/sdk/src/types/actions.ts index 59bbf372..9c27db03 100644 --- a/packages/sdk/src/types/actions.ts +++ b/packages/sdk/src/types/actions.ts @@ -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 }; diff --git a/packages/sdk/src/types/params.ts b/packages/sdk/src/types/params.ts index aa8e2038..cfea50f7 100644 --- a/packages/sdk/src/types/params.ts +++ b/packages/sdk/src/types/params.ts @@ -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;