diff --git a/packages/core/src/bridge/apis/contacts.ts b/packages/core/src/bridge/apis/contacts.ts index c31cf146..c00d7eb3 100644 --- a/packages/core/src/bridge/apis/contacts.ts +++ b/packages/core/src/bridge/apis/contacts.ts @@ -44,6 +44,7 @@ import type { FriendInfo, GroupMemberInfo, GroupRequestInfo, + PublicProfileFields, QQGroupInfo, UserProfileInfo, } from '@snowluma/protocol/qq-info'; @@ -94,6 +95,13 @@ function normalizeGroupRequestSequence( } const MEMBER_LIST_TTL_MS = 60_000; +const FRIEND_PROFILE_TTL_MS = 5 * 60_000; +const FRIEND_PROFILE_CONCURRENCY = 4; + +export interface FriendListOptions { + /** Explicitly query profiles; ordinary roster refreshes never fan out. */ + withProfile?: boolean; +} export class ContactsApi { /** @@ -112,6 +120,15 @@ export class ContactsApi { }); private robotUinRangesPromise_: Promise | null = null; readonly groupInvites = new GroupInviteTracker(); + private friendRosterGeneration = 0; + private rememberedFriendRosterGeneration = 0; + private activeProfiles = 0; + private readonly profileWaiters: (() => void)[] = []; + private readonly friendProfiles: SingleFlightCache = + createSingleFlightCache({ + ttlMs: FRIEND_PROFILE_TTL_MS, + load: (uin) => this.loadFriendProfile(uin), + }); constructor(private readonly ctx: BridgeContext) {} @@ -177,7 +194,73 @@ export class ContactsApi { return SendTuwenArk.invoke(this.ctx, params); } - private async fetchFriendRoster(): Promise { + private async loadFriendProfile(uin: number): Promise { + if (this.activeProfiles >= FRIEND_PROFILE_CONCURRENCY) { + await new Promise((resolve) => this.profileWaiters.push(resolve)); + } else { + this.activeProfiles++; + } + + try { + const profile = await this.fetchUserProfile(uin); + + return profile.profile ?? {}; + } catch (cause) { + log.warn( + 'friend profile query failed: user=%d error=%s', + uin, + cause instanceof Error ? cause.message : String(cause), + ); + throw new Error(`friend profile query failed for ${uin}`, { cause }); + } finally { + const next = this.profileWaiters.shift(); + + if (next) { + next(); + } else { + this.activeProfiles--; + } + } + } + + private async enrichFriendProfiles(entries: FriendListPageEntry[]): Promise { + const started = Date.now(); + + for (let offset = 0; offset < entries.length; offset += FRIEND_PROFILE_CONCURRENCY) { + const batch = entries.slice(offset, offset + FRIEND_PROFILE_CONCURRENCY); + const results = await Promise.allSettled( + batch.map(async ({ friend }) => { + const extra = await this.friendProfiles.get(friend.uin); + friend.profile = { ...structuredClone(extra), ...friend.profile }; + }), + ); + const errors = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + + if (errors.length > 0) { + log.warn( + 'friend profile enrichment failed: batch=%d failures=%d elapsedMs=%d', + offset, + errors.length, + Date.now() - started, + ); + throw new AggregateError( + errors.map((result) => result.reason), + 'friend profile enrichment failed', + ); + } + } + + log.debug( + 'friend profiles loaded: friends=%d elapsedMs=%d', + entries.length, + Date.now() - started, + ); + } + + private async fetchFriendRoster(options: FriendListOptions = {}): Promise { + const generation = ++this.friendRosterGeneration; const entries: FriendListPageEntry[] = []; const categories = new Map(); const seenCookies = new Set(); @@ -207,18 +290,25 @@ export class ContactsApi { cookie = next; } - const friends = entries.map((entry) => entry.friend); - this.ctx.identity.rememberFriends(friends); + if (options.withProfile) { + await this.enrichFriendProfiles(entries); + } + + if (generation > this.rememberedFriendRosterGeneration) { + this.ctx.identity.rememberFriends(entries.map((entry) => entry.friend)); + this.rememberedFriendRosterGeneration = generation; + } + return { entries, categories: [...categories.values()] }; } - async fetchFriendList(): Promise { - const roster = await this.fetchFriendRoster(); + async fetchFriendList(options: FriendListOptions = {}): Promise { + const roster = await this.fetchFriendRoster(options); return roster.entries.map((entry) => entry.friend); } - async fetchFriendCategories(): Promise { - const roster = await this.fetchFriendRoster(); + async fetchFriendCategories(options: FriendListOptions = {}): Promise { + const roster = await this.fetchFriendRoster(options); const buckets = new Map( roster.categories.map((category) => [category.categoryId, [] as FriendInfo[]]), ); diff --git a/packages/core/tests/actions/contacts.test.ts b/packages/core/tests/actions/contacts.test.ts index 29f76546..207de463 100644 --- a/packages/core/tests/actions/contacts.test.ts +++ b/packages/core/tests/actions/contacts.test.ts @@ -67,8 +67,8 @@ function friend( type: 1, layer1: { properties: [ - { code: 20002, value: nickname }, - { code: 103, value: remark }, + { code: 20002, value: Buffer.from(nickname) }, + { code: 103, value: Buffer.from(remark) }, ], }, }, @@ -478,8 +478,8 @@ describe('apis/contacts / categorized friend roster', () => { ]); await expect(api.fetchFriendList()).resolves.toEqual([ - { uin: 10001, uid: 'u1', nickname: 'Default', remark: '' }, - { uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A' }, + { uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 }, + { uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 }, ]); expect(sendRawPacket).toHaveBeenCalledTimes(2); }); @@ -508,14 +508,16 @@ describe('apis/contacts / categorized friend roster', () => { categoryName: '我的好友', memberCount: 1, sortId: 0, - friends: [{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '' }], + 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' }], + friends: [{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 }], }, { categoryId: 9, @@ -533,8 +535,8 @@ describe('apis/contacts / categorized friend roster', () => { expect(secondRequest.body?.cookie).toEqual(cookie); expect(rememberFriends).toHaveBeenCalledOnce(); expect(rememberFriends).toHaveBeenCalledWith([ - { uin: 10001, uid: 'u1', nickname: 'Default', remark: '' }, - { uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A' }, + { uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 }, + { uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 }, ]); }); diff --git a/packages/core/tests/actions/friend-profiles.test.ts b/packages/core/tests/actions/friend-profiles.test.ts new file mode 100644 index 00000000..f643df2e --- /dev/null +++ b/packages/core/tests/actions/friend-profiles.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; +import type { + OidbBase, + OidbFriend, + OidbSvcTrpcTcp0xFD4_1Response, +} from '@snowluma/proto-defs/oidb'; +import type { + OidbUserInfoRequest, + OidbUserInfoResponse, +} from '@snowluma/proto-defs/oidb-actions/base'; +import { IdentityService } from '@snowluma/protocol/identity-service'; +import { Bridge } from '../../src/bridge/bridge'; + +function deferred() { + let resolve!: () => void; + let reject!: (reason: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +const bridges: Bridge[] = []; +afterEach(async () => { + await Promise.all(bridges.splice(0).map((bridge) => bridge.dispose())); + vi.restoreAllMocks(); +}); + +function friend(uin: number, name = 'Roster'): OidbFriend { + return { + uin, + uid: `u_${uin}`, + customGroup: 7, + additional: [ + { + type: 1, + layer1: { + properties: [ + { code: 20002, value: Buffer.from(name) }, + { code: 102, value: Buffer.from('') }, + ], + }, + }, + ], + }; +} + +function setup(roster: () => OidbFriend[], load: (uin: number) => Promise = async () => {}) { + const bridge = new Bridge(IdentityService.memory('90001')); + bridges.push(bridge); + const profileCalls: number[] = []; + let active = 0; + let peak = 0; + const send = vi.spyOn(bridge, 'sendRawPacket').mockImplementation(async (cmd, payload) => { + let responseData: Uint8Array; + + if (cmd === 'OidbSvcTrpcTcp.0xfd4_1') { + const friends = roster(); + responseData = protobuf_encode>({ + body: { + friends, + categories: [ + { categoryId: 7, categoryName: 'Friends', memberCount: friends.length }, + ], + }, + }); + } else if (cmd === 'OidbSvcTrpcTcp.0xfe1_2') { + const uin = protobuf_decode>(payload).body!.uin!; + profileCalls.push(uin); + peak = Math.max(peak, ++active); + + try { + await load(uin); + } finally { + active--; + } + + responseData = protobuf_encode>({ + body: { + body: { + uin, + uid: `u_${uin}`, + properties: { + bytesProperties: [ + { code: 102, value: Buffer.from('Profile sign') }, + { code: 20020, value: Buffer.from('City') }, + ], + numberProperties: [ + { number1: 20037, number2: 0 }, + { number1: 41756, number2: 0 }, + ], + }, + }, + }, + }); + } else { + throw new Error(`unexpected request ${cmd}`); + } + + return { + success: true, + gotResponse: true, + errorCode: 0, + errorMessage: '', + responseData: Buffer.from(responseData), + }; + }); + return { + bridge, + api: bridge.apis.contacts, + send, + profileCalls, + peak: () => peak, + active: () => active, + }; +} + +describe('explicit friend profile enrichment', () => { + it('does not fan out on a cold 160-friend ordinary or categorized list', async () => { + const { api, profileCalls, send } = setup(() => + Array.from({ length: 160 }, (_, i) => friend(10000 + i)), + ); + expect(await api.fetchFriendList()).toHaveLength(160); + expect((await api.fetchFriendCategories())[0]?.friends).toHaveLength(160); + expect(profileCalls).toEqual([]); + expect(send).toHaveBeenCalledTimes(2); + }); + + it('shares the cache across list forms, preserves roster values and isolates accounts', async () => { + let now = Date.now(); + vi.spyOn(Date, 'now').mockImplementation(() => now); + const one = setup(() => [friend(10001)]); + const first = await one.api.fetchFriendList({ withProfile: true }); + expect(first[0]).toMatchObject({ + nickname: 'Roster', + profile: { sign: '', age: 0, vipFlag: false, city: 'City' }, + }); + first[0]!.profile!.city = 'caller mutation'; + const grouped = await one.api.fetchFriendCategories({ withProfile: true }); + expect(grouped[0]?.friends[0]?.profile?.city).toBe('City'); + expect(one.profileCalls).toEqual([10001]); + const two = setup(() => [friend(10001)]); + await two.api.fetchFriendList({ withProfile: true }); + expect(two.profileCalls).toEqual([10001]); + now += 300_001; + await one.api.fetchFriendList({ withProfile: true }); + expect(one.profileCalls).toEqual([10001, 10001]); + }); + + it('deduplicates overlapping lists and caps total concurrent queries at four', async () => { + const gates = new Map>(); + let page = 0; + const { api, profileCalls, peak } = setup( + () => (page++ === 0 ? [1, 2, 3, 4] : [3, 4, 5, 6]).map((id) => friend(10000 + id)), + (uin) => { + const gate = deferred(); + gates.set(uin, gate); + return gate.promise; + }, + ); + const first = api.fetchFriendList({ withProfile: true }); + const second = api.fetchFriendCategories({ withProfile: true }); + await vi.waitFor(() => expect(profileCalls).toHaveLength(4)); + + for (const gate of gates.values()) { + gate.resolve(); + } + + await vi.waitFor(() => expect(profileCalls).toHaveLength(6)); + + for (const gate of gates.values()) { + gate.resolve(); + } + + await Promise.all([first, second]); + expect(new Set(profileCalls).size).toBe(6); + expect(peak()).toBe(4); + }); + + it('drains a failed batch, surfaces the failure and retries only on a later request', async () => { + const gates = new Map>(); + let blocking = true; + const { api, bridge, profileCalls, active } = setup( + () => [1, 2, 3, 4, 5].map((id) => friend(10000 + id)), + (uin) => { + if (!blocking) { + return Promise.resolve(); + } + + const gate = deferred(); + gates.set(uin, gate); + return gate.promise; + }, + ); + let finished = false; + const result = api.fetchFriendList({ withProfile: true }); + const checked = expect(result).rejects.toThrow('friend profile enrichment failed'); + void result.then( + () => { + finished = true; + }, + () => { + finished = true; + }, + ); + await vi.waitFor(() => expect(profileCalls).toHaveLength(4)); + gates.get(10001)!.reject(new Error('synthetic timeout')); + await Promise.resolve(); + expect(finished).toBe(false); + + for (const [uin, gate] of gates) { + if (uin !== 10001) { + gate.resolve(); + } + } + + await checked; + expect(active()).toBe(0); + expect(profileCalls).toHaveLength(4); + expect(bridge.identity.friends).toEqual([]); + blocking = false; + await api.fetchFriendList({ withProfile: true }); + expect(profileCalls).toEqual([10001, 10002, 10003, 10004, 10001, 10005]); + }); + + it('does not let a slow enriched snapshot replace a newer ordinary roster', async () => { + const gate = deferred(); + let page = 0; + const { api, bridge, profileCalls } = setup( + () => [friend(10001, page++ === 0 ? 'Old' : 'New')], + () => gate.promise, + ); + const old = api.fetchFriendList({ withProfile: true }); + await vi.waitFor(() => expect(profileCalls).toHaveLength(1)); + await api.fetchFriendList(); + gate.resolve(); + expect((await old)[0]?.nickname).toBe('Old'); + expect(bridge.identity.findFriend(10001)?.nickname).toBe('New'); + }); +}); diff --git a/packages/mcp/src/generated/catalog.json b/packages/mcp/src/generated/catalog.json index 34e44618..bb36ca2a 100644 --- a/packages/mcp/src/generated/catalog.json +++ b/packages/mcp/src/generated/catalog.json @@ -6173,7 +6173,7 @@ "name": "get_friend_list", "aliases": [], "summary": "获取好友列表", - "returns": "好友列表数组,每项含 QQ 号、昵称与备注。", + "returns": "好友列表,包含账号、昵称、备注及回包已有的公开资料;with_profile=true 时主动补查,失败会报错。未返回的资料省略,零、空字符串和 false 保留。", "returnsSchema": { "type": "array", "items": { @@ -6190,6 +6190,171 @@ "remark": { "type": "string", "description": "好友备注" + }, + "category_id": { + "type": "integer", + "description": "好友分组编号,有来源时返回" + }, + "long_nick": { + "type": "string", + "description": "个性签名" + }, + "sex": { + "type": "string", + "enum": [ + "male", + "female", + "unknown" + ], + "description": "性别" + }, + "age": { + "type": "integer", + "description": "年龄" + }, + "level": { + "type": "integer", + "description": "QQ 等级" + }, + "birthday_year": { + "type": "integer", + "description": "生日年份,未公开时省略" + }, + "birthday_month": { + "type": "integer", + "description": "生日月份(1-12)" + }, + "birthday_day": { + "type": "integer", + "description": "生日日期(1-31)" + }, + "phone_num": { + "type": "string", + "description": "资料卡公开的电话号码" + }, + "email": { + "type": "string", + "description": "资料卡公开的邮箱" + }, + "country": { + "type": "string", + "description": "国家" + }, + "province": { + "type": "string", + "description": "省份" + }, + "city": { + "type": "string", + "description": "城市" + }, + "college": { + "type": "string", + "description": "学校" + }, + "is_qq_vip": { + "type": "boolean", + "description": "普通会员状态" + }, + "is_vip": { + "type": "boolean", + "description": "超级会员状态,兼容已有接口" + }, + "is_years_vip": { + "type": "boolean", + "description": "年费会员状态" + }, + "vip_level": { + "type": "integer", + "description": "会员等级" + }, + "privilege_icons": { + "type": "object", + "description": "业务图标;未返回该资料时省略整个字段,空列表表示没有该类图标", + "properties": { + "jump_url": { + "type": "string", + "description": "业务列表跳转地址" + }, + "open": { + "type": "array", + "description": "已开通业务", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "业务类型" + }, + "level": { + "type": "integer", + "description": "业务等级" + }, + "flag": { + "type": "integer", + "description": "服务端业务标志" + }, + "is_big": { + "type": "integer", + "description": "服务端大图标标志" + }, + "jump_url": { + "type": "string", + "description": "业务跳转地址" + }, + "icon_url": { + "type": "string", + "description": "普通图标地址" + }, + "deluxe_icon_url": { + "type": "string", + "description": "高级图标地址,可与普通图标同时存在" + } + } + } + }, + "closed": { + "type": "array", + "description": "未开通业务", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "业务类型" + }, + "level": { + "type": "integer", + "description": "业务等级" + }, + "flag": { + "type": "integer", + "description": "服务端业务标志" + }, + "is_big": { + "type": "integer", + "description": "服务端大图标标志" + }, + "jump_url": { + "type": "string", + "description": "业务跳转地址" + }, + "icon_url": { + "type": "string", + "description": "普通图标地址" + }, + "deluxe_icon_url": { + "type": "string", + "description": "高级图标地址,可与普通图标同时存在" + } + } + } + } + }, + "required": [ + "open", + "closed" + ] } }, "required": [ @@ -6206,11 +6371,28 @@ } ], "readOnly": true, - "params": [], + "params": [ + { + "name": "with_profile", + "type": "bool", + "required": false, + "schema": { + "type": "boolean" + }, + "default": false, + "desc": "主动补查好友公开资料,按账号缓存五分钟;大列表可能耗时较长" + } + ], "invariants": [], "inputSchema": { "type": "object", - "properties": {}, + "properties": { + "with_profile": { + "type": "boolean", + "description": "主动补查好友公开资料,按账号缓存五分钟;大列表可能耗时较长", + "default": false + } + }, "additionalProperties": true }, "category": "好友" @@ -6440,7 +6622,7 @@ "name": "get_friends_with_category", "aliases": [], "summary": "获取分组好友列表", - "returns": "好友分组数组;每组包含 categoryId、categoryName、categoryMbCount 和 buddyList。", + "returns": "好友分组数组;buddyList 与 get_friend_list 使用相同资料字段。默认只使用列表回包,with_profile=true 主动补查,失败会报错;未返回的资料省略。", "returnsSchema": { "type": "array", "description": "带分组的好友列表", @@ -6467,15 +6649,180 @@ "properties": { "user_id": { "type": "integer", - "description": "QQ 号" + "description": "好友 QQ 号" }, "nickname": { "type": "string", - "description": "昵称" + "description": "好友昵称" }, "remark": { "type": "string", "description": "好友备注" + }, + "category_id": { + "type": "integer", + "description": "好友分组编号,有来源时返回" + }, + "long_nick": { + "type": "string", + "description": "个性签名" + }, + "sex": { + "type": "string", + "enum": [ + "male", + "female", + "unknown" + ], + "description": "性别" + }, + "age": { + "type": "integer", + "description": "年龄" + }, + "level": { + "type": "integer", + "description": "QQ 等级" + }, + "birthday_year": { + "type": "integer", + "description": "生日年份,未公开时省略" + }, + "birthday_month": { + "type": "integer", + "description": "生日月份(1-12)" + }, + "birthday_day": { + "type": "integer", + "description": "生日日期(1-31)" + }, + "phone_num": { + "type": "string", + "description": "资料卡公开的电话号码" + }, + "email": { + "type": "string", + "description": "资料卡公开的邮箱" + }, + "country": { + "type": "string", + "description": "国家" + }, + "province": { + "type": "string", + "description": "省份" + }, + "city": { + "type": "string", + "description": "城市" + }, + "college": { + "type": "string", + "description": "学校" + }, + "is_qq_vip": { + "type": "boolean", + "description": "普通会员状态" + }, + "is_vip": { + "type": "boolean", + "description": "超级会员状态,兼容已有接口" + }, + "is_years_vip": { + "type": "boolean", + "description": "年费会员状态" + }, + "vip_level": { + "type": "integer", + "description": "会员等级" + }, + "privilege_icons": { + "type": "object", + "description": "业务图标;未返回该资料时省略整个字段,空列表表示没有该类图标", + "properties": { + "jump_url": { + "type": "string", + "description": "业务列表跳转地址" + }, + "open": { + "type": "array", + "description": "已开通业务", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "业务类型" + }, + "level": { + "type": "integer", + "description": "业务等级" + }, + "flag": { + "type": "integer", + "description": "服务端业务标志" + }, + "is_big": { + "type": "integer", + "description": "服务端大图标标志" + }, + "jump_url": { + "type": "string", + "description": "业务跳转地址" + }, + "icon_url": { + "type": "string", + "description": "普通图标地址" + }, + "deluxe_icon_url": { + "type": "string", + "description": "高级图标地址,可与普通图标同时存在" + } + } + } + }, + "closed": { + "type": "array", + "description": "未开通业务", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "业务类型" + }, + "level": { + "type": "integer", + "description": "业务等级" + }, + "flag": { + "type": "integer", + "description": "服务端业务标志" + }, + "is_big": { + "type": "integer", + "description": "服务端大图标标志" + }, + "jump_url": { + "type": "string", + "description": "业务跳转地址" + }, + "icon_url": { + "type": "string", + "description": "普通图标地址" + }, + "deluxe_icon_url": { + "type": "string", + "description": "高级图标地址,可与普通图标同时存在" + } + } + } + } + }, + "required": [ + "open", + "closed" + ] } }, "required": [ @@ -6501,11 +6848,28 @@ } ], "readOnly": true, - "params": [], + "params": [ + { + "name": "with_profile", + "type": "bool", + "required": false, + "schema": { + "type": "boolean" + }, + "default": false, + "desc": "主动补查好友公开资料,按账号缓存五分钟;大列表可能耗时较长" + } + ], "invariants": [], "inputSchema": { "type": "object", - "properties": {}, + "properties": { + "with_profile": { + "type": "boolean", + "description": "主动补查好友公开资料,按账号缓存五分钟;大列表可能耗时较长", + "default": false + } + }, "additionalProperties": true }, "category": "扩展" @@ -12877,6 +13241,162 @@ "returnsSchema": { "type": "object", "properties": { + "long_nick": { + "type": "string", + "description": "个性签名" + }, + "sex": { + "type": "string", + "description": "性别(male/female/unknown)" + }, + "age": { + "type": "integer", + "description": "年龄" + }, + "level": { + "type": "integer", + "description": "QQ 等级,同 qq_level(仅查到资料时返回)" + }, + "birthday_year": { + "type": "integer", + "description": "生日年份,兼容 NapCat;未公开或不可用时省略" + }, + "birthday_month": { + "type": "integer", + "description": "生日月份(1-12),兼容 NapCat;不可用时省略" + }, + "birthday_day": { + "type": "integer", + "description": "生日日期(1-31),兼容 NapCat;不可用时省略" + }, + "phone_num": { + "type": "string", + "description": "资料卡公开的电话号码" + }, + "email": { + "type": "string", + "description": "资料卡公开的邮箱" + }, + "country": { + "type": "string", + "description": "国家" + }, + "province": { + "type": "string", + "description": "省份" + }, + "city": { + "type": "string", + "description": "城市" + }, + "college": { + "type": "string", + "description": "学校" + }, + "is_qq_vip": { + "type": "boolean", + "description": "普通会员状态" + }, + "is_vip": { + "type": "boolean", + "description": "超级会员状态,兼容 NapCat;不可用时省略" + }, + "is_years_vip": { + "type": "boolean", + "description": "年费会员状态,不可用时省略" + }, + "vip_level": { + "type": "integer", + "description": "会员等级,不可用时省略" + }, + "privilege_icons": { + "type": "object", + "description": "业务图标;未返回该资料时省略整个字段,空列表表示没有该类图标", + "properties": { + "jump_url": { + "type": "string", + "description": "业务列表跳转地址" + }, + "open": { + "type": "array", + "description": "已开通业务", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "业务类型" + }, + "level": { + "type": "integer", + "description": "业务等级" + }, + "flag": { + "type": "integer", + "description": "服务端业务标志" + }, + "is_big": { + "type": "integer", + "description": "服务端大图标标志" + }, + "jump_url": { + "type": "string", + "description": "业务跳转地址" + }, + "icon_url": { + "type": "string", + "description": "普通图标地址" + }, + "deluxe_icon_url": { + "type": "string", + "description": "高级图标地址,可与普通图标同时存在" + } + } + } + }, + "closed": { + "type": "array", + "description": "未开通业务", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "业务类型" + }, + "level": { + "type": "integer", + "description": "业务等级" + }, + "flag": { + "type": "integer", + "description": "服务端业务标志" + }, + "is_big": { + "type": "integer", + "description": "服务端大图标标志" + }, + "jump_url": { + "type": "string", + "description": "业务跳转地址" + }, + "icon_url": { + "type": "string", + "description": "普通图标地址" + }, + "deluxe_icon_url": { + "type": "string", + "description": "高级图标地址,可与普通图标同时存在" + } + } + } + } + }, + "required": [ + "open", + "closed" + ] + }, "user_id": { "type": "integer", "description": "QQ 号" @@ -12897,18 +13417,6 @@ "type": "string", "description": "好友备注;非好友或未设置时为空字符串" }, - "sex": { - "type": "string", - "description": "性别(male/female/unknown)" - }, - "age": { - "type": "integer", - "description": "年龄" - }, - "long_nick": { - "type": "string", - "description": "个性签名" - }, "qq_level": { "type": "integer", "description": "QQ 等级(仅查到资料时返回)" @@ -12921,34 +13429,6 @@ "type": "integer", "description": "注册时间戳(秒),不可用时省略" }, - "birthday_year": { - "type": "integer", - "description": "生日年份,兼容 NapCat;未公开或不可用时省略" - }, - "birthday_month": { - "type": "integer", - "description": "生日月份(1-12),兼容 NapCat;不可用时省略" - }, - "birthday_day": { - "type": "integer", - "description": "生日日期(1-31),兼容 NapCat;不可用时省略" - }, - "is_vip": { - "type": "boolean", - "description": "超级会员状态,兼容 NapCat;不可用时省略" - }, - "is_years_vip": { - "type": "boolean", - "description": "年费会员状态,不可用时省略" - }, - "vip_level": { - "type": "integer", - "description": "会员等级,不可用时省略" - }, - "level": { - "type": "integer", - "description": "QQ 等级,同 qq_level(仅查到资料时返回)" - }, "status": { "type": "integer", "description": "在线状态码" diff --git a/packages/mcp/src/generated/catalog.ts b/packages/mcp/src/generated/catalog.ts index e2881c80..01031328 100644 --- a/packages/mcp/src/generated/catalog.ts +++ b/packages/mcp/src/generated/catalog.ts @@ -6176,7 +6176,7 @@ export const ACTIONS: CatalogAction[] = [ "name": "get_friend_list", "aliases": [], "summary": "获取好友列表", - "returns": "好友列表数组,每项含 QQ 号、昵称与备注。", + "returns": "好友列表,包含账号、昵称、备注及回包已有的公开资料;with_profile=true 时主动补查,失败会报错。未返回的资料省略,零、空字符串和 false 保留。", "returnsSchema": { "type": "array", "items": { @@ -6193,6 +6193,171 @@ export const ACTIONS: CatalogAction[] = [ "remark": { "type": "string", "description": "好友备注" + }, + "category_id": { + "type": "integer", + "description": "好友分组编号,有来源时返回" + }, + "long_nick": { + "type": "string", + "description": "个性签名" + }, + "sex": { + "type": "string", + "enum": [ + "male", + "female", + "unknown" + ], + "description": "性别" + }, + "age": { + "type": "integer", + "description": "年龄" + }, + "level": { + "type": "integer", + "description": "QQ 等级" + }, + "birthday_year": { + "type": "integer", + "description": "生日年份,未公开时省略" + }, + "birthday_month": { + "type": "integer", + "description": "生日月份(1-12)" + }, + "birthday_day": { + "type": "integer", + "description": "生日日期(1-31)" + }, + "phone_num": { + "type": "string", + "description": "资料卡公开的电话号码" + }, + "email": { + "type": "string", + "description": "资料卡公开的邮箱" + }, + "country": { + "type": "string", + "description": "国家" + }, + "province": { + "type": "string", + "description": "省份" + }, + "city": { + "type": "string", + "description": "城市" + }, + "college": { + "type": "string", + "description": "学校" + }, + "is_qq_vip": { + "type": "boolean", + "description": "普通会员状态" + }, + "is_vip": { + "type": "boolean", + "description": "超级会员状态,兼容已有接口" + }, + "is_years_vip": { + "type": "boolean", + "description": "年费会员状态" + }, + "vip_level": { + "type": "integer", + "description": "会员等级" + }, + "privilege_icons": { + "type": "object", + "description": "业务图标;未返回该资料时省略整个字段,空列表表示没有该类图标", + "properties": { + "jump_url": { + "type": "string", + "description": "业务列表跳转地址" + }, + "open": { + "type": "array", + "description": "已开通业务", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "业务类型" + }, + "level": { + "type": "integer", + "description": "业务等级" + }, + "flag": { + "type": "integer", + "description": "服务端业务标志" + }, + "is_big": { + "type": "integer", + "description": "服务端大图标标志" + }, + "jump_url": { + "type": "string", + "description": "业务跳转地址" + }, + "icon_url": { + "type": "string", + "description": "普通图标地址" + }, + "deluxe_icon_url": { + "type": "string", + "description": "高级图标地址,可与普通图标同时存在" + } + } + } + }, + "closed": { + "type": "array", + "description": "未开通业务", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "业务类型" + }, + "level": { + "type": "integer", + "description": "业务等级" + }, + "flag": { + "type": "integer", + "description": "服务端业务标志" + }, + "is_big": { + "type": "integer", + "description": "服务端大图标标志" + }, + "jump_url": { + "type": "string", + "description": "业务跳转地址" + }, + "icon_url": { + "type": "string", + "description": "普通图标地址" + }, + "deluxe_icon_url": { + "type": "string", + "description": "高级图标地址,可与普通图标同时存在" + } + } + } + } + }, + "required": [ + "open", + "closed" + ] } }, "required": [ @@ -6209,11 +6374,28 @@ export const ACTIONS: CatalogAction[] = [ } ], "readOnly": true, - "params": [], + "params": [ + { + "name": "with_profile", + "type": "bool", + "required": false, + "schema": { + "type": "boolean" + }, + "default": false, + "desc": "主动补查好友公开资料,按账号缓存五分钟;大列表可能耗时较长" + } + ], "invariants": [], "inputSchema": { "type": "object", - "properties": {}, + "properties": { + "with_profile": { + "type": "boolean", + "description": "主动补查好友公开资料,按账号缓存五分钟;大列表可能耗时较长", + "default": false + } + }, "additionalProperties": true }, "category": "好友" @@ -6443,7 +6625,7 @@ export const ACTIONS: CatalogAction[] = [ "name": "get_friends_with_category", "aliases": [], "summary": "获取分组好友列表", - "returns": "好友分组数组;每组包含 categoryId、categoryName、categoryMbCount 和 buddyList。", + "returns": "好友分组数组;buddyList 与 get_friend_list 使用相同资料字段。默认只使用列表回包,with_profile=true 主动补查,失败会报错;未返回的资料省略。", "returnsSchema": { "type": "array", "description": "带分组的好友列表", @@ -6470,15 +6652,180 @@ export const ACTIONS: CatalogAction[] = [ "properties": { "user_id": { "type": "integer", - "description": "QQ 号" + "description": "好友 QQ 号" }, "nickname": { "type": "string", - "description": "昵称" + "description": "好友昵称" }, "remark": { "type": "string", "description": "好友备注" + }, + "category_id": { + "type": "integer", + "description": "好友分组编号,有来源时返回" + }, + "long_nick": { + "type": "string", + "description": "个性签名" + }, + "sex": { + "type": "string", + "enum": [ + "male", + "female", + "unknown" + ], + "description": "性别" + }, + "age": { + "type": "integer", + "description": "年龄" + }, + "level": { + "type": "integer", + "description": "QQ 等级" + }, + "birthday_year": { + "type": "integer", + "description": "生日年份,未公开时省略" + }, + "birthday_month": { + "type": "integer", + "description": "生日月份(1-12)" + }, + "birthday_day": { + "type": "integer", + "description": "生日日期(1-31)" + }, + "phone_num": { + "type": "string", + "description": "资料卡公开的电话号码" + }, + "email": { + "type": "string", + "description": "资料卡公开的邮箱" + }, + "country": { + "type": "string", + "description": "国家" + }, + "province": { + "type": "string", + "description": "省份" + }, + "city": { + "type": "string", + "description": "城市" + }, + "college": { + "type": "string", + "description": "学校" + }, + "is_qq_vip": { + "type": "boolean", + "description": "普通会员状态" + }, + "is_vip": { + "type": "boolean", + "description": "超级会员状态,兼容已有接口" + }, + "is_years_vip": { + "type": "boolean", + "description": "年费会员状态" + }, + "vip_level": { + "type": "integer", + "description": "会员等级" + }, + "privilege_icons": { + "type": "object", + "description": "业务图标;未返回该资料时省略整个字段,空列表表示没有该类图标", + "properties": { + "jump_url": { + "type": "string", + "description": "业务列表跳转地址" + }, + "open": { + "type": "array", + "description": "已开通业务", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "业务类型" + }, + "level": { + "type": "integer", + "description": "业务等级" + }, + "flag": { + "type": "integer", + "description": "服务端业务标志" + }, + "is_big": { + "type": "integer", + "description": "服务端大图标标志" + }, + "jump_url": { + "type": "string", + "description": "业务跳转地址" + }, + "icon_url": { + "type": "string", + "description": "普通图标地址" + }, + "deluxe_icon_url": { + "type": "string", + "description": "高级图标地址,可与普通图标同时存在" + } + } + } + }, + "closed": { + "type": "array", + "description": "未开通业务", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "业务类型" + }, + "level": { + "type": "integer", + "description": "业务等级" + }, + "flag": { + "type": "integer", + "description": "服务端业务标志" + }, + "is_big": { + "type": "integer", + "description": "服务端大图标标志" + }, + "jump_url": { + "type": "string", + "description": "业务跳转地址" + }, + "icon_url": { + "type": "string", + "description": "普通图标地址" + }, + "deluxe_icon_url": { + "type": "string", + "description": "高级图标地址,可与普通图标同时存在" + } + } + } + } + }, + "required": [ + "open", + "closed" + ] } }, "required": [ @@ -6504,11 +6851,28 @@ export const ACTIONS: CatalogAction[] = [ } ], "readOnly": true, - "params": [], + "params": [ + { + "name": "with_profile", + "type": "bool", + "required": false, + "schema": { + "type": "boolean" + }, + "default": false, + "desc": "主动补查好友公开资料,按账号缓存五分钟;大列表可能耗时较长" + } + ], "invariants": [], "inputSchema": { "type": "object", - "properties": {}, + "properties": { + "with_profile": { + "type": "boolean", + "description": "主动补查好友公开资料,按账号缓存五分钟;大列表可能耗时较长", + "default": false + } + }, "additionalProperties": true }, "category": "扩展" @@ -12880,6 +13244,162 @@ export const ACTIONS: CatalogAction[] = [ "returnsSchema": { "type": "object", "properties": { + "long_nick": { + "type": "string", + "description": "个性签名" + }, + "sex": { + "type": "string", + "description": "性别(male/female/unknown)" + }, + "age": { + "type": "integer", + "description": "年龄" + }, + "level": { + "type": "integer", + "description": "QQ 等级,同 qq_level(仅查到资料时返回)" + }, + "birthday_year": { + "type": "integer", + "description": "生日年份,兼容 NapCat;未公开或不可用时省略" + }, + "birthday_month": { + "type": "integer", + "description": "生日月份(1-12),兼容 NapCat;不可用时省略" + }, + "birthday_day": { + "type": "integer", + "description": "生日日期(1-31),兼容 NapCat;不可用时省略" + }, + "phone_num": { + "type": "string", + "description": "资料卡公开的电话号码" + }, + "email": { + "type": "string", + "description": "资料卡公开的邮箱" + }, + "country": { + "type": "string", + "description": "国家" + }, + "province": { + "type": "string", + "description": "省份" + }, + "city": { + "type": "string", + "description": "城市" + }, + "college": { + "type": "string", + "description": "学校" + }, + "is_qq_vip": { + "type": "boolean", + "description": "普通会员状态" + }, + "is_vip": { + "type": "boolean", + "description": "超级会员状态,兼容 NapCat;不可用时省略" + }, + "is_years_vip": { + "type": "boolean", + "description": "年费会员状态,不可用时省略" + }, + "vip_level": { + "type": "integer", + "description": "会员等级,不可用时省略" + }, + "privilege_icons": { + "type": "object", + "description": "业务图标;未返回该资料时省略整个字段,空列表表示没有该类图标", + "properties": { + "jump_url": { + "type": "string", + "description": "业务列表跳转地址" + }, + "open": { + "type": "array", + "description": "已开通业务", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "业务类型" + }, + "level": { + "type": "integer", + "description": "业务等级" + }, + "flag": { + "type": "integer", + "description": "服务端业务标志" + }, + "is_big": { + "type": "integer", + "description": "服务端大图标标志" + }, + "jump_url": { + "type": "string", + "description": "业务跳转地址" + }, + "icon_url": { + "type": "string", + "description": "普通图标地址" + }, + "deluxe_icon_url": { + "type": "string", + "description": "高级图标地址,可与普通图标同时存在" + } + } + } + }, + "closed": { + "type": "array", + "description": "未开通业务", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "业务类型" + }, + "level": { + "type": "integer", + "description": "业务等级" + }, + "flag": { + "type": "integer", + "description": "服务端业务标志" + }, + "is_big": { + "type": "integer", + "description": "服务端大图标标志" + }, + "jump_url": { + "type": "string", + "description": "业务跳转地址" + }, + "icon_url": { + "type": "string", + "description": "普通图标地址" + }, + "deluxe_icon_url": { + "type": "string", + "description": "高级图标地址,可与普通图标同时存在" + } + } + } + } + }, + "required": [ + "open", + "closed" + ] + }, "user_id": { "type": "integer", "description": "QQ 号" @@ -12900,18 +13420,6 @@ export const ACTIONS: CatalogAction[] = [ "type": "string", "description": "好友备注;非好友或未设置时为空字符串" }, - "sex": { - "type": "string", - "description": "性别(male/female/unknown)" - }, - "age": { - "type": "integer", - "description": "年龄" - }, - "long_nick": { - "type": "string", - "description": "个性签名" - }, "qq_level": { "type": "integer", "description": "QQ 等级(仅查到资料时返回)" @@ -12924,34 +13432,6 @@ export const ACTIONS: CatalogAction[] = [ "type": "integer", "description": "注册时间戳(秒),不可用时省略" }, - "birthday_year": { - "type": "integer", - "description": "生日年份,兼容 NapCat;未公开或不可用时省略" - }, - "birthday_month": { - "type": "integer", - "description": "生日月份(1-12),兼容 NapCat;不可用时省略" - }, - "birthday_day": { - "type": "integer", - "description": "生日日期(1-31),兼容 NapCat;不可用时省略" - }, - "is_vip": { - "type": "boolean", - "description": "超级会员状态,兼容 NapCat;不可用时省略" - }, - "is_years_vip": { - "type": "boolean", - "description": "年费会员状态,不可用时省略" - }, - "vip_level": { - "type": "integer", - "description": "会员等级,不可用时省略" - }, - "level": { - "type": "integer", - "description": "QQ 等级,同 qq_level(仅查到资料时返回)" - }, "status": { "type": "integer", "description": "在线状态码" diff --git a/packages/onebot/CONTACT_PROFILES.md b/packages/onebot/CONTACT_PROFILES.md new file mode 100644 index 00000000..293a374e --- /dev/null +++ b/packages/onebot/CONTACT_PROFILES.md @@ -0,0 +1,51 @@ +# Contact profiles + +`get_friend_list` and `get_friends_with_category` preserve their existing result +shapes and add optional public profile fields to each friend. By default they +only read the paginated roster: they do not issue a profile request for every +friend. Both accept `with_profile: true` to explicitly query additional details. +Large lists may require a longer client timeout. + +Explicit enrichment uses a five-minute cache belonging to the current account, +deduplicates simultaneous queries and limits aggregate profile concurrency to +four. Values already supplied by the roster take precedence, including empty +strings, zero and false. An enrichment failure fails the action after draining +the current batch; it is logged, is not cached and is not automatically retried. +A later call may retry it. The ordinary flat list retains its existing logged +fallback to cached friends when the roster cannot be refreshed. + +Friend rows contain `user_id`, `nickname`, `remark`, and `category_id` when +available. Public fields also appear in `get_stranger_info` when QQ supplies them: + +| Fields | Meaning | +| --- | --- | +| `long_nick`, `sex`, `age`, `level` | Signature, gender, age and QQ level | +| `birthday_year`, `birthday_month`, `birthday_day` | Birthday; a hidden year is omitted | +| `phone_num`, `email` | Contact details made available by the profile | +| `country`, `province`, `city`, `college` | Location and school | +| `is_qq_vip`, `is_vip`, `is_years_vip`, `vip_level` | Ordinary membership, super membership (existing meaning of `is_vip`), annual membership and level | +| `privilege_icons` | `jump_url` if supplied, plus `open` and `closed` icon lists | + +Each privilege entry may contain `type`, `level`, `flag`, `is_big`, `jump_url`, +`icon_url` and `deluxe_icon_url`. The two icon URLs can coexist. Flags retain +their server values; they are not guessed to mean annual or premium membership. +Local downloaded icon paths are not part of the response. + +Missing data is omitted rather than replaced with false, zero or an empty list. +An explicitly returned empty icon-list body produces `open: [], closed: []`; +unavailable icon data omits `privilege_icons`. Existing required stranger fields +and aliases retain their earlier defaults. Malformed optional birthdays are +logged without recording their values; malformed text or privilege payloads +fail decoding. + +SDK calls keep the existing options argument: + +```ts +const friends = await client.getFriendList({ withProfile: true, timeoutMs: 120_000 }); +const categories = await client.call('get_friends_with_category', { with_profile: true }); +``` + +The exported `FriendInfo`, `StrangerInfo`, `PublicProfileFields` and +`ProfilePrivilege` types describe these fields. Returned availability depends +on QQ and the account's access; requesting enrichment does not guarantee every +field is present. diff --git a/packages/onebot/src/actions/contact-profile-schema.ts b/packages/onebot/src/actions/contact-profile-schema.ts new file mode 100644 index 00000000..b7701406 --- /dev/null +++ b/packages/onebot/src/actions/contact-profile-schema.ts @@ -0,0 +1,54 @@ +const privilegeSchema = { + type: 'object', + properties: { + type: { type: 'integer', description: '业务类型' }, + level: { type: 'integer', description: '业务等级' }, + flag: { type: 'integer', description: '服务端业务标志' }, + is_big: { type: 'integer', description: '服务端大图标标志' }, + jump_url: { type: 'string', description: '业务跳转地址' }, + icon_url: { type: 'string', description: '普通图标地址' }, + deluxe_icon_url: { type: 'string', description: '高级图标地址,可与普通图标同时存在' }, + }, +}; + +export const publicProfileProperties = { + long_nick: { type: 'string', description: '个性签名' }, + sex: { type: 'string', enum: ['male', 'female', 'unknown'], description: '性别' }, + age: { type: 'integer', description: '年龄' }, + level: { type: 'integer', description: 'QQ 等级' }, + birthday_year: { type: 'integer', description: '生日年份,未公开时省略' }, + birthday_month: { type: 'integer', description: '生日月份(1-12)' }, + birthday_day: { type: 'integer', description: '生日日期(1-31)' }, + phone_num: { type: 'string', description: '资料卡公开的电话号码' }, + email: { type: 'string', description: '资料卡公开的邮箱' }, + country: { type: 'string', description: '国家' }, + province: { type: 'string', description: '省份' }, + city: { type: 'string', description: '城市' }, + college: { type: 'string', description: '学校' }, + is_qq_vip: { type: 'boolean', description: '普通会员状态' }, + is_vip: { type: 'boolean', description: '超级会员状态,兼容已有接口' }, + is_years_vip: { type: 'boolean', description: '年费会员状态' }, + vip_level: { type: 'integer', description: '会员等级' }, + privilege_icons: { + type: 'object', + description: '业务图标;未返回该资料时省略整个字段,空列表表示没有该类图标', + properties: { + jump_url: { type: 'string', description: '业务列表跳转地址' }, + open: { type: 'array', description: '已开通业务', items: privilegeSchema }, + closed: { type: 'array', description: '未开通业务', items: privilegeSchema }, + }, + required: ['open', 'closed'], + }, +}; + +export const friendResultSchema = { + type: 'object', + properties: { + user_id: { type: 'integer', description: '好友 QQ 号' }, + nickname: { type: 'string', description: '好友昵称' }, + remark: { type: 'string', description: '好友备注' }, + category_id: { type: 'integer', description: '好友分组编号,有来源时返回' }, + ...publicProfileProperties, + }, + required: ['user_id', 'nickname', 'remark'], +}; diff --git a/packages/onebot/src/actions/extended.ts b/packages/onebot/src/actions/extended.ts index e52969aa..d026879c 100644 --- a/packages/onebot/src/actions/extended.ts +++ b/packages/onebot/src/actions/extended.ts @@ -21,6 +21,8 @@ import { } from '../types'; import { defineAction, groupAction, groupUserAction, f, type Field } from '../action-kit'; import { groupInfoReturnsSchema } from './group-info'; +import { friendResultSchema } from './contact-profile-schema'; +import { formatFriend } from '../modules/contact-profile'; import { GROUP_MESSAGE_EVENT, hashMessageIdInt32 } from '../message-id'; import { messageIdSchema, @@ -2647,7 +2649,8 @@ export const actions = [ ], summary: '获取分组好友列表', readOnly: true, - returns: '好友分组数组;每组包含 categoryId、categoryName、categoryMbCount 和 buddyList。', + returns: + '好友分组数组;buddyList 与 get_friend_list 使用相同资料字段。默认只使用列表回包,with_profile=true 主动补查,失败会报错;未返回的资料省略。', returnsSchema: { type: 'array', description: '带分组的好友列表', @@ -2660,33 +2663,30 @@ export const actions = [ buddyList: { type: 'array', description: '该分组内的好友', - items: { - type: 'object', - properties: { - user_id: { type: 'integer', description: 'QQ 号' }, - nickname: { type: 'string', description: '昵称' }, - remark: { type: 'string', description: '好友备注' }, - }, - required: ['user_id', 'nickname', 'remark'], - }, + items: friendResultSchema, }, }, required: ['categoryId', 'categoryName', 'categoryMbCount', 'buddyList'], }, }, - params: {}, - run: async (_p, ctx) => { - const categories = await ctx.bridge.apis.contacts.fetchFriendCategories(); + params: { + with_profile: f + .bool() + .default(false) + .describe('主动补查好友公开资料,按账号缓存五分钟;大列表可能耗时较长'), + }, + run: async (p, ctx) => { + const categories = await ctx.bridge.apis.contacts.fetchFriendCategories({ + withProfile: p.with_profile, + }); return okResponse( categories.map((category) => ({ categoryId: category.categoryId, categoryName: category.categoryName, categoryMbCount: category.memberCount, - buddyList: category.friends.map((friend) => ({ - user_id: friend.uin, - nickname: friend.nickname, - remark: friend.remark, - })), + buddyList: category.friends.map((friend) => + formatFriend(friend, category.categoryId), + ), })), ); }, diff --git a/packages/onebot/src/actions/friend.ts b/packages/onebot/src/actions/friend.ts index af7e4773..1035b72f 100644 --- a/packages/onebot/src/actions/friend.ts +++ b/packages/onebot/src/actions/friend.ts @@ -1,5 +1,6 @@ import { defineAction, f } from '../action-kit'; -import { okResponse } from '../types'; +import { failedResponse, okResponse, RETCODE } from '../types'; +import { friendResultSchema, publicProfileProperties } from './contact-profile-schema'; export const actions = [ defineAction({ @@ -12,23 +13,25 @@ export const actions = [ ], summary: '获取好友列表', readOnly: true, - returns: '好友列表数组,每项含 QQ 号、昵称与备注。', + returns: + '好友列表,包含账号、昵称、备注及回包已有的公开资料;with_profile=true 时主动补查,失败会报错。未返回的资料省略,零、空字符串和 false 保留。', returnsSchema: { type: 'array', - items: { - type: 'object', - properties: { - user_id: { type: 'integer', description: '好友 QQ 号' }, - nickname: { type: 'string', description: '好友昵称' }, - remark: { type: 'string', description: '好友备注' }, - }, - required: ['user_id', 'nickname', 'remark'], - }, + items: friendResultSchema, }, - params: {}, - run: async (_p, ctx) => { + params: { + with_profile: f + .bool() + .default(false) + .describe('主动补查好友公开资料,按账号缓存五分钟;大列表可能耗时较长'), + }, + run: async (p, ctx) => { if (ctx.getFriendList) { - return okResponse(await ctx.getFriendList()); + return okResponse(await ctx.getFriendList(p.with_profile)); + } + + if (p.with_profile) { + return failedResponse(RETCODE.ACTION_FAILED, 'friend profile provider unavailable'); } return okResponse([]); @@ -53,6 +56,7 @@ export const actions = [ returnsSchema: { type: 'object', properties: { + ...publicProfileProperties, user_id: { type: 'integer', description: 'QQ 号' }, uid: { type: 'string', description: '账号唯一标识(仅查到资料时返回)' }, qid: { type: 'string', description: '自定义账号标识(仅查到资料时返回)' }, diff --git a/packages/onebot/src/api-handler.ts b/packages/onebot/src/api-handler.ts index 63614bd2..8e16d453 100644 --- a/packages/onebot/src/api-handler.ts +++ b/packages/onebot/src/api-handler.ts @@ -76,7 +76,7 @@ export interface ApiActionContext { deleteMessage: (messageId: number, meta: MessageMeta) => Promise; canSendImage: () => boolean; canSendRecord: () => boolean; - getFriendList: () => Promise; + getFriendList: (withProfile?: boolean) => Promise; getGroupList: (noCache?: boolean) => Promise; getGroupInfo: (groupId: number, noCache?: boolean) => Promise; getGroupMemberList: (groupId: number, noCache?: boolean) => Promise; diff --git a/packages/onebot/src/instance-context.ts b/packages/onebot/src/instance-context.ts index fb495174..98b0b21f 100644 --- a/packages/onebot/src/instance-context.ts +++ b/packages/onebot/src/instance-context.ts @@ -100,7 +100,7 @@ export function buildApiContext(ref: OneBotInstanceContext): ApiActionContext { sendGroupMessage: (groupId, message, autoEscape) => sendGroupMessage(ref, groupId, message, autoEscape), deleteMessage: (_messageId, meta) => deleteMessage(bridge, meta), - getFriendList: () => getFriendList(bridge), + getFriendList: (withProfile) => getFriendList(bridge, withProfile), getGroupList: (noCache) => getGroupList(bridge, noCache), getGroupInfo: (groupId, noCache) => getGroupInfo(bridge, groupId, noCache), getGroupMemberList: (groupId, noCache) => getGroupMemberList(bridge, groupId, noCache), diff --git a/packages/onebot/src/modules/contact-actions.ts b/packages/onebot/src/modules/contact-actions.ts index 82b025ce..098eb455 100644 --- a/packages/onebot/src/modules/contact-actions.ts +++ b/packages/onebot/src/modules/contact-actions.ts @@ -15,6 +15,7 @@ import { import type { QidianCorpInfo } from '@snowluma/protocol/services/qidian/fetch-corp-info'; import type { OneBotInstanceContext } from '../instance-context'; import type { JsonObject } from '../types'; +import { formatFriend, formatPublicProfile } from './contact-profile'; const REQUESTER_PROFILE_CONCURRENCY = 4; const log = createLogger('OneBot.Contacts'); @@ -50,21 +51,28 @@ async function fetchSingleGroupMembers( } } -function toFriendRow(friend: { uin: number; nickname: string; remark: string }): JsonObject { - return { - user_id: friend.uin, - nickname: friend.nickname, - remark: friend.remark, - }; -} - -export async function getFriendList(bridge: BridgeInterface): Promise { +export async function getFriendList( + bridge: BridgeInterface, + withProfile = false, +): Promise { let rows: JsonObject[]; try { - rows = (await bridge.apis.contacts.fetchFriendList()).map(toFriendRow); - } catch { - rows = bridge.identity.friends.map(toFriendRow); + rows = (await bridge.apis.contacts.fetchFriendList({ withProfile })).map((friend) => + formatFriend(friend), + ); + } catch (error) { + log.warn( + 'friend list refresh failed: withProfile=%s error=%s', + withProfile, + error instanceof Error ? error.message : String(error), + ); + + if (withProfile) { + throw error; + } + + rows = bridge.identity.friends.map((friend) => formatFriend(friend)); } const occupied = new Set(rows.map((row) => Number(row.user_id))); @@ -394,6 +402,8 @@ function formatStrangerInfo(p: UserProfileInfo, corp?: QidianCorpInfo | null): J ...(p.svipFlag !== undefined ? { is_vip: p.svipFlag } : {}), ...(p.yearVipFlag !== undefined ? { is_years_vip: p.yearVipFlag } : {}), ...(p.vipLevel !== undefined ? { vip_level: p.vipLevel } : {}), + ...(p.vipFlag !== undefined ? { is_qq_vip: p.vipFlag } : {}), + ...formatPublicProfile(p.profile), status: p.status ?? 0, extStatus: p.extStatus ?? 0, ext_status: p.extStatus ?? 0, @@ -446,7 +456,12 @@ export async function getStrangerInfo( const p = await bridge.apis.contacts.fetchUserProfile(userId); const corp = await strangerCorpInfo(bridge, p); return formatStrangerInfo(p, corp); - } catch { + } catch (error) { + log.warn( + 'user profile refresh failed, checking cached profile: user=%d error=%s', + userId, + error instanceof Error ? error.message : String(error), + ); const p = bridge.identity.findUserProfile(userId); if (p) { diff --git a/packages/onebot/src/modules/contact-profile.ts b/packages/onebot/src/modules/contact-profile.ts new file mode 100644 index 00000000..66a85c19 --- /dev/null +++ b/packages/onebot/src/modules/contact-profile.ts @@ -0,0 +1,64 @@ +import type { FriendInfo, ProfilePrivilege, PublicProfileFields } from '@snowluma/protocol/qq-info'; +import type { JsonObject } from '../types'; + +function formatPrivilege(value: ProfilePrivilege): JsonObject { + return { + ...(value.type !== undefined ? { type: value.type } : {}), + ...(value.level !== undefined ? { level: value.level } : {}), + ...(value.flag !== undefined ? { flag: value.flag } : {}), + ...(value.isBig !== undefined ? { is_big: value.isBig } : {}), + ...(value.jumpUrl !== undefined ? { jump_url: value.jumpUrl } : {}), + ...(value.iconUrl !== undefined ? { icon_url: value.iconUrl } : {}), + ...(value.deluxeIconUrl !== undefined ? { deluxe_icon_url: value.deluxeIconUrl } : {}), + }; +} + +export function formatPublicProfile(profile: PublicProfileFields = {}): JsonObject { + const result: JsonObject = {}; + + for (const [source, target] of [ + ['sign', 'long_nick'], + ['sex', 'sex'], + ['age', 'age'], + ['level', 'level'], + ['birthdayYear', 'birthday_year'], + ['birthdayMonth', 'birthday_month'], + ['birthdayDay', 'birthday_day'], + ['phoneNum', 'phone_num'], + ['email', 'email'], + ['country', 'country'], + ['province', 'province'], + ['city', 'city'], + ['college', 'college'], + ['vipFlag', 'is_qq_vip'], + ['svipFlag', 'is_vip'], + ['yearVipFlag', 'is_years_vip'], + ['vipLevel', 'vip_level'], + ] as const) { + if (profile[source] !== undefined) { + result[target] = profile[source]; + } + } + + if (profile.privilegeIcons !== undefined) { + result.privilege_icons = { + ...(profile.privilegeIcons.jumpUrl !== undefined + ? { jump_url: profile.privilegeIcons.jumpUrl } + : {}), + open: profile.privilegeIcons.open.map(formatPrivilege), + closed: profile.privilegeIcons.closed.map(formatPrivilege), + }; + } + + return result; +} + +export function formatFriend(friend: FriendInfo, categoryId = friend.categoryId): JsonObject { + return { + user_id: friend.uin, + nickname: friend.nickname, + remark: friend.remark, + ...(categoryId !== undefined ? { category_id: categoryId } : {}), + ...formatPublicProfile(friend.profile), + }; +} diff --git a/packages/onebot/tests/contact-actions.test.ts b/packages/onebot/tests/contact-actions.test.ts index 7159486e..a61d0147 100644 --- a/packages/onebot/tests/contact-actions.test.ts +++ b/packages/onebot/tests/contact-actions.test.ts @@ -180,6 +180,69 @@ function makeGroupRequest(overrides: Partial = {}): GroupReque }; } +describe('public contact profile output', () => { + const profile = { + sign: '', + age: 0, + level: 0, + sex: 'unknown' as const, + vipFlag: false, + svipFlag: false, + country: 'CN', + city: 'City', + email: '', + birthdayMonth: 2, + birthdayDay: 29, + privilegeIcons: { + open: [{ type: 1, iconUrl: 'normal', deluxeIconUrl: 'deluxe' }], + closed: [], + }, + }; + const expected = { + long_nick: '', + age: 0, + level: 0, + sex: 'unknown', + is_qq_vip: false, + is_vip: false, + country: 'CN', + city: 'City', + email: '', + birthday_month: 2, + birthday_day: 29, + privilege_icons: { + open: [{ type: 1, icon_url: 'normal', deluxe_icon_url: 'deluxe' }], + closed: [], + }, + }; + + it('returns the same public fields for friends and stranger profiles without invented values', async () => { + const fetchFriendList = vi.fn(async () => [ + { ...makeFriend(123, 'Name'), categoryId: 0, profile }, + ]); + const bridge = fakeBridge({ + fetchFriendList, + fetchUserProfile: vi.fn(async () => ({ ...makeProfile(123, 'Name'), profile })), + }); + const friends = await getFriendList(bridge, true); + expect(friends[0]).toMatchObject({ user_id: 123, category_id: 0, ...expected }); + expect(friends[0]).not.toHaveProperty('phone_num'); + expect(friends[0]).not.toHaveProperty('birthday_year'); + expect(fetchFriendList).toHaveBeenCalledWith({ withProfile: true }); + expect(await getStrangerInfo(bridge, 123)).toMatchObject(expected); + }); + + it('does not mask explicit enrichment failures with cached friends', async () => { + const bridge = fakeBridge({ + fetchFriendList: vi.fn().mockRejectedValue(new Error('profile failed')), + identity: fakeIdentity({ friends: [makeFriend(123, 'Cached')] }), + }); + await expect(getFriendList(bridge, true)).rejects.toThrow('profile failed'); + // The legacy ordinary-list cache behavior remains available. + expect((await getFriendList(bridge))[0]?.nickname).toBe('Cached'); + }); +}); + // ─── Tests ─── describe('onebot/contact-actions / getLoginInfo', () => { diff --git a/packages/onebot/tests/extended-actions.test.ts b/packages/onebot/tests/extended-actions.test.ts index b9443e21..e18dc157 100644 --- a/packages/onebot/tests/extended-actions.test.ts +++ b/packages/onebot/tests/extended-actions.test.ts @@ -828,6 +828,56 @@ describe('extended-actions / get_essence_msg_list', () => { }); describe('extended-actions / get_friends_with_category', () => { + it('forwards explicit enrichment and uses the same public field names', async () => { + const fetchFriendCategories = vi.fn(async () => [ + { + categoryId: 0, + categoryName: 'Friends', + memberCount: 1, + sortId: 0, + friends: [ + { + uin: 10001, + uid: 'u1', + nickname: 'Name', + remark: '', + profile: { + sign: '', + age: 0, + vipFlag: false, + privilegeIcons: { open: [], closed: [] }, + }, + }, + ], + }, + ]); + const handler = makeHandler(fakeCtx(fakeBridge({ fetchFriendCategories }))); + expect( + await handler.handle('get_friends_with_category', { with_profile: true }), + ).toMatchObject({ + status: 'ok', + data: [ + { + buddyList: [ + { + user_id: 10001, + category_id: 0, + long_nick: '', + age: 0, + is_qq_vip: false, + privilege_icons: { open: [], closed: [] }, + }, + ], + }, + ], + }); + expect(fetchFriendCategories).toHaveBeenCalledWith({ withProfile: true }); + expect( + await handler.handle('get_friends_with_category', { with_profile: 'invalid' }), + ).toMatchObject({ status: 'failed' }); + expect(fetchFriendCategories).toHaveBeenCalledOnce(); + }); + it('returns the exact categorized friend contract', async () => { const fetchFriendCategories = vi.fn(async () => [ { @@ -848,7 +898,7 @@ describe('extended-actions / get_friends_with_category', () => { categoryId: 7, categoryName: 'Work', categoryMbCount: 1, - buddyList: [{ user_id: 10001, nickname: 'Alice', remark: 'A' }], + buddyList: [{ user_id: 10001, nickname: 'Alice', remark: 'A', category_id: 7 }], }, ]); }); diff --git a/packages/onebot/tests/friend-actions.test.ts b/packages/onebot/tests/friend-actions.test.ts index 6a901ece..af5a69a5 100644 --- a/packages/onebot/tests/friend-actions.test.ts +++ b/packages/onebot/tests/friend-actions.test.ts @@ -1,9 +1,26 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ApiActionContext } from '../src/api-handler'; import { actions } from '../src/actions/friend'; const getStrangerInfo = actions.find((action) => action.names[0] === 'get_stranger_info'); +describe('get_friend_list profile option', () => { + it('defaults off, validates the option and forwards explicit enrichment', async () => { + const action = actions.find((entry) => entry.names[0] === 'get_friend_list')!; + const getFriendList = vi.fn(async () => []); + const handler = action.toHandler({ getFriendList } as unknown as ApiActionContext); + await handler({}); + expect(getFriendList).toHaveBeenLastCalledWith(false); + await handler({ with_profile: true }); + expect(getFriendList).toHaveBeenLastCalledWith(true); + expect(await handler({ with_profile: 'invalid' })).toMatchObject({ status: 'failed' }); + expect(getFriendList).toHaveBeenCalledTimes(2); + expect( + await action.toHandler({} as ApiActionContext)({ with_profile: true }), + ).toMatchObject({ status: 'failed', wording: 'friend profile provider unavailable' }); + }); +}); + if (!getStrangerInfo) { throw new Error('get_stranger_info action missing'); } diff --git a/packages/onebot/tests/sdk-action-params.test.ts b/packages/onebot/tests/sdk-action-params.test.ts index ecb5f922..e8a764e7 100644 --- a/packages/onebot/tests/sdk-action-params.test.ts +++ b/packages/onebot/tests/sdk-action-params.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { actionParams } from '../../sdk/tests/fixtures/action-contracts'; import { actions as extended } from '../src/actions/extended'; +import { actions as friend } from '../src/actions/friend'; import { actions as albums } from '../src/actions/group-album'; import { actions as groupAdmin } from '../src/actions/group-admin'; import { actions as qzone } from '../src/actions/qzone'; @@ -9,6 +10,7 @@ import { actions as streamDownload } from '../src/actions/stream-download'; import { actions as streamFile } from '../src/actions/stream-file'; const actions = [ + ...friend, ...extended, ...albums, ...groupAdmin, diff --git a/packages/proto-defs/src/oidb-actions/base.ts b/packages/proto-defs/src/oidb-actions/base.ts index a7547ba6..f6759483 100644 --- a/packages/proto-defs/src/oidb-actions/base.ts +++ b/packages/proto-defs/src/oidb-actions/base.ts @@ -277,6 +277,26 @@ export interface OidbUserInfoResponseBody { export interface OidbUserInfoResponse { body?: pb<1, OidbUserInfoResponseBody>; } + +// Profile property 107 contains separate opened and closed privilege lists. +// The two icon URLs are independent fields, not a oneof. +export interface OidbProfilePrivilege { + type?: pb<1, int_32>; + level?: pb<4, int_32>; + flag?: pb<5, int_32>; + iconUrl?: pb<6, bytes>; + deluxeIconUrl?: pb<7, bytes>; + jumpUrl?: pb<8, bytes>; + isBig?: pb<9, int_32>; +} +export interface OidbProfilePrivilegeLists { + jumpUrl?: pb<2, bytes>; + open?: pb_repeated<3, OidbProfilePrivilege>; + closed?: pb_repeated<4, OidbProfilePrivilege>; +} +export interface OidbProfilePrivileges { + lists?: pb<3, OidbProfilePrivilegeLists>; +} export interface AvatarInfo { url?: pb<5, string>; } diff --git a/packages/proto-defs/src/oidb.ts b/packages/proto-defs/src/oidb.ts index 7bdec830..8fe59eb4 100644 --- a/packages/proto-defs/src/oidb.ts +++ b/packages/proto-defs/src/oidb.ts @@ -32,10 +32,16 @@ export interface OidbEmpty {} // Oidb.0xFD4_1 Friend list export interface OidbFriendProperty { code?: pb<1, uint_32>; - value?: pb<2, string>; + value?: pb<2, bytes>; +} + +export interface OidbFriendNumberProperty { + key?: pb<1, uint_32>; + value?: pb<2, int_32>; } export interface OidbFriendLayer1 { + numberProperties?: pb_repeated<1, OidbFriendNumberProperty>; properties?: pb_repeated<2, OidbFriendProperty>; } diff --git a/packages/protocol/src/oidb-services/contacts/fetch-friend-list-page.ts b/packages/protocol/src/oidb-services/contacts/fetch-friend-list-page.ts index e7822378..00efeecf 100644 --- a/packages/protocol/src/oidb-services/contacts/fetch-friend-list-page.ts +++ b/packages/protocol/src/oidb-services/contacts/fetch-friend-list-page.ts @@ -14,6 +14,7 @@ import type { OidbFriendListRequest } from '@snowluma/proto-defs/oidb-actions/ba import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; import type { FriendInfo } from '../../qq-info'; import { invokeOidb, type OidbSender } from '../../oidb-service'; +import { decodePublicProfile, PUBLIC_PROFILE_KEYS } from './public-profile'; export interface FriendListPageEntry { friend: FriendInfo; @@ -33,8 +34,9 @@ export interface FriendListPage { cookie?: Uint8Array; } -function friendProperties(raw: OidbFriend): Map { - const props = new Map(); +function friendProperties(raw: OidbFriend) { + const props = new Map(); + const numbers = new Map(); for (const additional of raw.additional ?? []) { if ((additional.type ?? 0) !== 1 || !additional.layer1) { @@ -42,22 +44,31 @@ function friendProperties(raw: OidbFriend): Map { } for (const property of additional.layer1.properties ?? []) { - props.set(property.code ?? 0, property.value ?? ''); + props.set(property.code ?? 0, property.value ?? new Uint8Array(0)); + } + + for (const property of additional.layer1.numberProperties ?? []) { + numbers.set(property.key ?? 0, property.value ?? 0); } } - return props; + return { props, numbers }; } function mapFriend(raw: OidbFriend): FriendListPageEntry { - const props = friendProperties(raw); + const { props, numbers } = friendProperties(raw); + const profile = decodePublicProfile(props, numbers); return { categoryId: raw.customGroup ?? 0, friend: { uin: raw.uin ?? 0, uid: raw.uid ?? '', - nickname: props.get(20002) ?? String(raw.uin ?? 0), - remark: props.get(103) ?? '', + nickname: props.has(20002) + ? Buffer.from(props.get(20002)!).toString('utf8') + : String(raw.uin ?? 0), + remark: Buffer.from(props.get(103) ?? []).toString('utf8'), + categoryId: raw.customGroup ?? 0, + ...(Object.keys(profile).length ? { profile } : {}), }, }; } @@ -80,7 +91,12 @@ export namespace FetchFriendListPage { field6: 1, field7: 0x7fffffff, body: [ - { type: 1, number: { numbers: [103, 102, 20002, 27394] } }, + { + type: 1, + number: { + numbers: [...new Set([103, 102, 20002, 27394, ...PUBLIC_PROFILE_KEYS])], + }, + }, { type: 4, number: { numbers: [100, 101, 102] } }, ], field10002: [13578, 13579, 13573, 13572, 13568], diff --git a/packages/protocol/src/oidb-services/contacts/fetch-user-profile-by-uid.ts b/packages/protocol/src/oidb-services/contacts/fetch-user-profile-by-uid.ts index 3a3d480d..5ff401c2 100644 --- a/packages/protocol/src/oidb-services/contacts/fetch-user-profile-by-uid.ts +++ b/packages/protocol/src/oidb-services/contacts/fetch-user-profile-by-uid.ts @@ -24,12 +24,30 @@ import type { import type { UserProfileInfo } from '../../qq-info'; import { applyBirthday, applyOnlineStatus, applyProfileDetails } from './fetch-user-profile'; import { invokeOidb, type OidbSender } from '../../oidb-service'; +import { decodePublicProfile, PUBLIC_PROFILE_KEYS } from './public-profile'; const REQUESTED_KEYS = [ - 20002, 27394, 20009, 20031, 101, 103, 102, 20020, 20003, 20026, 105, 27372, 27406, 20037, 41756, - 41757, 42241, + 20002, + 27394, + 20009, + 20031, + 101, + 103, + 102, + 20020, + 20003, + 20026, + 105, + 27372, + 27406, + 20037, + 41756, + 41757, + 42241, // 企点标志(QQ 企点 / 企业版 QQ 账号):员工号实测为 1,普通账号为 0 - 40410, 42031, + 40410, + 42031, + ...PUBLIC_PROFILE_KEYS, ]; export namespace FetchUserProfileByUid { @@ -47,7 +65,7 @@ export namespace FetchUserProfileByUid { export const serialize = (_ctx: Deps, p: Params): OidbUserInfoByUidRequest => ({ uid: p.uid, - keys: REQUESTED_KEYS.map((k) => ({ key: k })), + keys: [...new Set(REQUESTED_KEYS)].map((k) => ({ key: k })), }); export const deserialize = (_ctx: Deps, body: OidbUserInfoResponse): UserProfileInfo => { @@ -110,6 +128,12 @@ export namespace FetchUserProfileByUid { applyOnlineStatus(info, bytesMap, numMap); applyProfileDetails(info, numMap); applyBirthday(info, bytesMap); + const profile = decodePublicProfile(bytesMap, numMap); + + if (Object.keys(profile).length > 0) { + info.profile = profile; + } + // 企点标志:40410 / 42031,服务器仅在返回时才出现(普通账号缺省 → 0) info.qidianMasterFlag = numMap.get(42031) ?? 0; info.qidianCrewFlag = numMap.get(40410) ?? 0; diff --git a/packages/protocol/src/oidb-services/contacts/fetch-user-profile.ts b/packages/protocol/src/oidb-services/contacts/fetch-user-profile.ts index 3b07e07f..9f804e86 100644 --- a/packages/protocol/src/oidb-services/contacts/fetch-user-profile.ts +++ b/packages/protocol/src/oidb-services/contacts/fetch-user-profile.ts @@ -29,6 +29,7 @@ import type { import type { UserProfileInfo } from '../../qq-info'; import { unpackStatusWord } from '../extras/get-stranger-status'; import { invokeOidb, type OidbSender } from '../../oidb-service'; +import { decodeMembership, decodePublicProfile, PUBLIC_PROFILE_KEYS } from './public-profile'; export function applyOnlineStatus( info: UserProfileInfo, @@ -63,27 +64,7 @@ export function applyProfileDetails(info: UserProfileInfo, numMap: Map ({ uin: p.uin, - keys: REQUESTED_KEYS.map((k) => ({ key: k })), + keys: [...new Set(REQUESTED_KEYS)].map((k) => ({ key: k })), }); /** Defaults the response uin to `requestedUin` because the server @@ -154,6 +152,10 @@ export namespace FetchUserProfile { throw new Error('user info response body missing'); } + if (requestedUin > 0 && body.body.uin != null && body.body.uin !== requestedUin) { + throw new Error('profile response does not match the requested account'); + } + const info: UserProfileInfo = { uin: body.body.uin ?? requestedUin, uid: body.body.uid ?? '', @@ -209,6 +211,12 @@ export namespace FetchUserProfile { applyOnlineStatus(info, bytesMap, numMap); applyProfileDetails(info, numMap); applyBirthday(info, bytesMap); + const profile = decodePublicProfile(bytesMap, numMap); + + if (Object.keys(profile).length > 0) { + info.profile = profile; + } + // 企点标志:40410 / 42031,服务器仅在返回时才出现(普通账号缺省 → 0) info.qidianMasterFlag = numMap.get(42031) ?? 0; info.qidianCrewFlag = numMap.get(40410) ?? 0; diff --git a/packages/protocol/src/oidb-services/contacts/public-profile.ts b/packages/protocol/src/oidb-services/contacts/public-profile.ts new file mode 100644 index 00000000..4b148c50 --- /dev/null +++ b/packages/protocol/src/oidb-services/contacts/public-profile.ts @@ -0,0 +1,194 @@ +import { protobuf_decode, protobuf_getUnknownFields } from '@snowluma/proton'; +import { createLogger } from '@snowluma/common/logger'; +import type { + OidbProfilePrivileges, + OidbProfilePrivilege, +} from '@snowluma/proto-defs/oidb-actions/base'; +import type { ProfilePrivilege, PublicProfileFields } from '../../qq-info'; + +// Shared by the roster and both profile request forms. +export const PUBLIC_PROFILE_KEYS = [ + 102, 105, 107, 20003, 20004, 20020, 20009, 20011, 20016, 20021, 20031, 20037, 41756, 41757, + 42241, +]; +const log = createLogger('Protocol.Profile'); + +function text(value: Uint8Array, code: number): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(value); + } catch (cause) { + throw new Error(`invalid UTF-8 in profile property ${code}`, { cause }); + } +} + +export function decodeBirthday( + raw: Uint8Array, +): Pick { + if (raw.length !== 4) { + throw new Error('invalid birthday length in profile property 20031'); + } + + const year = (raw[0]! << 8) | raw[1]!; + const month = raw[2]!; + const day = raw[3]!; + + if (month === 0 && day === 0) { + return {}; + } + + const date = new Date(0); + date.setUTCFullYear(year || 2000, month - 1, day); + + if (year > 9999 || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) { + throw new Error('invalid date in profile property 20031'); + } + + return { + ...(year > 0 ? { birthdayYear: year } : {}), + birthdayMonth: month, + birthdayDay: day, + }; +} + +function privilege(entry: OidbProfilePrivilege): ProfilePrivilege { + checkKnownFields(entry, [1, 4, 5, 6, 7, 8, 9]); + return { + ...(entry.type != null ? { type: entry.type } : {}), + ...(entry.level != null ? { level: entry.level } : {}), + ...(entry.flag != null ? { flag: entry.flag } : {}), + ...(entry.isBig != null ? { isBig: entry.isBig } : {}), + ...(entry.jumpUrl != null ? { jumpUrl: text(entry.jumpUrl, 107) } : {}), + ...(entry.iconUrl != null ? { iconUrl: text(entry.iconUrl, 107) } : {}), + ...(entry.deluxeIconUrl != null ? { deluxeIconUrl: text(entry.deluxeIconUrl, 107) } : {}), + }; +} + +function checkKnownFields(value: unknown, tags: readonly number[]): void { + for (const field of protobuf_getUnknownFields(value)) { + if (tags.includes(field.fieldNumber)) { + throw new Error(`unexpected wire type for privilege field ${field.fieldNumber}`); + } + } +} + +export function decodeMembership( + numbers: ReadonlyMap, +): Pick { + const result: Pick = + {}; + const flags = numbers.get(41756); + + if (flags !== undefined) { + result.vipFlag = (flags & 1) !== 0; + result.yearVipFlag = (flags & 2) !== 0; + result.svipFlag = (flags & 0x100) !== 0; + } + + const level = numbers.get(41757); + + if (level !== undefined) { + const mask = level & 0xfff; + + if (mask !== 0) { + result.vipLevel = 33 - Math.clz32(mask); + } else if ( + result.vipFlag || + result.yearVipFlag || + result.svipFlag || + numbers.get(42241) === 1 + ) { + result.vipLevel = 1; + } else if (flags !== undefined && numbers.has(42241)) { + result.vipLevel = 0; + } + } + + return result; +} + +export function decodePublicProfile( + bytes: ReadonlyMap, + numbers: ReadonlyMap, +): PublicProfileFields { + const result: PublicProfileFields = {}; + + for (const [code, field] of [ + [102, 'sign'], + [20011, 'email'], + [20016, 'phoneNum'], + [20003, 'country'], + [20004, 'province'], + [20020, 'city'], + [20021, 'college'], + ] as const) { + const value = bytes.get(code); + + if (value !== undefined) { + result[field] = text(value, code); + } + } + + for (const [code, field] of [ + [20037, 'age'], + [105, 'level'], + ] as const) { + const value = numbers.get(code); + + if (value !== undefined && Number.isInteger(value) && value >= 0) { + result[field] = value; + } + } + + const sex = numbers.get(20009); + + if (sex !== undefined) { + result.sex = sex === 1 ? 'male' : sex === 2 ? 'female' : 'unknown'; + } + + const birthday = bytes.get(20031); + + if (birthday !== undefined && birthday.length > 0) { + // Keep the existing optional-birthday behavior, but make malformed + // upstream values diagnosable without recording personal data. + try { + Object.assign(result, decodeBirthday(birthday)); + } catch (error) { + log.warn( + 'birthday unavailable: bytes=%d error=%s', + birthday.length, + error instanceof Error ? error.message : String(error), + ); + } + } + + Object.assign(result, decodeMembership(numbers)); + + const icons = bytes.get(107); + + if (icons !== undefined) { + try { + const decoded = protobuf_decode(icons); + checkKnownFields(decoded, [3]); + + if (!decoded.lists) { + // The client treats an absent list body as unavailable, not + // as a returned list containing zero entries. + return result; + } + + checkKnownFields(decoded.lists, [2, 3, 4]); + + result.privilegeIcons = { + ...(decoded.lists.jumpUrl != null + ? { jumpUrl: text(decoded.lists.jumpUrl, 107) } + : {}), + open: (decoded.lists.open ?? []).map(privilege), + closed: (decoded.lists.closed ?? []).map(privilege), + }; + } catch (cause) { + throw new Error('invalid privilege data in profile property 107', { cause }); + } + } + + return result; +} diff --git a/packages/protocol/src/qq-info.ts b/packages/protocol/src/qq-info.ts index 661407b5..81b7ec30 100644 --- a/packages/protocol/src/qq-info.ts +++ b/packages/protocol/src/qq-info.ts @@ -1,3 +1,39 @@ +export interface ProfilePrivilege { + type?: number; + level?: number; + flag?: number; + isBig?: number; + jumpUrl?: string; + iconUrl?: string; + deluxeIconUrl?: string; +} + +/** Only fields actually supplied by the profile service; absent values stay absent. */ +export interface PublicProfileFields { + sign?: string; + sex?: 'male' | 'female' | 'unknown'; + age?: number; + level?: number; + birthdayYear?: number; + birthdayMonth?: number; + birthdayDay?: number; + phoneNum?: string; + email?: string; + country?: string; + province?: string; + city?: string; + college?: string; + vipFlag?: boolean; + svipFlag?: boolean; + yearVipFlag?: boolean; + vipLevel?: number; + privilegeIcons?: { + jumpUrl?: string; + open: ProfilePrivilege[]; + closed: ProfilePrivilege[]; + }; +} + export interface UserProfileInfo { uin: number; uid: string; @@ -12,6 +48,7 @@ export interface UserProfileInfo { * Already requested in `fetchUserProfile` keys[]; LagrangeV2 * `FetchStrangerService.cs` confirms `// Level`. */ level: number; + profile?: PublicProfileFields; regTime?: number; /** * 生日 — OIDB 0xFE1_2 bytes-property key 20031。 @@ -46,6 +83,8 @@ export interface FriendInfo { uid: string; nickname: string; remark: string; + categoryId?: number; + profile?: PublicProfileFields; } export interface FriendCategoryInfo { diff --git a/packages/protocol/tests/oidb-services/contacts/fetch-friend-list-page.test.ts b/packages/protocol/tests/oidb-services/contacts/fetch-friend-list-page.test.ts index 5222fb2f..defb5dec 100644 --- a/packages/protocol/tests/oidb-services/contacts/fetch-friend-list-page.test.ts +++ b/packages/protocol/tests/oidb-services/contacts/fetch-friend-list-page.test.ts @@ -63,10 +63,18 @@ describe('FetchFriendListPage namespace', () => { expect(out.nextUin).toBeUndefined(); }); - it('emits the verbatim property request list (codes 100/101/102/103/20002/27394)', () => { + 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] } }, + { + 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] } }, ]); }); @@ -92,8 +100,8 @@ describe('FetchFriendListPage namespace', () => { type: 1, layer1: { properties: [ - { code: 20002, value: 'Alice' }, - { code: 103, value: 'A' }, + { code: 20002, value: Buffer.from('Alice') }, + { code: 103, value: Buffer.from('A') }, ], }, }, @@ -114,7 +122,13 @@ describe('FetchFriendListPage namespace', () => { entries: [ { categoryId: 7, - friend: { uin: 10001, uid: 'u1', nickname: 'Alice', remark: 'A' }, + friend: { + uin: 10001, + uid: 'u1', + nickname: 'Alice', + remark: 'A', + categoryId: 7, + }, }, ], categories: [ @@ -138,7 +152,7 @@ describe('FetchFriendListPage namespace', () => { entries: [ { categoryId: 0, - friend: { uin: 0, uid: '', nickname: '0', remark: '' }, + friend: { uin: 0, uid: '', nickname: '0', remark: '', categoryId: 0 }, }, ], categories: [], diff --git a/packages/protocol/tests/oidb-services/contacts/fetch-user-profile.test.ts b/packages/protocol/tests/oidb-services/contacts/fetch-user-profile.test.ts index 73ef86a5..0f7bacc9 100644 --- a/packages/protocol/tests/oidb-services/contacts/fetch-user-profile.test.ts +++ b/packages/protocol/tests/oidb-services/contacts/fetch-user-profile.test.ts @@ -99,6 +99,7 @@ describe('FetchUserProfile namespace', () => { yearVipFlag: !!(flags & 2), svipFlag: !!(flags & 0x100), vipLevel: level, + profile: { vipLevel: level }, }); }, ); diff --git a/packages/protocol/tests/oidb-services/contacts/public-profile.test.ts b/packages/protocol/tests/oidb-services/contacts/public-profile.test.ts new file mode 100644 index 00000000..131544fc --- /dev/null +++ b/packages/protocol/tests/oidb-services/contacts/public-profile.test.ts @@ -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>({ + 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>({ + 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>({ body: { body: { uin: 10002 } } }), + ); + await expect(FetchUserProfile.invoke(deps, { uin: 10001 })).rejects.toThrow( + 'does not match', + ); + }); +}); diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 463e2621..2854cf39 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add typed public contact profiles and opt-in friend-list enrichment via `getFriendList({ withProfile: true })` or `with_profile` on either list action; existing request options remain compatible. See [contact profiles](../onebot/CONTACT_PROFILES.md). + - Add a Node reverse WebSocket server with authenticated per-account sessions, Universal and paired API/Event channels, existing action/event/stream helpers, bounded buffers and explicit disconnect handling; see [REVERSE_WEBSOCKET.md](REVERSE_WEBSOCKET.md). - Add one-step local media sends, the optional `@snowluma/sdk/node` file source, stage-aware errors, explicit quoted replies, and cancellable WebSocket event waits; see [MESSAGING.md](MESSAGING.md). Existing methods and handler/context types remain compatible. diff --git a/packages/sdk/src/client/api-client.ts b/packages/sdk/src/client/api-client.ts index 8605fd37..66c43adc 100644 --- a/packages/sdk/src/client/api-client.ts +++ b/packages/sdk/src/client/api-client.ts @@ -297,8 +297,12 @@ export abstract class SnowLumaApiClient { return this.call('delete_msg', { message_id: messageId }, options); } - getFriendList(options?: RequestOptions) { - return this.call('get_friend_list', {}, options); + getFriendList(options?: RequestOptions & { withProfile?: boolean }) { + return this.call( + 'get_friend_list', + options?.withProfile === undefined ? {} : { with_profile: options.withProfile }, + options, + ); } getStrangerInfo(userId: number, options?: RequestOptions) { diff --git a/packages/sdk/src/types/actions.ts b/packages/sdk/src/types/actions.ts index a093d948..1d10aa1b 100644 --- a/packages/sdk/src/types/actions.ts +++ b/packages/sdk/src/types/actions.ts @@ -1,5 +1,6 @@ import type { JsonArray, JsonObject, JsonValue } from './json.js'; import type { + GetFriendListParams, CreateGroupFileFolderParams, DeleteFriendParams, DeleteGroupFileFolderParams, @@ -149,6 +150,8 @@ import type { EmptyData, ForwardMessageResult, FriendCategoryResult, + FriendInfo, + StrangerInfo, FriendMessageHistory, GroupAdminSettings, GroupAdminPermissions, @@ -230,8 +233,8 @@ export interface SnowLumaActionMap { send_group_msg: { params: SendGroupMsgParams; data: SendMessageResult }; get_msg: { params: MessageIdParams; data: JsonObject }; delete_msg: { params: MessageIdParams; data: EmptyData }; - get_friend_list: { params: JsonObject; data: JsonObject[] }; - get_stranger_info: { params: UserIdParams; data: JsonObject }; + get_friend_list: { params: GetFriendListParams; data: FriendInfo[] }; + get_stranger_info: { params: UserIdParams; data: StrangerInfo }; delete_friend: { params: DeleteFriendParams; data: EmptyData }; get_group_list: { params: GetGroupListParams; data: JsonObject[] }; get_group_info: { params: GetGroupInfoParams; data: JsonObject }; @@ -330,7 +333,7 @@ export interface SnowLumaActionMap { fetch_super_face_id: { params: FetchFaceEntityParams; data: SuperFaceInfo }; get_emoji_likes: { params: GetEmojiLikesParams; data: JsonObject }; fetch_emoji_like: { params: FetchEmojiLikeParams; data: JsonObject }; - get_friends_with_category: { params: JsonObject; data: FriendCategoryResult[] }; + get_friends_with_category: { params: GetFriendListParams; data: FriendCategoryResult[] }; set_friends_category: { params: SetFriendsCategoryParams; data: EmptyData }; get_online_clients: { params: GetOnlineClientsParams; data: OnlineClientsInfo }; _get_model_show: { params: JsonObject; data: { variants: JsonArray } }; diff --git a/packages/sdk/src/types/params.ts b/packages/sdk/src/types/params.ts index 2b006376..69ae145e 100644 --- a/packages/sdk/src/types/params.ts +++ b/packages/sdk/src/types/params.ts @@ -804,3 +804,7 @@ export type DownloadFileStreamParams = JsonObject & { export interface TestDownloadStreamParams extends JsonObject { error?: ActionBooleanInput; } +export interface GetFriendListParams extends JsonObject { + /** Query missing public profiles explicitly; cached per account for five minutes. */ + with_profile?: boolean; +} diff --git a/packages/sdk/src/types/results.ts b/packages/sdk/src/types/results.ts index f5383a94..a218963a 100644 --- a/packages/sdk/src/types/results.ts +++ b/packages/sdk/src/types/results.ts @@ -105,10 +105,58 @@ export interface GroupNoticeInfo { send_to_new_members: boolean; } -export interface CategorizedFriend { +export interface ProfilePrivilege extends JsonObject { + type?: number; + level?: number; + flag?: number; + is_big?: number; + jump_url?: string; + icon_url?: string; + deluxe_icon_url?: string; +} + +/** Fields are present only when supplied by QQ; empty strings, false and zero are valid. */ +export interface PublicProfileFields extends JsonObject { + long_nick?: string; + sex?: 'male' | 'female' | 'unknown'; + age?: number; + level?: number; + birthday_year?: number; + birthday_month?: number; + birthday_day?: number; + phone_num?: string; + email?: string; + country?: string; + province?: string; + city?: string; + college?: string; + is_qq_vip?: boolean; + is_vip?: boolean; + is_years_vip?: boolean; + vip_level?: number; + privilege_icons?: { + jump_url?: string; + open: ProfilePrivilege[]; + closed: ProfilePrivilege[]; + }; +} + +export interface FriendInfo extends PublicProfileFields { user_id: number; nickname: string; remark: string; + category_id?: number; +} + +export interface CategorizedFriend extends FriendInfo {} + +export interface StrangerInfo extends PublicProfileFields { + user_id: number; + nickname: string; + remark: string; + sex: 'male' | 'female' | 'unknown'; + age: number; + long_nick: string; } export interface FriendCategoryResult { diff --git a/packages/sdk/tests/api-client.test.ts b/packages/sdk/tests/api-client.test.ts index 7190b999..943ddae3 100644 --- a/packages/sdk/tests/api-client.test.ts +++ b/packages/sdk/tests/api-client.test.ts @@ -48,6 +48,18 @@ describe('toJsonObject', () => { }); describe('SnowLumaApiClient.call', () => { + it('preserves friend-list request options while enabling public profile enrichment', async () => { + const client = okClient([]); + await client.getFriendList({ timeoutMs: 60000, withProfile: true }); + expect(client.calls[0]).toMatchObject({ + action: 'get_friend_list', + params: { with_profile: true }, + options: { timeoutMs: 60000 }, + }); + await client.getFriendList({ timeoutMs: 1000 }); + expect(client.calls[1]).toMatchObject({ params: {}, options: { timeoutMs: 1000 } }); + }); + it('returns response data when status is ok and retcode is 0', async () => { const client = okClient({ user_id: 1787882683, nickname: 'luma' }); diff --git a/packages/sdk/tests/fixtures/action-contracts.ts b/packages/sdk/tests/fixtures/action-contracts.ts index ea3e4b00..350fc32e 100644 --- a/packages/sdk/tests/fixtures/action-contracts.ts +++ b/packages/sdk/tests/fixtures/action-contracts.ts @@ -30,6 +30,8 @@ const qzonePublicReply = { // The same examples are compiled against the published declarations and parsed // by the real server action definitions. They never invoke QQ or perform I/O. export const actionParams = { + get_friend_list: { with_profile: true }, + get_friends_with_category: { with_profile: false }, get_group_admin_permissions: { group_id: '10001', user_id: '10002' }, set_group_admin_permissions: { group_id: '10001', @@ -192,6 +194,8 @@ export const actionParams = { // Keep this independent of SnowLumaActionMap: a missing map entry must fail the // consumer's coverage check rather than silently remove that example. export type CoveredAction = + | 'get_friend_list' + | 'get_friends_with_category' | 'get_group_admin_permissions' | 'set_group_admin_permissions' | 'set_group_admin_receive_verify' @@ -286,6 +290,29 @@ const flashInfo = { }; export const actionResults = { + get_friend_list: [ + { + user_id: 10001, + nickname: 'Friend', + remark: '', + category_id: 0, + long_nick: '', + age: 0, + is_qq_vip: false, + privilege_icons: { + open: [{ type: 1, icon_url: 'normal', deluxe_icon_url: 'deluxe' }], + closed: [], + }, + }, + ], + get_friends_with_category: [ + { + categoryId: 0, + categoryName: 'Friends', + categoryMbCount: 1, + buddyList: [{ user_id: 10001, nickname: 'Friend', remark: '', city: 'City' }], + }, + ], get_group_admin_permissions: { group_id: 10001, user_id: 10002, @@ -478,7 +505,11 @@ export const actionResults = { }, attach_info: 'comments-from-detail', }, - get_qzone_share_ark: { ark: '{"app":"com.tencent.tuwen.lua"}', requires_confirmation: false, notice: null }, + get_qzone_share_ark: { + ark: '{"app":"com.tencent.tuwen.lua"}', + requires_confirmation: false, + notice: null, + }, get_qzone_public_comments: { cell_comment: { comments: [qzonePublicComment] }, attach_info: 'next-comments',