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
This commit is contained in:
DrSmoothl
2026-07-21 23:27:01 +08:00
parent ff60221330
commit eed2e692b1
16 changed files with 596 additions and 36 deletions
+50
View File
@@ -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<RobotUinRangeSnapshot> | null = null;
constructor(private readonly ctx: BridgeContext) { }
private fetchRobotUinRanges(groupId: number): Promise<RobotUinRangeSnapshot> {
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<string> {
return GetBuddyRecommendArk.invoke(this.ctx, { userId, phoneNumber });
@@ -241,6 +281,10 @@ export class ContactsApi {
}
private async fetchGroupMemberListUncached(groupId: number): Promise<GroupMemberInfo[]> {
// 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;
}
+118 -1
View File
@@ -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<OidbBase<OidbRobotUinRangeResponse>>({ body }),
),
};
}
function memberListPacket(body: OidbSvcTrpcTcp0xFE7_3Response): SendPacketResult {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE7_3Response>>({ 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<SendPacketResult> => {
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<OidbBase<OidbRobotUinRangeRequest>>(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<SendPacketResult> => {
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();
});
});
@@ -14,6 +14,7 @@ function makeGroupMember(uin: number, uid: string): GroupMemberInfo {
uid,
nickname: '',
card: '',
isRobot: false,
role: 'member',
level: 0,
title: '',
+78 -1
View File
@@ -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');
@@ -39,6 +39,7 @@ function makeGroupMember(uin: number, uid: string): GroupMemberInfo {
uid,
nickname: '',
card: '',
isRobot: false,
role: 'member',
level: 0,
title: '',
+8
View File
@@ -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": [
+8
View File
@@ -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": [
@@ -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: '',
});
+39 -20
View File
@@ -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<void> {
async function fetchSingleGroupMembers(
bridge: BridgeInterface,
groupId: number,
force = false,
): Promise<GroupMemberInfo[]> {
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<JsonObject[]> {
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<JsonObject | null> {
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,
+66 -7
View File
@@ -60,7 +60,11 @@ function fakeBridge(overrides: Record<string, any> = {}): BridgeInterface {
/** Same Proxy trick for the IdentityService surface that contact-actions touches. */
function fakeIdentity(overrides: Record<string, unknown> = {}): 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', () => {
@@ -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>;
+18
View File
@@ -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>;
+25 -7
View File
@@ -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,
};
}
@@ -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<OidbRobotUinRangeRequest>): Uint8Array =>
protobuf_encode<OidbBase<OidbRobotUinRangeRequest>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbRobotUinRangeResponse> =>
protobuf_decode<OidbBase<OidbRobotUinRangeResponse>>(bytes);
export const invoke = (deps: Deps): Promise<RobotUinRangeSnapshot> =>
invokeOidb(deps, FetchRobotUinRanges, undefined);
}
+2
View File
@@ -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;
@@ -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<OidbBase<OidbRobotUinRangeRequest>>(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');
});
});