Skip to content

中间件与组件

消息流上有两类相邻的扩展点:defineMiddleware@zhin.js/middleware)拦截消息流,defineComponent@zhin.js/component)把结构化 props 渲染成可发送的内容(通常是图片)。游戏插件把它们配在一起用——中间件归一输入,组件统一输出。

defineMiddleware

插件包根目录下的 middlewares/ 是约定目录,每个 .ts 文件默认导出 defineMiddleware(...)

ts
import { defineMiddleware } from '@zhin.js/middleware';
import type { Message } from '@zhin.js/core/runtime';

export default defineMiddleware<Message>({
  target: 'inbound',
  order: 10,
  async handle(context, next) {
    const text = context.input.content?.trim() ?? '';
    if (!text) {
      await next();
      return;
    }
    // …处理;调用 next() 放行,直接 return 则拦截
    await next();
  },
});

声明项

字段默认取值说明
phase'before-dispatch'before-dispatch / after-dispatch在命令派发前还是后执行
target'inbound'inbound / outbound拦截入站消息还是出站信封
order0安全整数同 phase 内的排序键

两个 target 看到的 context.input 不同。inbound 下是 Runtime Message,可读 contentsender$reply(...)——拦截时不调 next(),直接 $reply 后 return 即可(游戏插件的文本入口就是这个模式)。outbound 下是出站信封(OutboundEnvelope),payload 是即将发给平台的 wire 段,可做审计、改写、追加。

执行序

同一 target 内,排序键依次为:phase → order → 插件拓扑序(Root 先,子插件按树序)→ slot id。链是洋葱模型:

两条约束:next() 最多调用一次,重复调用抛 Middleware next() called more than once;不调 next() 即中断链条,终点不执行。context 还带有 config / use(token) / owner / generation,与其它能力上下文一致。

真实示例见 plugins/games/rps/middlewares/rps-choice.ts:识别游戏 payload 文本(rps:<session>:<choice>)或数字 fallback(「1 石头 2 布 3 剪刀」),命中则处理并 $reply,否则 next() 放行给后续中间件与命令派发。

defineComponent

components/ 是约定目录(支持 .tsx),每个文件默认导出 defineComponent(...)

tsx
// components/status-card.ts(提炼自 examples/minimal-bot)
import { defineComponent } from '@zhin.js/component';
import { raw } from 'zhin.js/core/runtime';
import { Card, CardHeader, Row, StatChip, h, wrapCardHtml, DEFAULT_CARD_THEME } from '@zhin.js/satori';

interface StatusCardProps {
  readonly title: string;
  readonly lines: readonly { label: string; value: string }[];
}

export default defineComponent<StatusCardProps>({
  render({ title, lines }) {
    const body = h(Card, {
      children: [
        h(CardHeader, { title, meta: 'minimal-bot' }),
        h(Row, {
          gap: 10,
          children: lines.map((line) =>
            h(StatChip, { label: line.label, value: line.value, accent: DEFAULT_CARD_THEME.accentMem })),
        }),
      ],
    });
    return raw({
      type: 'html',
      data: { html: wrapCardHtml(body, DEFAULT_CARD_THEME.canvas), width: 540 },
    });
  },
});

调用与解析

命令(或任何出站内容)用 component(name, props) 调用:

ts
import { component } from 'zhin.js/core/runtime';

return component('status-card', { title: 'my-bot', lines: [{ label: 'RSS', value: '42MB' }] });

组件按调用者插件沿插件树向上解析:子插件可以先用自己的同名组件覆盖,找不到再取父插件的。渲染是递归的——组件的 render 可以再返回 component(...),深度上限 32。render(props, context)context 额外带 requester(调用方插件节点),以及常规的 config / use / owner / generation

渲染为图片(html-renderer)

raw({ type: 'html', data: { html, width } }) 段在出站归一化时转换:

渲染 Host 是可选资源(htmlRendererToken,由 @zhin.js/html-renderer 提供),未安装时自动降级为文本,组件代码不用改。默认宽度 540px、格式 PNG;data.text 可作为自定义降级文案,data.fileName 自定义图片文件名。sandbox 适配器是个特例:它直接消费 html 段(Console UI 内嵌展示),不经过图片/文本归一化。整个降级是「尽力而为」——渲染抛错也会落到文本,不会阻塞发送。

游戏插件的双模式降级

游戏插件(plugins/games/* + @zhin.js/game-kit)把「按钮键盘 + 文本数字」做成同一份内容的两种消费方式。支持键盘的平台走按钮模式:buildGridKeyboard / buildChoiceKeyboard 产出 keyboard 段,按钮 payload 形如 ttt:<sessionId>:<cell>。没有按钮能力的平台走文本模式:同一条消息附 ASCII 棋盘和 fallbackHint(如「落子:回复数字 1-9(仅空格)」),fallback.map 把数字映射回 payload。

ts
// plugins/games/tic-tac-toe/src/board-view.ts(节选)
return buildGridKeyboard({
  gamePrefix: TTT_PREFIX,
  sessionId,
  rows: 3,
  cols: 3,
  cells: boardToCells(board, highlight),
  statusLine,
  renderAscii: renderTttAscii,
  fallbackHint: '落子:回复数字 1-9(仅空格)',
  postChoices: terminal ? [{ id: 'restart', label: '🔄 再来一局', style: 'primary' }] : undefined,
  channelType,
});

配套的 inbound 中间件把两条输入路径归一:直接 payload(按钮回调转文本)走 parseGridPayload;纯数字文本经 buildGridFallbackMap 反查为 payload,再走同一处理函数。到这里就能看出,中间件与组件降级是同一思想的两侧:内容只写一遍,按平台能力选择呈现