From eed2e692b101215f9ab6aebcff44149eea1b7f4e Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 21 Jul 2026 23:27:01 +0800 Subject: [PATCH] fix(onebot): expose robot status for group members Fetch QQ's versioned robot UIN ranges, classify and persist member status, and surface is_robot from both group member actions. Preserve unknown legacy cache entries and fail visibly when classification is unavailable. Closes #265 --- packages/core/src/bridge/apis/contacts.ts | 50 ++++++++ packages/core/tests/actions/contacts.test.ts | 119 +++++++++++++++++- .../core/tests/bridge-member-identity.test.ts | 1 + packages/core/tests/identity-service.test.ts | 79 +++++++++++- packages/core/tests/msg-push-handler.test.ts | 1 + packages/mcp/src/generated/catalog.json | 8 ++ packages/mcp/src/generated/catalog.ts | 8 ++ packages/onebot/src/actions/group-info.ts | 4 + .../onebot/src/modules/contact-actions.ts | 59 ++++++--- packages/onebot/tests/contact-actions.test.ts | 73 +++++++++-- packages/proto-defs/src/oidb-actions/base.ts | 9 ++ packages/proto-defs/src/oidb.ts | 18 +++ packages/protocol/src/identity-service.ts | 32 +++-- .../contacts/fetch-robot-uin-ranges.ts | 86 +++++++++++++ packages/protocol/src/qq-info.ts | 2 + .../contacts/fetch-robot-uin-ranges.test.ts | 83 ++++++++++++ 16 files changed, 596 insertions(+), 36 deletions(-) create mode 100644 packages/protocol/src/oidb-services/contacts/fetch-robot-uin-ranges.ts create mode 100644 packages/protocol/tests/oidb-services/contacts/fetch-robot-uin-ranges.test.ts diff --git a/packages/core/src/bridge/apis/contacts.ts b/packages/core/src/bridge/apis/contacts.ts index 4b3d58df..a173d25b 100644 --- a/packages/core/src/bridge/apis/contacts.ts +++ b/packages/core/src/bridge/apis/contacts.ts @@ -4,6 +4,11 @@ import { FetchGroupDetail } from '@snowluma/protocol/oidb-services/contacts/fetc import { FetchGroupList } from '@snowluma/protocol/oidb-services/contacts/fetch-group-list'; import { FetchGroupMemberListPage } from '@snowluma/protocol/oidb-services/contacts/fetch-group-member-list-page'; import { FetchGroupRequests } from '@snowluma/protocol/oidb-services/contacts/fetch-group-requests'; +import { + FetchRobotUinRanges, + type RobotUinRange, + type RobotUinRangeSnapshot, +} 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 { GetBuddyRecommendArk } from '@snowluma/protocol/oidb-services/contacts/get-buddy-recommend-ark'; @@ -74,6 +79,12 @@ export function permissionToRole(permission: number): string { } } +export function isRobotUin(uin: number, ranges: readonly RobotUinRange[]): boolean { + return Number.isInteger(uin) + && uin > 0 + && ranges.some(({ minUin, maxUin }) => uin >= minUin && uin <= maxUin); +} + const MEMBER_LIST_TTL_MS = 60_000; export class ContactsApi { @@ -91,9 +102,38 @@ export class ContactsApi { ttlMs: MEMBER_LIST_TTL_MS, load: (groupId) => this.fetchGroupMemberListUncached(groupId), }); + private robotUinRangesPromise_: Promise | null = null; constructor(private readonly ctx: BridgeContext) { } + private fetchRobotUinRanges(groupId: number): Promise { + if (this.robotUinRangesPromise_) return this.robotUinRangesPromise_; + + const pending = FetchRobotUinRanges.invoke(this.ctx).then((snapshot) => { + log.info( + 'loaded robot UIN ranges uin=%s version=%d count=%d', + this.ctx.identity.uin, + snapshot.version, + snapshot.ranges.length, + ); + return snapshot; + }); + this.robotUinRangesPromise_ = pending; + + // A failed first load must be observable and retryable. Keep the rejection + // visible to the caller instead of converting every member to false. + void pending.catch((err: unknown) => { + if (this.robotUinRangesPromise_ === pending) this.robotUinRangesPromise_ = null; + log.error( + 'failed to load robot UIN ranges: uin=%s group=%d err=%s', + this.ctx.identity.uin, + groupId, + err instanceof Error ? (err.stack ?? err.message) : String(err), + ); + }); + return pending; + } + /** Server-built ARK share card (JSON string) recommending a friend. */ getBuddyRecommendArk(userId: number, phoneNumber = ''): Promise { return GetBuddyRecommendArk.invoke(this.ctx, { userId, phoneNumber }); @@ -241,6 +281,10 @@ export class ContactsApi { } private async fetchGroupMemberListUncached(groupId: number): Promise { + // QQ's native group service obtains the robot classification separately + // from 0xFE7_3. Start both reads together so the metadata adds no serial + // round trip to paginated member fetches. + const robotSnapshotPromise = this.fetchRobotUinRanges(groupId); const members: GroupMemberInfo[] = []; let token = ''; do { @@ -251,6 +295,7 @@ export class ContactsApi { uid: raw.uin?.uid ?? '', nickname: raw.memberName ?? '', card: raw.memberCard?.memberCard ?? '', + isRobot: false, role: permissionToRole(raw.permission ?? 0), level: raw.level?.level ?? 0, title: raw.specialTitle ?? '', @@ -262,6 +307,11 @@ export class ContactsApi { token = resp.token ?? ''; } while (token); + const robotSnapshot = await robotSnapshotPromise; + for (const member of members) { + member.isRobot = isRobotUin(member.uin, robotSnapshot.ranges); + } + this.ctx.identity.rememberGroupMembers(groupId, members); return members; } diff --git a/packages/core/tests/actions/contacts.test.ts b/packages/core/tests/actions/contacts.test.ts index ef5f37a1..082dae36 100644 --- a/packages/core/tests/actions/contacts.test.ts +++ b/packages/core/tests/actions/contacts.test.ts @@ -5,9 +5,14 @@ import type { OidbBase, OidbFriend, OidbFriendCategory, + OidbRobotUinRangeResponse, OidbSvcTrpcTcp0xFD4_1Response, + OidbSvcTrpcTcp0xFE7_3Response, } from '@snowluma/proto-defs/oidb'; -import type { OidbFriendListRequest } from '@snowluma/proto-defs/oidb-actions/base'; +import type { + OidbFriendListRequest, + OidbRobotUinRangeRequest, +} from '@snowluma/proto-defs/oidb-actions/base'; import { ContactsApi } from '../../src/bridge/apis/contacts'; @@ -55,6 +60,30 @@ function packet(body: OidbSvcTrpcTcp0xFD4_1Response): SendPacketResult { }; } +function robotRangePacket(body: OidbRobotUinRangeResponse): SendPacketResult { + return { + success: true, + gotResponse: true, + errorCode: 0, + errorMessage: '', + responseData: Buffer.from( + protobuf_encode>({ body }), + ), + }; +} + +function memberListPacket(body: OidbSvcTrpcTcp0xFE7_3Response): SendPacketResult { + return { + success: true, + gotResponse: true, + errorCode: 0, + errorMessage: '', + responseData: Buffer.from( + protobuf_encode>({ body }), + ), + }; +} + function apiForPages(pages: OidbSvcTrpcTcp0xFD4_1Response[]) { let index = 0; const sendRawPacket = vi.fn(async ( @@ -160,3 +189,91 @@ describe('apis/contacts / categorized friend roster', () => { .rejects.toThrow('repeated friend-list cookie aa'); }); }); + +describe('apis/contacts / robot group-member classification', () => { + it('loads QQ robot ranges once and marks every fetched member', async () => { + const rememberGroupMembers = vi.fn(); + const sendRawPacket = vi.fn(async (cmd: string, _data: Uint8Array): Promise => { + if (cmd === 'OidbSvcTrpcTcp.0x496_0') { + return robotRangePacket({ + robotConfig: { + version: 206, + ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }], + }, + }); + } + if (cmd === 'OidbSvcTrpcTcp.0xfe7_3') { + return memberListPacket({ + groupUin: 42, + members: [ + { uin: { uid: 'u_robot', uin: 3_889_054_356 }, memberName: 'robot' }, + { uin: { uid: 'u_person', uin: 1_234_567_890 }, memberName: 'person' }, + ], + }); + } + throw new Error(`unexpected command: ${cmd}`); + }); + const api = new ContactsApi({ + sendRawPacket, + identity: { uin: '10001', rememberGroupMembers }, + } as any); + + const first = await api.fetchGroupMemberList(42); + const second = await api.fetchGroupMemberList(42, { force: true }); + + expect(first).toEqual([ + expect.objectContaining({ uin: 3_889_054_356, isRobot: true }), + expect.objectContaining({ uin: 1_234_567_890, isRobot: false }), + ]); + expect(second).toEqual(first); + expect(rememberGroupMembers).toHaveBeenCalledWith(42, first); + expect(sendRawPacket.mock.calls.map(([cmd]) => cmd).sort()).toEqual([ + 'OidbSvcTrpcTcp.0x496_0', + 'OidbSvcTrpcTcp.0xfe7_3', + 'OidbSvcTrpcTcp.0xfe7_3', + ]); + expect(sendRawPacket.mock.calls.filter(([cmd]) => cmd === 'OidbSvcTrpcTcp.0x496_0')) + .toHaveLength(1); + + const rangeCall = sendRawPacket.mock.calls.find(([cmd]) => cmd === 'OidbSvcTrpcTcp.0x496_0')!; + const request = protobuf_decode>(rangeCall[1]); + expect(request.body).toMatchObject({ justFetchMsgConfig: 1, type: 1 }); + }); + + it('propagates an invalid range snapshot and retries it on the next fetch', async () => { + let rangeAttempts = 0; + const sendRawPacket = vi.fn(async (cmd: string, _data: Uint8Array): Promise => { + if (cmd === 'OidbSvcTrpcTcp.0x496_0') { + rangeAttempts += 1; + return rangeAttempts === 1 + ? robotRangePacket({}) + : robotRangePacket({ + robotConfig: { + version: 206, + ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }], + }, + }); + } + if (cmd === 'OidbSvcTrpcTcp.0xfe7_3') { + return memberListPacket({ + groupUin: 42, + members: [{ uin: { uid: 'u_robot', uin: 3_889_054_356 }, memberName: 'robot' }], + }); + } + throw new Error(`unexpected command: ${cmd}`); + }); + const rememberGroupMembers = vi.fn(); + const api = new ContactsApi({ + sendRawPacket, + identity: { uin: '10001', rememberGroupMembers }, + } as any); + + await expect(api.fetchGroupMemberList(42)) + .rejects.toThrow('0x496_0 response missing robot range config'); + await expect(api.fetchGroupMemberList(42)) + .resolves.toEqual([expect.objectContaining({ isRobot: true })]); + + expect(rangeAttempts).toBe(2); + expect(rememberGroupMembers).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/core/tests/bridge-member-identity.test.ts b/packages/core/tests/bridge-member-identity.test.ts index fbe3f25e..0d209b5f 100644 --- a/packages/core/tests/bridge-member-identity.test.ts +++ b/packages/core/tests/bridge-member-identity.test.ts @@ -14,6 +14,7 @@ function makeGroupMember(uin: number, uid: string): GroupMemberInfo { uid, nickname: '', card: '', + isRobot: false, role: 'member', level: 0, title: '', diff --git a/packages/core/tests/identity-service.test.ts b/packages/core/tests/identity-service.test.ts index 0c15b296..0e8672b5 100644 --- a/packages/core/tests/identity-service.test.ts +++ b/packages/core/tests/identity-service.test.ts @@ -51,12 +51,13 @@ function makeGroup(): QQGroupInfo { }; } -function makeMember(uin: number, uid: string, card = ''): GroupMemberInfo { +function makeMember(uin: number, uid: string, card = '', isRobot = false): GroupMemberInfo { return { uin, uid, nickname: `nick-${uin}`, card, + isRobot, role: 'member', level: 1, title: '', @@ -96,6 +97,82 @@ describe('IdentityService', () => { } }); + it('persists group member robot classification across restarts', () => { + const dbPath = tempDbPath('persist-robot'); + + { + const identity = new IdentityService(SELF_UIN, dbPath); + identity.rememberGroups([makeGroup()]); + identity.rememberGroupMembers(GROUP_ID, [makeMember(33333, 'u_robot', '', true)]); + identity.close(); + } + + { + const identity = new IdentityService(SELF_UIN, dbPath); + expect(identity.findGroupMember(GROUP_ID, 33333)?.isRobot).toBe(true); + identity.close(); + } + }); + + it('migrates legacy member caches without inventing a robot classification', () => { + const dbPath = tempDbPath('migrate-robot'); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const legacy = new DatabaseSync(dbPath); + legacy.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid TEXT UNIQUE, + uin INTEGER UNIQUE, + nickname TEXT NOT NULL DEFAULT '', + remark TEXT NOT NULL DEFAULT '', + is_friend INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL + ); + CREATE TABLE groups ( + group_id INTEGER PRIMARY KEY, + group_name TEXT NOT NULL DEFAULT '', + remark TEXT NOT NULL DEFAULT '', + member_count INTEGER NOT NULL DEFAULT 0, + member_max INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL + ); + CREATE TABLE group_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL, + uid TEXT, + uin INTEGER, + nickname TEXT NOT NULL DEFAULT '', + card TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'member', + level INTEGER NOT NULL DEFAULT 0, + title TEXT NOT NULL DEFAULT '', + join_time INTEGER NOT NULL DEFAULT 0, + last_sent_time INTEGER NOT NULL DEFAULT 0, + shut_up_time INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL, + UNIQUE(group_id, uid), + UNIQUE(group_id, uin) + ); + INSERT INTO groups (group_id, group_name, updated_at) + VALUES (${GROUP_ID}, 'legacy-group', 1); + INSERT INTO group_members (group_id, uid, uin, nickname, updated_at) + VALUES (${GROUP_ID}, 'u_legacy', 33333, 'legacy-member', 1); + `); + legacy.close(); + + const identity = new IdentityService(SELF_UIN, dbPath); + expect(identity.findGroupMember(GROUP_ID, 33333)?.isRobot).toBeUndefined(); + identity.close(); + + const migrated = new DatabaseSync(dbPath); + const columns = migrated.prepare('PRAGMA table_info(group_members)').all() as Array<{ name: string }>; + expect(columns.map((column) => column.name)).toContain('is_robot'); + migrated.close(); + }); + it('keeps updateGroupMember changes after reopening the persistent cache', () => { const dbPath = tempDbPath('update-member'); const member = makeMember(33333, 'u_member', 'old-card'); diff --git a/packages/core/tests/msg-push-handler.test.ts b/packages/core/tests/msg-push-handler.test.ts index 3fb548b5..ce1b47d7 100644 --- a/packages/core/tests/msg-push-handler.test.ts +++ b/packages/core/tests/msg-push-handler.test.ts @@ -39,6 +39,7 @@ function makeGroupMember(uin: number, uid: string): GroupMemberInfo { uid, nickname: '', card: '', + isRobot: false, role: 'member', level: 0, title: '', diff --git a/packages/mcp/src/generated/catalog.json b/packages/mcp/src/generated/catalog.json index ed9def79..db5d44fc 100644 --- a/packages/mcp/src/generated/catalog.json +++ b/packages/mcp/src/generated/catalog.json @@ -4512,6 +4512,10 @@ "type": "string", "description": "群名片" }, + "is_robot": { + "type": "boolean", + "description": "是否为机器人" + }, "sex": { "type": "string", "enum": [ @@ -4663,6 +4667,10 @@ "type": "string", "description": "群名片" }, + "is_robot": { + "type": "boolean", + "description": "是否为机器人" + }, "sex": { "type": "string", "enum": [ diff --git a/packages/mcp/src/generated/catalog.ts b/packages/mcp/src/generated/catalog.ts index 8e424992..59838326 100644 --- a/packages/mcp/src/generated/catalog.ts +++ b/packages/mcp/src/generated/catalog.ts @@ -4515,6 +4515,10 @@ export const ACTIONS: CatalogAction[] = [ "type": "string", "description": "群名片" }, + "is_robot": { + "type": "boolean", + "description": "是否为机器人" + }, "sex": { "type": "string", "enum": [ @@ -4666,6 +4670,10 @@ export const ACTIONS: CatalogAction[] = [ "type": "string", "description": "群名片" }, + "is_robot": { + "type": "boolean", + "description": "是否为机器人" + }, "sex": { "type": "string", "enum": [ diff --git a/packages/onebot/src/actions/group-info.ts b/packages/onebot/src/actions/group-info.ts index 29c54e67..81f06a94 100644 --- a/packages/onebot/src/actions/group-info.ts +++ b/packages/onebot/src/actions/group-info.ts @@ -80,6 +80,7 @@ export const actions = [ user_id: { type: 'integer', description: 'QQ 号' }, nickname: { type: 'string', description: '昵称' }, card: { type: 'string', description: '群名片' }, + is_robot: { type: 'boolean', description: '是否为机器人' }, sex: { type: 'string', enum: ['male', 'female', 'unknown'], description: '性别' }, age: { type: 'integer', description: '年龄' }, join_time: { type: 'integer', description: '入群时间戳(秒)' }, @@ -118,6 +119,7 @@ export const actions = [ user_id: { type: 'integer', description: 'QQ 号' }, nickname: { type: 'string', description: '昵称' }, card: { type: 'string', description: '群名片' }, + is_robot: { type: 'boolean', description: '是否为机器人' }, sex: { type: 'string', enum: ['male', 'female', 'unknown'], description: '性别' }, age: { type: 'integer', description: '年龄' }, join_time: { type: 'integer', description: '入群时间戳(秒)' }, @@ -141,12 +143,14 @@ export const actions = [ const info = await ctx.getGroupMemberInfo(groupId, userId, noCache); return okResponse(info ?? { group_id: groupId, user_id: userId, nickname: '', card: '', + is_robot: false, sex: 'unknown', age: 0, join_time: 0, last_sent_time: 0, level: '0', role: 'member', title: '', }); } return okResponse({ group_id: groupId, user_id: userId, nickname: '', card: '', + is_robot: false, sex: 'unknown', age: 0, join_time: 0, last_sent_time: 0, level: '0', role: 'member', title: '', }); diff --git a/packages/onebot/src/modules/contact-actions.ts b/packages/onebot/src/modules/contact-actions.ts index 26191a51..37dbaead 100644 --- a/packages/onebot/src/modules/contact-actions.ts +++ b/packages/onebot/src/modules/contact-actions.ts @@ -1,8 +1,10 @@ import { mapWithConcurrency } from '@snowluma/common/concurrency'; +import { createLogger } from '@snowluma/common/logger'; import type { BridgeInterface } from '@snowluma/core/bridge-interface'; import type { IdentityService } from '@snowluma/protocol/identity-service'; import { formatGroupRequestFlag, + type GroupMemberInfo, type GroupRequestInfo, type UserProfileInfo, } from '@snowluma/protocol/qq-info'; @@ -10,6 +12,7 @@ import type { OneBotInstanceContext } from '../instance-context'; import type { JsonObject } from '../types'; const REQUESTER_PROFILE_CONCURRENCY = 4; +const log = createLogger('OneBot.Contacts'); export interface GroupSystemMessageQuery { groupId?: number; @@ -22,11 +25,21 @@ export function getLoginInfo(ref: OneBotInstanceContext): { userId: number; nick return { userId, nickname }; } -async function refreshSingleGroupMembers(bridge: BridgeInterface, groupId: number): Promise { +async function fetchSingleGroupMembers( + bridge: BridgeInterface, + groupId: number, + force = false, +): Promise { try { - await bridge.apis.contacts.fetchGroupMemberList(groupId); - } catch { - // 需要打log以便排查问题,但不应当让调用者感知到这个失败。 + return await bridge.apis.contacts.fetchGroupMemberList(groupId, { force }); + } catch (err) { + log.warn( + 'group member refresh failed: uin=%s group=%d err=%s', + bridge.identity.uin, + groupId, + err instanceof Error ? (err.stack ?? err.message) : String(err), + ); + throw err; } } @@ -156,15 +169,23 @@ export async function getGroupMemberList( noCache?: boolean, ): Promise { if (noCache) { - await refreshSingleGroupMembers(bridge, groupId); - return getCachedGroupMembers(bridge.identity, groupId); + const members = await fetchSingleGroupMembers(bridge, groupId, true); + return members.map(m => formatGroupMember(groupId, m)); } try { const members = await bridge.apis.contacts.fetchGroupMemberList(groupId); return members.map(m => formatGroupMember(groupId, m)); - } catch { - return getCachedGroupMembers(bridge.identity, groupId); + } catch (err) { + log.warn( + 'group member fetch failed, using classified cache: uin=%s group=%d err=%s', + bridge.identity.uin, + groupId, + err instanceof Error ? (err.stack ?? err.message) : String(err), + ); + const cached = getCachedGroupMembers(bridge.identity, groupId); + if (cached.length === 0) throw err; + return cached; } } @@ -174,8 +195,9 @@ export async function getGroupMemberInfo( userId: number, noCache?: boolean, ): Promise { - if (noCache || !bridge.identity.findGroupMember(groupId, userId)) { - await refreshSingleGroupMembers(bridge, groupId); + const cached = bridge.identity.findGroupMember(groupId, userId); + if (noCache || !cached || cached.isRobot === undefined) { + await fetchSingleGroupMembers(bridge, groupId, noCache === true); } const m = bridge.identity.findGroupMember(groupId, userId); if (!m) return null; @@ -339,22 +361,19 @@ function getCachedGroupMembers(identity: IdentityService, groupId: number): Json function formatGroupMember( groupId: number, - member: { - uin: number; - nickname: string; - card: string; - joinTime: number; - lastSentTime: number; - level: number; - role: string; - title: string; - }, + member: GroupMemberInfo, ): JsonObject { + if (member.isRobot === undefined) { + throw new Error( + `group member robot classification unavailable: group=${groupId} member=${member.uin}`, + ); + } return { group_id: groupId, user_id: member.uin, nickname: member.nickname, card: member.card, + is_robot: member.isRobot, sex: 'unknown', age: 0, join_time: member.joinTime, diff --git a/packages/onebot/tests/contact-actions.test.ts b/packages/onebot/tests/contact-actions.test.ts index b2ea3d3a..aa0c1705 100644 --- a/packages/onebot/tests/contact-actions.test.ts +++ b/packages/onebot/tests/contact-actions.test.ts @@ -60,7 +60,11 @@ function fakeBridge(overrides: Record = {}): BridgeInterface { /** Same Proxy trick for the IdentityService surface that contact-actions touches. */ function fakeIdentity(overrides: Record = {}): BridgeInterface['identity'] { - return new Proxy(overrides as any, { + const target = Object.create(Object.getPrototypeOf(overrides), { + uin: { value: '10001', enumerable: true }, + ...Object.getOwnPropertyDescriptors(overrides), + }); + return new Proxy(target as any, { get(target, prop) { if (prop in target) return target[prop]; throw new Error(`fakeIdentity: '${String(prop)}' was not stubbed for this test`); @@ -82,9 +86,10 @@ function makeGroup(groupId: number, groupName: string, members: GroupMemberInfo[ }; } -function makeMember(uin: number, nickname: string, card = ''): GroupMemberInfo { +function makeMember(uin: number, nickname: string, card = '', isRobot = false): GroupMemberInfo { return { uin, uid: `u_${uin}`, nickname, card, + isRobot, role: 'member', level: 1, title: '', joinTime: 0, lastSentTime: 0, shutUpTime: 0, }; @@ -323,13 +328,20 @@ describe('onebot/contact-actions / getGroupInfo', () => { describe('onebot/contact-actions / getGroupMemberList', () => { it('returns the fetched roster mapped to OneBot shape', async () => { - const members = [makeMember(11, 'alice', 'A'), makeMember(22, 'bob', 'B')]; + const members = [makeMember(11, 'alice', 'A', true), makeMember(22, 'bob', 'B')]; const bridge = fakeBridge({ fetchGroupMemberList: vi.fn(async () => members), }); const out = await getGroupMemberList(bridge, 800); expect(out).toHaveLength(2); - expect(out[0]).toMatchObject({ group_id: 800, user_id: 11, nickname: 'alice', card: 'A' }); + expect(out[0]).toMatchObject({ + group_id: 800, + user_id: 11, + nickname: 'alice', + card: 'A', + is_robot: true, + }); + expect(out[1]).toMatchObject({ user_id: 22, is_robot: false }); }); it('falls back to the cached roster when fetch fails', async () => { @@ -345,12 +357,25 @@ describe('onebot/contact-actions / getGroupMemberList', () => { expect(out[0]).toMatchObject({ user_id: 33, nickname: 'cached' }); }); - it('returns [] when no roster is known and fetch fails', async () => { + it('propagates fetch failure when no classified roster is known', async () => { const bridge = fakeBridge({ fetchGroupMemberList: vi.fn(async () => { throw new Error('net'); }), identity: fakeIdentity({ findGroup: () => null }), }); - expect(await getGroupMemberList(bridge, 999)).toEqual([]); + await expect(getGroupMemberList(bridge, 999)).rejects.toThrow('net'); + }); + + it('rejects an unclassified cached roster instead of reporting false', async () => { + const member = { ...makeMember(34, 'unknown'), isRobot: undefined }; + const bridge = fakeBridge({ + fetchGroupMemberList: vi.fn(async () => { throw new Error('net'); }), + identity: fakeIdentity({ + findGroup: (gid: number) => gid === 901 ? makeGroup(901, '', [member]) : null, + }), + }); + + await expect(getGroupMemberList(bridge, 901)) + .rejects.toThrow('group member robot classification unavailable'); }); }); @@ -365,7 +390,7 @@ describe('onebot/contact-actions / getGroupMemberInfo', () => { }), }); const out = await getGroupMemberInfo(bridge, 1000, 44); - expect(out).toMatchObject({ user_id: 44, nickname: 'dave', card: 'D' }); + expect(out).toMatchObject({ user_id: 44, nickname: 'dave', card: 'D', is_robot: false }); expect(bridge.apis.contacts.fetchGroupMemberList).not.toHaveBeenCalled(); }); @@ -384,6 +409,40 @@ describe('onebot/contact-actions / getGroupMemberInfo', () => { expect(fetchGroupMemberList).toHaveBeenCalledOnce(); expect(out).toMatchObject({ user_id: 55, nickname: 'eve' }); }); + + it('refreshes a persisted member whose robot classification is unknown', async () => { + let member: GroupMemberInfo = { ...makeMember(66, 'legacy'), isRobot: undefined }; + const fetchGroupMemberList = vi.fn(async () => { + member = makeMember(66, 'legacy', '', true); + return [member]; + }); + const bridge = fakeBridge({ + fetchGroupMemberList, + identity: fakeIdentity({ + findGroupMember: (gid: number, uin: number) => + gid === 1200 && uin === 66 ? member : null, + }), + }); + + const out = await getGroupMemberInfo(bridge, 1200, 66); + + expect(fetchGroupMemberList).toHaveBeenCalledWith(1200, { force: false }); + expect(out).toMatchObject({ user_id: 66, is_robot: true }); + }); + + it('rejects when an unknown cached classification cannot be refreshed', async () => { + const member: GroupMemberInfo = { ...makeMember(77, 'unknown'), isRobot: undefined }; + const bridge = fakeBridge({ + fetchGroupMemberList: vi.fn(async () => { throw new Error('range unavailable'); }), + identity: fakeIdentity({ + findGroupMember: (gid: number, uin: number) => + gid === 1300 && uin === 77 ? member : null, + }), + }); + + await expect(getGroupMemberInfo(bridge, 1300, 77)) + .rejects.toThrow('range unavailable'); + }); }); describe('onebot/contact-actions / getStrangerInfo', () => { diff --git a/packages/proto-defs/src/oidb-actions/base.ts b/packages/proto-defs/src/oidb-actions/base.ts index eff21543..8b898da3 100644 --- a/packages/proto-defs/src/oidb-actions/base.ts +++ b/packages/proto-defs/src/oidb-actions/base.ts @@ -338,6 +338,15 @@ export interface OidbGroupMemberListRequest { body?: pb<4, OidbGroupMemberListBody>; token?: pb<15, string>; } +// 0x496_0 — fetch QQ's versioned robot-UIN classification ranges. +// Field names follow NodeIKernelRobotService.getRobotUinRange. QQ sends the +// four scalar values directly (there is no nested `req` message on the wire). +export interface OidbRobotUinRangeRequest { + justFetchMsgConfig?: pb<5, uint_32>; + type?: pb<6, uint_32>; + version?: pb_optional<7, uint_32>; + aioKeywordVersion?: pb_optional<8, uint_32>; +} export interface GroupRecallInfo { sequence?: pb<1, uint_32>; random?: pb<2, uint_32>; diff --git a/packages/proto-defs/src/oidb.ts b/packages/proto-defs/src/oidb.ts index dbb5d97e..fae55318 100644 --- a/packages/proto-defs/src/oidb.ts +++ b/packages/proto-defs/src/oidb.ts @@ -160,6 +160,24 @@ export interface OidbSvcTrpcTcp0xFE7_3Response { token?: pb<15, string>; } +// OIDB 0x496_0 — dynamic robot UIN ranges. QQ's native +// NodeIKernelRobotService uses this config when deciding whether a group +// member is a robot; the ranges are server-versioned and must not be +// hard-coded client-side. +export interface OidbRobotUinRange { + minUin?: pb<1, uint_64>; + maxUin?: pb<2, uint_64>; +} + +export interface OidbRobotUinRangeConfig { + version?: pb<1, uint_32>; + ranges?: pb_repeated<2, OidbRobotUinRange>; +} + +export interface OidbRobotUinRangeResponse { + robotConfig?: pb<5, OidbRobotUinRangeConfig>; +} + // OIDB.0x10C0 Group Request export interface OidbSvcTrpcTcp0x10C0ResponseUser { uid?: pb<1, string>; diff --git a/packages/protocol/src/identity-service.ts b/packages/protocol/src/identity-service.ts index fec2b25e..126875ee 100644 --- a/packages/protocol/src/identity-service.ts +++ b/packages/protocol/src/identity-service.ts @@ -76,6 +76,7 @@ interface MemberInput { joinTime?: number; lastSentTime?: number; shutUpTime?: number; + isRobot?: boolean; active?: boolean; } @@ -102,6 +103,7 @@ interface MemberRow { join_time: number; last_sent_time: number; shut_up_time: number; + is_robot: number | null; active: number; } @@ -179,6 +181,7 @@ export class IdentityService { this.db.exec('PRAGMA journal_mode = WAL'); this.db.exec('PRAGMA synchronous = NORMAL'); this.initSchema(); + this.migrateSchema(); this.loadSnapshot(); } @@ -711,6 +714,7 @@ export class IdentityService { join_time INTEGER NOT NULL DEFAULT 0, last_sent_time INTEGER NOT NULL DEFAULT 0, shut_up_time INTEGER NOT NULL DEFAULT 0, + is_robot INTEGER, active INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL, UNIQUE(group_id, uid), @@ -725,6 +729,14 @@ export class IdentityService { `); } + private migrateSchema(): void { + const columns = this.db!.prepare('PRAGMA table_info(group_members)').all() as Array<{ name: string }>; + if (columns.some((column) => column.name === 'is_robot')) return; + + this.db!.exec('ALTER TABLE group_members ADD COLUMN is_robot INTEGER'); + this.log.info('identity schema migrated: added group_members.is_robot'); + } + private loadSnapshot(): void { const friendRows = this.pstmt( `SELECT uid, uin, nickname, remark @@ -766,7 +778,7 @@ export class IdentityService { const select = this.pstmt( `SELECT group_id, uid, uin, nickname, card, role, level, title, - join_time, last_sent_time, shut_up_time + join_time, last_sent_time, shut_up_time, is_robot FROM group_members WHERE group_id = ? AND active = 1`, ); @@ -783,6 +795,7 @@ export class IdentityService { join_time: number; last_sent_time: number; shut_up_time: number; + is_robot: number | null; }>; const members = rows.map(rowToMemberInfo); this.setGroupMembersInMemory(groupId, members); @@ -904,6 +917,7 @@ export class IdentityService { joinTime: normalizeNonNegative(input.joinTime, primary?.join_time ?? 0), lastSentTime: normalizeNonNegative(input.lastSentTime, primary?.last_sent_time ?? 0), shutUpTime: normalizeNonNegative(input.shutUpTime, primary?.shut_up_time ?? 0), + isRobot: input.isRobot === undefined ? (primary?.is_robot ?? null) : input.isRobot ? 1 : 0, active: input.active === false ? 0 : input.active === true ? 1 : (primary?.active ?? 1), updatedAt: nowSeconds(), }; @@ -915,7 +929,7 @@ export class IdentityService { this.pstmt( `UPDATE group_members SET uid = ?, uin = ?, nickname = ?, card = ?, role = ?, level = ?, title = ?, - join_time = ?, last_sent_time = ?, shut_up_time = ?, active = ?, updated_at = ? + join_time = ?, last_sent_time = ?, shut_up_time = ?, is_robot = ?, active = ?, updated_at = ? WHERE id = ?`, ).run( merged.uid, @@ -928,6 +942,7 @@ export class IdentityService { merged.joinTime, merged.lastSentTime, merged.shutUpTime, + merged.isRobot, merged.active, merged.updatedAt, primary.id, @@ -938,8 +953,8 @@ export class IdentityService { this.pstmt( `INSERT INTO group_members (group_id, uid, uin, nickname, card, role, level, title, - join_time, last_sent_time, shut_up_time, active, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + join_time, last_sent_time, shut_up_time, is_robot, active, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( input.groupId, merged.uid, @@ -952,6 +967,7 @@ export class IdentityService { merged.joinTime, merged.lastSentTime, merged.shutUpTime, + merged.isRobot, merged.active, merged.updatedAt, ); @@ -982,7 +998,7 @@ export class IdentityService { if (uid && uin !== null) { return this.pstmt( `SELECT id, group_id, uid, uin, nickname, card, role, level, title, - join_time, last_sent_time, shut_up_time, active + join_time, last_sent_time, shut_up_time, is_robot, active FROM group_members WHERE group_id = ? AND (uid = ? OR uin = ?) ORDER BY id`, @@ -991,7 +1007,7 @@ export class IdentityService { if (uid) { return this.pstmt( `SELECT id, group_id, uid, uin, nickname, card, role, level, title, - join_time, last_sent_time, shut_up_time, active + join_time, last_sent_time, shut_up_time, is_robot, active FROM group_members WHERE group_id = ? AND uid = ? ORDER BY id`, @@ -1000,7 +1016,7 @@ export class IdentityService { if (uin !== null) { return this.pstmt( `SELECT id, group_id, uid, uin, nickname, card, role, level, title, - join_time, last_sent_time, shut_up_time, active + join_time, last_sent_time, shut_up_time, is_robot, active FROM group_members WHERE group_id = ? AND uin = ? ORDER BY id`, @@ -1187,6 +1203,7 @@ function rowToMemberInfo(row: { join_time: number; last_sent_time: number; shut_up_time: number; + is_robot: number | null; }): GroupMemberInfo { return { uid: row.uid ?? '', @@ -1199,6 +1216,7 @@ function rowToMemberInfo(row: { joinTime: row.join_time, lastSentTime: row.last_sent_time, shutUpTime: row.shut_up_time, + isRobot: row.is_robot === null ? undefined : row.is_robot !== 0, }; } diff --git a/packages/protocol/src/oidb-services/contacts/fetch-robot-uin-ranges.ts b/packages/protocol/src/oidb-services/contacts/fetch-robot-uin-ranges.ts new file mode 100644 index 00000000..0d4de574 --- /dev/null +++ b/packages/protocol/src/oidb-services/contacts/fetch-robot-uin-ranges.ts @@ -0,0 +1,86 @@ +// 0x496_0 — fetch QQ's server-versioned robot UIN ranges. +// +// The 0xFE7_3 group-member response has no robot flag. QQ's own renderer +// initializes its robot service with this read-only request and classifies +// non-buddy UINs against the returned inclusive ranges. Keeping the config +// dynamic avoids baking Tencent's current allocation into SnowLuma. + +import type { OidbBase, OidbRobotUinRangeResponse } from '@snowluma/proto-defs/oidb'; +import type { OidbRobotUinRangeRequest } from '@snowluma/proto-defs/oidb-actions/base'; +import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; +import { invokeOidb, type OidbSender } from '../../oidb-service'; + +export interface RobotUinRange { + minUin: number; + maxUin: number; +} + +export interface RobotUinRangeSnapshot { + version: number; + ranges: RobotUinRange[]; +} + +function decodeUin(value: bigint | number | undefined, label: string): number { + const decoded = Number(value ?? 0); + if (!Number.isSafeInteger(decoded) || decoded <= 0 || decoded > 0xFFFF_FFFF) { + throw new Error(`0x496_0 response has invalid ${label}: ${String(value)}`); + } + return decoded; +} + +export namespace FetchRobotUinRanges { + export const command = 0x496; + export const subCommand = 0; + + export type Deps = OidbSender; + + export const serialize = (): OidbRobotUinRangeRequest => ({ + justFetchMsgConfig: 1, + type: 1, + version: 0, + aioKeywordVersion: 0, + }); + + export const deserialize = ( + _ctx: Deps, + body: OidbRobotUinRangeResponse, + ): RobotUinRangeSnapshot => { + const config = body.robotConfig; + if (!config) { + throw new Error('0x496_0 response missing robot range config'); + } + + if (config.version === undefined) { + throw new Error('0x496_0 response missing robot range config version'); + } + const version = Number(config.version); + if (!Number.isSafeInteger(version) || version < 0) { + throw new Error(`0x496_0 response has invalid config version: ${String(config.version)}`); + } + + if (!config.ranges || config.ranges.length === 0) { + throw new Error('0x496_0 response has no robot UIN ranges'); + } + const ranges = config.ranges.map((range, index) => { + const minUin = decodeUin(range.minUin, `range[${index}].minUin`); + const maxUin = decodeUin(range.maxUin, `range[${index}].maxUin`); + if (minUin > maxUin) { + throw new Error( + `0x496_0 response has inverted range[${index}]: ${minUin}-${maxUin}`, + ); + } + return { minUin, maxUin }; + }); + + return { version, ranges }; + }; + + export const encode = (env: OidbBase): Uint8Array => + protobuf_encode>(env); + + export const decode = (bytes: Uint8Array): OidbBase => + protobuf_decode>(bytes); + + export const invoke = (deps: Deps): Promise => + invokeOidb(deps, FetchRobotUinRanges, undefined); +} diff --git a/packages/protocol/src/qq-info.ts b/packages/protocol/src/qq-info.ts index 85160177..f817f7d7 100644 --- a/packages/protocol/src/qq-info.ts +++ b/packages/protocol/src/qq-info.ts @@ -34,6 +34,8 @@ export interface GroupMemberInfo { uid: string; nickname: string; card: string; + /** Undefined until QQ's server-provided robot ranges classify this member. */ + isRobot?: boolean; role: string; // 'owner' | 'admin' | 'member' level: number; title: string; diff --git a/packages/protocol/tests/oidb-services/contacts/fetch-robot-uin-ranges.test.ts b/packages/protocol/tests/oidb-services/contacts/fetch-robot-uin-ranges.test.ts new file mode 100644 index 00000000..f7fc36c1 --- /dev/null +++ b/packages/protocol/tests/oidb-services/contacts/fetch-robot-uin-ranges.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; +import { protobuf_decode } from '@snowluma/proton'; +import type { OidbBase } from '@snowluma/proto-defs/oidb'; +import type { OidbRobotUinRangeRequest } from '@snowluma/proto-defs/oidb-actions/base'; +import type { SendPacketResult } from '@snowluma/common/packet-sender'; + +import { FetchRobotUinRanges } from '../../../src/oidb-services/contacts/fetch-robot-uin-ranges'; + +// Captured from QQ's 0x496_0 response and reduced to the versioned range +// config. Keeping this as wire bytes makes the test independent of the +// encoder used by the implementation. +const RESPONSE_FIXTURE = Buffer.from( + '0896091000180022492a4708ce01120c08fee8fdb20c10fee8fdb20c' + + '120c08cda8fed00a10cfc5ffd00a120a08c0f8e01f10c0f8e01f12' + + '0c08c0dcb5be0e10ffe0f2be0e120c0880fd8ef80e10ffa9f1fc0e', + 'hex', +); + +function makeSender(responseData = RESPONSE_FIXTURE) { + const result: SendPacketResult = { + success: true, + gotResponse: true, + errorCode: 0, + errorMessage: '', + responseData, + }; + return { sendRawPacket: vi.fn(async () => result) }; +} + +describe('FetchRobotUinRanges namespace', () => { + it('routes the read-only request to 0x496_0 with QQ-compatible fields', async () => { + const sender = makeSender(); + + await FetchRobotUinRanges.invoke(sender); + + expect(sender.sendRawPacket).toHaveBeenCalledOnce(); + const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!; + expect(wireName).toBe('OidbSvcTrpcTcp.0x496_0'); + const request = protobuf_decode>(bytes); + expect(request.body).toEqual({ + justFetchMsgConfig: 1, + type: 1, + version: 0, + aioKeywordVersion: 0, + }); + }); + + it('decodes the server version and every inclusive UIN range', async () => { + const sender = makeSender(); + + await expect(FetchRobotUinRanges.invoke(sender)).resolves.toEqual({ + version: 206, + ranges: [ + { minUin: 3328144510, maxUin: 3328144510 }, + { minUin: 2854196301, maxUin: 2854216399 }, + { minUin: 66600000, maxUin: 66600000 }, + { minUin: 3889000000, maxUin: 3889999999 }, + { minUin: 4010000000, maxUin: 4019999999 }, + ], + }); + }); + + it('rejects a successful envelope that omits the robot range config', async () => { + const sender = makeSender(Buffer.from('089609100018002200', 'hex')); + + await expect(FetchRobotUinRanges.invoke(sender)) + .rejects.toThrow('0x496_0 response missing robot range config'); + }); + + it('rejects a robot config without its version', () => { + expect(() => FetchRobotUinRanges.deserialize(makeSender(), { + robotConfig: { + ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }], + }, + })).toThrow('0x496_0 response missing robot range config version'); + }); + + it('rejects an empty range set instead of classifying everyone as human', () => { + expect(() => FetchRobotUinRanges.deserialize(makeSender(), { + robotConfig: { version: 206, ranges: [] }, + })).toThrow('0x496_0 response has no robot UIN ranges'); + }); +});