chore: 文档库全量重新入库(2026-10-20)
build / build-and-scan (push) Canceled after 0s

This commit is contained in:
admin committed 2026-10-20 15:05:55 +08:00
commit 5ec99ce227
668 files changed
+164448

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
/**
* IM 平台 · **插件/客户端接入 SDK(v1 · REST 面)**
*
* ## 为什么要有这个文件
*
* 插件与端侧都活在**没有平台代码**的地方(插件 = 用户实例里的一个 node 进程;端侧 = 桌面壳 /
* 浏览器)⇒ 它们只能走 HTTP,而在此之前**每个插件都在自己重写**同一套样板:
* 拼 URL、塞 `x-dsh-im-instance-token`、判 `{ok:false,reason}`、把状态码当语义……
* 实测代价见 `poc/im-agent-bridge/lib/index.js`(498 行里大半是这些)。
*
* ## 三处形态判据(⛔ 别按"发布一个平台实现包"去做)
*
* 1. **实现恒随平台走**:SDK 里 ⛔ **不含任何平台实现**(不 import 平台模块、不内嵌库名 / 连接串),
* 它只是一层**协议客户端** ⇒ 版本漂移面为零。
* 2. **零依赖、可 vendoring**:单文件 ESM、只用全局 `fetch`(Node ≥ 18)⇒
* 插件作者可以直接把这个文件拷进自己的包,或按 `package.json` 的 `name` 依赖。
* 3. **⛔ 不装载凭据**:实例凭据从 env 读(`DSH_IM_INSTANCE_TOKEN`,平台在起实例时注入),
* ⛔ SDK 不落盘、不缓存、不写日志(`toString()` 里也⛔ 不含 token)。
*
* ## ⚠️ 本版**不含** WS 传输层(有意)
*
* 长连接的帧协议 / 心跳 / 重连口径随 E 单客户端(`poc/im-conversation-tabs`)定稿,
* 现在抽一份会立刻变成"两套实现对不上"。⇒ 传输层 SDK 排在客户端定稿之后;
* **本文件只覆盖 REST 面**(房间 / 消息 / 数据 / 就绪判读),够插件把业务跑通。
*
* ## 用法(插件侧)
*
* ```js
* import { createImClient } from './im-plugin-client/index.mjs'
*
* export function apply(ctx) {
* const im = createImClient({ pluginId: 'dsh-plugin-carbon' }) // 包名,用于数据面身份
* ctx.on?.('some-event', async () => {
* const t = im.data.table('carbon_entries')
* const r = await t.find({ eq: { kind: 'note' }, limit: 20 })
* if (!r.ok) return ctx.log?.(`数据面不可用:${r.reason} ${r.detail}`)
* await t.insert({ entry_id: 'e1', payload: { … } })
* })
* }
* ```
*
* ⛔ **插件侧不得出现任何 SQL 字符串、不得自行连库、不得猜库名**(`DB-03 §二` 红线)。
*
* @module dshs/sdk/im-plugin-client
*/
/** 平台基址 env(平台在起实例时注入;⛔ 不硬编码域名)。 */
const ENV_URL = 'DSH_PLATFORM_IM_URL'
/** 实例凭据 env(短期、重启即换;⛔ 不落盘)。 */
const ENV_TOKEN = 'DSH_IM_INSTANCE_TOKEN'
/** 版本号(随 SDK 口径变更递增;插件报障时请带上它)。 */
export const SDK_VERSION = '1.0.0'
/**
* 建一个接入客户端。
*
* @param {object} [options]
* @param {string} [options.baseUrl] 平台基址;缺省读 `DSH_PLATFORM_IM_URL`。
* @param {string} [options.token] 实例凭据;缺省读 `DSH_IM_INSTANCE_TOKEN`。
* @param {string} [options.pluginId] **插件包名**(如 `@dsh-local/trpg-kit`)—— 数据面身份,
* 以 `x-dsh-im-plugin-id` 头送出。⚠️ 只有数据面接口需要它;不带 ⇒ 数据面接口返回 400。
* @param {number} [options.timeoutMs] 单次请求超时(默认 15000)。
* @returns {ImClient}
*/
export function createImClient(options = {}) {
const baseUrl = trimSlash(options.baseUrl ?? process.env[ENV_URL] ?? '')
const token = options.token ?? process.env[ENV_TOKEN] ?? ''
const pluginId = options.pluginId ?? ''
const timeoutMs = options.timeoutMs ?? 15_000
/** 配置是否齐备(缺 ⇒ 全部调用立刻回 `not-registered`,⛔ 不发请求、⛔ 不抛)。 */
const configured = baseUrl !== '' && token !== ''
/** 一次底层请求:统一注入凭据头、统一超时、统一把失败折成 `{ok:false,reason,detail}`。 */
async function request(method, path, body, extraHeaders = {}) {
if (!configured) {
return {
ok: false,
reason: 'not-registered',
detail: `未配置 ${ENV_URL} / ${ENV_TOKEN}(平台未注入或本进程不在实例内)`,
}
}
const headers = {
'x-dsh-im-instance-token': token,
...(pluginId === '' ? {} : { 'x-dsh-im-plugin-id': pluginId }),
...extraHeaders,
}
if (body !== undefined) headers['content-type'] = 'application/json'
const ac = new AbortController()
const timer = setTimeout(() => ac.abort(), timeoutMs)
try {
const res = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : 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-args', detail: `响应不是 JSON(HTTP ${res.status})` }
}
// 平台的数据面契约:**失败也回 200 + `{ok:false,reason,detail}`** ⇒ 这里原样透传。
if (typeof parsed === 'object' && parsed !== null && 'ok' in parsed) {
return { ...parsed, httpStatus: res.status }
}
// 身份 / 就绪类问题走非 200(401 / 409 / 403…)—— 折成同一个 `ok:false` 形状,
// 让插件侧**只需判 `ok`**,⛔ 不必自己维护一张状态码表。
// ⚠️ `detail` 优先于 `error`:平台的失败体是 `{error:<码>, reason, detail:<人话>}`,
// 给插件看**人话**(原话口径:不写"未达预期"了事)。
return {
ok: false,
reason: res.status === 401 ? 'not-registered' : res.status === 409 ? 'disabled' : 'bad-args',
detail: parsed.detail ?? parsed.message ?? parsed.error ?? `HTTP ${res.status}`,
httpStatus: res.status,
}
} catch (err) {
const aborted = err?.name === 'AbortError'
return {
ok: false,
reason: aborted ? 'timeout' : 'disabled',
detail: aborted ? `请求超过 ${timeoutMs} ms` : `网络失败:${String(err?.message ?? err)}`,
}
} finally {
clearTimeout(timer)
}
}
const query = (params) => {
const usp = new URLSearchParams()
for (const [k, v] of Object.entries(params ?? {})) {
if (v === undefined || v === null) continue
usp.set(k, String(v))
}
const s = usp.toString()
return s === '' ? '' : `?${s}`
}
/** 数据面 facade —— 形状与 `DB-03 §五` 的 `im.data.table(...)` 一致。 */
const data = {
/**
* 就绪判读(**建议插件启动时先问一句**,不必靠"调了才知道报错")。
* 回 `{ok, available, reason, detail, dbName, tables}`;`available:false` 时 ⛔ 别硬试写。
*/
status: () => request('GET', '/api/im/plugin-data/status'),
/** 取一张表的句柄(表名 = 声明里的 `name`,⛔ 不带 `p_<pluginId>_` 前缀)。 */
table(name) {
const path = `/api/im/data/${encodeURIComponent(name)}`
return {
insert: (row) => request('POST', path, row),
update: (id, patch) => request('PATCH', `${path}/${encodeURIComponent(id)}`, patch),
remove: (id) => request('DELETE', `${path}/${encodeURIComponent(id)}`),
count: (filter = {}) => request('GET', `${path}/count${query(filterToQuery(filter))}`),
find: (filter = {}) => request('GET', `${path}${query(filterToQuery(filter))}`),
}
},
}
/** 房间 / 消息面(与网页端**同一套接口、同一套鉴权**,`DB-03 §八·补`)。 */
const rooms = {
list: (filter = {}) => request('GET', `/api/im/rooms${query(filter)}`),
create: (input) => request('POST', '/api/im/rooms', input),
join: (roomId) => request('POST', `/api/im/rooms/${encodeURIComponent(roomId)}/join`),
members: (roomId) => request('GET', `/api/im/rooms/${encodeURIComponent(roomId)}/members`),
messages: (roomId, opts = {}) =>
request('GET', `/api/im/rooms/${encodeURIComponent(roomId)}/messages${query(opts)}`),
send: (roomId, input) => request('POST', `/api/im/rooms/${encodeURIComponent(roomId)}/messages`, input),
}
return {
version: SDK_VERSION,
/** 配置齐备?(`false` ⇒ 所有调用都会回 `not-registered`,⛔ 不会抛) */
get configured() {
return configured
},
data,
rooms,
request,
/** 诊断串:**⛔ 不含 token**(只回"有没有配、配到哪台")。 */
toString: () => `ImClient(v${SDK_VERSION}, baseUrl=${baseUrl || '<未配置>'}, token=${token === '' ? '无' : '已注入'}, pluginId=${pluginId || '<无>'})`,
}
}
/** `DataFilter` → 查询串(`eq.<列>=` + 四个保留键;与平台 `filterOf` 同口径)。 */
function filterToQuery(filter) {
const out = {}
for (const [k, v] of Object.entries(filter.eq ?? {})) out[`eq.${k}`] = v
if (filter.afterCursor !== undefined) out.after = filter.afterCursor
if (filter.order !== undefined) out.order = filter.order
if (filter.limit !== undefined) out.limit = filter.limit
return out
}
/** 去掉尾斜杠(⛔ 不在这里做 URL 合法性校验 —— 那会让"配错"变成"起不来")。 */
function trimSlash(url) {
return String(url).replace(/\/+$/, '')
}
/**
* @typedef {object} ImClient
* @property {string} version
* @property {boolean} configured
* @property {object} data
* @property {object} rooms
* @property {(method: string, path: string, body?: unknown, headers?: Record<string,string>) => Promise<{ok: boolean, reason?: string, detail?: string}>} request
*/
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@dshs/im-plugin-client",
"version": "1.0.0",
"private": true,
"description": "IM 平台插件/客户端接入 SDK(v1 · REST 面)—— 零依赖、可 vendoring,⛔ 不含平台实现与凭据",
"type": "module",
"main": "index.mjs",
"exports": {
".": "./index.mjs"
},
"engines": {
"node": ">=18"
},
"files": [
"index.mjs"
]
}
+498
View File
@@ -0,0 +1,498 @@
/**
* 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 ?? {})
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@dshs/im-plugin-host",
"version": "1.0.0",
"private": true,
"description": "IM 插件宿主 SDK(实例内插件接入平台)—— 登记 / 出向广播 / bot 发言 / 增量订阅;零依赖、可 vendoring",
"type": "module",
"main": "index.mjs",
"exports": {
".": "./index.mjs"
},
"engines": {
"node": ">=18"
},
"files": [
"index.mjs"
]
}