499 lines
22 KiB
JavaScript
499 lines
22 KiB
JavaScript
/**
|
||
* 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 ?? {})
|
||
}
|