Message Flow
When a user sends /ping in a group chat, through to the Bot's reply landing back on the platform, every step passes through a unidirectional pipeline: inbound flows from the platform Endpoint to commands/AI, outbound flows from plugin code to the platform Endpoint. The entire pipeline is orchestrated by ImRuntime (the MessageGateway implementation in @zhin.js/core), with each step reading from the current generation snapshot -- naturally hot-reload safe.
Inbound: Adapter -> Middleware -> Command -> AI Fallback
The actual code locations for each step:
Endpoint normalization. The platform adapter's Endpoint (e.g., the sandbox's
SandboxWsEndpoint) normalizes platform events intoIncomingMessageand calls themessageGatewayTokeninjected at creation time:tsinterface IncomingMessage { readonly adapter: CapabilityId; // Source endpoint's capability ID readonly target: string; // Scene, e.g., "group:123" / "private:456" readonly content: string; readonly id?: string; // Platform message ID readonly sender?: string; readonly metadata?: Readonly<Record<string, unknown>>; // e.g., endpoint name }Lease and Message.
ImRuntime.receivefirstacquire()s the current generation snapshot (in-flight messages are not interrupted by reloads, see Generation and Lifecycle), looks up the owner plugin for that endpoint as the default requester, and constructs aMessage.Messagecarries two outbound closures:$reply(content)and$replyFrom(owner, content); after dispatch ends, the reply scope is closed, and calling$replyafterward throwsMessage reply scope has ended.Inbound middleware.
MiddlewareIndexsorts byphase(before-dispatchfirst,after-dispatchlater) andorder, wrapping each around the terminal action:tsdefineMiddleware({ phase: 'before-dispatch', // Default target: 'inbound', // Default; 'outbound' intercepts outbound order: 0, async handle(context, next) { // context.input is Message (inbound) or OutboundEnvelope (outbound) await next(); // Not calling next() intercepts the message }, });Command dispatch.
MessageDispatcherfirst resolves the command prefix (by default based on the message's adapter instance configuration:endpoints[i].commandPrefixoverrides the top-levelcommandPrefix, defaulting to''with no prefix, see Config as Data). If the prefix doesn't match, it's an immediate miss; if the prefix matches, it is stripped and passed toCommandIndex.dispatch. When a command has a return value, the dispatcher automatically replies using the command owner's identity via$replyFrom(owner, value).AI fallback. On command miss (or unmatched plain text), the
unmatchedHandlertakes over -- a Host with@zhin.js/agentinstalled routes it to the Agent for reply; without it, the message is silently discarded.Event broadcast. After dispatch completes, a
RuntimeMessageEventis emitted toonMessagesubscribers (containing direction, adapter, target, sender, acontentPreviewof up to 200 characters, and timestamp). The Console's real-time message stream consumes this.
Outbound: $reply -> Render -> Middleware -> Endpoint
- SendContent four forms (
packages/im/core/src/plugin-runtime/im/contracts.ts): string;component(name, props)component call (recursively rendered viaComponentIndex, depth limit 32);raw(payload)passthrough; and nested arrays of any of the three. - Envelope carries
adapter,target,requester(the originating plugin, used for component permissions and auditing),generation, and providesreplace(payload)for outbound middleware to rewrite content. - Outbound middleware shares the same definition as inbound;
target: 'outbound'intercepts outbound messages. - The last mile is in
AdapterIndex.send: the endpoint must declareoutboundcapability and be instarted && !stoppedstate, otherwise an error is thrown; once passed,endpoint.send()is called to deliver to the platform.
All sending should go through this unified pipeline ($reply / $replyFrom / gateway.send). Do not directly hold platform SDKs in plugins to send messages -- that would bypass rendering, middleware, and event broadcasting.
Endpoint 1:N Expansion
When an adapter plugin instance configuration declares endpoints: [{name, ...}], AdapterIndex expands it into N independent endpoint records (for configuration merge rules see Config as Data):
- Each record's capability ID is in the form
<slot id>~<name>, with its own lifecycle (start/open/close/stop) and online status; - The
$adapteron a message carries the expanded identifier (e.g.,icqq~8596238), and replies are routed back to the corresponding account via the same path; - The Console side addresses by
(adapter, endpointId).AdapterIndex.resolvematches in order: local name, capability ID, owner path segment, and the Endpoint's runtime name (e.g., ICQQ's uin). When multiple matches occur, the exact endpoint name takes priority.
Therefore, "two QQ accounts each receiving their own messages and sending their own replies" requires no special code -- just configure two endpoint entries.