Skip to content

api-commands — 斜杠指令系统契约 ​

包名: @aalis/api-commands
源码: packages/api-commands/src/index.ts
实现: @aalis/plugin-commands

概述 ​

定义斜杠指令系统:链式 builder、点路径子指令、描述符 commands 与绑定接口 BoundCommands。所有插件登记的指令汇聚到 CommandService,由 plugin-commands 解析并派发。

关键类型 ​

指令层级用 name 的点路径表达('memory.clear.all'),位置参数写在 inline DSL 里:'memory.set <key:string> [value:text]'。handler 形参是 (argv, ...positionals),不是对象式 CommandDefinition。

ts
interface CommandArgv {
  session: { sessionId: string; platform: string; userId?: string; sessionType?: string; raw: string };
  options: Record<string, unknown>;
}

type CommandHandler = (
  argv: CommandArgv,
  ...positionals: unknown[]
) => Promise<string | TextRef | undefined> | string | TextRef | undefined;

interface CommandBuilder {
  alias(name: string): CommandBuilder;
  option(name: string, syntax: string, options?: OptionRegisterOptions): CommandBuilder;
  argument(name: string, description: string | TextRef): CommandBuilder;
  action(handler: CommandHandler): CommandBuilder;
  usage(text: string): CommandBuilder;
  example(line: string): CommandBuilder;
}

interface OptionRegisterOptions {
  description?: string | TextRef;
  default?: unknown;
  required?: boolean;
  choices?: readonly (string | OptionChoice)[] | (() => readonly (string | OptionChoice)[]);
}

interface OptionChoice {
  value: string;
  description?: string | TextRef;
}

interface CommandMeta {
  visibility?: CapabilityVisibility;
  confirm?: CapabilityConfirm;
  risk?: CapabilityRisk;
  usage?: string;
  examples?: string[];
}

session.sessionType 是平台给的类型名,从入站消息原样透传(ExecutionInput.sessionType 同此)。类型名由平台自定,不能据它判会话是不是多人:要判,按会话 id 找到认领它的适配器(resolvePlatformBySession),用 api-platform 的 sessionTypesOf 与 resolveSessionType 按它声明的 multiParty 判,不看类型名是不是 'private',见 platform 服务。

描述、参数与取值 ​

  • 描述的第一句是摘要:/help 概览与子指令列表只显示描述的第一句(按句子切分,分号与换行另算句末),显示宽度超过 48 列(中日韩文字记 2 列)截断加省略号;详情里显示完整描述。所以第一句写成一句短话,细节写在后面,如 '清空当前会话记忆;用 --type 选择清理类型'。不另设摘要字段。
  • 说明与回复可以是文字对象:描述、位置参数与选项的说明、取值的说明可以写本包字典的 t('键')(@aalis/schema-i18n),handler 也可以返回文字对象,都按这条指令所在会话的语言显示;usage 与 example 是用户照着敲的写法,保持字符串。
  • 位置参数的说明:argument(name, description) 给 DSL 里声明的位置参数加说明,名字不在 DSL 里时抛错。帮助里位置参数写成 <名字>(必填)与 [名字](可选),带说明的另列在「参数」下。
  • 带说明的取值:choices 的每一项可以只写值,也可以写成 { value, description },帮助里逐项列在选项下面。string 选项只收其中之一,取值不在里面时回「只能是」;string[] 选项不按它校验,由指令自己核(如 /clear --type 按登记的清理类型核)。取值随运行时变化的(如按登记表列出的)把 choices 写成返回它们的函数,帮助与取值校验每次调用、现取。注册表交出的 OptionSpec.choices 一律是这样的函数(登记时写的数组也包一层),读它的调用一次得到当前的取值。
  • -h 与 --help 是保留选项:任何指令后面写 -h 或 --help(-- 之前),回的就是 /help <指令>,别的参数一概不管、不执行;声明名为 help、h 或别名为 -h、--help 的选项时登记抛错。

子指令 ​

/clear all          → 点路径 clear.all
/clear              → 命中 clear 根节点的 action(若有)
/db migrate up      → db.migrate.up

父指令有子指令、自身不声明位置参数时,匹配不到子指令的多余词(写错的子指令名,或写多了的选项值)不会被忽略:回「未知子指令或多余参数」并指向 /help <指令>,不执行父指令,也不进入权限确认。父指令要接收自由文本,就声明位置参数,如 'note [tail:text]'。子指令按最长前缀匹配,先于位置参数:自由文本的第一个词恰好与某个子指令同名时,整条输入路由到该子指令,不进父指令,只给这个词加引号也一样;把整段自由文本括进一对引号,就作为一个参数交给父指令(整段恰好是子指令名时除外)。

可见性沿树继承(restricted 父分组 → restricted 子节点,除非子节点重新声明),可在 authority 配置的 authorityOverrides 里按能力键单独改写最低等级(key 形如 command:clear.all,值为整数等级)。

帮助怎么按调用者过滤、怎么排版,见 plugin-commands 的「帮助」。

绑定门面 ​

ts
interface BoundCommands extends CommandService {
  command(name: string, description?: string | TextRef, meta?: CommandMeta): CommandBuilder;
}

commands.use.command(...) 把 pluginName 填成本次激活 id,登记记在资源口的 registrar 上,按点路径名(去掉 inline 位置参数)为键:

  • 提供者在场时 builder 的 alias / option / action 等调用同步转发;缺席时记下,上线后按序重放。
  • 提供者换人时整体重挂到新提供者;本次激活关闭时与原语同一拍撤回,不等它所提供服务的下游交接。
  • 同一激活再登记同名指令,替换先前那次(只留一层);不同激活的同名指令照旧各占一层,后来者生效。
  • 提供者在场时它拒收的登记(如指令名不合法)原样抛给调用方;缺席时登记照收,上线时再登记失败只记 warn(重放到一半压上的那一层随之撤掉)、留待下次换人重试。
  • 撤回时调 unregister(registryKey, pluginName),只摘自己那一层声明。不要自己调不带 contextId 的 unregister——那会摘掉同名指令的全部层。

服务接口(节选) ​

ts
interface CommandService {
  prefix: string;
  command(name: string, description?: string | TextRef, meta?: InternalCommandMeta): CommandBuilder;
  unregister(name: string, contextId?: string): void;
  execute(name: string, ctx: ExecutionInput): Promise<string | undefined>;
  parseCommand(input: string): { name: string; args: string[]; raw: string } | null;
  hasMatch(head: string, tokens?: string[]): boolean;
  has(name: string): boolean;
  get(name: string): Command | undefined;
  getAll(): Command[];
  setExecutionGuard(guard: ExecutionGuard | undefined): void;
}

execute 的回复按 ctx 的 { sessionId, platform }(plugin-i18n 的会话设置 language;owner 会话与它的子会话按全局层)的语言渲染成字符串交出。

setExecutionGuard 注入执行守卫;传 undefined 摘掉,需要更高等级或确认的指令退回 fail-closed。plugin-authority 激活时注入、停用时摘掉。守卫上下文的参与者只有发出者(ExecutionInput 的 platform 与 userId,没有账号的是无主体),守卫拦截时回 { ok: false, reason },原因作为回复交给发出者。

unregister 的 contextId:插件自己的清理必须传它(绑定门面的退订已代传);缺省会摘掉全部层(管理面用)。注销键是点路径(inline DSL 在注册时被切掉),传 'memory.clear <key:string>' 会键不匹配、静默 no-op。

典型用法 ​

ts
import { commands } from '@aalis/api-commands';
import { definePlugin, optional } from '@aalis/core';

export default definePlugin({
  name: '@acme/plugin-example-commands',
  uses: { commands: optional(commands) },
  apply({ commands }) {
    commands.use
      .command('persona [persona:string]', '查看或切换人格', { visibility: 'restricted' })
      .argument('persona', '要切换到的人格;不写时查看当前人格')
      .action(async (argv, persona) => {
        if (typeof persona !== 'string' || persona.length === 0) return '当前人格:…';
        return `已切换到 ${persona}`;
      });
  },
});

实现者 ​

相关 ​

  • ExecutionGuard 见 api-authority
  • ExecutionInput.skipConfirm 用于没人可问的受信来源(scheduler 触发的指令、runtime 一次性子命令)免 session 档的确认,只对授权身份是 owner 的执行生效;authorize 仍生效
  • ExecutionInput.confirmIntent 由能当面问到发出者的调用方(plugin-commands 的入站相位)提供,原样交给执行守卫做意图确认

文档对应 dev@c4e046e41,core 0.21.0,构建于 2026-11-02
基于 MIT 许可发布