@@ -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[]]),
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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": "在线状态码"
|
||||
|
||||
@@ -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": "在线状态码"
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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),
|
||||
),
|
||||
})),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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: '自定义账号标识(仅查到资料时返回)' },
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user