feat(onebot): implement online client queries

Replace the get_online_clients placeholder with the latest QQ online-device snapshot and return stable OneBot fields for client ID, device name, and localized device category.

Fail explicitly when no snapshot has been observed or when callers request an unsupported fresh refresh, while preserving an observed empty snapshot as a successful result. Update SDK types and generated MCP schemas, and add action-level coverage for every state.
This commit is contained in:
DrSmoothl
2026-07-19 19:20:28 +08:00
parent 168f414cd9
commit f3bfec04a5
7 changed files with 209 additions and 30 deletions
+50 -5
View File
@@ -4982,14 +4982,42 @@
{
"name": "get_online_clients",
"aliases": [],
"summary": "获取在线客户端(占位,OneBot v11 形状)",
"returns": "{ clients }:在线设备列表(占位,clients 恒为空数组)。",
"summary": "获取在线客户端",
"returns": "{ clients }:QQ 本次会话最近推送的在线设备快照。",
"returnsSchema": {
"type": "object",
"properties": {
"clients": {
"type": "array",
"description": "在线设备列表(占位,恒空)"
"description": "在线设备列表",
"items": {
"type": "object",
"properties": {
"app_id": {
"type": "integer",
"description": "客户端 ID"
},
"device_name": {
"type": "string",
"description": "设备名称"
},
"device_kind": {
"type": "string",
"enum": [
"电脑",
"Pad",
"手机",
"未知设备"
],
"description": "设备类别"
}
},
"required": [
"app_id",
"device_name",
"device_kind"
]
}
}
},
"required": [
@@ -4997,11 +5025,28 @@
]
},
"readOnly": true,
"params": [],
"params": [
{
"name": "no_cache",
"type": "bool",
"required": false,
"schema": {
"type": "boolean"
},
"default": false,
"desc": "是否强制刷新;QQ 当前仅暴露本地快照,true 会明确失败"
}
],
"invariants": [],
"inputSchema": {
"type": "object",
"properties": {},
"properties": {
"no_cache": {
"type": "boolean",
"description": "是否强制刷新;QQ 当前仅暴露本地快照,true 会明确失败",
"default": false
}
},
"additionalProperties": true
},
"category": "扩展"
+50 -5
View File
@@ -4985,14 +4985,42 @@ export const ACTIONS: CatalogAction[] = [
{
"name": "get_online_clients",
"aliases": [],
"summary": "获取在线客户端(占位,OneBot v11 形状)",
"returns": "{ clients }:在线设备列表(占位,clients 恒为空数组)。",
"summary": "获取在线客户端",
"returns": "{ clients }:QQ 本次会话最近推送的在线设备快照。",
"returnsSchema": {
"type": "object",
"properties": {
"clients": {
"type": "array",
"description": "在线设备列表(占位,恒空)"
"description": "在线设备列表",
"items": {
"type": "object",
"properties": {
"app_id": {
"type": "integer",
"description": "客户端 ID"
},
"device_name": {
"type": "string",
"description": "设备名称"
},
"device_kind": {
"type": "string",
"enum": [
"电脑",
"Pad",
"手机",
"未知设备"
],
"description": "设备类别"
}
},
"required": [
"app_id",
"device_name",
"device_kind"
]
}
}
},
"required": [
@@ -5000,11 +5028,28 @@ export const ACTIONS: CatalogAction[] = [
]
},
"readOnly": true,
"params": [],
"params": [
{
"name": "no_cache",
"type": "bool",
"required": false,
"schema": {
"type": "boolean"
},
"default": false,
"desc": "是否强制刷新;QQ 当前仅暴露本地快照,true 会明确失败"
}
],
"invariants": [],
"inputSchema": {
"type": "object",
"properties": {},
"properties": {
"no_cache": {
"type": "boolean",
"description": "是否强制刷新;QQ 当前仅暴露本地快照,true 会明确失败",
"default": false
}
},
"additionalProperties": true
},
"category": "扩展"
+45 -10
View File
@@ -1199,25 +1199,60 @@ export const actions = [
},
}),
// get_online_clients — 在线设备列表。NapCat 也拿不到(注册监听后 sleep 500ms
// 直接返回空),且其返回是裸数组 [],偏离 OneBot v11 / go-cqhttp 规范。
// SnowLuma 暂无对应单包 SSO cmd,返回 OneBot v11 标准空壳 { clients: [] }
// ——刻意采用规范形状而非 NapCat 的裸 [],故对照 NapCat 客户端此处不严格 parity。
defineAction({
name: 'get_online_clients',
summary: '获取在线客户端(占位,OneBot v11 形状)',
summary: '获取在线客户端',
readOnly: true,
returns: '{ clients }:在线设备列表(占位,clients 恒为空数组)。',
returns: '{ clients }:QQ 本次会话最近推送的在线设备快照。',
returnsSchema: {
type: 'object',
properties: {
clients: { type: 'array', description: '在线设备列表(占位,恒空)' },
clients: {
type: 'array',
description: '在线设备列表',
items: {
type: 'object',
properties: {
app_id: { type: 'integer', description: '客户端 ID' },
device_name: { type: 'string', description: '设备名称' },
device_kind: {
type: 'string',
enum: ['电脑', 'Pad', '手机', '未知设备'],
description: '设备类别',
},
},
required: ['app_id', 'device_name', 'device_kind'],
},
},
},
required: ['clients'],
},
params: {},
run: async () => {
return okResponse({ clients: [] });
params: {
no_cache: f.bool().default(false).describe('是否强制刷新;QQ 当前仅暴露本地快照,true 会明确失败'),
},
run: async (p, ctx) => {
if (p.no_cache) {
return failedResponse(RETCODE.ACTION_FAILED,
'online-client fresh refresh is unavailable; QQ exposes only its local snapshot');
}
const snapshot = ctx.bridge.getOnlineClients();
if (snapshot === null) {
return failedResponse(RETCODE.ACTION_FAILED,
'online-client snapshot has not been observed in this session');
}
const kindLabels = {
computer: '电脑',
pad: 'Pad',
phone: '手机',
unknown: '未知设备',
} as const;
return okResponse({
clients: snapshot.map((device) => ({
app_id: device.appId,
device_name: device.deviceName,
device_kind: kindLabels[device.deviceKind],
})),
});
},
}),
+52 -7
View File
@@ -1059,9 +1059,9 @@ describe('extended-actions / ocr_image', () => {
// NOTE two deliberate divergences from NapCat:
// • _get_model_show reuses NapCat's array/variants shape but ECHOES the
// requested model instead of NapCat's hardcoded 'napcat'.
// • get_online_clients returns the OneBot-v11/go-cqhttp { clients: [] }
// envelope, NOT NapCat's (non-standard) bare []. A strict-NapCat client
// would expect an array here; we intentionally follow the spec instead.
// • get_online_clients returns the OneBot-v11/go-cqhttp { clients }
// envelope, NOT NapCat's (non-standard) bare array. Its contents come from
// the latest QQ online-device snapshot observed by this Bridge lifecycle.
describe('extended-actions / TierB compat stubs', () => {
it('_get_model_show returns the napcat-shaped variants array, echoing the model', async () => {
@@ -1087,10 +1087,55 @@ describe('extended-actions / TierB compat stubs', () => {
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: null });
});
it('get_online_clients returns the OneBot-standard {clients:[]} envelope', async () => {
const res = await makeHandler(fakeCtx(fakeBridge())).handle('get_online_clients', {});
expect(res.status).toBe('ok');
expect(res.data).toMatchObject({ clients: [] });
it('get_online_clients returns the latest observed QQ device snapshot', async () => {
const getOnlineClients = vi.fn(() => [{
appId: 537242075,
instanceId: 202,
clientType: 1,
platform: 3,
deviceName: 'DESKTOP-TEST',
deviceKind: 'computer',
}]);
const res = await makeHandler(fakeCtx(fakeBridge({ getOnlineClients })))
.handle('get_online_clients', {});
expect(getOnlineClients).toHaveBeenCalledOnce();
expect(res).toMatchObject({
status: 'ok',
retcode: 0,
data: {
clients: [{
app_id: 537242075,
device_name: 'DESKTOP-TEST',
device_kind: '电脑',
}],
},
});
});
it('get_online_clients reports an unavailable snapshot instead of a false empty list', async () => {
const res = await makeHandler(fakeCtx(fakeBridge({ getOnlineClients: () => null })))
.handle('get_online_clients', {});
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
expect(res.wording).toMatch(/snapshot has not been observed/i);
});
it('get_online_clients preserves an observed empty snapshot as a successful result', async () => {
const res = await makeHandler(fakeCtx(fakeBridge({ getOnlineClients: () => [] })))
.handle('get_online_clients', {});
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: { clients: [] } });
});
it('get_online_clients rejects a forced refresh that QQ does not expose on the packet wire', async () => {
const getOnlineClients = vi.fn(() => []);
const res = await makeHandler(fakeCtx(fakeBridge({ getOnlineClients })))
.handle('get_online_clients', { no_cache: true });
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
expect(res.wording).toMatch(/fresh refresh is unavailable/i);
expect(getOnlineClients).not.toHaveBeenCalled();
});
it('_mark_all_as_read passes every observed session to the batched SSO implementation', async () => {
+2 -1
View File
@@ -22,6 +22,7 @@ import type {
GetGroupMemberListParams,
GetGroupMessageHistoryParams,
GetMediaParams,
GetOnlineClientsParams,
GetPrivateFileUrlParams,
GroupForwardMessageParams,
GroupIdParams,
@@ -199,7 +200,7 @@ export interface SnowLumaActionMap {
get_emoji_likes: { params: GetEmojiLikesParams; data: JsonObject };
fetch_emoji_like: { params: FetchEmojiLikeParams; data: JsonObject };
get_friends_with_category: { params: JsonObject; data: FriendCategoryResult[] };
get_online_clients: { params: JsonObject; data: OnlineClientsInfo };
get_online_clients: { params: GetOnlineClientsParams; data: OnlineClientsInfo };
_get_model_show: { params: JsonObject; data: { variants: JsonArray } };
_set_model_show: { params: JsonObject; data: EmptyData };
'.get_word_slices': { params: JsonObject; data: JsonValue };
+2
View File
@@ -36,6 +36,8 @@ export interface NoCacheParams extends JsonObject {
no_cache?: boolean;
}
export interface GetOnlineClientsParams extends NoCacheParams {}
export interface GetGroupListParams extends NoCacheParams {}
export interface GetGroupInfoParams extends GroupIdParams {
+8 -2
View File
@@ -1,4 +1,4 @@
import type { JsonArray, JsonObject, JsonValue } from './json';
import type { JsonObject, JsonValue } from './json';
export interface SendMessageResult {
message_id: number;
@@ -116,8 +116,14 @@ export interface ClientKeyInfo {
expireTime: string;
}
export interface OnlineClientInfo {
app_id: number;
device_name: string;
device_kind: '电脑' | 'Pad' | '手机' | '未知设备';
}
export interface OnlineClientsInfo {
clients: JsonArray;
clients: OnlineClientInfo[];
}
export interface UrlSafetyInfo {