/** * 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__` 前缀)。 */ 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) => Promise<{ok: boolean, reason?: string, detail?: string}>} request */