/** * IM 插件宿主 SDK(v1.0.0 · 2026-09-26 增反向回调)—— **实例内插件**接入 IM 平台的唯一入口。 * * ## 它接的是哪条线 * 插件跑在**用户实例内**(这样才用得到该用户自配的模型与自己的数据),IM 内核在**平台**。 * 本 SDK 用**实例凭据**(平台起实例时注入的环境变量)跨进程接上那三条面: * ① **登记**(把你的 manifest 声明面交给平台) * ② **出向**(广播帧 / 以 bot 身份发言) * ③ 🔴 **反向回调**(本期新增)—— 平台把「要你判定 / 要你处理」的工作项**放在那里**, * 你**拨出**长轮询把它拉走,在**自己进程内**执行,再把结果回投(`onSpeakRule` / `onEvent`)。 * * ## 为什么不需要你开端口 * 平台对插件的纪律是**拨出式**:宿主调插件 —— 而本 SDK 里所有调用**都是你拨出去的** * (HTTP 到平台)。⛔ 插件**不得**自己监听端口。反向回调也遵守这条:**平台不打进实例**, * 是**你**去拉。 * * ## 三件事你该知道 * - 读消息 / 读房间 / 读成员:**已有面**,直接用实例凭据调(本 SDK 的 `subscribe` 封好了)。 * - **发言规则 hook(`speakRules.check`)—— 跨进程已支持**(`host.onSpeakRule()`): * 平台在**写之前**把待判定的发言交给长轮询,你在自己进程里判,回 `{ok:false, detail}` * 即**拒绝**该条发言。⚠️ 超时语义由**契约的 `onTimeout`** 决定(缺省 `'allow'` ⇒ 放行): * **回合制 / 限速型必须声明 `'deny'`**,否则实例不在线时消息照发(等同没装规则)。 * - **事件订阅(`events.onEvent`)—— 跨进程已支持**(`host.onEvent()`):平台把事件放进 * 你的队列,长轮询取走后在本进程执行。⚠️ 队列**有界**(满则丢最旧)⇒ 别把事件当可靠消息流, * 要可靠性请用 `subscribe()` 的游标增量(那条路不会丢)。 * - 面板 `render` / `onAction`:面板在**实例内**渲染(E 单落点),所以那些函数**就在你 * 自己的进程里跑**,⛔ 不需要跨进程——需要写数据/发言时用本 SDK 的出向即可。 * * 零依赖 · 单文件 · 可 vendoring:⛔ 不含平台实现、⛔ 不含任何密钥(凭据由实例注入)。 * * @module @dshs/im-plugin-host */ /** * SDK 版本(与 `im-plugin-client` 各自独立演进)。 * * - `1.0.0` → 登记 / 出向 / 读面("插件 → 平台"单向) * - `1.1.0`(2026-09-26)→ **新增跨进程反向回调**:`onSpeakRule()` / `onEvent()` / * `startCallbacks()` / `callbacks` 读数。⚠️ **向后兼容**:不注册回调 ⇒ 行为与 1.0.0 逐字相同。 */ export const HOST_SDK_VERSION = '1.1.0' /** 实例内由平台注入的两个环境变量(⛔ 不要手填 / 不要硬编码)。 */ const ENV_URL = 'DSH_PLATFORM_IM_URL' const ENV_TOKEN = 'DSH_IM_INSTANCE_TOKEN' /** 插件身份头(与平台 `/api/im/data/*` 同一口径)。 */ const PLUGIN_HEADER = 'x-dsh-im-plugin-id' /** 长轮询的服务端硬上限(与平台 `ImBridgeCallbackBus.maxPollHoldMs` 同一口径)。 */ const SERVER_POLL_HOLD_MS = 20_000 function trimSlash(url) { return String(url).replace(/\/+$/, '') } function sleep(ms) { return new Promise((resolve) => { const timer = setTimeout(resolve, ms) if (typeof timer.unref === 'function') timer.unref() }) } /** * 建一个插件宿主。 * * @param {object} options * @param {string} options.pluginId - 你的包名(如 `@dsh-local/im-plugin-demo`)。 * @param {object} options.manifest - 声明面(⛔ 不含函数):`roomTypes` / `payloads` / `roles` / * `bots` / `panels` / `speakRules` / `events` / `outbounds` / `tables`。函数字段会被**剔除** * (跨不了进程)——函数侧改用 `onSpeakRule()` / `onEvent()` 注册。 * @param {string} [options.baseUrl] - 覆盖平台地址(缺省读 `DSH_PLATFORM_IM_URL`)。 * @param {string} [options.token] - 覆盖实例凭据(缺省读 `DSH_IM_INSTANCE_TOKEN`)。 * @param {number} [options.timeoutMs] - 单次 HTTP 超时(缺省 8000)。 * @param {(e: object) => void} [options.onLog] - 具名日志(⛔ 本 SDK 不 print,交你决定去哪)。 * @returns {Promise} 宿主对象(`ready()` / `frame()` / `say()` / `subscribe()` / * `onSpeakRule()` / `onEvent()` / `startCallbacks()` / `close()`)。 */ export async function createImPluginHost(options = {}) { const pluginId = options.pluginId if (typeof pluginId !== 'string' || pluginId.trim() === '') { throw new Error('pluginId 必填(= 你的包名)') } const baseUrl = trimSlash(options.baseUrl ?? process.env[ENV_URL] ?? '') const token = options.token ?? process.env[ENV_TOKEN] ?? '' const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 8000 const onLog = typeof options.onLog === 'function' ? options.onLog : () => {} const manifest = sanitizeManifest(options.manifest ?? {}) let registered = false let closed = false /** 注册的发言规则(`check` 在**你自己进程**里跑,⛔ 不跨进程)。 */ const speakRules = [] /** 注册的事件订阅者。 */ const eventSubs = [] let callbacksStarted = false let callbacksStopped = false let pollBackoffMs = 0 /** 当前在途的长轮询(`close()` 要能把它掐断,否则进程退不出去)。 */ let activePoll = null const callbackStats = { polls: 0, items: 0, settled: 0, failed: 0 } /** 统一请求:带实例凭据 + 插件身份头;**永不抛**,只回 `{ok:false, reason, detail}`。 */ async function call(path, { method = 'POST', body, timeoutMs: override, signal } = {}) { if (baseUrl === '' || token === '') { return { ok: false, reason: 'not-configured', detail: `缺少 ${baseUrl === '' ? ENV_URL : ENV_TOKEN}(平台起实例时注入;本地自测请显式传 baseUrl/token)`, } } const ac = new AbortController() const timer = setTimeout(() => ac.abort(), Number.isFinite(override) ? override : timeoutMs) if (signal !== undefined) { if (signal.aborted) ac.abort() else signal.addEventListener('abort', () => ac.abort(), { once: true }) } try { const res = await fetch(`${baseUrl}${path}`, { method, headers: { 'content-type': 'application/json', [PLUGIN_HEADER]: pluginId, 'x-dsh-im-instance-token': token, }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: ac.signal, }) const text = await res.text() let parsed try { parsed = text === '' ? {} : JSON.parse(text) } catch { return { ok: false, reason: 'bad-response', detail: `平台回了非 JSON(HTTP ${res.status})` } } if (!res.ok) { return { ok: false, reason: parsed?.error ?? `http-${res.status}`, detail: parsed?.detail ?? `HTTP ${res.status}`, } } return parsed } catch (err) { const aborted = err !== null && typeof err === 'object' && err.name === 'AbortError' return { ok: false, reason: aborted ? 'timeout' : 'network', detail: String(err) } } finally { clearTimeout(timer) } } /** * 限时跑一段插件代码 —— **只是防"你永久卡死把长轮询拖停"**, * ⛔ **不是**超时判据:发言规则的超时**由平台侧决定**(按契约 `onTimeout` 兜底)。 */ async function runGuarded(fn, limitMs, label) { let timer const guard = new Promise((resolve) => { timer = setTimeout(() => resolve({ __guardTimeout: true }), Math.max(1, limitMs)) }) try { const value = await Promise.race([Promise.resolve().then(fn), guard]) if (value !== null && typeof value === 'object' && value.__guardTimeout === true) { return { ok: false, reason: 'timeout', detail: `${label} 超过守护上限 ${limitMs} ms` } } return value } catch (err) { return { __threw: true, detail: String(err) } } finally { clearTimeout(timer) } } /** 回投一次调用结果(⛔ 幂等:平台侧对过期 `callId` 会回 `unknown-call-id`,忽略即可)。 */ async function settleCall(callId, verdict) { callbackStats.settled += 1 const body = { callId, ok: verdict.ok === true } if (verdict.reason !== undefined) body.reason = verdict.reason if (verdict.detail !== undefined) body.detail = verdict.detail const res = await call('/api/im/plugins/bridge/result', { body }) if (res.ok !== true) onLog({ at: 'settle', ok: false, callId, reason: res.reason, detail: res.detail }) return res } /** 处理一个工作项。 */ async function handleItem(item) { if (item === null || typeof item !== 'object') return const callId = item.callId if (item.kind === 'speak.check') { const input = item.input ?? {} const roomType = input.roomType const rule = speakRules.find((r) => r.roomTypes === undefined || r.roomTypes.includes(roomType)) if (rule === undefined) { // 无匹配规则 ⇒ **放行**(与平台侧"无插件 ⇒ 自由并发"同源)。 await settleCall(callId, { ok: true, detail: '本插件无匹配的发言规则 ⇒ 放行' }) return } const limit = Math.max(3000, (Number(item.timeoutMs) || 50) * 4) const verdict = await runGuarded(() => rule.check(input), limit, 'speakRules.check') if (verdict !== null && typeof verdict === 'object' && verdict.__threw === true) { // 🔴 与**同进程**语义逐字一致:插件故障 ⇒ **放行**(回落自由并发),并留下具名日志。 onLog({ at: 'speak.check', ok: false, roomType, detail: verdict.detail }) await settleCall(callId, { ok: true, detail: `check 抛错 ⇒ 放行兜底:${verdict.detail}` }) return } await settleCall(callId, { ok: verdict?.ok === true, reason: verdict?.reason, detail: verdict?.detail, }) return } if (item.kind === 'event.deliver') { const event = item.event ?? {} const matched = eventSubs.filter( (s) => (s.events === undefined || s.events.includes(event.name)) && (s.roomTypes === undefined || s.roomTypes.includes(event.roomType)), ) if (matched.length === 0) { await settleCall(callId, { ok: true, detail: '无匹配订阅者(事件已忽略)' }) return } const limit = Math.max(3000, (Number(item.timeoutMs) || 50) * 4) let ok = true let detail for (const sub of matched) { const res = await runGuarded(() => sub.onEvent(event), limit, 'events.onEvent') if (res !== null && typeof res === 'object' && (res.__threw === true || res.ok === false)) { ok = false detail = res.__threw === true ? res.detail : res.detail onLog({ at: 'onEvent', ok: false, event: event.name, detail }) } } await settleCall(callId, { ok, ...(detail === undefined ? {} : { detail }) }) } } /** 长轮询循环 —— 空批 ⇒ **立即重拨**(服务端已经替你等过了);失败 ⇒ 指数退避。 */ async function pollLoop(waitMs) { while (!callbacksStopped && !closed) { callbackStats.polls += 1 activePoll = new AbortController() const res = await call('/api/im/plugins/bridge/pull', { body: { waitMs, max: 32 }, timeoutMs: waitMs + 5000, signal: activePoll.signal, }) activePoll = null if (callbacksStopped || closed) return if (res.ok === false) { if (res.reason === 'not-configured') return callbackStats.failed += 1 pollBackoffMs = pollBackoffMs === 0 ? 1000 : Math.min(pollBackoffMs * 2, 30_000) onLog({ at: 'callbacks', ok: false, reason: res.reason, detail: res.detail }) await sleep(pollBackoffMs) continue } pollBackoffMs = 0 const items = Array.isArray(res.items) ? res.items : [] for (const item of items) { callbackStats.items += 1 try { await handleItem(item) } catch (err) { onLog({ at: 'callbacks-item', ok: false, detail: String(err) }) } } } } const host = { pluginId, /** 是否已登记成功(登记失败**不影响**插件其余功能,只是出向会报 `plugin-unavailable`)。 */ get registered() { return registered }, /** * 完成登记(**幂等**:重复调 = 覆盖,便于热重启自愈)。 * * ⚠️ 登记失败**不抛** —— 回具名结论,由你决定是否重试(建议退避重试,别死循环)。 * ✅ 登记成功后,若你已注册过 `onSpeakRule` / `onEvent` ⇒ **自动开始长轮询**。 */ async ready() { const res = await call('/api/im/plugins/register', { body: manifest }) registered = res.ok === true if (!registered) onLog({ at: 'register', ok: false, reason: res.reason, detail: res.detail }) else if (speakRules.length > 0 || eventSubs.length > 0) host.startCallbacks() return res }, /** 注销本实例的全部插件(插件卸载时调;⛔ 不影响已有房间与数据)。 */ async unregister() { const res = await call('/api/im/plugins/unregister', { body: { pluginId } }) registered = false return res }, /** * **注册发言规则**(`speakRules.check` 的函数侧)—— * 平台在**写之前**把待判定的发言交给你,你在**自己进程内**判。 * * 🔴 超时语义**不在你这里定**:契约里声明 `onTimeout`(缺省 `'allow'`)。 * **回合制 / 限速型必须声明 `'deny'`** —— 否则你不在线时消息照发。 * ⚠️ 你的 `check` 抛错 ⇒ 平台按"插件故障"**放行**(与同进程语义一致)。 * * @param {{rule?: 'free'|'turn-based'|'rate-limited', roomTypes?: string[], * onTimeout?: 'allow'|'deny', check: (input: object) => object|Promise}} rule */ onSpeakRule(rule) { if (rule === null || typeof rule !== 'object' || typeof rule.check !== 'function') { throw new Error('onSpeakRule 需要一个含 check(input) 的对象') } speakRules.push(rule) if (registered) host.startCallbacks() return host }, /** * **注册事件订阅**(`events.onEvent` 的函数侧)—— 事件由长轮询取回后在**本进程**执行。 * * ⚠️ 队列**有界**(满则丢最旧并计数)⇒ 事件是"尽力送达",要可靠增量请用 `subscribe()`。 * * @param {{events?: string[], roomTypes?: string[], onEvent: (event: object) => unknown}} sub */ onEvent(sub) { if (sub === null || typeof sub !== 'object' || typeof sub.onEvent !== 'function') { throw new Error('onEvent 需要一个含 onEvent(event) 的对象') } eventSubs.push(sub) if (registered) host.startCallbacks() return host }, /** * 启动反向回调长轮询(**幂等** —— 重复调只启一条;⛔ 别自己写第二条)。 * * 🔴 **一条实例只该有一条常驻长轮询**(不是每插件一条):它直接占用平台的连接额度。 * @param {{waitMs?: number}} [opts] - 单次挂起时长(缺省 18000,服务端硬上限 20 s)。 * @returns {boolean} 是否真的启动了(未配置 / 已启动 ⇒ `false`)。 */ startCallbacks(opts = {}) { if (callbacksStarted || callbacksStopped || closed) return false if (baseUrl === '' || token === '') { onLog({ at: 'callbacks', ok: false, reason: 'not-configured', detail: '缺 baseUrl/token ⇒ 反向回调未启动(其它功能不受影响)', }) return false } callbacksStarted = true const waitMs = Math.max(1000, Math.min(Number(opts.waitMs) || 18_000, SERVER_POLL_HOLD_MS - 500)) void pollLoop(waitMs) return true }, /** 反向回调读数(⛔ 只回计数,不含内容)。 */ get callbacks() { return { started: callbacksStarted, stopped: callbacksStopped, speakRules: speakRules.length, subscribers: eventSubs.length, ...callbackStats, } }, /** * **广播一帧**(ephemeral:⛔ 不落库、不进历史、不参与去重)。 * * 适用:光标 / 在线编辑 / CRDT 增量 / 进度 / 状态灯。 * 🔴 **断线期间丢帧是档位语义**(⛔ 不是缺陷):要可靠性请把状态**定期落快照**(数据面)。 * @param {{roomId: string, channel: string, data: object, seq?: number, outboundId?: string}} input */ async frame(input) { const { roomId, channel, data, seq, outboundId } = input ?? {} return call('/api/im/plugins/out/frame', { body: { roomId, channel, data: data ?? {}, ...(seq === undefined ? {} : { seq }), ...(outboundId === undefined ? {} : { outboundId }) }, }) }, /** * **以 bot 身份发言**(落库:进历史、可被补拉与检索)。 * * 🔴 前置:该 `botId` 必须**已是该房成员**(平台会查;不是 ⇒ `outbound-denied`)—— * 让 bot 进房是**房主/管理员**的动作,⛔ 不是插件能自己做的。 * @param {{roomId: string, botId: string, payload: object, visibility?: 'room'|'private', outboundId?: string}} input */ async say(input) { const { roomId, botId, payload, visibility, outboundId } = input ?? {} return call('/api/im/plugins/out/message', { body: { roomId, botId, payload: payload ?? {}, ...(visibility === undefined ? {} : { visibility }), ...(outboundId === undefined ? {} : { outboundId }) }, }) }, /** 我的房间里能看到的房间(走既有面;以实例所属用户身份,判据恒为成员表)。 */ async rooms(query = {}) { const qs = new URLSearchParams( Object.entries(query).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => [k, String(v)]), ).toString() return call(`/api/im/rooms${qs === '' ? '' : `?${qs}`}`, { method: 'GET' }) }, /** 拉某房的消息(游标增量;`since` 缺省 = 最新若干条)。 */ async messages(roomId, { since, limit } = {}) { const qs = new URLSearchParams() if (since !== undefined) qs.set('since', String(since)) if (limit !== undefined) qs.set('limit', String(limit)) const suffix = qs.toString() === '' ? '' : `?${qs}` return call(`/api/im/rooms/${encodeURIComponent(roomId)}/messages${suffix}`, { method: 'GET' }) }, /** * **增量订阅一个房**(轮询实现 —— 零依赖,⛔ 不用你自己写 WS 客户端)。 * * 语义:每 `intervalMs` 拉一次 `since=<上次最大 seq>`,把**新增**消息逐条交 `handler`。 * ⚠️ 断线期间的增量靠游标补齐(不会丢消息);轮询间隔决定**延迟上界**(缺省 1.5 s)。 * ⛔ 别把间隔设到 < 300 ms(对平台是无效负载)。 * * @returns {() => void} 停止订阅(**记得在插件卸载时调**,否则定时器泄漏) */ subscribe(roomId, handler, { intervalMs = 1500, limit = 100 } = {}) { let cursor let stopped = false const tick = async () => { if (stopped) return const res = await host.messages(roomId, { ...(cursor === undefined ? {} : { since: cursor }), limit }) if (res.ok === false) { onLog({ at: 'subscribe', ok: false, roomId, reason: res.reason, detail: res.detail }) return } const list = Array.isArray(res.messages) ? res.messages : [] for (const msg of list) { if (cursor === undefined || msg.seq > cursor) cursor = msg.seq try { await handler(msg) } catch (err) { onLog({ at: 'subscribe-handler', ok: false, roomId, detail: String(err) }) } } } const timer = setInterval(tick, Math.max(300, Number(intervalMs) || 1500)) if (typeof timer.unref === 'function') timer.unref() void tick() return () => { stopped = true clearInterval(timer) } }, /** 收尾:停长轮询 + 注销(进程退出前调)。 */ async close() { if (closed) return closed = true callbacksStopped = true // 掐断在途的长轮询 —— 否则它要挂到服务端 hold 到期,进程退不出去。 activePoll?.abort() try { await host.unregister() } catch { // 收尾失败不影响退出(平台侧另有实例级清理) } }, } return host } /** * 剔除 manifest 里的**函数字段**(它们跨不了进程)。 * * 🔴 保留 `outbounds` / `tables` / 各扩展点的**声明字段**(名 / 类型 / 房间类型面 / 失败模式), * 剔除 `check` / `validate` / `onEvent` / `render` / `onAction`。⛔ 不静默丢整条 —— * 只丢函数本身,其余照传(否则平台会以为你没声明该扩展点)。 * ⚠️ 反向通道(2026-09-26):`speakRules` / `events` 的**声明**照传(平台靠它路由「该问谁」), * 函数侧改用 `host.onSpeakRule()` / `host.onEvent()` 在本进程注册。 */ export function sanitizeManifest(manifest) { const FUNCTION_KEYS = new Set(['check', 'validate', 'onEvent', 'render', 'onAction']) const clone = (value) => { if (Array.isArray(value)) return value.map(clone) if (value !== null && typeof value === 'object') { const out = {} for (const [k, v] of Object.entries(value)) { if (typeof v === 'function') continue if (FUNCTION_KEYS.has(k)) continue out[k] = clone(v) } return out } return value } return clone(manifest ?? {}) }