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:
@@ -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,
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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: '',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user