feat(onebot): expose qidian enterprise name on stranger info

When a stranger is a qidian account, attach the enterprise name from the corp data card. Non-qidian accounts keep an empty name.

Refs #404
This commit is contained in:
makotowu
2026-08-26 23:31:15 +08:00
committed by GitHub
parent e1bb9b4722
commit c1a0091143
15 changed files with 238 additions and 10 deletions
+10
View File
@@ -19,6 +19,10 @@ import {
} from '@snowluma/protocol/oidb-services/contacts/fetch-robot-uin-ranges';
import { FetchUserProfile } from '@snowluma/protocol/oidb-services/contacts/fetch-user-profile';
import { FetchUserProfileByUid } from '@snowluma/protocol/oidb-services/contacts/fetch-user-profile-by-uid';
import {
fetchQidianCorpInfo as fetchQidianCorpInfoWire,
type QidianCorpInfo,
} from '@snowluma/protocol/services/qidian/fetch-corp-info';
import { GetBuddyRecommendArk } from '@snowluma/protocol/oidb-services/contacts/get-buddy-recommend-ark';
import { GetGroupRecommendArk } from '@snowluma/protocol/oidb-services/contacts/get-group-recommend-ark';
import { SendTuwenArk, type SendTuwenArkParams } from '@snowluma/protocol/oidb-services/contacts/send-tuwen-ark';
@@ -348,6 +352,12 @@ export class ContactsApi {
return info;
}
/** 企点企业资料卡(#404 后续 PR)。仅在判断命中的是企点账号后再调用。
* best-effort:非企点账号或拉取失败返回 null,不影响上层资料读取。 */
async fetchQidianCorpInfo(uin: number): Promise<QidianCorpInfo | null> {
return fetchQidianCorpInfoWire(this.ctx, uin);
}
async fetchGroupRequests(
filtered = false,
count = 50,
+5 -1
View File
@@ -7445,7 +7445,7 @@
"name": "get_stranger_info",
"aliases": [],
"summary": "获取陌生人信息",
"returns": "用户资料:QQ 号、昵称、好友备注、性别、年龄与个性签名,命中资料时另含等级与企点标志。",
"returns": "用户资料:QQ 号、昵称、好友备注、性别、年龄与个性签名,命中资料时另含等级、企点标志与企业名称。",
"returnsSchema": {
"type": "object",
"properties": {
@@ -7517,6 +7517,10 @@
"qidian_crew_flag_2": {
"type": "integer",
"description": "企点保留标志,0 或 1;普通账号为 0"
},
"qidian_enterprise_name": {
"type": "string",
"description": "企点企业名称;非企点账号或未获取到时为空字符串"
}
},
"required": [
+5 -1
View File
@@ -7448,7 +7448,7 @@ export const ACTIONS: CatalogAction[] = [
"name": "get_stranger_info",
"aliases": [],
"summary": "获取陌生人信息",
"returns": "用户资料:QQ 号、昵称、好友备注、性别、年龄与个性签名,命中资料时另含等级与企点标志。",
"returns": "用户资料:QQ 号、昵称、好友备注、性别、年龄与个性签名,命中资料时另含等级、企点标志与企业名称。",
"returnsSchema": {
"type": "object",
"properties": {
@@ -7520,6 +7520,10 @@ export const ACTIONS: CatalogAction[] = [
"qidian_crew_flag_2": {
"type": "integer",
"description": "企点保留标志,0 或 1;普通账号为 0"
},
"qidian_enterprise_name": {
"type": "string",
"description": "企点企业名称;非企点账号或未获取到时为空字符串"
}
},
"required": [
+2 -1
View File
@@ -32,7 +32,7 @@ export const actions = [
name: 'get_stranger_info',
summary: '获取陌生人信息',
readOnly: true,
returns: '用户资料:QQ 号、昵称、好友备注、性别、年龄与个性签名,命中资料时另含等级与企点标志。',
returns: '用户资料:QQ 号、昵称、好友备注、性别、年龄与个性签名,命中资料时另含等级、企点标志与企业名称。',
returnsSchema: {
type: 'object',
properties: {
@@ -53,6 +53,7 @@ export const actions = [
qidian_master_flag: { type: 'integer', description: '企点主号标志,0 或 1;普通账号为 0' },
qidian_crew_flag: { type: 'integer', description: '企点员工标志,0 或 1;普通账号为 0' },
qidian_crew_flag_2: { type: 'integer', description: '企点保留标志,0 或 1;普通账号为 0' },
qidian_enterprise_name: { type: 'string', description: '企点企业名称;非企点账号或未获取到时为空字符串' },
},
required: ['user_id', 'nickname', 'remark', 'sex', 'age', 'long_nick'],
},
+24 -4
View File
@@ -7,6 +7,7 @@ import {
type GroupRequestInfo,
type UserProfileInfo,
} from '@snowluma/protocol/qq-info';
import type { QidianCorpInfo } from '@snowluma/protocol/services/qidian/fetch-corp-info';
import type { OneBotInstanceContext } from '../instance-context';
import type { JsonObject } from '../types';
@@ -289,7 +290,7 @@ export async function getGroupFiles(
};
}
function formatStrangerInfo(p: UserProfileInfo): JsonObject {
function formatStrangerInfo(p: UserProfileInfo, corp?: QidianCorpInfo | null): JsonObject {
return {
user_id: p.uin,
nickname: p.nickname,
@@ -308,23 +309,42 @@ function formatStrangerInfo(p: UserProfileInfo): JsonObject {
qidian_master_flag: p.qidianMasterFlag ?? 0,
qidian_crew_flag: p.qidianCrewFlag ?? 0,
qidian_crew_flag_2: p.qidianCrewFlag2 ?? 0,
// #404 后续 PR:企点企业资料卡。企业资料卡不含独立的企业 ID,
// 因此这里只透出企业名称(SsoCorpInfo.corpName)。
qidian_enterprise_name: corp?.name ?? '',
};
}
/** 命中的是企点账号时才去拉一次企业资料卡(best-effort,失败返回 null)。 */
async function strangerCorpInfo(
bridge: BridgeInterface,
p: UserProfileInfo,
): Promise<QidianCorpInfo | null> {
if (p.qidianMasterFlag === 0 && p.qidianCrewFlag === 0) return null;
try {
return await bridge.apis.contacts.fetchQidianCorpInfo(p.uin);
} catch {
return null;
}
}
export async function getStrangerInfo(
bridge: BridgeInterface,
userId: number,
): Promise<JsonObject | null> {
try {
const p = await bridge.apis.contacts.fetchUserProfile(userId);
return formatStrangerInfo(p);
const corp = await strangerCorpInfo(bridge, p);
return formatStrangerInfo(p, corp);
} catch {
const p = bridge.identity.findUserProfile(userId);
if (p) {
return formatStrangerInfo({
const profile: UserProfileInfo = {
...p,
remark: p.remark || bridge.identity.findFriend(userId)?.remark || '',
});
};
const corp = await strangerCorpInfo(bridge, profile);
return formatStrangerInfo(profile, corp);
}
const friend = bridge.identity.findFriend(userId);
@@ -614,6 +614,55 @@ describe('onebot/contact-actions / getStrangerInfo', () => {
});
});
it('passes through the qidian enterprise (企业) data-card when available', async () => {
const fetchQidianCorpInfo = vi.fn(async () => ({
name: '测试企业', intro: '测试简介', website: 'https://example.com',
slogan: '测试签名', address: '测试地址', phone: '400-0000-0000', email: 'contact@example.com',
}));
const bridge = fakeBridge({
apis: {
contacts: {
fetchUserProfile: vi.fn(async () => ({
...makeProfile(55555, 'QidianWorker', 'male'),
qidianMasterFlag: 0,
qidianCrewFlag: 1,
qidianCrewFlag2: 0,
})),
fetchQidianCorpInfo,
},
},
});
const out = await getStrangerInfo(bridge, 55555);
expect(out).toMatchObject({
user_id: 55555,
qidian_master_flag: 0,
qidian_crew_flag: 1,
qidian_crew_flag_2: 0,
qidian_enterprise_name: '测试企业',
});
expect(fetchQidianCorpInfo).toHaveBeenCalledWith(55555);
});
it('leaves enterprise fields empty for non-qidian accounts and never calls the corp lookup', async () => {
const fetchQidianCorpInfo = vi.fn(async () => ({
name: 'x', intro: '', website: '', slogan: '', address: '', phone: '', email: '',
}));
const bridge = fakeBridge({
apis: {
contacts: {
fetchUserProfile: vi.fn(async () => makeProfile(88888, 'Normal', 'male')),
fetchQidianCorpInfo,
},
},
});
const out = await getStrangerInfo(bridge, 88888);
expect(out).toMatchObject({
user_id: 88888,
qidian_enterprise_name: '',
});
expect(fetchQidianCorpInfo).not.toHaveBeenCalled();
});
it('falls back to identity.findUserProfile when fetch fails but the profile is cached', async () => {
const bridge = fakeBridge({
fetchUserProfile: vi.fn(async () => { throw new Error('net'); }),
@@ -34,6 +34,7 @@ describe('get_stranger_info action', () => {
qidian_master_flag: { type: 'integer' },
qidian_crew_flag: { type: 'integer' },
qidian_crew_flag_2: { type: 'integer' },
qidian_enterprise_name: { type: 'string' },
},
});
expect(schema?.required).toContain('remark');
@@ -823,6 +823,7 @@ describe('buildApiContext contact reads', () => {
qidian_master_flag: 0,
qidian_crew_flag: 0,
qidian_crew_flag_2: 0,
qidian_enterprise_name: '',
});
});
+4
View File
@@ -44,6 +44,10 @@
"types": "./src/get-c2c-msg.ts",
"default": "./src/get-c2c-msg.ts"
},
"./qidian-corp": {
"types": "./src/qidian-corp.ts",
"default": "./src/qidian-corp.ts"
},
"./notify": {
"types": "./src/notify.ts",
"default": "./src/notify.ts"
+18
View File
@@ -0,0 +1,18 @@
import type { pb, uint_32 } from '@snowluma/proton';
// trpc.basic.corp.Datacard.SsoCorpInfo — 企点企业资料卡信息。
// 请求:field1 = 目标账号 UIN(varint)。
// 响应字段为企点企业资料卡:名称 / 简介 / 官网 / 企业签名 / 地址 / 电话 / 邮箱。
export interface QidianCorpInfoRequest {
uin?: pb<1, uint_32>;
}
export interface QidianCorpInfoResponse {
corpName?: pb<2, string>;
intro?: pb<3, string>;
website?: pb<4, string>;
slogan?: pb<5, string>;
address?: pb<6, string>;
phone?: pb<7, string>;
email?: pb<8, string>;
}
+4
View File
@@ -92,6 +92,10 @@
"types": "./src/highway/*.ts",
"default": "./src/highway/*.ts"
},
"./services/*": {
"types": "./src/services/*.ts",
"default": "./src/services/*.ts"
},
"./web/*": {
"types": "./src/web/*.ts",
"default": "./src/web/*.ts"
@@ -0,0 +1,52 @@
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import type { QidianCorpInfoRequest, QidianCorpInfoResponse } from '@snowluma/proto-defs/qidian-corp';
// 企点企业资料卡(trpc.basic.corp.Datacard.SsoCorpInfo)。
// 请求体为 protobuf field1 = 目标账号 UIN(varint)
// 返回企业名称等资料卡字段(来源见 SnowLuma issue #404 讨论)。
export const QIDIAN_CORP_INFO_CMD = 'trpc.basic.corp.Datacard.SsoCorpInfo';
export interface QidianCorpInfo {
name: string;
intro: string;
website: string;
slogan: string;
address: string;
phone: string;
email: string;
}
export interface RawSender {
sendRawPacket(serviceCmd: string, body: Uint8Array, timeoutMs?: number): Promise<SendPacketResult>;
}
/**
* Fetch the qidian enterprise data-card for a qidian account UIN.
* Returns null when the account is not a qidian corp user or the server
* returns no corp info. Best-effort by design — callers must NOT fail the
* whole profile read if this lookup throws / returns null.
*/
export async function fetchQidianCorpInfo(
sender: RawSender,
uin: number,
timeoutMs?: number,
): Promise<QidianCorpInfo | null> {
const req = protobuf_encode<QidianCorpInfoRequest>({ uin });
const result = await sender.sendRawPacket(QIDIAN_CORP_INFO_CMD, req, timeoutMs);
if (!result.success || !result.gotResponse || !result.responseData || result.responseData.length === 0) {
return null;
}
const decoded = protobuf_decode<QidianCorpInfoResponse>(result.responseData);
const name = decoded?.corpName ?? '';
if (!name) return null;
return {
name,
intro: decoded?.intro ?? '',
website: decoded?.website ?? '',
slogan: decoded?.slogan ?? '',
address: decoded?.address ?? '',
phone: decoded?.phone ?? '',
email: decoded?.email ?? '',
};
}
@@ -53,7 +53,7 @@ describe('FetchUserProfileByUid namespace (UID form)', () => {
it('decodes qidian flags from number-properties (0 when absent)', async () => {
const qidian = makeSender({
body: {
uin: 3004251964, uid: 'u',
uin: 10002, uid: 'u',
properties: {
bytesProperties: [],
numberProperties: [
@@ -52,7 +52,7 @@ describe('FetchUserProfile namespace', () => {
// 企点员工号:两个标志均为 1;普通账号服务器不返回这两个 key → 缺省 0
const qidian = makeSender({
body: {
uin: 3004251964, uid: 'u',
uin: 10002, uid: 'u',
properties: {
bytesProperties: [],
numberProperties: [
@@ -62,7 +62,7 @@ describe('FetchUserProfile namespace', () => {
},
} as any,
});
const out = await FetchUserProfile.invoke(qidian, { uin: 3004251964 });
const out = await FetchUserProfile.invoke(qidian, { uin: 10002 });
expect(out.qidianMasterFlag).toBe(1);
expect(out.qidianCrewFlag).toBe(1);
expect(out.qidianCrewFlag2).toBe(0);
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_encode } from '@snowluma/proton';
import type { QidianCorpInfoResponse } from '@snowluma/proto-defs/qidian-corp';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import {
fetchQidianCorpInfo,
QIDIAN_CORP_INFO_CMD,
} from '../../../src/services/qidian/fetch-corp-info';
function sender(body: Uint8Array, opts: Partial<SendPacketResult> = {}) {
return {
sendRawPacket: vi.fn(async () => ({
success: true,
gotResponse: true,
responseData: body,
...opts,
})),
};
}
const CORP_BODY = protobuf_encode<QidianCorpInfoResponse>({
corpName: '测试企业',
intro: '测试简介',
website: 'https://example.com',
slogan: '测试签名',
address: '测试地址',
phone: '400-0000-0000',
email: 'contact@example.com',
});
describe('services/qidian / fetchQidianCorpInfo', () => {
it('sends the corp info command with the uin as field 1', async () => {
const s = sender(CORP_BODY);
const out = await fetchQidianCorpInfo(s, 10001);
expect(out?.name).toBe('测试企业');
expect(s.sendRawPacket).toHaveBeenCalledWith(
QIDIAN_CORP_INFO_CMD,
expect.any(Uint8Array),
undefined,
);
const body = s.sendRawPacket.mock.calls[0][1] as Uint8Array;
expect(Buffer.from(body).toString('hex')).toBe('08914e');
});
it('decodes the corp data-card fields', async () => {
const out = await fetchQidianCorpInfo(sender(CORP_BODY), 10001);
expect(out).toMatchObject({
name: '测试企业',
website: 'https://example.com',
email: 'contact@example.com',
});
});
it('returns null for an empty / failed response', async () => {
expect(await fetchQidianCorpInfo(sender(new Uint8Array(0)), 1)).toBeNull();
expect(
await fetchQidianCorpInfo(sender(new Uint8Array(0), { success: false, errorMessage: 'x' }), 1),
).toBeNull();
});
});