From c1a0091143c6201399ba136a3d4e91cb2c39ccfb Mon Sep 17 00:00:00 2001 From: makotowu <2046840544@qq.com> Date: Wed, 26 Aug 2026 23:31:15 +0800 Subject: [PATCH] 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 --- packages/core/src/bridge/apis/contacts.ts | 10 ++++ packages/mcp/src/generated/catalog.json | 6 +- packages/mcp/src/generated/catalog.ts | 6 +- packages/onebot/src/actions/friend.ts | 3 +- .../onebot/src/modules/contact-actions.ts | 28 +++++++-- packages/onebot/tests/contact-actions.test.ts | 49 +++++++++++++++ packages/onebot/tests/friend-actions.test.ts | 1 + .../onebot/tests/instance-context.test.ts | 1 + packages/proto-defs/package.json | 4 ++ packages/proto-defs/src/qidian-corp.ts | 18 ++++++ packages/protocol/package.json | 4 ++ .../src/services/qidian/fetch-corp-info.ts | 52 ++++++++++++++++ .../fetch-user-profile-by-uid.test.ts | 2 +- .../contacts/fetch-user-profile.test.ts | 4 +- .../services/qidian/fetch-corp-info.test.ts | 60 +++++++++++++++++++ 15 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 packages/proto-defs/src/qidian-corp.ts create mode 100644 packages/protocol/src/services/qidian/fetch-corp-info.ts create mode 100644 packages/protocol/tests/services/qidian/fetch-corp-info.test.ts diff --git a/packages/core/src/bridge/apis/contacts.ts b/packages/core/src/bridge/apis/contacts.ts index 63f781a9..ad114006 100644 --- a/packages/core/src/bridge/apis/contacts.ts +++ b/packages/core/src/bridge/apis/contacts.ts @@ -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 { + return fetchQidianCorpInfoWire(this.ctx, uin); + } + async fetchGroupRequests( filtered = false, count = 50, diff --git a/packages/mcp/src/generated/catalog.json b/packages/mcp/src/generated/catalog.json index 9c266f35..ff71543c 100644 --- a/packages/mcp/src/generated/catalog.json +++ b/packages/mcp/src/generated/catalog.json @@ -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": [ diff --git a/packages/mcp/src/generated/catalog.ts b/packages/mcp/src/generated/catalog.ts index b2e02cbd..ae11f581 100644 --- a/packages/mcp/src/generated/catalog.ts +++ b/packages/mcp/src/generated/catalog.ts @@ -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": [ diff --git a/packages/onebot/src/actions/friend.ts b/packages/onebot/src/actions/friend.ts index 69e303c2..a8ae1da6 100644 --- a/packages/onebot/src/actions/friend.ts +++ b/packages/onebot/src/actions/friend.ts @@ -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'], }, diff --git a/packages/onebot/src/modules/contact-actions.ts b/packages/onebot/src/modules/contact-actions.ts index 464853ac..223b8cd4 100644 --- a/packages/onebot/src/modules/contact-actions.ts +++ b/packages/onebot/src/modules/contact-actions.ts @@ -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 { + 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 { 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); diff --git a/packages/onebot/tests/contact-actions.test.ts b/packages/onebot/tests/contact-actions.test.ts index 7644a13b..122abaa6 100644 --- a/packages/onebot/tests/contact-actions.test.ts +++ b/packages/onebot/tests/contact-actions.test.ts @@ -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'); }), diff --git a/packages/onebot/tests/friend-actions.test.ts b/packages/onebot/tests/friend-actions.test.ts index 910075d7..26ea357d 100644 --- a/packages/onebot/tests/friend-actions.test.ts +++ b/packages/onebot/tests/friend-actions.test.ts @@ -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'); diff --git a/packages/onebot/tests/instance-context.test.ts b/packages/onebot/tests/instance-context.test.ts index 512a1cde..663b60e7 100644 --- a/packages/onebot/tests/instance-context.test.ts +++ b/packages/onebot/tests/instance-context.test.ts @@ -823,6 +823,7 @@ describe('buildApiContext contact reads', () => { qidian_master_flag: 0, qidian_crew_flag: 0, qidian_crew_flag_2: 0, + qidian_enterprise_name: '', }); }); diff --git a/packages/proto-defs/package.json b/packages/proto-defs/package.json index 47159f02..19a14bb5 100644 --- a/packages/proto-defs/package.json +++ b/packages/proto-defs/package.json @@ -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" diff --git a/packages/proto-defs/src/qidian-corp.ts b/packages/proto-defs/src/qidian-corp.ts new file mode 100644 index 00000000..efc08ec1 --- /dev/null +++ b/packages/proto-defs/src/qidian-corp.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>; +} diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 8db3e07d..225fb162 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -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" diff --git a/packages/protocol/src/services/qidian/fetch-corp-info.ts b/packages/protocol/src/services/qidian/fetch-corp-info.ts new file mode 100644 index 00000000..3721b7b9 --- /dev/null +++ b/packages/protocol/src/services/qidian/fetch-corp-info.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; +} + +/** + * 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 { + const req = protobuf_encode({ 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(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 ?? '', + }; +} diff --git a/packages/protocol/tests/oidb-services/contacts/fetch-user-profile-by-uid.test.ts b/packages/protocol/tests/oidb-services/contacts/fetch-user-profile-by-uid.test.ts index 49842dcb..524c300c 100644 --- a/packages/protocol/tests/oidb-services/contacts/fetch-user-profile-by-uid.test.ts +++ b/packages/protocol/tests/oidb-services/contacts/fetch-user-profile-by-uid.test.ts @@ -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: [ diff --git a/packages/protocol/tests/oidb-services/contacts/fetch-user-profile.test.ts b/packages/protocol/tests/oidb-services/contacts/fetch-user-profile.test.ts index 5893bfd6..c4bf9979 100644 --- a/packages/protocol/tests/oidb-services/contacts/fetch-user-profile.test.ts +++ b/packages/protocol/tests/oidb-services/contacts/fetch-user-profile.test.ts @@ -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); diff --git a/packages/protocol/tests/services/qidian/fetch-corp-info.test.ts b/packages/protocol/tests/services/qidian/fetch-corp-info.test.ts new file mode 100644 index 00000000..39cc7dfa --- /dev/null +++ b/packages/protocol/tests/services/qidian/fetch-corp-info.test.ts @@ -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 = {}) { + return { + sendRawPacket: vi.fn(async () => ({ + success: true, + gotResponse: true, + responseData: body, + ...opts, + })), + }; +} + +const CORP_BODY = protobuf_encode({ + 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(); + }); +});