171 lines
8.6 KiB
JavaScript
171 lines
8.6 KiB
JavaScript
/**
|
||
* @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 }),
|
||
}
|
||
}
|