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

211 lines
9.3 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 · 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
*/