feat(mcp): complete action results and invocation examples

Document result shapes and practical calls for all built-in actions,
including prerequisites, streaming workflows and compatibility limits.

Preserve existing interfaces and execution behavior, and verify the
catalog against action validation and representative responses.
This commit is contained in:
DrSmoothl
2026-10-23 15:45:07 +08:00
parent 34a73084e0
commit 2d659b0ceb
27 changed files with 14140 additions and 33 deletions
+3 -1
View File
@@ -101,7 +101,9 @@ WebUI deployment targets use a reversible presentation overlay. Read [the host a
## Generated files and related changes
OneBot action definitions own validation and catalog metadata. After changing an action name, parameter, result description or execution mode:
OneBot action definitions own validation and catalog metadata. Each bundled action supplies a `returnsSchema` for successful OneBot `data` and at least one `examples` entry with valid parameters and any required identifier sources. Stream schemas describe individual successful frames, not MCP aggregated results. Keep undocumented upstream fields open rather than inventing a structure, and label unsupported or placeholder behavior explicitly.
After changing an action name, parameter, result description/schema, example or execution mode:
1. Update the owning definition in `packages/onebot/src/actions` and its tests.
2. Run `pnpm --filter @snowluma/mcp gen`.
+42
View File
@@ -5,6 +5,48 @@ and read/write gates. No endpoint is contacted by catalog tools or by a local
runtime overview. The action catalog remains a bundled snapshot, not a claim
about the connected server's capabilities.
## Action result schemas and examples
`get_action` and `snowluma://onebot/actions` include `returnsSchema` and
`examples` for every bundled action, including aliases. Both are authored beside
the OneBot action definition and copied into the generated catalog. List/search
responses remain compact; use `get_action` to retrieve the complete contract.
`returnsSchema` describes successful OneBot **`data`**, not the full OneBot
envelope or the MCP `structuredContent` wrapper. Check `status` and `retcode`
before reading it. For stream actions, the schema describes each successful
frame's `data`; MCP downloads instead return saved-file metadata and generic
streams return a frame collection. Error frames and failed envelopes are not
successful results. Unsupported actions use `{ "not": {} }` because they have no
successful result. Compatibility placeholders explicitly document their limits.
Each example has `summary`, `params`, and optional `notes` explaining prerequisites
and identifier sources. These are illustrative calls, never run by catalog tools.
Replace example account/group IDs, opaque handles and paths with values from the
current account before execution. Notes identify operations with multiple steps,
such as completing an upload after all chunks have been acknowledged.
For example, `get_action({ "name": "get_msg" })` documents signed message IDs:
```json
{
"action": "get_msg",
"params": { "message_id": -123456 }
}
```
Pass this object to `query_action` with a real message ID. Writes and streams use
`invoke_action` in write mode. For automatic upload, pass
`{ "action": "upload_file_stream", "execution": { "input_file": "/allowed/hello.txt" } }`
to `invoke_action`, with a real file inside the configured upload root. Ordinary
action paths refer to the SnowLuma server, whereas `execution.input_file` and MCP
download paths refer to the MCP host.
Do not substitute zero for a signed message ID. Resource-only forward uploads
and flash shares have a documented `message_id: 0` sentinel; it cannot be used
for message lookup or recall. Also preserve `null` versus `{}`, optional fields,
and decimal strings such as media `file_size` exactly as documented.
## Structured results
Every tool advertises an `outputSchema` and returns `structuredContent` alongside
+2
View File
@@ -18,6 +18,8 @@ it('generates src/generated/catalog.ts from the live action specs', () => {
for (const a of actions) {
expect(a.inputSchema).toBeTypeOf('object');
expect(a.name).toBeTruthy();
expect(a.returnsSchema, `${a.name} result schema`).toBeTypeOf('object');
expect(a.examples?.length, `${a.name} invocation examples`).toBeGreaterThan(0);
}
const body =
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -66,7 +66,8 @@ const DOCS_TOOLS: Tool[] = [
},
{
name: 'get_action',
description: '获取 action 文档、参数 JSON Schema、约束和返回类型。',
description:
'获取 action 文档、参数与返回数据 JSON Schema、约束和调用示例。示例中的标识和路径需替换;流式返回结构描述单帧数据。',
inputSchema: {
type: 'object',
properties: { name: { type: 'string', description: 'action 名(接受别名)' } },
+8 -1
View File
@@ -26,8 +26,15 @@ export interface CatalogAction {
category?: string;
summary?: string;
returns?: string;
/** JSON Schema for the action's `data` payload (absent when undocumented). */
/** Successful OneBot `data`, or one successful stream frame's `data`.
* Does not describe the envelope or MCP's aggregated stream result. */
returnsSchema?: Record<string, unknown>;
/** Illustrative OneBot params; identifiers/paths must be replaced before use. */
examples?: ReadonlyArray<{
summary: string;
params: Record<string, unknown>;
notes?: string;
}>;
/** True only for pure data-fetch actions (no side effects). Drives the
* read/write tool routing: read-only → query_action, else → invoke_action.
* Classified at the source spec by what the action's `run` actually does. */
+245
View File
@@ -0,0 +1,245 @@
import { describe, expect, it, vi } from 'vitest';
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js';
import { collectActionDocs } from '@snowluma/onebot/action-docs';
import { ApiHandler, type ApiActionContext } from '@snowluma/onebot/api-handler';
import type { JsonObject, JsonValue } from '@snowluma/onebot/types';
import { ACTIONS } from '../src/generated/catalog';
import { callTool } from '../src/tools';
const validator = new AjvJsonSchemaValidator();
function resultValidator(name: string) {
const action = ACTIONS.find((entry) => entry.name === name);
if (!action?.returnsSchema) {
throw new Error(`missing result schema: ${name}`);
}
return validator.getValidator(action.returnsSchema);
}
function matchesResult(name: string, data: unknown): void {
const result = resultValidator(name)(data);
expect(result.valid, `${name}: ${result.errorMessage}`).toBe(true);
}
/** Unexpected dependencies fail rather than contacting an account or a network. */
function context(values: Record<string, unknown> = {}): ApiActionContext {
return new Proxy(values, {
get(target, key) {
if (typeof key === 'string' && Object.hasOwn(target, key)) {
return target[key];
}
throw new Error(`unstubbed action context: ${String(key)}`);
},
}) as unknown as ApiActionContext;
}
describe('bundled action contracts', () => {
it('matches the owning specs, without relying on a second hand-authored registry', () => {
expect(ACTIONS).toEqual(JSON.parse(JSON.stringify(collectActionDocs())));
});
it.each(ACTIONS)('$name publishes compilable schemas and schema-valid examples', (action) => {
expect(() => resultValidator(action.name)).not.toThrow();
const validateInput = validator.getValidator(action.inputSchema);
expect(action.examples?.length).toBeGreaterThan(0);
for (const example of action.examples ?? []) {
const result = validateInput(example.params);
expect(result.valid, `${action.name}: ${result.errorMessage}`).toBe(true);
}
});
it('exposes full contracts through get_action and aliases without executing anything', async () => {
const client = { call: vi.fn(), callStream: vi.fn() };
for (const action of ACTIONS) {
for (const name of [action.name, ...action.aliases]) {
const result = await callTool('get_action', { name }, { mode: 'docs', client });
expect(result.structuredContent.result).toEqual(action);
expect(result.content[0]).toEqual({
type: 'text',
text: JSON.stringify(action, null, 2),
});
}
}
expect(client.call).not.toHaveBeenCalled();
expect(client.callStream).not.toHaveBeenCalled();
});
it.each(['list_actions', 'search_actions'])('keeps %s compact', async (name) => {
const result = await callTool(name, { query: 'send_group_msg' }, { mode: 'docs' });
const block = result.content[0];
expect(block.type).toBe('text');
if (block.type !== 'text') {
throw new Error('expected text result');
}
const payload = JSON.parse(block.text);
expect(payload.actions.length).toBeGreaterThan(0);
for (const action of payload.actions) {
expect(action).not.toHaveProperty('examples');
expect(action).not.toHaveProperty('returnsSchema');
}
});
});
describe('result schemas against actual OneBot handlers', () => {
it('preserves signed message IDs and documents the upload-only sentinel separately', async () => {
const api = new ApiHandler(
context({
sendGroupMessage: async () => ({ messageId: -123 }),
sendForwardMsg: async () => ({ forwardId: 'example-forward' }),
}),
);
const send = await api.handle('send_group_msg', { group_id: 20001, message: 'hello' });
expect(send).toEqual({ status: 'ok', retcode: 0, data: { message_id: -123 } });
matchesResult('send_group_msg', send.data);
expect(resultValidator('send_group_msg')({ message_id: 0 }).valid).toBe(false);
for (const name of ['upload_forward_msg', 'upload_foward_msg']) {
const uploaded = await api.handle(name, {
group_id: 20001,
messages: [
{
type: 'node',
data: { user_id: 10001, nickname: 'example', content: 'hello' },
},
],
});
expect(uploaded.status).toBe('ok');
expect(uploaded.data).toMatchObject({ message_id: 0, res_id: 'example-forward' });
matchesResult(name, uploaded.data);
if (name === 'upload_forward_msg') {
expect(uploaded.data).toHaveProperty('group_id', 20001);
} else {
expect(uploaded.data).not.toHaveProperty('group_id');
}
}
});
it('distinguishes null, empty objects, and failed unsupported calls', async () => {
const api = new ApiHandler(
context({
bridge: {
apis: {
friend: { setRemark: async () => {} },
profile: { setInputStatus: async () => {} },
},
},
}),
);
const empty = await api.handle('set_friend_remark', { user_id: 10001, remark: 'example' });
expect(empty.data).toBeNull();
matchesResult('set_friend_remark', empty.data);
expect(resultValidator('set_friend_remark')({}).valid).toBe(false);
const object = await api.handle('set_input_status', { user_id: 10001 });
expect(object.data).toEqual({});
matchesResult('set_input_status', object.data);
expect(resultValidator('set_input_status')(null).valid).toBe(false);
for (const name of ['set_restart', 'create_collection', '.get_word_slices']) {
const failure = await api.handle(name, {});
expect(failure.status).toBe('failed');
expect(resultValidator(name)(failure.data).valid).toBe(false);
}
});
it('documents actual media string sizes and optional converted record fields', async () => {
const media = {
file: 'https://example.com/media',
url: 'https://example.com/media',
file_size: '42',
file_name: 'voice.amr',
};
const api = new ApiHandler(
context({
getImageInfo: async () => media,
getRecordInfo: async () => media,
bridge: {
apis: {
extras: { convertRecord: async () => ({ base64: 'aGVsbG8=', size: 5 }) },
},
},
}),
);
for (const name of ['get_image', 'get_file', 'get_record']) {
const result = await api.handle(name, { file: 'cached-media' });
expect(result.status).toBe('ok');
matchesResult(name, result.data);
expect(resultValidator(name)({ ...media, file_size: 42 }).valid).toBe(false);
}
const converted = await api.handle('get_record', {
file: 'cached-media',
out_format: 'mp3',
});
expect(converted.data).toEqual({ ...media, out_format: 'mp3', base64: 'aGVsbG8=' });
matchesResult('get_record', converted.data);
});
it('describes get_msg data without the event envelope for both conversation types', async () => {
for (const messageType of ['group', 'private']) {
const event: JsonObject = {
post_type: 'message',
self_id: 10002,
message_type: messageType,
message_id: -123,
user_id: 10001,
time: 1700000000,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
...(messageType === 'group' ? { group_id: 20001 } : { target_id: 10002 }),
};
const api = new ApiHandler(
context({
getMessage: async () => event,
refreshMessageMedia: async () => {},
}),
);
const result = await api.handle('get_msg', { message_id: -123 });
expect(result.status).toBe('ok');
expect(result.data).toMatchObject({ real_id: -123, message_id: -123 });
expect(result.data).not.toHaveProperty('post_type');
expect(result.data).not.toHaveProperty('self_id');
matchesResult('get_msg', result.data);
}
});
it('describes successful stream frame data, not MCP aggregate results or failed frames', async () => {
const api = new ApiHandler(context());
const frames: JsonValue[] = [];
const response = await api.handle(
'test_download_stream',
{ error: false },
{
send: async (frame) => {
frames.push(frame);
},
},
);
expect(frames).toHaveLength(10);
expect(response.status).toBe('ok');
for (const frame of [...frames, response.data]) {
matchesResult('test_download_stream', frame);
}
expect(
resultValidator('test_download_stream')({ frame_count: 11, frames, terminal: response })
.valid,
).toBe(false);
expect(
resultValidator('test_download_stream')({ type: 'error', data_type: 'error' }).valid,
).toBe(false);
const failure = await api.handle('test_download_stream', { error: true });
expect(failure.status).toBe('failed');
});
});
+15
View File
@@ -68,6 +68,21 @@ function renderAction(doc: ActionDoc): string {
lines.push('', `**返回:** \`${doc.returns}\``);
}
for (const example of doc.examples ?? []) {
lines.push('', `**示例:${example.summary}**`);
if (example.notes) {
lines.push('', example.notes);
}
lines.push(
'',
'```json',
JSON.stringify({ action: doc.name, params: example.params }, null, 2),
'```',
);
}
return lines.join('\n');
}
+19 -3
View File
@@ -629,6 +629,16 @@ const RULES: RuleBuilders<Record<string, unknown>> = {
export interface ParamDoc extends FieldDoc {
name: string;
}
/** Documentation only: never execute examples while collecting the catalog. */
export interface ActionExample {
summary: string;
/** OneBot params, also passed as the MCP action tool's `params`. */
params: JsonObject;
/** Prerequisites, placeholder sources, or ordering within a workflow. */
notes?: string;
}
export interface ActionDoc {
name: string;
aliases: string[];
@@ -636,10 +646,11 @@ export interface ActionDoc {
category?: string;
summary?: string;
returns?: string;
/** JSON Schema for the `data` payload inside the OneBot envelope (NOT the
* whole envelope). Optional + authored incrementally; absent ⇒ data shape
* is documented only by the prose `returns`. */
/** JSON Schema for successful OneBot `data`, not the envelope. For streams,
* describes each successful frame, not an MCP aggregated tool result.
* Optional for third-party specs; the bundled catalog requires coverage. */
returnsSchema?: JsonSchema;
examples?: readonly ActionExample[];
/** True only for pure data-fetch actions (no side effects). Drives the MCP's
* read/write tool routing; defaults to false (treated as a write). */
readOnly: boolean;
@@ -687,6 +698,7 @@ interface ActionDef<S extends Spec> {
returns?: string;
/** JSON Schema for the `data` payload (NOT the envelope). See ActionDoc. */
returnsSchema?: JsonSchema;
examples?: readonly ActionExample[];
/** Mark true ONLY for pure data-fetch actions with no side effects. Default
* false = write. Surfaced via describe() into the catalog for the MCP's
* read/write routing. Classify by what `run` actually does, not the name. */
@@ -775,6 +787,7 @@ export function defineAction<S extends Spec>(def: ActionDef<S>): ActionSpec<S> {
summary: def.summary,
returns: def.returns,
returnsSchema: def.returnsSchema,
...(def.examples ? { examples: def.examples } : {}),
readOnly: def.readOnly ?? false,
params: entries.map(([name, field]) => ({ name, ...field.doc })),
invariants: rules.map((rule) => rule.doc),
@@ -797,6 +810,7 @@ interface StreamActionDef<S extends Spec> {
summary?: string;
returns?: string;
returnsSchema?: JsonSchema;
examples?: readonly ActionExample[];
readOnly?: boolean;
params: S;
rules?: (r: RuleBuilders<InferParams<S>>) => readonly CrossFieldRule[];
@@ -823,6 +837,7 @@ export function defineStreamAction<S extends Spec>(def: StreamActionDef<S>): Str
summary: def.summary,
returns: def.returns,
returnsSchema: def.returnsSchema,
examples: def.examples,
readOnly: def.readOnly,
params: def.params,
rules: def.rules,
@@ -865,6 +880,7 @@ interface PresetDef<S extends Spec, Extra> {
summary?: string;
returns?: string;
returnsSchema?: JsonSchema;
examples?: readonly ActionExample[];
readOnly?: boolean;
params?: S;
rules?: (r: RuleBuilders<InferParams<S> & Extra>) => readonly CrossFieldRule[];
@@ -0,0 +1,126 @@
import type { JsonSchema } from './action-kit';
// Documentation fragments only. They never validate or transform runtime data.
export const messageIdSchema: JsonSchema = {
type: 'integer',
minimum: -2147483648,
maximum: 2147483647,
not: { const: 0 },
description: '非零有符号 32 位消息 ID,负值合法;必须原样保留',
};
export const messageResultSchema: JsonSchema = {
type: 'object',
properties: { message_id: messageIdSchema },
required: ['message_id'],
};
export const messageSegmentSchema: JsonSchema = {
type: 'object',
properties: {
type: { type: 'string', description: '消息段类型,例如 text、image、reply' },
data: {
type: 'object',
description: '字段随消息段类型变化,例如 text 段包含 text,reply 段包含 id',
additionalProperties: true,
},
},
required: ['type', 'data'],
};
/** Stored events retain subtype-specific fields; legacy records can omit fields. */
export const messageDataSchema = {
type: 'object',
properties: {
time: { type: 'integer', description: '消息时间,Unix 秒' },
message_type: { type: 'string', enum: ['private', 'group'] },
sub_type: { type: 'string', description: '消息子类型' },
message_id: messageIdSchema,
real_id: { type: 'integer', description: '原始 message_id;旧历史记录缺失时可为 0' },
user_id: { type: 'integer', description: '发送者 QQ 号' },
group_id: { type: 'integer', description: '群聊消息的群号,私聊可省略' },
target_id: { type: 'integer', description: '私聊目标 QQ 号(若有)' },
message: { type: 'array', items: messageSegmentSchema },
raw_message: { type: 'string', description: '消息的 CQ 码字符串形式' },
font: { type: 'integer' },
sender: {
type: 'object',
properties: {
user_id: { type: 'integer' },
nickname: { type: 'string' },
card: { type: 'string' },
sex: { type: 'string', enum: ['male', 'female', 'unknown'] },
age: { type: 'integer' },
area: { type: 'string' },
level: { type: 'string' },
role: { type: 'string', enum: ['owner', 'admin', 'member'] },
title: { type: 'string' },
},
additionalProperties: true,
},
},
additionalProperties: true,
} satisfies JsonSchema;
// Forward nodes carry their original identifiers, not locally hashed message IDs.
export const forwardNodeSchema: JsonSchema = {
...messageDataSchema,
properties: {
...messageDataSchema.properties,
self_id: { type: 'integer' },
post_type: { const: 'message' },
message_format: { const: 'array' },
message_id: { type: 'integer', description: '转发节点原始标识,缺失时为 0' },
message_seq: { type: 'integer', description: '节点消息序号,缺失时使用节点标识或 0' },
real_id: { type: 'integer', description: '与节点 message_id 相同' },
},
required: [
'self_id',
'user_id',
'time',
'message_id',
'message_seq',
'real_id',
'message_type',
'sender',
'raw_message',
'font',
'sub_type',
'message',
'message_format',
'post_type',
],
};
export const mediaInfoSchema: JsonSchema = {
type: 'object',
properties: {
file: { type: 'string', description: '媒体地址;图片无地址时可为原文件标识' },
url: { type: 'string', description: '媒体下载地址,图片地址可能为空' },
file_size: { type: 'string', description: '原媒体字节数的十进制字符串' },
file_name: { type: 'string', description: '文件名或缓存标识' },
},
required: ['file', 'url', 'file_size', 'file_name'],
};
export const flashFileSchema: JsonSchema = {
type: 'object',
properties: {
fileset_id: { type: 'string' },
file_name: { type: 'string' },
orig_name: { type: 'string' },
size: { type: 'number', description: '文件字节数;此接口字段名为 size' },
share_url: { type: 'string' },
file_id: { type: 'string' },
download_url: { type: 'string' },
},
required: [
'fileset_id',
'file_name',
'orig_name',
'size',
'share_url',
'file_id',
'download_url',
],
};
File diff suppressed because it is too large Load Diff
+29
View File
@@ -4,6 +4,12 @@ import { okResponse } from '../types';
export const actions = [
defineAction({
name: 'get_friend_list',
examples: [
{
summary: '获取好友列表',
params: {},
},
],
summary: '获取好友列表',
readOnly: true,
returns: '好友列表数组,每项含 QQ 号、昵称与备注。',
@@ -31,6 +37,15 @@ export const actions = [
defineAction({
name: 'get_stranger_info',
examples: [
{
summary: '获取陌生人信息',
params: {
user_id: 10001,
},
notes: '替换为需要查询的 QQ 号。',
},
],
summary: '获取陌生人信息',
readOnly: true,
returns:
@@ -109,6 +124,20 @@ export const actions = [
defineAction({
name: 'delete_friend',
examples: [
{
summary: '删除好友',
params: {
user_id: 10001,
block: false,
},
notes: '会删除指定好友;仅在明确需要删除时调用。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '删除好友',
params: { user_id: f.userId().describe('QQ 号'), block: f.bool().default(false) },
run: async (p, ctx) => {
+286
View File
@@ -4,6 +4,21 @@ import { okResponse } from '../types';
export const actions = [
groupUserAction({
name: 'set_group_kick',
examples: [
{
summary: '踢出群成员',
params: {
group_id: 20001,
user_id: 10001,
reject_add_request: false,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '踢出群成员',
params: { reject_add_request: f.bool().default(false) },
run: async (p, ctx) => {
@@ -18,6 +33,21 @@ export const actions = [
groupAction({
name: 'set_group_kick_members',
examples: [
{
summary: '批量踢出群成员',
params: {
group_id: 20001,
user_id: [10001, 10002],
reject_add_request: false,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '批量踢出群成员',
params: {
user_id: f.array(f.memberId()).nonEmpty(),
@@ -35,6 +65,21 @@ export const actions = [
groupUserAction({
name: 'set_group_ban',
examples: [
{
summary: '禁言群成员(duration=0 解除)',
params: {
group_id: 20001,
user_id: 10001,
duration: 600,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。 duration 为秒,0 表示解除禁言。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '禁言群成员(duration=0 解除)',
params: { duration: f.duration().default(1800) },
run: async (p, ctx) => {
@@ -45,6 +90,20 @@ export const actions = [
groupAction({
name: 'set_group_whole_ban',
examples: [
{
summary: '全员禁言开关',
params: {
group_id: 20001,
enable: true,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '全员禁言开关',
params: { enable: f.bool().default(true) },
run: async (p, ctx) => {
@@ -55,6 +114,22 @@ export const actions = [
groupAction({
name: 'set_group_add_option',
examples: [
{
summary: '设置加群选项',
params: {
group_id: 20001,
add_type: 2,
group_question: '入群用途?',
group_answer: '学习交流',
},
notes: '修改加群设置前先用 get_group_admin_settings 查看当前状态,并确认目标设置值。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置加群选项',
params: {
add_type: f.int({ min: 0 }).default(0),
@@ -74,6 +149,21 @@ export const actions = [
groupAction({
name: 'set_group_search',
examples: [
{
summary: '设置群被搜索方式(群指纹 / 群号搜索开关)',
params: {
group_id: 20001,
no_finger_open: 0,
no_code_finger_open: 0,
},
notes: '替换群号;数值含义以当前群设置为准。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群被搜索方式(群指纹 / 群号搜索开关)',
params: {
no_finger_open: f.int({ min: 0 }).optional(),
@@ -91,6 +181,20 @@ export const actions = [
groupAction({
name: 'set_group_member_invite_policy',
examples: [
{
summary: '设置群成员邀请策略',
params: {
group_id: 20001,
policy: 'require_approval',
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群成员邀请策略',
params: {
policy: f.enum('disabled', 'require_approval', 'no_approval', 'no_approval_under_100'),
@@ -103,6 +207,20 @@ export const actions = [
groupAction({
name: 'set_group_new_member_history_visibility',
examples: [
{
summary: '设置新成员是否可查看历史消息',
params: {
group_id: 20001,
visible: true,
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置新成员是否可查看历史消息',
params: { visible: f.bool() },
run: async (p, ctx) => {
@@ -113,6 +231,20 @@ export const actions = [
groupAction({
name: 'set_group_member_permissions',
examples: [
{
summary: '设置群成员权限(仅群主可改)',
params: {
group_id: 20001,
allow_member_upload_album: false,
},
notes: '至少传一个权限字段;仅修改传入的权限,其余保持原样。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群成员权限(仅群主可改)',
params: {
allow_member_upload_album: f.bool().optional(),
@@ -138,6 +270,15 @@ export const actions = [
groupAction({
name: 'get_group_admin_settings',
examples: [
{
summary: '获取群管理设置的当前值',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群管理设置的当前值',
readOnly: true,
returns: '与对应设置接口字段对齐的当前群管理设置。',
@@ -210,6 +351,15 @@ export const actions = [
defineAction({
name: 'get_group_tags',
examples: [
{
summary: '读取某个群分类下的资料标签',
params: {
group_class: 1,
},
notes: 'group_class 使用实际群分类编号。',
},
],
summary: '读取某个群分类下的资料标签',
readOnly: true,
returns: '该分类下的资料标签列表。',
@@ -239,6 +389,20 @@ export const actions = [
defineAction({
name: 'add_group_tag',
examples: [
{
summary: '向某个群分类添加资料标签',
params: {
group_class: 1,
name: '学习',
},
notes: '替换实际群分类和标签;需要当前账号有管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '向某个群分类添加资料标签',
params: {
group_class: f.int({ min: 0, max: 0xffffffff }),
@@ -252,6 +416,20 @@ export const actions = [
defineAction({
name: 'delete_group_tag',
examples: [
{
summary: '从某个群分类移除资料标签',
params: {
group_class: 1,
name: '学习',
},
notes: '替换实际群分类和标签;需要当前账号有管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '从某个群分类移除资料标签',
params: {
group_class: f.int({ min: 0, max: 0xffffffff }),
@@ -265,6 +443,21 @@ export const actions = [
groupUserAction({
name: 'set_group_admin',
examples: [
{
summary: '设置/取消管理员',
params: {
group_id: 20001,
user_id: 10001,
enable: true,
},
notes: '需要群主权限;会授予该成员管理员身份。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置/取消管理员',
params: { enable: f.bool().default(true) },
run: async (p, ctx) => {
@@ -275,6 +468,21 @@ export const actions = [
groupUserAction({
name: 'set_group_card',
examples: [
{
summary: '设置群名片(空字符串清除)',
params: {
group_id: 20001,
user_id: 10001,
card: '示例群名片',
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群名片(空字符串清除)',
params: { card: f.string().default('') },
run: async (p, ctx) => {
@@ -285,6 +493,20 @@ export const actions = [
groupAction({
name: 'set_group_name',
examples: [
{
summary: '设置群名',
params: {
group_id: 20001,
group_name: '学习交流群',
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群名',
params: { group_name: f.string().default('') },
run: async (p, ctx) => {
@@ -295,6 +517,19 @@ export const actions = [
groupAction({
name: 'set_group_leave',
examples: [
{
summary: '退群',
params: {
group_id: 20001,
},
notes: '会退出该群,请确认目标群号后调用。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '退群',
run: async (p, ctx) => {
await ctx.bridge.apis.groupAdmin.leave(p.group_id);
@@ -304,6 +539,21 @@ export const actions = [
groupUserAction({
name: 'set_group_special_title',
examples: [
{
summary: '设置群头衔',
params: {
group_id: 20001,
user_id: 10001,
special_title: '学习委员',
},
notes: '替换群号和成员 QQ 号;操作是否允许取决于当前账号在该群的权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群头衔',
params: { special_title: f.string().default('') },
run: async (p, ctx) => {
@@ -319,12 +569,34 @@ export const actions = [
// No-op stubs (accept and ignore any params, as before).
defineAction({
name: 'set_group_anonymous',
examples: [
{
summary: '匿名开关(未实现,返回 ok)',
params: {},
notes: '兼容占位接口:当前返回成功但不会修改匿名设置。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '匿名开关(未实现,返回 ok)',
params: {},
run: () => okResponse(),
}),
defineAction({
name: 'set_group_anonymous_ban',
examples: [
{
summary: '匿名禁言(未实现,返回 ok)',
params: {},
notes: '兼容占位接口:当前返回成功但不会修改匿名设置。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '匿名禁言(未实现,返回 ok)',
params: {},
run: () => okResponse(),
@@ -332,6 +604,20 @@ export const actions = [
groupAction({
name: 'set_group_portrait',
examples: [
{
summary: '设置群头像',
params: {
group_id: 20001,
file: 'file:///path/to/avatar.png',
},
notes: '示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '设置群头像',
params: { file: f.image() },
run: async (p, ctx) => {
+228 -2
View File
@@ -46,9 +46,70 @@ const albumCoverSchema = {
required: ['type', 'image'],
};
// Media-list entries are passed through rather than normalized like covers.
// Missing fields can be absent; decoded undefined values become null.
const mediaUrlSchema = {
type: ['object', 'null'],
properties: {
url: { type: ['string', 'null'] },
width: { type: ['integer', 'null'] },
height: { type: ['integer', 'null'] },
},
};
const mediaPhotoUrlsSchema = {
type: ['array', 'null'],
items: {
type: 'object',
properties: { spec: { type: ['integer', 'null'] }, url: mediaUrlSchema },
},
};
const mediaImageSchema = {
type: ['object', 'null'],
properties: {
name: { type: ['string', 'null'] },
sloc: { type: ['string', 'null'] },
lloc: { type: ['string', 'null'], description: '图片长定位标识,供评论、删除等操作使用' },
photoUrls: mediaPhotoUrlsSchema,
defaultUrl: mediaUrlSchema,
isGif: { type: ['boolean', 'null'] },
hasRaw: { type: ['boolean', 'null'] },
},
};
const albumMediaSchema = {
type: 'object',
properties: {
type: { type: ['integer', 'null'] },
image: mediaImageSchema,
video: {
type: ['object', 'null'],
properties: {
id: { type: ['string', 'null'], description: '视频标识,供评论、删除等操作使用' },
url: { type: ['string', 'null'] },
cover: mediaImageSchema,
width: { type: ['integer', 'null'] },
height: { type: ['integer', 'null'] },
videoTime: { type: ['string', 'null'], description: '时长,十进制字符串' },
videoUrl: mediaPhotoUrlsSchema,
},
},
uploader: { type: ['string', 'null'] },
batchId: { type: ['string', 'null'], description: '上传批次,十进制字符串' },
uploadTime: { type: ['string', 'null'], description: '上传时间,十进制字符串' },
},
};
export const actions = [
groupAction({
name: 'get_group_album_list',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
readOnly: true,
returns: '群相册列表数组,每项为一个相册的基本信息。',
returnsSchema: {
@@ -90,6 +151,16 @@ export const actions = [
// get_qun_album_list — NapCat-compatible view of QQ NT's AlbumService.
groupAction({
name: 'get_qun_album_list',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
attach_info: '',
},
notes: '后续页原样传回上一页的 attach_info;has_more=false 表示结束。',
},
],
readOnly: true,
returns: 'NapCat 风格的相册列表封套:{album_list, attach_info, has_more}。',
returnsSchema: {
@@ -153,6 +224,22 @@ export const actions = [
groupAction({
name: 'upload_image_to_qun_album',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
album_name: '活动照片',
file: 'file:///path/to/photo.png',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
params: {
album_id: f.string({ allowEmpty: false }),
album_name: f.string({ allowEmpty: false }),
@@ -166,6 +253,18 @@ export const actions = [
groupAction({
name: 'upload_video_to_qun_album',
examples: [
{
summary: '上传视频到群相册',
params: {
group_id: 20001,
album_id: '<album-id>',
album_name: '活动视频',
file: 'file:///path/to/video.mp4',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。',
},
],
summary: '上传视频到群相册',
returns: '{ id: string }',
returnsSchema: {
@@ -193,6 +292,17 @@ export const actions = [
groupAction({
name: 'get_group_album_media_list',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
attach_info: '',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 下一页传回 nextAttachInfo。',
},
],
readOnly: true,
returns:
'相册图片/视频列表及下一页分页游标;视频项包含 id、url、cover、尺寸、时长和多规格地址。',
@@ -201,8 +311,8 @@ export const actions = [
properties: {
mediaList: {
type: 'array',
description: '相册媒体项列表(各项字段不固定)',
items: { type: 'object' },
description: '相册图片或视频,未返回的字段可省略或为 null',
items: albumMediaSchema,
},
nextAttachInfo: { type: 'string', description: '下一页分页游标(空串表示无更多)' },
},
@@ -224,6 +334,58 @@ export const actions = [
groupAction({
name: 'do_group_album_comment',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
lloc: '<media-lloc-or-video-id>',
content: '拍得很好',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。',
},
],
returnsSchema: {
type: 'object',
properties: {
id: {
type: 'string',
},
user: {
type: 'object',
properties: {
uin: {
type: 'string',
},
},
required: ['uin'],
},
content: {
type: 'array',
items: {
type: 'object',
properties: {
type: {
type: 'integer',
},
content: {
type: 'string',
},
},
required: ['type', 'content'],
},
},
time: {
type: 'string',
description: '十进制时间字符串',
},
clientKey: {
type: 'string',
},
},
required: ['id', 'user', 'content', 'time', 'clientKey'],
},
params: {
album_id: f.string({ allowEmpty: false }),
lloc: f.string({ allowEmpty: false }),
@@ -242,6 +404,28 @@ export const actions = [
groupAction({
name: 'set_group_album_media_like',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
batch_id: '1700000000',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 batch_id 必须是实际批次的十进制字符串。',
},
],
returnsSchema: {
type: 'object',
properties: {
id: {
type: ['string', 'null'],
},
status: {
type: ['integer', 'null'],
},
},
},
params: {
album_id: f.string({ allowEmpty: false }),
batch_id: f.string({ allowEmpty: false }),
@@ -262,6 +446,28 @@ export const actions = [
// 取消点赞群相册媒体
groupAction({
name: 'cancel_group_album_media_like',
examples: [
{
summary: '基本调用',
params: {
group_id: 20001,
album_id: '<album-id>',
batch_id: '1700000000',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。 batch_id 必须是实际批次的十进制字符串。',
},
],
returnsSchema: {
type: 'object',
properties: {
id: {
type: ['string', 'null'],
},
status: {
type: ['integer', 'null'],
},
},
},
params: {
album_id: f.string({ allowEmpty: false }),
batch_id: f.string({ allowEmpty: false }),
@@ -281,6 +487,26 @@ export const actions = [
groupAction({
name: 'del_group_album_media',
examples: [
{
summary: '删除群相册图片或视频',
params: {
group_id: 20001,
album_id: '<album-id>',
lloc: '<media-lloc-or-video-id>',
},
notes: 'album_id 来自 get_group_album_list;lloc 或视频 id、batch_id 来自 get_group_album_media_list。替换群号及对应资源标识。',
},
],
returnsSchema: {
type: 'object',
properties: {
success: {
const: true,
},
},
required: ['success'],
},
summary: '删除群相册图片或视频',
params: {
album_id: f.string({ allowEmpty: false }),
+229
View File
@@ -20,6 +20,26 @@ function busidOr102(raw: unknown): number {
export const actions = [
groupAction({
name: 'upload_group_file',
examples: [
{
summary: '上传群文件',
params: {
group_id: 20001,
file: 'file:///path/to/report.pdf',
name: '报告.pdf',
},
notes: '示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。',
},
],
returnsSchema: {
type: 'object',
properties: {
file_id: {
type: 'string',
},
},
required: ['file_id'],
},
summary: '上传群文件',
returns: '{ file_id: string }',
// `folder` / `folder_id` are two aliases for one target dir; the first
@@ -47,6 +67,26 @@ export const actions = [
defineAction({
name: 'upload_private_file',
examples: [
{
summary: '上传私聊文件',
params: {
user_id: 10001,
file: 'file:///path/to/report.pdf',
name: '报告.pdf',
},
notes: '示例路径位于 SnowLuma 服务端,须替换为该服务可读取的实际文件。 默认上传并发送给该好友。',
},
],
returnsSchema: {
type: 'object',
properties: {
file_id: {
type: 'string',
},
},
required: ['file_id'],
},
summary: '上传私聊文件',
returns: '{ file_id: string }',
params: {
@@ -101,6 +141,16 @@ export const actions = [
groupAction({
name: 'get_group_file_url',
examples: [
{
summary: '获取群文件下载链接',
params: {
group_id: 20001,
file_id: '<file-id-from-get_group_root_files>',
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群文件下载链接',
readOnly: true,
returns: '群文件下载链接。',
@@ -125,6 +175,15 @@ export const actions = [
groupAction({
name: 'get_group_root_files',
examples: [
{
summary: '获取群根目录文件列表',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群根目录文件列表',
readOnly: true,
returns: '群文件系统信息(文件与文件夹列表)。',
@@ -180,6 +239,16 @@ export const actions = [
groupAction({
name: 'get_group_files_by_folder',
examples: [
{
summary: '获取群子目录文件列表',
params: {
group_id: 20001,
folder_id: '<folder-id-from-get_group_root_files>',
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群子目录文件列表',
readOnly: true,
returns: '群文件系统信息(文件与文件夹列表)。',
@@ -238,6 +307,20 @@ export const actions = [
groupAction({
name: 'delete_group_file',
examples: [
{
summary: '删除群文件',
params: {
group_id: 20001,
file_id: '<file-id-from-get_group_root_files>',
},
notes: '替换群文件列表中的实际 file_id;需要对应文件操作权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '删除群文件',
params: { file_id: f.fileId() },
run: async (p, ctx) => {
@@ -248,6 +331,22 @@ export const actions = [
groupAction({
name: 'move_group_file',
examples: [
{
summary: '移动群文件',
params: {
group_id: 20001,
file_id: '<file-id>',
parent_directory: '/',
target_directory: '<target-folder-id>',
},
notes: '目录和文件标识均来自该群文件列表。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '移动群文件',
params: {
file_id: f.fileId(),
@@ -269,6 +368,27 @@ export const actions = [
// (文件当前所在目录)+ new_name。SnowLuma 的 file_id 即原始 fileId,无需 UUID 解码。
groupAction({
name: 'rename_group_file',
examples: [
{
summary: '重命名群文件',
params: {
group_id: 20001,
file_id: '<file-id>',
current_parent_directory: '/',
new_name: '新文件名.pdf',
},
notes: 'file_id 来自群文件列表,目录必须是文件当前所在目录。',
},
],
returnsSchema: {
type: 'object',
properties: {
ok: {
const: true,
},
},
required: ['ok'],
},
summary: '重命名群文件',
params: {
file_id: f.fileId(),
@@ -290,6 +410,76 @@ export const actions = [
groupAction({
name: 'create_group_file_folder',
examples: [
{
summary: '创建群文件夹',
params: {
group_id: 20001,
name: '共享资料',
parent_id: '/',
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
returnsSchema: {
type: 'object',
properties: {
result: {
type: 'object',
properties: {
retCode: {
const: 0,
},
retMsg: {
const: 'success',
},
},
required: ['retCode', 'retMsg'],
},
groupItem: {
type: 'object',
properties: {
folderInfo: {
type: 'object',
properties: {
folderId: {
type: 'string',
},
folderName: {
type: 'string',
},
folderPath: {
type: 'string',
},
createTime: {
type: 'integer',
},
modifyTime: {
type: 'integer',
},
createUin: {
type: 'integer',
},
modifyUin: {
type: 'integer',
},
},
required: [
'folderId',
'folderName',
'folderPath',
'createTime',
'modifyTime',
'createUin',
'modifyUin',
],
},
},
required: ['folderInfo'],
},
},
required: ['result', 'groupItem'],
},
summary: '创建群文件夹',
// NapCat-aligned data so callers can read back the new folderId (#195):
// { result, groupItem: { folderInfo: { folderId, folderName, … } } }.
@@ -311,6 +501,20 @@ export const actions = [
groupAction({
name: 'delete_group_file_folder',
examples: [
{
summary: '删除群文件夹',
params: {
group_id: 20001,
folder_id: '<folder-id-from-get_group_root_files>',
},
notes: '会删除指定群文件夹,确认资源后调用。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '删除群文件夹',
params: { folder_id: f.string({ allowEmpty: false }) },
run: async (p, ctx) => {
@@ -321,6 +525,21 @@ export const actions = [
groupAction({
name: 'rename_group_file_folder',
examples: [
{
summary: '重命名群文件夹',
params: {
group_id: 20001,
folder_id: '<folder-id>',
new_folder_name: '课程资料',
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '重命名群文件夹',
// new_folder_name / name are aliases; first non-empty wins and must be
// non-empty (legacy `asString(new_folder_name) || asString(name)` + `!newName`).
@@ -346,6 +565,16 @@ export const actions = [
defineAction({
name: 'get_private_file_url',
examples: [
{
summary: '获取私聊文件下载链接',
params: {
user_id: 10001,
file_id: '<file-id-from-private-message>',
},
notes: '使用私聊文件消息段中的 file_id,必要时原样传 file_hash。',
},
],
summary: '获取私聊文件下载链接',
readOnly: true,
returns: '私聊文件下载链接。',
+215
View File
@@ -33,6 +33,16 @@ export const groupInfoReturnsSchema = {
export const actions = [
groupAction({
name: 'get_group_share_link',
examples: [
{
summary: '获取群分享链接',
params: {
group_id: 20001,
need_short_url: true,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群分享链接',
readOnly: true,
params: {
@@ -59,6 +69,14 @@ export const actions = [
defineAction({
name: 'get_group_list',
examples: [
{
summary: '获取群列表',
params: {
no_cache: false,
},
},
],
summary: '获取群列表',
readOnly: true,
returns: '群信息对象数组。',
@@ -112,6 +130,15 @@ export const actions = [
groupAction({
name: 'get_group_info',
examples: [
{
summary: '获取群信息',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群信息',
readOnly: true,
returns: '群信息对象。',
@@ -144,6 +171,15 @@ export const actions = [
groupAction({
name: 'get_group_member_list',
examples: [
{
summary: '获取群成员列表',
params: {
group_id: 20001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群成员列表',
readOnly: true,
returns: '群成员信息对象数组。',
@@ -225,6 +261,16 @@ export const actions = [
groupUserAction({
name: 'get_group_member_info',
examples: [
{
summary: '获取群成员信息',
params: {
group_id: 20001,
user_id: 10001,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群成员信息',
readOnly: true,
returns: '群成员信息对象。',
@@ -327,6 +373,164 @@ export const actions = [
groupAction({
name: 'get_group_honor_info',
examples: [
{
summary: '获取群荣誉信息',
params: {
group_id: 20001,
type: 'all',
},
notes: '未请求的分类仍返回空数组;当前龙王可能为 null。',
},
],
returnsSchema: {
type: 'object',
properties: {
group_id: {
type: 'integer',
},
current_talkative: {
anyOf: [
{
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
{
type: 'null',
},
],
},
talkative_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
},
performer_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
},
legend_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
},
emotion_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
},
strong_newbie_list: {
type: 'array',
items: {
type: 'object',
properties: {
user_id: {
type: ['number', 'string', 'null'],
},
nickname: {
type: 'string',
},
avatar: {
type: 'string',
},
description: {
type: 'string',
},
},
required: ['user_id', 'nickname', 'avatar', 'description'],
},
description: '当前实现始终为空数组',
},
},
required: [
'group_id',
'current_talkative',
'talkative_list',
'performer_list',
'legend_list',
'emotion_list',
'strong_newbie_list',
],
},
summary: '获取群荣誉信息',
readOnly: true,
params: { type: f.string().optional() },
@@ -360,6 +564,17 @@ export const actions = [
defineAction({
name: 'get_group_system_msg',
examples: [
{
summary: '获取群系统消息',
params: {
group_id: 20001,
only_pending: true,
count: 20,
},
notes: '将 group_id 替换为当前账号可访问的群号。',
},
],
summary: '获取群系统消息',
readOnly: true,
returns: '群系统消息数组,可按群号或未处理状态过滤。',
+30
View File
@@ -5,6 +5,12 @@ import { okResponse } from '../types';
export const actions = [
defineAction({
name: 'get_login_info',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns: '当前登录账号的 QQ 号与昵称。',
returnsSchema: {
@@ -24,6 +30,12 @@ export const actions = [
defineAction({
name: 'get_status',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns:
'运行状态。`online` 表示账号在线;`good` 表示已确认的收发链路健康状态;`time` 为当前账号服务实例运行秒数,与 #sl 同源。',
@@ -55,6 +67,12 @@ export const actions = [
defineAction({
name: 'get_version_info',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns: '实现与协议版本信息。',
returnsSchema: {
@@ -78,6 +96,12 @@ export const actions = [
defineAction({
name: 'can_send_image',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns: '能力查询结果。',
returnsSchema: {
@@ -93,6 +117,12 @@ export const actions = [
defineAction({
name: 'can_send_record',
examples: [
{
summary: '基本调用',
params: {},
},
],
readOnly: true,
returns: '能力查询结果。',
returnsSchema: {
+88
View File
@@ -2,6 +2,7 @@ import { createLogger } from '@snowluma/common/logger';
import type { JsonObject, JsonValue } from '../types';
import { defineAction, groupAction, f, type Field } from '../action-kit';
import { RETCODE, failedResponse, okResponse } from '../types';
import { messageResultSchema, messageIdSchema, messageDataSchema } from '../action-result-schemas';
/** send_msg only (#426). Some clients send `group_id: ""` on private chats.
* Treat that exact empty string as absent. Do not change `f.groupId()`. */
@@ -23,6 +24,17 @@ const log = createLogger('OneBot');
export const actions = [
defineAction({
name: 'send_custom_face',
examples: [
{
summary: '发送账号收藏表情(emoji_id 或 MD5),保留表情显示样式',
params: {
group_id: 20001,
emoji_id: '<emoji-id-from-fetch_custom_face_detail>',
},
notes: '群聊和私聊目标只能提供一个;emoji_id 来自 fetch_custom_face_detail。',
},
],
returnsSchema: messageResultSchema,
summary: '发送账号收藏表情(emoji_id 或 MD5),保留表情显示样式',
returns: '{ message_id: number }',
params: {
@@ -68,6 +80,18 @@ export const actions = [
// validated by the spec; message_type is left lenient for parity.
defineAction({
name: 'send_msg',
examples: [
{
summary: '发送消息(按 message_type/群号 自动路由群聊或私聊)',
params: {
message_type: 'group',
group_id: 20001,
message: '你好',
},
notes: '必须提供对应目标;私聊使用 message_type=private 和 user_id。',
},
],
returnsSchema: messageResultSchema,
summary: '发送消息(按 message_type/群号 自动路由群聊或私聊)',
returns: '{ message_id: number }',
params: {
@@ -109,6 +133,17 @@ export const actions = [
defineAction({
name: 'send_private_msg',
examples: [
{
summary: '发送私聊消息',
params: {
user_id: 10001,
message: '你好',
},
notes: '替换目标好友 QQ 号;临时会话需另传来源 group_id。',
},
],
returnsSchema: messageResultSchema,
summary: '发送私聊消息',
returns: '{ message_id: number }',
params: {
@@ -135,6 +170,24 @@ export const actions = [
groupAction({
name: 'send_group_msg',
examples: [
{
summary: '发送群消息',
params: {
group_id: 20001,
message: [
{
type: 'text',
data: {
text: '你好',
},
},
],
},
notes: '替换目标群号;成功返回的 message_id 可为负值。',
},
],
returnsSchema: messageResultSchema,
summary: '发送群消息',
returns: '{ message_id: number }',
params: { message: f.message(), auto_escape: f.bool().default(false) },
@@ -146,6 +199,28 @@ export const actions = [
defineAction({
name: 'get_msg',
examples: [
{
summary: '获取消息',
params: {
message_id: -123456,
},
notes: '使用消息事件或发送结果中的真实 message_id,负值合法;消息须在当前账号的消息存储中可查询。',
},
],
returnsSchema: {
allOf: [
messageDataSchema,
{
type: 'object',
properties: {
real_id: messageIdSchema,
},
required: ['real_id'],
},
],
description: '消息事件去除 post_type/self_id 并增加 real_id;群聊和私聊字段有所不同',
},
summary: '获取消息',
readOnly: true,
returns:
@@ -170,6 +245,19 @@ export const actions = [
defineAction({
name: 'delete_msg',
examples: [
{
summary: '撤回消息',
params: {
message_id: -123456,
},
notes: '使用消息事件或发送结果中的真实 message_id,负值合法;消息须在当前账号的消息存储中可查询。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '撤回消息',
params: { message_id: f.messageId() },
run: async (p, ctx) => {
+143
View File
@@ -41,6 +41,16 @@ export const actions = [
// 复用 qzone.qq.com 的 cookie/g_tk 基建(与群相册同源),纯 web,无 trpc。
defineAction({
name: 'get_qzone_msg_list',
examples: [
{
summary: '获取 QQ 空间说说列表(默认机器人自己的空间)',
params: {
pos: 0,
num: 20,
},
notes: '默认读取当前账号;指定 target_uin 时需有访问权限。',
},
],
readOnly: true,
returns: '说说列表对象,含说说总数与本页说说数组。',
returnsSchema: {
@@ -87,6 +97,17 @@ export const actions = [
defineAction({
name: 'get_qzone_msg_detail',
examples: [
{
summary: '获取 QQ 空间说说详情与一页评论(含服务端内联回复,需有访问权限)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
pos: 0,
num: 20,
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。 replies 仅为服务端内联回复,不保证完整。',
},
],
readOnly: true,
summary: '获取 QQ 空间说说详情与一页评论(含服务端内联回复,需有访问权限)',
returns:
@@ -139,6 +160,17 @@ export const actions = [
// 每条 feed 含结构化字段 + 预渲染 html 原样透传(深度解析交由调用方)。
defineAction({
name: 'get_qzone_feeds',
examples: [
{
summary:
'获取 QQ 空间好友动态(feed);page_num 仅首页可靠,深翻页需时间游标(暂未实现)',
params: {
page_num: 1,
count: 10,
},
notes: '目前仅首页可靠,不应假设任意页码均能完整翻页。',
},
],
readOnly: true,
returns: '好友动态对象,含本页 feed 数组与是否有更多页。',
returnsSchema: {
@@ -187,6 +219,29 @@ export const actions = [
// 注意:发说说为主动行为,高频会被 Qzone 风控,调用方需自行限流。
defineAction({
name: 'send_qzone_msg',
examples: [
{
summary:
'发表说说(QQ 空间,支持纯文字或带图;传 images 自动上传;可设置查看权限)',
params: {
content: '今天的学习记录',
ugc_right: 64,
},
notes: '会发布说说;ugc_right=64 仅自己可见。16/128 还必须提供 target_uins。',
},
],
returnsSchema: {
type: 'object',
properties: {
tid: {
type: 'string',
},
time: {
type: 'integer',
},
},
required: ['tid', 'time'],
},
summary: '发表说说(QQ 空间,支持纯文字或带图;传 images 自动上传;可设置查看权限)',
params: {
content: f.string({ allowEmpty: false }).describe('说说正文'),
@@ -241,6 +296,19 @@ export const actions = [
// delete_qzone_msg — 删除机器人自己空间的一条说说(按 tid)。写操作。
defineAction({
name: 'delete_qzone_msg',
examples: [
{
summary: '删除一条说说(QQ 空间,按 tid)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '删除一条说说(QQ 空间,按 tid)',
params: {
tid: f
@@ -257,6 +325,17 @@ export const actions = [
// 写操作;ugc_right/target_uins 语义与 send_qzone_msg 一致。
defineAction({
name: 'set_qzone_msg_right',
examples: [
{
summary: '修改一条已发说说的查看权限(QQ 空间,按 tid)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
ugc_right: 16,
target_uins: [10001],
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。 16 表示仅所选好友可见。',
},
],
returns: '更新后的权限对象。',
returnsSchema: {
type: 'object',
@@ -304,6 +383,20 @@ export const actions = [
// 传真实值更可靠;不传按 0。写操作;高频点赞会被 Qzone 风控,调用方需限流。
defineAction({
name: 'like_qzone',
examples: [
{
summary: '给一条说说点赞(QQ 空间)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
target_uin: 10001,
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '给一条说说点赞(QQ 空间)',
params: {
tid: f.string({ allowEmpty: false }).describe('说说 tid'),
@@ -324,6 +417,20 @@ export const actions = [
// 注意:取消赞端点(internal_unlike_app)暂未经真机核实,待抓包确认。
defineAction({
name: 'unlike_qzone',
examples: [
{
summary: '取消对一条说说的点赞(QQ 空间;取消赞端点待真机核实)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
target_uin: 10001,
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '取消对一条说说的点赞(QQ 空间;取消赞端点待真机核实)',
params: {
tid: f.string({ allowEmpty: false }).describe('说说 tid'),
@@ -345,6 +452,27 @@ export const actions = [
// 写操作;高频评论会被 Qzone 风控,调用方需限流。
defineAction({
name: 'comment_qzone',
examples: [
{
summary: '评论一条说说(QQ 空间,支持纯文字或带图;传 images 自动上传)',
params: {
tid: '<tid-from-get_qzone_msg_list>',
target_uin: 10001,
content: '感谢分享',
},
notes: 'tid 来自 get_qzone_msg_list;操作需有该说说的访问或管理权限。',
},
],
returnsSchema: {
type: 'object',
properties: {
comment_id: {
type: 'string',
description: '服务端未提供评论 ID 时为空串',
},
},
required: ['comment_id'],
},
summary: '评论一条说说(QQ 空间,支持纯文字或带图;传 images 自动上传)',
params: {
tid: f.string({ allowEmpty: false }).describe('说说 tid'),
@@ -377,6 +505,21 @@ export const actions = [
// enable=true 拉黑,enable=false 解除拉黑。
defineAction({
name: 'set_qzone_ban',
examples: [
{
summary:
'拉黑或解除拉黑某人(修改机器人自身 QQ 空间黑名单;enable=true 拉黑,false 解除)',
params: {
user_id: 10001,
enable: true,
},
notes: '修改当前账号空间黑名单。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '拉黑或解除拉黑某人(修改机器人自身 QQ 空间黑名单;enable=true 拉黑,false 解除)',
params: {
user_id: f.userId().describe('目标 QQ 号'),
+29
View File
@@ -4,6 +4,20 @@ import { okResponse } from '../types';
export const actions = [
defineAction({
name: 'set_friend_add_request',
examples: [
{
summary: '处理好友添加请求',
params: {
flag: '<flag-from-friend-request-event>',
approve: true,
},
notes: 'flag 必须原样使用当前账号好友请求事件的 flag,不能根据 QQ 号构造。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '处理好友添加请求',
params: {
flag: f.string({ allowEmpty: false }),
@@ -17,6 +31,21 @@ export const actions = [
defineAction({
name: 'set_group_add_request',
examples: [
{
summary: '处理加群请求',
params: {
flag: '<flag-from-group-request-event>',
sub_type: 'add',
approve: true,
},
notes: 'flag 和 sub_type 原样取自同一条群请求事件;邀请事件使用 invite。',
},
],
returnsSchema: {
type: 'null',
description: '成功时 data 为 null;失败另见响应的 status、retcode 和 wording',
},
summary: '处理加群请求',
params: {
// Opaque handle from the original request event; callers must echo it.
@@ -28,6 +28,89 @@ import { okResponse, type ApiResponse, type JsonObject, type JsonValue } from '.
import { type StreamSink, StreamStatus } from '../streaming';
import { STREAM_ROOT, ensureStreamDirectory, registerActiveStreamItem } from '../stream-storage';
const downloadFrameSchema = {
oneOf: [
{
type: 'object',
properties: {
type: {
const: 'stream',
},
data_type: {
const: 'file_info',
},
file_name: {
type: 'string',
},
file_size: {
type: 'integer',
description: '字节数,0 可表示未知',
},
chunk_size: {
type: 'integer',
},
},
required: ['type', 'data_type', 'file_name', 'file_size', 'chunk_size'],
},
{
type: 'object',
properties: {
type: {
const: 'stream',
},
data_type: {
const: 'file_chunk',
},
index: {
type: 'integer',
},
data: {
type: 'string',
description: '该分块的 Base64 数据',
},
size: {
type: 'integer',
},
progress: {
type: 'number',
minimum: 0,
maximum: 100,
},
base64_size: {
type: 'integer',
},
},
required: ['type', 'data_type', 'index', 'data', 'size', 'progress', 'base64_size'],
},
{
type: 'object',
properties: {
type: {
const: 'response',
},
data_type: {
const: 'file_complete',
},
file_name: {
type: 'string',
},
total_chunks: {
type: 'integer',
},
total_bytes: {
type: 'integer',
},
message: {
type: 'string',
},
},
required: ['type', 'data_type', 'file_name', 'total_chunks', 'total_bytes', 'message'],
},
],
description:
'每个成功流式帧的 data;失败帧另由 status/retcode/wording 与 type=error 表示,不是 MCP 保存文件后的工具结果',
};
const DEFAULT_CHUNK_BYTES = 64 * 1024;
const MAX_CHUNK_REQUEST_BYTES = 16 * 1024 * 1024; // cap the client-requested chunk size
const MAX_DOWNLOAD_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB total
@@ -542,6 +625,17 @@ const downloadParams = {
const downloadFileStream = defineStreamAction({
name: 'download_file_stream',
examples: [
{
summary: '以流式方式下载文件(stream 目录本地文件 / URL / 缓存媒体)',
params: {
file: 'https://example.com/report.pdf',
chunk_size: 65536,
},
notes: '替换实际公开可访问地址;经 invoke_action 调用,文件保存到 MCP 下载目录。',
},
],
returnsSchema: downloadFrameSchema,
summary: '以流式方式下载文件(stream 目录本地文件 / URL / 缓存媒体)',
returns: '流式帧:file_info → file_chunk* → file_complete',
params: downloadParams,
@@ -550,6 +644,17 @@ const downloadFileStream = defineStreamAction({
const downloadFileImageStream = defineStreamAction({
name: 'download_file_image_stream',
examples: [
{
summary: '以流式方式下载图片(缓存图片 id / URL / stream 目录本地文件)',
params: {
file_id: '<image-file-from-message>',
chunk_size: 65536,
},
notes: 'file_id 来自当前账号图片消息;经 invoke_action 调用。',
},
],
returnsSchema: downloadFrameSchema,
summary: '以流式方式下载图片(缓存图片 id / URL / stream 目录本地文件)',
returns: '流式帧:file_info → file_chunk* → file_complete',
params: downloadParams,
@@ -558,6 +663,17 @@ const downloadFileImageStream = defineStreamAction({
const downloadFileRecordStream = defineStreamAction({
name: 'download_file_record_stream',
examples: [
{
summary: '以流式方式下载语音(缓存语音 id / URL / stream 目录本地文件)',
params: {
file_id: '<record-file-from-message>',
chunk_size: 65536,
},
notes: 'file_id 来自当前账号语音消息;经 invoke_action 调用。',
},
],
returnsSchema: downloadFrameSchema,
summary: '以流式方式下载语音(缓存语音 id / URL / stream 目录本地文件)',
returns: '流式帧:file_info → file_chunk* → file_complete',
params: downloadParams,
@@ -567,6 +683,50 @@ const downloadFileRecordStream = defineStreamAction({
const testDownloadStream = defineStreamAction({
name: 'test_download_stream',
examples: [
{
summary: '测试下载流(推送 10 个数据帧,验证流式传输,不触达 QQ)',
params: {
error: false,
},
notes: '经 invoke_action 调用,仅测试传输,不访问 QQ。',
},
],
returnsSchema: {
oneOf: [
{
type: 'object',
properties: {
type: {
const: 'stream',
},
data_type: {
const: 'data_chunk',
},
data: {
type: 'string',
},
},
required: ['type', 'data_type', 'data'],
},
{
type: 'object',
properties: {
type: {
const: 'response',
},
data_type: {
const: 'data_complete',
},
data: {
type: 'string',
},
},
required: ['type', 'data_type', 'data'],
},
],
description: '成功时 10 个 data_chunk 后接 data_complete;error=true 时最终为失败帧',
},
summary: '测试下载流(推送 10 个数据帧,验证流式传输,不触达 QQ)',
returns: '流式帧:data_chunk*10 → data_complete(error=true 时以 error 帧结束)',
params: { error: f.bool().default(false).describe('是否触发测试错误') },
+117
View File
@@ -457,6 +457,98 @@ type UploadParams = {
const uploadFileStream = defineStreamAction({
name: 'upload_file_stream',
examples: [
{
summary: '上传分块',
params: {
stream_id: 'example-upload-001',
filename: 'hello.txt',
total_chunks: 1,
file_size: 5,
chunk_index: 0,
chunk_data: 'aGVsbG8=',
},
notes: '第 1 步:每次上传生成新的 stream_id;这里上传 hello 的唯一分块。通过 invoke_action 调用,分块确认不等于整文件完成。',
},
{
summary: '完成同一上传',
params: {
stream_id: 'example-upload-001',
is_complete: true,
},
notes: '第 2 步:等待全部分块确认后,用同一个 stream_id 单独请求完成;成功时返回 file_path。MCP 的 invoke_action 也可通过 execution.input_file 自动完成上传。',
},
],
returnsSchema: {
oneOf: [
{
type: 'object',
properties: {
type: {
const: 'stream',
},
stream_id: {
type: 'string',
},
status: {
enum: ['file_created', 'chunk_received'],
},
received_chunks: {
type: 'integer',
},
total_chunks: {
type: 'integer',
},
},
required: ['type', 'stream_id', 'status', 'received_chunks', 'total_chunks'],
},
{
type: 'object',
properties: {
type: {
const: 'response',
},
stream_id: {
type: 'string',
},
status: {
const: 'file_complete',
},
received_chunks: {
type: 'integer',
},
total_chunks: {
type: 'integer',
},
file_path: {
type: 'string',
},
file_name: {
type: 'string',
},
file_size: {
type: 'integer',
},
sha256: {
type: 'string',
},
},
required: [
'type',
'stream_id',
'status',
'received_chunks',
'total_chunks',
'file_path',
'file_name',
'file_size',
'sha256',
],
},
],
description:
'分块确认与完成帧的 data;reset 和其他失败由错误帧返回。必须等到 file_complete 才能使用 file_path',
},
summary: '以流式分块方式上传文件到机器人本地(返回可用于发送的本地路径)',
returns:
'流式帧:分块确认 type=stream、完成 type=response(含 file_path/file_name/file_size/sha256)',
@@ -496,6 +588,31 @@ function cleanDir(dir: string): number {
const cleanStreamTempFile = defineAction({
name: 'clean_stream_temp_file',
examples: [
{
summary: '清理流式传输临时文件(仅清理 stream 上传/下载目录)',
params: {},
notes: '清理未使用的流式临时文件;不会清理正在传输的项目。',
},
],
returnsSchema: {
type: 'object',
properties: {
message: {
const: 'success',
},
removed: {
type: 'integer',
},
freed_bytes: {
type: 'integer',
},
skipped_active: {
type: 'integer',
},
},
required: ['message', 'removed', 'freed_bytes', 'skipped_active'],
},
summary: '清理流式传输临时文件(仅清理 stream 上传/下载目录)',
returns: '{ message, removed }',
params: {},
@@ -62,6 +62,14 @@ const faceSchema = {
export const actions = [
defineAction({
name: 'fetch_sys_faces',
examples: [
{
summary: '获取 QQ 系统表情目录',
params: {
refresh: false,
},
},
],
summary: '获取 QQ 系统表情目录',
readOnly: true,
returns: '按分组返回完整的 QQ 系统表情映射。',
@@ -96,6 +104,14 @@ export const actions = [
defineAction({
name: 'fetch_face_entity',
examples: [
{
summary: '按编号查询 QQ 系统表情',
params: {
face_id: 14,
},
},
],
summary: '按编号查询 QQ 系统表情',
readOnly: true,
returns: '表情详情;编号不存在时返回 null。',
@@ -112,6 +128,14 @@ export const actions = [
defineAction({
name: 'search_sys_faces',
examples: [
{
summary: '搜索 QQ 系统表情',
params: {
query: '微笑',
},
},
],
summary: '搜索 QQ 系统表情',
readOnly: true,
returns: '匹配编号、名称、别名或分组名的表情列表。',
@@ -131,6 +155,14 @@ export const actions = [
defineAction({
name: 'fetch_super_face_id',
examples: [
{
summary: '判断 QQ 系统表情是否使用超级表情格式',
params: {
face_id: 14,
},
},
],
summary: '判断 QQ 系统表情是否使用超级表情格式',
readOnly: true,
returns: '是否使用超级表情格式。',
+51
View File
@@ -2,6 +2,9 @@
// declarative action and renders coherent metadata/markdown.
import { describe, it, expect } from 'vitest';
import { collectActionDocs, renderActionDocsMarkdown } from '../src/action-docs';
import { ACTION_REGISTRY } from '../src/actions';
import { defineAction, defineStreamAction } from '../src/action-kit';
import { okResponse } from '../src/types';
describe('action-docs', () => {
const docs = collectActionDocs();
@@ -75,6 +78,54 @@ describe('action-docs', () => {
expect(md).toContain('# OneBot Actions');
expect(md).toContain('`set_group_ban`');
expect(md).toContain('| 参数 | 类型 | 必填 | 默认 | 说明 |');
expect(md).toContain('"action": "set_group_ban"');
expect(md).toContain('"duration": 600');
});
it.each(ACTION_REGISTRY.actions)(
'$canonical examples pass the real parameter parser',
({ spec, doc }) => {
expect(doc.returnsSchema).toBeTypeOf('object');
expect(doc.examples?.length).toBeGreaterThan(0);
// Both built-in spec kinds expose a pure parser; do not call handlers
// here, since examples include writes and process-lifecycle actions.
if (!('parse' in spec) || typeof spec.parse !== 'function') {
throw new Error(`${doc.name} has no pure parameter parser`);
}
for (const example of doc.examples ?? []) {
expect(example.summary).not.toBe('');
const keys = new Set(doc.params.map((param) => param.name));
for (const key of Object.keys(example.params)) {
expect(keys.has(key), `${doc.name} unknown example param ${key}`).toBe(true);
}
expect(spec.parse(example.params), `${doc.name}: ${example.summary}`).toMatchObject(
{ ok: true },
);
}
},
);
it('keeps documentation metadata optional for existing custom specs', () => {
for (const define of [defineAction, defineStreamAction]) {
const spec = define({ name: 'custom', params: {}, run: () => okResponse() });
expect(spec.describe().examples).toBeUndefined();
expect(spec.describe().returnsSchema).toBeUndefined();
expect(spec.parse({})).toMatchObject({ ok: true });
}
});
it('isolates collected examples and schemas from caller mutations', () => {
const copy = collectActionDocs();
const action = copy.find((doc) => doc.name === 'send_group_msg')!;
action.examples![0].params.group_id = 99999;
action.returnsSchema!.type = 'null';
const fresh = collectActionDocs().find((doc) => doc.name === action.name)!;
expect(fresh.examples![0].params.group_id).toBe(20001);
expect(fresh.returnsSchema!.type).toBe('object');
});
it('documents custom reply content on every forward entry point', () => {
@@ -403,7 +403,7 @@ describe('set_group_ban describe', () => {
expect(doc.summary).toBe('禁言群成员(duration=0 解除)');
expect(doc.readOnly).toBe(false);
expect(doc.returns).toBeUndefined();
expect(doc.returnsSchema).toBeUndefined();
expect(doc.returnsSchema).toMatchObject({ type: 'null' });
expect(doc.stream).toBeUndefined();
expect(doc.invariants).toEqual([]);
expect(doc.params).toEqual([
@@ -228,7 +228,7 @@ describe('set_friend_add_request describe', () => {
expect(doc.summary).toBe('处理好友添加请求');
expect(doc.readOnly).toBe(false);
expect(doc.returns).toBeUndefined();
expect(doc.returnsSchema).toBeUndefined();
expect(doc.returnsSchema).toMatchObject({ type: 'null' });
expect(doc.stream).toBeUndefined();
expect(doc.invariants).toEqual([]);
expect(doc.params).toEqual([
@@ -526,7 +526,7 @@ describe('set_group_add_request describe', () => {
expect(doc.summary).toBe('处理加群请求');
expect(doc.readOnly).toBe(false);
expect(doc.returns).toBeUndefined();
expect(doc.returnsSchema).toBeUndefined();
expect(doc.returnsSchema).toMatchObject({ type: 'null' });
expect(doc.stream).toBeUndefined();
expect(doc.invariants).toEqual([]);
expect(doc.params).toEqual([