Files
dsh_shenxian/sdk/im-plugin-host/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

499 lines
22 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.
/**
* 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<object>} 宿主对象(`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<object>}} 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 ?? {})
}