Skip to content

Commands (defineCommand)

Create a commands/ directory in your plugin package root and drop a hello.ts file in it -- users can then type hello in a group chat to trigger it. The file path is the command name, and after editing a file, hot reload takes effect immediately without restarting the process. This pipeline is provided by the @zhin.js/command Feature (inherited via platformFeatures when depending on zhin.js); no manual registration needed. Authors import from zhin.js/commanddo not pnpm add @zhin.js/command.

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

export default defineCommand({
  description: 'Say hello',
  execute: () => 'Hello from zhin.',
});

Single-file Bots can register the same definition in the plugin entry:

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

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

addCommand and directory discovery share the same CommandIndex, manifest, conflict detection, and generation lifecycle. When commands grow in number, move the definition to a commands/hello.ts default export; the directory mode also narrows HMR granularity to individual command files.

File Routing

Command name = plugin tree path segments (instanceKey, without root, joined by .) + . + the file's relative path segments (joined by spaces). Root plugins (the application itself) have no prefix.

FilePluginCommand name
commands/hello.tsroothello
commands/endpoint/list.tsqqqq.endpoint list
commands/endpoint/add/[[name]].tsqqqq.endpoint add [name]
commands/foo.tsa under b (root/b/a)b.a.foo

First, nesting: commands/ is scanned recursively, and nested directories map directly to subcommand segments. Static file / directory names must pass isCapabilityLocalSegment (zhin.js):

  • ASCII kebab: /^[a-z0-9][a-z0-9-]*$/ (e.g. hello.ts, lottery-today.ts)
  • Unicode names: at least one non-ASCII character and no ASCII uppercase, e.g. 赞我.ts (trigger word 赞我)
  • Dynamic parameter files remain ASCII-only: [name].ts / [[name]].ts, etc.

instanceKey and other convention directories (middlewares / …) stay ASCII kebab. tools/ allows ASCII kebab or snake (e.g. send_user_like.ts).

Dynamic parameter segments use Next.js-style file names to declare their shape and must be the last segment of the path; type and default value are not written into the file name — they are declared in defineCommand({ params }), where params.<name>.type is required and default is optional:

File nameShapeHelp displayparams declaration
[name].tsRequired parameter<name>params: { name: { type: 'string' } }
[[name]].tsOptional parameter[name]params: { name: { type: 'string', default: '' } }
[...name].tsCatch-all (consumes all remaining input)<...name>params: { name: { type: 'text' } }; at runtime params.name is an array
[[...name]].tsOptional catch-all[...name]Same as above; an empty array when not provided

Consistency is validated at startup: when a default is present the file name must use double brackets ([[name]]), and a parameter shape in the file name without a matching params declaration both throw CommandPathSyntaxError.

Child plugin constraint: the first command path segment must be static. Child plugin commands are auto-prefixed by the plugin path (e.g. remind.add), and a dynamic parameter may only be the last segment (at most one) — so commands/[note].ts works in the root plugin but throws Invalid Command path at startup in a child plugin (no static segment to anchor to). In child plugins, always put dynamic parameters under a static directory: commands/add/[note].ts (command name remind.add <note>).

The element granularity of a catch-all array depends on params.<name>.type: text collects per message segment (plain-text input arrives as a single element); word / string split on whitespace into words; number / integer / float / boolean split into words and convert each one — any word that fails conversion makes the whole command not match; structured types such as mention / image collect per message segment.

Parameter categorySupported typesMatch result
Textstring / word / textString; text can consume continuous text
Numericnumber / integer / floatFinite number; integer requires an integer, float requires a decimal
Booleanbooleantrue / false
IM segmentsmention / image / face / reply / forward / dice / rpsCorresponding field of canonical segment

Structured IM parameters do not support default values. At runtime, segment-matcher matches directly on canonical segments, without first degrading image, mention, etc. to text; type mismatches are treated as "command not matched" during dispatch.

Route conflict has two rules: static priority -- list.ts always wins over [name].ts, and among dynamic routes, those with more static segments (more specific) take priority; same-shape rejection -- duplicate registration of the same route shape reports an error at startup (Duplicate runtime Command).

Real-world example (plugins/adapters/qq/commands/endpoint/remove/[name].ts, command definition generated by the endpoint management command suite):

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

export default qqEndpointCommands.remove;

execute Context

execute(context) receives a frozen CommandContext:

FieldTypeDescription
argsreadonly string[]Remaining words after command name matching (split by whitespace)
paramsRecord<string, CommandParameterValue>Typed values from parsed dynamic parameter segments; structured parameters can be media objects
segmentsreadonly CommandSegment[]Remaining structured segments after command pattern consumption; preserves media, mention, and other non-text information
configReadonly<TConfig>This plugin's configuration snapshot (from zhin.config.yml)
inputTInput | undefinedCall source; when dispatched via IM, it is a Runtime Message (satisfying CommandMessage); may be absent for Host / execute(name) calls
adapterstring | undefinedAdapter plugin instance id (e.g., root/icqq)
endpointstring | undefinedEndpoint name (metadata.endpoint)
sceneCommandScene | undefined{ id, type, name? }; prefers upstream structured fields, otherwise parsed from target / metadata
senderCommandSender | undefined{ id, name?, role: string[] }; role contains platform identity and framework roles
use(token)<T>(token: Token<T>) => TRetrieve Plugin Runtime resources (see below)
ownerPluginNodeSnapshotPlugin node that owns the command
generationnumberCurrent generation number

TInput defaults to being constrained to CommandMessage (the command-side message contract). Because @zhin.js/command cannot import @zhin.js/core due to architectural layering, it declares this independently; the Runtime Message structure is compatible.

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

use(token) reads resources registered during the plugin's setup() via resources.provide(...); it throws when absent. The QQ command above uses use(qqRuntimeStateToken) to get the adapter's shared state -- this is the standard pattern for command-adapter collaboration.

Return Values and Component Rendering

The return value (or the resolved value of a Promise) of execute is the reply content, typed as SendContent. It can be a string (used directly as a text reply), component(name, props) (invokes component rendering, exported from zhin.js/core/runtime), raw(payload) (a wire segment sent as-is, such as { type: 'html', data: { html, width } }), or an array of these three for multi-segment messages. Returning undefined means no reply -- the command has already replied via input.$reply(...), or intentionally stays silent.

The full pipeline for IM inbound:

During dispatch, compiled command patterns are tried in deterministic priority order: static commands before dynamic commands, and among dynamic commands, more specific paths take priority. After a match, remaining text is split by whitespace into args, and the full rich-message tail is preserved in segments. Therefore qq.endpoint remove mybot matches qq.endpoint remove <name>, args is empty, and params.name === 'mybot'.

Structured parameter example:

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

export default defineCommand({
  params: {
    asset: { type: 'image', description: 'Image to upload' },
  },
  execute: ({ params, args, segments }) => ({
    uploaded: params.asset,
    captionWords: args,
    remainingSegments: segments,
  }),
});

When a message consists of the text upload , an image segment, and caption text, params.asset is the image's MediaRef, and the caption is provided both as the args text-compatible view and the segments structured view.

master / trusted Permission Model

The framework-level sender roles have three tiers: user -> trusted -> master (master implies trusted). Roles are resolved from adapter instance configuration:

yaml
# zhin.config.yml
plugins:
  qq:
    master: '10001'        # Top-level master (endpoint owner)
    endpoints:
      - name: main
        appid: ${QQ_APPID}
        master: '10001'    # endpoints[i] can override per-item

The master / trusted lists are read by Core's role resolution (resolveSenderRoles, packages/im/core/src/built/ai-trigger.ts); permits like role(master), role(trusted), and Agent tool permissions all rely on the same role system.

alias / permit / shortcut

defineCommand supports declarative aliases, permissions, and whole-message shortcuts (validated at build time; conflicts throw):

ts
export default defineCommand({
  description: 'ICQQ like',
  alias: ['zan'],                    // multi-word OK, e.g. 'gh issue'
  permit: ['adapter(icqq)'],         // array AND; commas inside one entry OR
  // shortcut: { '赞满': { count: 10 } }, // global exact full-message match; may break owner prefix
  execute: async (ctx) => { /* ... */ },
});
FieldBehavior
aliasReplaces all local static segments, then re-applies the owner prefix (child plugins need qq.ep; bare ep does not match). Dynamic $param still follows.
permitBuiltin DSL only: adapter|group|private|channel|user|role(...). Failure is a silent miss (matched: false). CommandIndex.execute skips permit when there is no session.
shortcutRecord: trigger → prefilled params. Exact match after trim. May be global (no owner prefix required).

Conflict keys are the full word sequence (b and b list may coexist). Primary routes, aliases, and shortcut keys are mutually exclusive.

You can still do finer business checks inside execute (for example isEndpointOperator for endpoint management commands):

ts
export function isEndpointOperator(config: unknown, input: unknown): boolean {
  const cfg = (config ?? {}) as { master?: unknown; endpoints?: unknown };
  const masters = new Set<string>();
  // Collect top-level master and each endpoints[i].master ...
  if (masters.size === 0) return true; // Allow all when master is not configured
  const sender = String((input as { sender?: unknown } | null)?.sender ?? '').trim();
  return !!sender && masters.has(sender);
}

The key points of this pattern: use config (plugin configuration) to get the declared master list, use input (message) to get the sender id, then compare and return a rejection message (Only master can execute <Adapter> endpoint management commands). When master is not configured, all are allowed, and the first person to scan-bind becomes the owner (the QQ binding flow writes the operator as the new endpoint's master). Note that input is not necessarily a message (it could be a Host call), so a type guard is needed before getting the sender; for non-message sources, $reply degrades to a no-op.

Adapter Endpoint Management Command Suite

@zhin.js/adapter's createEndpointCommands(spec, defineCommand) generates list / add / remove commands for <adapter> endpoint. Except for email (smtp/imap nested objects, not expressible in kv) and sandbox (built-in debug adapter, no credentials), all platform adapters are integrated: qq, icqq, napcat, onebot11, onebot12, milky, satori, slack, telegram, discord, kook, lark, dingtalk, line, wecom, wechat-mp, weixin-ilink, github.

  • <adapter>.endpoint list: running endpoints (runtime state registered by the adapter's create()) + the configuration list from plugins.<adapterKey>.endpoints in zhin.config.yml.
  • <adapter>.endpoint add <name> <key=value...>: manual field entry. Credential field values with env: true are written to .env (key names derived as <ADAPTER>_<NAME>_<FIELD> in uppercase, e.g., TELEGRAM_BOT1_TOKEN, SLACK_BOT1_SIGNING_SECRET), with ${REF} references saved in yaml; other fields are written inline. YAML uses Document-node-level operations to preserve existing comments; duplicate names are rejected; both add/remove go through the master gate described above.
  • <adapter>.endpoint remove <name>: removes from configuration (takes effect on restart; .env keys are retained for manual cleanup).
  • Special add flows (such as QQ scan-code binding) are handled by the spec.bindFlow hook taking over the add command; QQ therefore has a fourth command qq.endpoint cancel.

Integrating an adapter requires only four steps (using telegram as an example):

ts
// 1. src/telegram-runtime-state.ts -- runtime endpoint registry token
export const telegramRuntimeStateToken = defineEndpointRuntimeStateToken('telegram');

// 2. plugin.ts setup() -- provide state; register in adapters/telegram.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 -- generate commands (defineCommand is injected
//    from the adapter side, provider packages must not import each other)
export const telegramEndpointCommands = createEndpointCommands({
  adapterKey: 'telegram',          // = plugins.<key> in zhin.config.yml
  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/endpoint/{list.ts, add/[[name]].ts, remove/[name].ts}
export default telegramEndpointCommands.list; // / .add / .remove

fields aligns with the adapter's schema.json endpoints.items.properties; add's kv arguments go through args (remaining words after longest prefix match), and values containing = are split at the first =.

commandPrefix Adapter Configuration

By default there is no prefix: any text will be tried for command matching. After configuring commandPrefix for an adapter instance, only messages starting with the prefix enter command matching:

yaml
plugins:
  qq:
    commandPrefix: '/'     # Only "/qq.endpoint list" triggers
    endpoints:
      - name: main
        commandPrefix: ''  # endpoints[i] can override the top-level

Resolution rules (packages/im/core/src/plugin-runtime/im/message-dispatcher.ts): read commandPrefix from the adapter instance the message belongs to (default ''); when the instance declares an endpoints array, find the entry by the message's source endpoint name, where entry.commandPrefix overrides the top-level. After prefix stripping, command matching proceeds; messages not matching the prefix fall into the unmatched path (such as AI conversation).

Troubleshooting Tips

description appears in command listings, so it's recommended to always include one. Command name conflicts (same-name static commands or same-shape dynamic routes) throw errors at startup; running a startup after configuration changes catches them early. Commands returning Promise can implement multi-round interactions -- resolve the first reply, then append subsequent ones with input.$reply; see the QQ qq.endpoint add scan-code binding flow for reference.