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