Files
dsh_shenxian/poc/business-plugins-im/minimal-callbacks/index.mjs
T

170 lines
8.6 KiB
JavaScript
Raw Normal View History

/**
* @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 }),
}
}