feat(onebot): expose public contact profiles

Refs #615
This commit is contained in:
DrSmoothl
2026-11-02 08:06:27 +08:00
parent 2b63f375fc
commit 45c3a6841a
34 changed files with 2427 additions and 211 deletions
+97 -7
View File
@@ -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<RobotUinRangeSnapshot> | null = null;
readonly groupInvites = new GroupInviteTracker();
private friendRosterGeneration = 0;
private rememberedFriendRosterGeneration = 0;
private activeProfiles = 0;
private readonly profileWaiters: (() => void)[] = [];
private readonly friendProfiles: SingleFlightCache<number, PublicProfileFields> =
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<FriendRoster> {
private async loadFriendProfile(uin: number): Promise<PublicProfileFields> {
if (this.activeProfiles >= FRIEND_PROFILE_CONCURRENCY) {
await new Promise<void>((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<void> {
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<FriendRoster> {
const generation = ++this.friendRosterGeneration;
const entries: FriendListPageEntry[] = [];
const categories = new Map<number, FriendListPageCategory>();
const seenCookies = new Set<string>();
@@ -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<FriendInfo[]> {
const roster = await this.fetchFriendRoster();
async fetchFriendList(options: FriendListOptions = {}): Promise<FriendInfo[]> {
const roster = await this.fetchFriendRoster(options);
return roster.entries.map((entry) => entry.friend);
}
async fetchFriendCategories(): Promise<FriendCategoryInfo[]> {
const roster = await this.fetchFriendRoster();
async fetchFriendCategories(options: FriendListOptions = {}): Promise<FriendCategoryInfo[]> {
const roster = await this.fetchFriendRoster(options);
const buckets = new Map(
roster.categories.map((category) => [category.categoryId, [] as FriendInfo[]]),
);
+10 -8
View File
@@ -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 },
]);
});
@@ -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<void>((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<void> = 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<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({
body: {
friends,
categories: [
{ categoryId: 7, categoryName: 'Friends', memberCount: friends.length },
],
},
});
} else if (cmd === 'OidbSvcTrpcTcp.0xfe1_2') {
const uin = protobuf_decode<OidbBase<OidbUserInfoRequest>>(payload).body!.uin!;
profileCalls.push(uin);
peak = Math.max(peak, ++active);
try {
await load(uin);
} finally {
active--;
}
responseData = protobuf_encode<OidbBase<OidbUserInfoResponse>>({
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<number, ReturnType<typeof deferred>>();
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<number, ReturnType<typeof deferred>>();
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');
});
});
+528 -48
View File
@@ -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": "在线状态码"
+528 -48
View File
@@ -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": "在线状态码"
+51
View File
@@ -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.
@@ -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'],
};
+18 -18
View File
@@ -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),
),
})),
);
},
+18 -14
View File
@@ -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: '自定义账号标识(仅查到资料时返回)' },
+1 -1
View File
@@ -76,7 +76,7 @@ export interface ApiActionContext {
deleteMessage: (messageId: number, meta: MessageMeta) => Promise<void>;
canSendImage: () => boolean;
canSendRecord: () => boolean;
getFriendList: () => Promise<JsonObject[]>;
getFriendList: (withProfile?: boolean) => Promise<JsonObject[]>;
getGroupList: (noCache?: boolean) => Promise<JsonObject[]>;
getGroupInfo: (groupId: number, noCache?: boolean) => Promise<JsonObject | null>;
getGroupMemberList: (groupId: number, noCache?: boolean) => Promise<JsonObject[]>;
+1 -1
View File
@@ -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),
+28 -13
View File
@@ -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<JsonObject[]> {
export async function getFriendList(
bridge: BridgeInterface,
withProfile = false,
): Promise<JsonObject[]> {
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) {
@@ -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),
};
}
@@ -180,6 +180,69 @@ function makeGroupRequest(overrides: Partial<GroupRequestInfo> = {}): 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', () => {
+51 -1
View File
@@ -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 }],
},
]);
});
+18 -1
View File
@@ -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');
}
@@ -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,
@@ -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>;
}
+7 -1
View File
@@ -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>;
}
@@ -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<number, string> {
const props = new Map<number, string>();
function friendProperties(raw: OidbFriend) {
const props = new Map<number, Uint8Array>();
const numbers = new Map<number, number>();
for (const additional of raw.additional ?? []) {
if ((additional.type ?? 0) !== 1 || !additional.layer1) {
@@ -42,22 +44,31 @@ function friendProperties(raw: OidbFriend): Map<number, string> {
}
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],
@@ -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;
@@ -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<number, n
info.regTime = registered;
}
const flags = numMap.get(41756);
if (flags !== undefined) {
info.vipFlag = (flags & 1) !== 0;
info.yearVipFlag = (flags & 2) !== 0;
info.svipFlag = (flags & 0x100) !== 0;
}
const level = numMap.get(41757);
if (level !== undefined) {
const mask = level & 0xfff;
if (mask !== 0) {
info.vipLevel = 33 - Math.clz32(mask);
} else if (info.vipFlag || info.yearVipFlag || info.svipFlag || numMap.get(42241) === 1) {
info.vipLevel = 1;
} else if (flags !== undefined && numMap.has(42241)) {
info.vipLevel = 0;
}
}
Object.assign(info, decodeMembership(numMap));
}
/**
@@ -120,10 +101,27 @@ export function applyBirthday(info: UserProfileInfo, bytesMap: Map<number, Uint8
}
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 FetchUserProfile {
@@ -139,7 +137,7 @@ export namespace FetchUserProfile {
export const serialize = (_ctx: Deps, p: Params): OidbUserInfoRequest => ({
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;
@@ -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<PublicProfileFields, 'birthdayYear' | 'birthdayMonth' | 'birthdayDay'> {
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<number, number>,
): Pick<PublicProfileFields, 'vipFlag' | 'yearVipFlag' | 'svipFlag' | 'vipLevel'> {
const result: Pick<PublicProfileFields, 'vipFlag' | 'yearVipFlag' | 'svipFlag' | 'vipLevel'> =
{};
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<number, Uint8Array>,
numbers: ReadonlyMap<number, number>,
): 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<OidbProfilePrivileges>(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;
}
+39
View File
@@ -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 {
@@ -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: [],
@@ -99,6 +99,7 @@ describe('FetchUserProfile namespace', () => {
yearVipFlag: !!(flags & 2),
svipFlag: !!(flags & 0x100),
vipLevel: level,
profile: { vipLevel: level },
});
},
);
@@ -0,0 +1,176 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbSvcTrpcTcp0xFD4_1Response } from '@snowluma/proto-defs/oidb';
import type { OidbUserInfoResponse } from '@snowluma/proto-defs/oidb-actions/base';
import { FetchFriendListPage } from '../../../src/oidb-services/contacts/fetch-friend-list-page';
import { FetchUserProfile } from '../../../src/oidb-services/contacts/fetch-user-profile';
import { FetchUserProfileByUid } from '../../../src/oidb-services/contacts/fetch-user-profile-by-uid';
import { decodePublicProfile } from '../../../src/oidb-services/contacts/public-profile';
// Synthetic wire fixture, independent of the privilege schema: top-level tag 3;
// body tags 2 (jump), 3 (open), 4 (closed); both entry URLs are present.
const icons = Buffer.from(
'1a2c1204636172641a1e08022000280032066e6f726d616c3a0664656c75786542046a756d704801220408032001',
'hex',
);
const bytes = [
{ code: 102, value: Buffer.from('') },
{ code: 20011, value: Buffer.from('mail@example.test') },
{ code: 20016, value: Buffer.from('') },
{ code: 20003, value: Buffer.from('中国') },
{ code: 20004, value: Buffer.from('省份') },
{ code: 20020, value: Buffer.from('城市') },
{ code: 20005, value: Buffer.from('邮编') },
{ code: 20021, value: Buffer.from('学校') },
{ code: 20031, value: Uint8Array.from([0, 0, 2, 29]) },
{ code: 107, value: icons },
];
const numbers = [
[20037, 0],
[105, 0],
[20009, 2],
[41756, 0],
[41757, 0],
[42241, 0],
] as const;
function sender(responseData: Uint8Array) {
return {
sendRawPacket: vi.fn(async () => ({
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(responseData),
})),
};
}
describe('public contact profiles', () => {
it.each(['roster', 'uin', 'uid'])(
'preserves presence and privilege lists through the %s wire codec',
async (form) => {
let profile;
if (form === 'roster') {
const page = await FetchFriendListPage.invoke(
sender(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({
body: {
friends: [
{
uin: 10001,
customGroup: 0,
additional: [
{
type: 1,
layer1: {
properties: bytes,
numberProperties: numbers.map(
([key, value]) => ({ key, value }),
),
},
},
],
},
],
},
}),
),
{},
);
expect(page.entries[0]?.friend.categoryId).toBe(0);
profile = page.entries[0]?.friend.profile;
} else {
const deps = sender(
protobuf_encode<OidbBase<OidbUserInfoResponse>>({
body: {
body: {
uin: 10001,
uid: 'u_test',
properties: {
bytesProperties: bytes,
numberProperties: numbers.map(([number1, number2]) => ({
number1,
number2,
})),
},
},
},
}),
);
const user =
form === 'uin'
? await FetchUserProfile.invoke(deps, { uin: 10001 })
: await FetchUserProfileByUid.invoke(deps, { uid: 'u_test' });
profile = user.profile;
}
expect(profile).toEqual({
sign: '',
age: 0,
level: 0,
sex: 'female',
email: 'mail@example.test',
phoneNum: '',
country: '中国',
province: '省份',
city: '城市',
college: '学校',
birthdayMonth: 2,
birthdayDay: 29,
vipFlag: false,
svipFlag: false,
yearVipFlag: false,
vipLevel: 0,
privilegeIcons: {
jumpUrl: 'card',
open: [
{
type: 2,
level: 0,
flag: 0,
iconUrl: 'normal',
deluxeIconUrl: 'deluxe',
jumpUrl: 'jump',
isBig: 1,
},
],
closed: [{ type: 3, level: 1 }],
},
});
},
);
it('distinguishes missing, unavailable and explicitly empty icon lists', () => {
expect(decodePublicProfile(new Map(), new Map())).toEqual({});
expect(decodePublicProfile(new Map([[107, Buffer.alloc(0)]]), new Map())).toEqual({});
expect(decodePublicProfile(new Map([[107, Buffer.from([0x1a, 0])]]), new Map())).toEqual({
privilegeIcons: { open: [], closed: [] },
});
});
it('rejects truncated icons and invalid text without returning successful empty data', () => {
expect(() =>
decodePublicProfile(new Map([[107, Buffer.from([0x1a, 0xff])]]), new Map()),
).toThrow('invalid privilege data');
expect(() =>
decodePublicProfile(new Map([[107, Buffer.from([0x18, 0])]]), new Map()),
).toThrow('invalid privilege data');
expect(() =>
decodePublicProfile(new Map([[107, Buffer.from('1a041a020a00', 'hex')]]), new Map()),
).toThrow('invalid privilege data');
expect(() =>
decodePublicProfile(new Map([[20011, Buffer.from([0xff])]]), new Map()),
).toThrow('invalid UTF-8');
});
it('rejects a profile response belonging to a different requested account', async () => {
const deps = sender(
protobuf_encode<OidbBase<OidbUserInfoResponse>>({ body: { body: { uin: 10002 } } }),
);
await expect(FetchUserProfile.invoke(deps, { uin: 10001 })).rejects.toThrow(
'does not match',
);
});
});
+2
View File
@@ -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.
+6 -2
View File
@@ -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) {
+6 -3
View File
@@ -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 } };
+4
View File
@@ -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;
}
+49 -1
View File
@@ -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 {
+12
View File
@@ -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' });
+32 -1
View File
@@ -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',