Skip to content

Commands (defineCommand) ​

Create commands/hello/index.ts in a plugin package and users can type hello to trigger it. Only index.ts or index.tsx is a command entry; sibling files are helpers. The @zhin.js/command Feature provides discovery and hot reload.

ts
// commands/hello/index.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/index.ts default export; the directory mode also narrows HMR granularity to individual command files.

File Routing ​

The command name comes from the directory path under commands/, with segments joined by spaces. The Endpoint first consumes its own commandPrefix, which defaults to an empty string. The plugin owner remains part of Capability identity and configuration scope; it enters user input only when plugin config explicitly sets commandNamespace.

FilePluginCommand name
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: admina under b (root/b/a)admin 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/, lottery-today/)
  • Unicode names: at least one non-ASCII character and no ASCII uppercase, e.g. 赞我/ (trigger word 赞我)
  • Dynamic parameter directories remain ASCII-only: [name]/ / [[name]]/, etc.

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

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

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

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

A dynamic parameter may be the top-level first segment. In both Root and child plugins, commands/[note]/index.ts matches one top-level segment; commands/remind/[note]/index.ts becomes remind <note>. A command still allows only one dynamic segment, and it must be last. If different owners publish the same user route, generation construction fails with a conflict.

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/index.ts always wins over [name]/index.ts, and among dynamic routes, those with more static segments take priority; effective-route rejection -- routes that still collide after applying explicit commandNamespace values fail generation startup. Users can assign different namespaces to incompatible plugins.

Real-world example (plugins/adapters/qq/commands/qq/endpoint/remove/[name]/index.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]/index.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
  execute: async (ctx) => { /* ... */ },
});
FieldBehavior
aliasReplaces all local static segments. Dynamic $param still follows; owner does not participate in routing.
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.

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()) plus plugins.<adapterKey>.endpoints from the active Root configuration.
  • <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 the Root configuration; other fields are written inline. YAML and JSON use the same transaction port, and YAML comments are preserved; 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.

The commands depend only on EndpointConfigurationStore; they do not access the filesystem. The official CLI provides endpointConfigurationStoreToken at the composition root and persists the active YAML or JSON Root configuration plus .env. Custom RootRuntime compositions that enable these commands must provide the same port. plugins must be an object map; legacy arrays are rejected and require an explicit migration.

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/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 -- 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/telegram/endpoint/list/index.ts, add/[[name]]/index.ts, remove/[name]/index.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.