Files
dsh_shenxian/poc/business-plugins-im/minimal-callbacks/index.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

171 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 }),
}
}