Skip to content

命令(defineCommand) ​

在插件包根目录创建 commands/hello/index.ts,用户就能输入 hello 触发它。只有路由目录内的 index.ts / index.tsx 是命令入口,同目录其他文件都是 helper。该链路由 @zhin.js/command Feature 提供;依赖 zhin.js 的 Root 会经 platformFeatures 自动挂载。

ts
// commands/hello/index.ts
import { defineCommand } from 'zhin.js/command';

export default defineCommand({
  description: '打个招呼',
  execute: () => 'Hello from zhin.',
});

单文件 Bot 可以在插件入口注册同一个 definition:

ts
import { definePlugin } from 'zhin.js';

export default definePlugin({
  name: 'my-bot',
  setup({ addCommand }) {
    addCommand('hello', defineCommand({
      description: '打个招呼',
      execute: () => 'Hello from one file.',
    }));
  },
});

addCommand 与目录发现共用 CommandIndex、清单、冲突检测和 generation 生命周期。 当命令变多时,把 definition 移到 commands/hello/index.ts 默认导出即可;目录模式还能把 HMR 粒度缩小到单个命令文件。

文件路由 ​

命令名由 commands/ 下到 index.ts 的目录路径决定,目录段以空格连接。插件 owner 不会隐式进入用户输入。Endpoint 先处理自己的 commandPrefix(默认空字符串);插件配置只有显式设置 commandNamespace 时才会在本插件所有命令、别名和 shortcut 前增加命名空间。

文件所属插件命令名
commands/hello/index.tsroothello
commands/qq/endpoint/list/index.tsqqqq endpoint list
commands/qq/endpoint/add/[[name]]/index.tsqqqq endpoint add [name]
commands/foo/index.ts + commandNamespace: adminb 下的 aadmin foo

先看嵌套:commands/ 递归扫描,嵌套目录直接映射为子命令段。静态段文件名 / 目录名须通过 isCapabilityLocalSegment(zhin.js):

  • ASCII kebab:/^[a-z0-9][a-z0-9-]*$/(如 hello/、lottery-today/)
  • Unicode 名:含至少一个非 ASCII 字符、无 ASCII 大写,如 赞我/
  • 动态参数目录限 ASCII:[name]/ / [[name]]/ 等

instanceKey、中间件等其它约定目录仍为 ASCII kebab。tools/ 允许 ASCII kebab 或 snake(如 send_user_like/)。

动态参数段用 Next.js 风格目录名声明形态,且必须是路径的最后一段;类型与默认值不写进目录名,统一在 defineCommand({ params }) 里声明——params.<name>.type 必填,default 可选:

参数目录形态帮助显示params 声明
[name]/index.ts必需参数<name>params: { name: { type: 'string' } }
[[name]]/index.ts可选参数[name]params: { name: { type: 'string', default: '' } }
[...name]/index.ts捕获所有(消费剩余全部输入)<...name>params: { name: { type: 'text' } },运行时 params.name 为数组
[[...name]]/index.ts可选捕获所有[...name]同上,未提供时为空数组

一致性在启动期校验:有 default 时目录名必须用双方括号([[name]]),目录声明了参数形态但 params 里缺对应声明,都会抛 CommandPathSyntaxError。

动态参数可以直接作为顶层首段。无论 Root 还是子插件,commands/[note]/index.ts 都匹配顶层单段输入;放在静态目录 commands/remind/[note]/index.ts 时形成 remind <note>。每条命令仍只允许一个动态段,并且必须位于路径末尾。不同 owner 发布相同用户路由时,generation 构建会直接报告冲突。

捕获所有的数组元素粒度由 params.<name>.type 决定:text 逐消息段收集(纯文本输入整体为一个元素);word / string 按空白逐词切分;number / integer / float / boolean 逐词切分后逐个转换,任一词转换失败即视为命令不匹配;mention / image 等结构化类型逐消息段收集。

参数类别支持的 type匹配结果
文本string / word / text字符串;text 可消费连续文本
数值number / integer / float有限数值;integer 要求整数,float 要求小数
布尔booleantrue / false
IM 段mention / image / face / reply / forward / dice / rpscanonical segment 对应字段

结构化 IM 参数不支持默认值。运行时由 segment-matcher 直接在 canonical segments 上匹配,不会先把 image、mention 等降级成文本;类型不匹配在派发时视为「命令不匹配」。

路由冲突有两条规则:静态优先——list/index.ts 永远赢过 [name]/index.ts,动态路由之间静态段多者优先;有效路由同形拒绝——应用显式 commandNamespace 后仍相同的路由会在 generation 启动时报错。用户可给冲突插件配置不同命名空间。

真实示例(plugins/adapters/qq/commands/qq/endpoint/remove/[name]/index.ts,命令定义由endpoint 管理命令套件生成):

ts
import { qqEndpointCommands } from '../../../../src/qq-endpoint-commands.js';

export default qqEndpointCommands.remove;

execute 上下文 ​

execute(context) 收到一个冻结的 CommandContext:

字段类型说明
argsreadonly string[]命令名匹配之后剩余的词(按空白切分)
paramsRecord<string, CommandParameterValue>动态参数段解析后的类型化值;结构化参数可为媒体对象
segmentsreadonly CommandSegment[]命令模式消费后剩余的结构化段;保留媒体、mention 等非文本信息
configReadonly<TConfig>本插件的配置快照(来自 zhin.config.yml)
inputTInput | undefined调用来源;IM 派发时为 Runtime Message(满足 CommandMessage),Host / execute(name) 可能缺省
adapterstring | undefined适配器插件实例 id(如 root/icqq)
endpointstring | undefinedEndpoint 名(metadata.endpoint)
sceneCommandScene | undefined{ id, type, name? };优先上游结构化字段,否则从 target / metadata 解析
senderCommandSender | undefined{ id, name?, role: string[] };role 含平台身份与框架角色
use(token)<T>(token: Token<T>) => T取 Plugin Runtime 资源(见下)
ownerPluginNodeSnapshot命令所属插件节点
generationnumber当前代际号

TInput 默认约束为 CommandMessage(命令侧消息契约)。因架构分层 @zhin.js/command 不能 import @zhin.js/core,故独立声明;Runtime Message 结构兼容。

ts
export default defineCommand({
  description: '谁在哪',
  execute: ({ adapter, endpoint, scene, sender }) =>
    `${sender?.name ?? sender?.id} @ ${scene?.type}:${scene?.id} via ${adapter}/${endpoint} roles=${sender?.role.join(',')}`,
});

use(token) 读的是插件 setup() 时 resources.provide(...) 注册的资源,缺失时抛错。上面的 QQ 命令用 use(qqRuntimeStateToken) 拿到适配器共享状态——这是命令与适配器协作的标准方式。

返回值与组件渲染 ​

execute 的返回值(或 Promise 解出的值)即回复内容,类型为 SendContent。它可以是字符串(直接作为文本回复)、component(name, props)(调用组件渲染,zhin.js/core/runtime 导出)、raw(payload)(原样下线的 wire 段,如 { type: 'html', data: { html, width } }),也可以是这三者组成的数组表示多段消息。返回 undefined 则不回复——命令自己已通过 input.$reply(...) 回复,或主动静默。

IM 入站的完整链路:

派发时按确定性优先级尝试已编译的命令模式:静态命令先于动态命令,动态命令中更具体的 路径优先。命中后,剩余文本按空白切分进入 args,完整富消息尾部保留在 segments。 因此 qq endpoint remove mybot 会命中 qq endpoint remove <name>,args 为空、 params.name === 'mybot'。

结构化参数示例:

ts
// commands/upload/[asset]/index.ts
import { defineCommand } from 'zhin.js/command';

export default defineCommand({
  params: {
    asset: { type: 'image', description: '要上传的图片' },
  },
  execute: ({ params, args, segments }) => ({
    uploaded: params.asset,
    captionWords: args,
    remainingSegments: segments,
  }),
});

当消息由文本 upload 、image 段和 caption 文本组成时,params.asset 是 image 的 MediaRef,caption 同时以 args 文本兼容视图和 segments 结构化视图提供。

master / trusted 权限模式 ​

框架层发送者角色为三档:user → trusted → master(master 隐含 trusted)。角色由适配器实例配置解析:

yaml
# zhin.config.yml
plugins:
  qq:
    master: '10001'        # 顶层 master(endpoint owner)
    endpoints:
      - name: main
        appid: ${QQ_APPID}
        master: '10001'    # endpoints[i] 可逐项覆盖

master / trusted 名单由 Core 的角色解析读取(resolveSenderRoles,packages/im/core/src/built/ai-trigger.ts),role(master)、role(trusted) 这类 permit 以及 Agent 工具的 permissions 都基于同一套角色。

alias / permit / shortcut ​

defineCommand 支持声明式别名、权限与整句快捷方式(构建期校验;命中冲突抛错):

ts
export default defineCommand({
  description: 'ICQQ 点赞',
  alias: ['zan'],                    // 可多词,如 'gh issue'
  permit: ['adapter(icqq)'],         // 数组 AND;单项内逗号 OR
  // shortcut: { '赞满': { count: 10 } }, // 全局整句精确匹配
  execute: async (ctx) => { /* ... */ },
});
字段行为
alias替换全部本地静态段;动态 $param 接在后面,owner 不参与路由
permit仅内置 DSL:adapter|group|private|channel|user|role(...);未过则静默未命中(matched: false);CommandIndex.execute 无 session 时跳过
shortcutRecord:触发串 → 预填 params;trim 后全文相等才命中

冲突键按完整词序列(b 与 b list 可共存)。正式路由、alias、shortcut 键互斥。

仍可在 execute 里做更细的业务权限(例如 endpoint 管理命令的 isEndpointOperator):

ts
export function isEndpointOperator(config: unknown, input: unknown): boolean {
  const cfg = (config ?? {}) as { master?: unknown; endpoints?: unknown };
  const masters = new Set<string>();
  // 收集顶层 master 与各 endpoints[i].master …
  if (masters.size === 0) return true; // 未配置 master 时放行
  const sender = String((input as { sender?: unknown } | null)?.sender ?? '').trim();
  return !!sender && masters.has(sender);
}

这个模式的要点:用 config(插件配置)拿到声明的 master 名单,用 input(消息)拿到发送者 id,比对后返回拒绝文案(仅 master 可执行 <Adapter> endpoint 管理命令)。未配置 master 时放行,首个扫码绑定者即成为 owner(QQ 绑定流程会把操作者写为新 endpoint 的 master)。注意 input 不一定是消息(可能是 Host 调用),取发送者前要做类型守卫;非消息来源时 $reply 降级为 no-op。

适配器 endpoint 管理命令套件 ​

@zhin.js/adapter 的 createEndpointCommands(spec, defineCommand) 为适配器生成 <adapter> endpoint 的 list / add / remove 三个命令。除 email(smtp/imap 为嵌套对象,kv 无法表达)与 sandbox(内置调试适配器,无凭据)外,全部平台适配器均已接入:qq、icqq、napcat、onebot11、onebot12、milky、satori、slack、telegram、discord、kook、lark、dingtalk、line、wecom、wechat-mp、weixin-ilink、github。

  • <adapter> endpoint list:运行中的 endpoints(adapter create() 注册的 runtime state)+ 当前 Root 配置里 plugins.<adapterKey>.endpoints 的配置清单。
  • <adapter> endpoint add <name> <key=value...>:手动录入字段。env: true 的凭据字段值写入 .env(键名派生为 <ADAPTER>_<NAME>_<FIELD> 大写,如 TELEGRAM_BOT1_TOKEN、SLACK_BOT1_SIGNING_SECRET),Root 配置中保存 ${REF} 引用;其余字段内联写入。YAML/JSON 均通过同一个事务端口更新,YAML 保留既有注释;重名拒绝;add/remove 都走上面的 master 门禁。
  • <adapter> endpoint remove <name>:从配置移除(重启生效,.env 键保留待手动清理)。
  • 特殊 add 流程(如 QQ 扫码绑定)经 spec.bindFlow 钩子接管 add 命令;QQ 因此多出第四个命令 qq endpoint cancel。

命令本身只依赖 EndpointConfigurationStore,不读取文件系统。官方 CLI 在 composition root 提供 endpointConfigurationStoreToken,负责当前 YAML/JSON Root 配置与 .env 的持久化。自行组装 RootRuntime 且启用这些命令时,必须提供同一端口的实现。plugins 必须是对象映射;旧数组 形态不会在运行时自动转换,应先执行显式迁移。

接入一个适配器只需四步(以 telegram 为例):

ts
// 1. src/telegram-runtime-state.ts —— 运行中 endpoint 注册表 token
export const telegramRuntimeStateToken = defineEndpointRuntimeStateToken('telegram');

// 2. plugin.ts setup() —— provide 状态;adapters/telegram/index.ts create() 里登记
context.resources.provide(telegramRuntimeStateToken, createEndpointRuntimeState());
// create(): context.use(telegramRuntimeStateToken).endpoints.set(config.name, { name: config.name, mode: config.mode });

// 3. src/telegram-endpoint-commands.ts —— 生成命令(defineCommand 由适配器侧注入,
//    provider 包之间禁止互相 import)
export const telegramEndpointCommands = createEndpointCommands({
  adapterKey: 'telegram',          // = zhin.config.yml 的 plugins.<key>
  adapterDisplayName: 'Telegram',
  fields: [{ key: 'token', required: true, env: true, description: 'Telegram bot token' }],
  running: (use) => use(telegramRuntimeStateToken).endpoints.values(),
  describeEntry: (entry) => `token: ${String(entry.token)}`,
}, defineCommand);

// 4. commands/telegram/endpoint/list/index.ts、add/[[name]]/index.ts、remove/[name]/index.ts
export default telegramEndpointCommands.list; // / .add / .remove

fields 与该适配器 schema.json 的 endpoints.items.properties 对齐;add 的 kv 参数走 args(最长前缀匹配后剩余的词),值含 = 时按首个 = 切分。

commandPrefix 适配器配置 ​

默认无前缀:任意文本都会尝试按命令匹配。给适配器实例配置 commandPrefix 后,只有以前缀开头的消息才进入命令匹配:

yaml
plugins:
  qq:
    commandPrefix: '/'     # 仅 "/qq endpoint list" 触发
    endpoints:
      - name: main
        commandPrefix: ''  # endpoints[i] 可逐项覆盖顶层

解析规则(packages/im/core/src/plugin-runtime/im/message-dispatcher.ts):按消息所属适配器实例读 commandPrefix(默认 '');实例声明了 endpoints 数组时,按消息的来源 endpoint 名找 entry,entry.commandPrefix 覆盖顶层。前缀剥离后再做命令匹配;不匹配前缀的消息落入未命中路径(如 AI 对话)。

排错提示 ​

description 会出现在命令清单里,建议都写。命令名冲突(同名静态命令或同形动态路由)在启动期抛错,改配置时启动一次就能尽早发现。返回 Promise 的命令可以做多轮交互——先 resolve 首条回复,后续用 input.$reply 追加,参考 qq endpoint add 的扫码绑定流程。