/** * @dsh-local/im-plugin-minimal-callbacks —— **可跑的最小示例(含跨进程反向回调)**。 * * ## 它解决什么问题 * 本目录是「插件作者从 0 到能跑」的**唯一落点**:`poc/business-plugins-im/` 下另外三个示例 * (`mud-rate` / `office-tasks` / `trpg-turn`)写于**反向通道落地之前** ⇒ 它们把 `check` / * `onEvent` 直接写在 manifest 里,那是**同进程**形态,**跨不了进程**。 * 本示例是补齐后的正确写法。 * * ## 覆盖的两条反向回调(跨进程 · 平台 → 插件) * ① **同步发言规则** `host.onSpeakRule()` —— 平台**写之前**把待判定的发言交给长轮询, * 你在**自己进程内**判,`{ok:false, detail}` ⇒ 拒发。本示例 = **回合制**, * 🔴 并且**必须**在 manifest 里声明 `onTimeout: 'deny'`(理由见下)。 * ② **异步事件订阅** `host.onEvent()` —— 平台把事件放进你的有界队列,长轮询取回后在本进程执行。 * * ## 🔴 为什么回合制必须声明 `onTimeout: 'deny'` * `speakRules[].onTimeout` 的**缺省值是 `'allow'`**(放行)—— 这是为了与平台既有口径 * 「无插件 / 插件故障 ⇒ 放行(回落自由并发)」**逐字一致**,不让插件把房间卡死。 * 但对**回合制 / 限速型**来说,"放行"就是**规则失效**:你的实例不在线(重启 / 崩溃 / 部署窗口) * 时,非当前回合者的消息会**照常写入**,等于没装规则。 * ⇒ **回合制 / 限速型必须显式声明 `'deny'`**,否则超时即破规则。 * * ## ⛔ 三条纪律(本示例刻意遵守,照抄即可) * - **不开端口**:所有调用都是**你拨出去**的(HTTP 到平台);⛔ 插件不得监听端口。 * - **manifest 里不许塞函数**:`check` / `validate` / `onEvent` / `render` / `onAction` * **跨不了进程**,SDK 会自动剔除 ⇒ 函数侧一律走 `onSpeakRule()` / `onEvent()` 注册。 * - **一条实例只该有一条常驻长轮询**(`ready()` 自动启,⛔ 别自己再写第二条)——它占平台连接额度。 * * @module @dsh-local/im-plugin-minimal-callbacks */ // 示例内用**相对路径**引入 SDK(本仓自带单文件,零依赖、可 vendoring)。 // 真实插件里请把 `sdk/im-plugin-host/index.mjs` 拷进你自己的包,再 `import ... from '<你的包内路径>'`。 import { createImPluginHost } from '../../../sdk/im-plugin-host/index.mjs' /** 你的包名(🔴 SDK 的 `pluginId` 参数 = 包名,不是平台内部 id)。 */ export const PLUGIN_ID = '@dsh-local/im-plugin-minimal-callbacks' /** 本示例只用一种房间类型,便于一眼看懂。 */ export const ROOM_TYPE = 'demo-turn' const TIMEOUT_MS = 50 const CIRCUIT = { threshold: 5, cooldownMs: 30_000 } /** * 声明面(交给平台)—— ⛔ **不含任何函数**。 * * 声明**照传**(平台靠它路由"该问哪个插件");函数侧在 `start()` 里用 `onSpeakRule()` / `onEvent()` 注册。 */ export const manifest = { pluginId: 'im_plugin_minimal_callbacks', packageName: PLUGIN_ID, label: '最小回调示例', version: '0.1.0', // ── 扩展点 1 · 房间类型 ────────────────────────────────────────────────── roomTypes: [ { type: ROOM_TYPE, label: '回合制示例房', requiresReview: false, transport: 'chat', defaultConfig: { turnOf: '' }, uses: ['roomTypes', 'speakRules', 'events'], timeoutMs: TIMEOUT_MS, circuit: CIRCUIT, }, ], // ── 扩展点 2 · bot(出向发言要借它的身份) ────────────────────────────── bots: [{ botId: 'bot_minimal', label: '示例助手', timeoutMs: TIMEOUT_MS, circuit: CIRCUIT }], // ── 扩展点 3 · 出向声明(⛔ 未声明 ⇒ 出向恒 `plugin-unavailable`) ────── outbounds: [ { outboundId: 'minimal_out', label: '出向', roomTypes: [ROOM_TYPE], timeoutMs: TIMEOUT_MS, circuit: CIRCUIT }, ], // ── 扩展点 4 · 发言规则(**声明**) ───────────────────────────────────── // 🔴 `onTimeout: 'deny'` 是**本示例的要点**:回合制下超时必须拒,否则规则形同不存在。 speakRules: [ { rule: 'turn-based', roomTypes: [ROOM_TYPE], onTimeout: 'deny', timeoutMs: TIMEOUT_MS, circuit: CIRCUIT }, ], // ── 扩展点 5 · 事件订阅(**声明**) ───────────────────────────────────── events: [{ events: ['message.created'], roomTypes: [ROOM_TYPE], timeoutMs: TIMEOUT_MS, circuit: CIRCUIT }], } /** * 回合制判定(**纯函数**,导出以便单测直接断言 —— 跨进程后你更需要单测,因为平台侧不再有你的代码)。 * * 规则:`config.turnOf` = 当前该行动的人。 * - 空 ⇒ **视为自由**(未开局不该把房间卡死); * - 是本人 ⇒ 放行; * - 系统消息 / bot / agent 回写 ⇒ 放行(否则 agent 代答会把自己卡死); * - 其余 ⇒ 拒,并给**可读原因**(`detail` 会进平台的审计 / 回给调用方)。 * * ⚠️ 本函数**抛错**时平台按"插件故障 ⇒ 放行"处理(与同进程语义逐字一致)—— * 所以"判不了就拒"(fail-closed)要靠**返回** `{ok:false}`,⛔ 不是靠抛错。 */ export function decideTurn(input) { const turnOf = input?.config?.turnOf if (typeof turnOf !== 'string' || turnOf === '') return { ok: true, reason: 'ok' } if (turnOf === input.authorId) return { ok: true, reason: 'ok' } if (input.authorKind === 'bot' || input.envelope?.via === 'system') return { ok: true, reason: 'ok' } return { ok: false, reason: 'bad-args', detail: `还没轮到「${input.authorId}」行动(当前回合 = ${turnOf})`, } } /** * 起插件:登记 + 挂两条回调 + 自动开始长轮询。 * * @param {object} [options] * @param {string} [options.baseUrl] - 缺省读 `DSH_PLATFORM_IM_URL`(平台起实例时注入)。 * @param {string} [options.token] - 缺省读 `DSH_IM_INSTANCE_TOKEN`。 * @param {(e: object) => void} [options.onLog] - 具名日志出口(⛔ SDK 自己不 print)。 * @returns {Promise<{host: object, reg: object, counters: () => object}>} */ export async function start(options = {}) { const onLog = typeof options.onLog === 'function' ? options.onLog : (e) => console.warn('[minimal-callbacks]', e) const host = await createImPluginHost({ pluginId: PLUGIN_ID, manifest, ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }), ...(options.token === undefined ? {} : { token: options.token }), onLog, }) // ── ① 同步发言规则(跨进程)───────────────────────────────────────────── host.onSpeakRule({ rule: 'turn-based', roomTypes: [ROOM_TYPE], onTimeout: 'deny', // 🔴 与 manifest 的声明**必须一致**;回合制 / 限速型写 'deny' check: (input) => decideTurn(input), }) // ── ② 异步事件订阅(跨进程 · 尽力送达)───────────────────────────────── let events = 0 host.onEvent({ events: ['message.created'], roomTypes: [ROOM_TYPE], onEvent: async (event) => { events += 1 // ⚠️ 事件队列**有界**(满则丢最旧并计 `bridgeDropped`)⇒ ⛔ 别把这里当可靠消息流; // 要可靠性请用 `host.subscribe()`(游标增量,不会丢)。 // // 本示例:把"有人行动了"广播成一帧(ephemeral:不落库 / 不进历史)。 // 真实业务里这里可以写库、调模型、发公告 —— 都是**在你自己的进程内**跑。 await host.frame({ roomId: event.roomId, channel: 'turn.activity', data: { at: event.at, authorId: event.data?.authorId ?? '', seq: event.data?.seq ?? 0 }, outboundId: 'minimal_out', }) }, }) // 🔴 登记成功后,只要上面注册过回调 ⇒ `ready()` **自动开始长轮询**(幂等,别自己再启一条)。 const reg = await host.ready() if (reg.ok !== true) onLog({ at: 'start', ok: false, reason: reg.reason, detail: reg.detail }) return { host, reg, /** 计数读数(⛔ 只回计数,不含内容)—— 上生产后靠它看"回调到底跑没跑"。 */ counters: () => ({ ...host.callbacks, events }), } }