This commit is contained in:
commit
5ec99ce227
668 files changed
+164448
No files matched your search
@@ -0,0 +1,77 @@
|
||||
# 最小可跑示例 · IM 插件(含跨进程反向回调)
|
||||
|
||||
> 目标:**插件作者有一个能直接跑的起点** —— 不用先啃完契约、不用先连真平台。
|
||||
> 反向通道(平台 → 插件)已于 **2026-09-26 落地并上 47**;本目录是它的**唯一示例落点**。
|
||||
|
||||
## 一键跑
|
||||
|
||||
```bash
|
||||
cd poc/business-plugins-im/minimal-callbacks
|
||||
node self-check.mjs # 25 例;退出码 0 = 全绿
|
||||
```
|
||||
|
||||
不需要 `npm install`(**零依赖**)、不需要平台、不需要环境变量 —— 自检自带一个
|
||||
`mock-platform.mjs`(模拟登记 / 长轮询 / 回投 / 出向四个端点),把完整协议跑一遍。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `index.mjs` | **主体**:声明面 `manifest` + 拼装 `start()`;两条回调用 `onSpeakRule()` / `onEvent()` 注册 |
|
||||
| `mock-platform.mjs` | 极简「平台侧」模拟(⚠️ 只对齐协议形状,不替代真平台) |
|
||||
| `self-check.mjs` | 自检 25 例:登记 / 规则拒与放行 / 事件送达 / 未接线不变 |
|
||||
| `package.json` | `type: module`;`npm run self-check` 同义 |
|
||||
|
||||
## 覆盖的两条反向回调(跨进程)
|
||||
|
||||
| 回调 | 语义 | 本示例怎么用 |
|
||||
|---|---|---|
|
||||
| `host.onSpeakRule()` → `speakRules.check` | **同步**:平台**写之前**问插件,带 deadline | **回合制**:没轮到的人发言 ⇒ `{ok:false, detail}` ⇒ 拒发 |
|
||||
| `host.onEvent()` → `events.onEvent` | **异步**:不阻塞消息流,尽力送达 | 有人发言 ⇒ 广播一帧(`host.frame()`) |
|
||||
|
||||
## 🔴 三个必须记住的点
|
||||
|
||||
1. **回合制 / 限速型必须显式声明 `onTimeout: 'deny'`**
|
||||
—— 缺省是 `'allow'`(为了与平台既有口径「无插件 / 插件故障 ⇒ 放行」逐字一致)。
|
||||
对回合制来说"放行"就等于**规则失效**:你的实例不在线(重启 / 崩溃 / 部署窗口)时,
|
||||
非当前回合者的消息会照常写入。声明位在 `manifest.speakRules[].onTimeout`。
|
||||
2. **`manifest` 里不许塞函数**(`check` / `validate` / `onEvent` / `render` / `onAction`)——
|
||||
跨不了进程,SDK 会自动剔除。函数侧一律走 `onSpeakRule()` / `onEvent()` 注册;
|
||||
自检里有一条专门断言"送上平台的 manifest 确实没有函数"。
|
||||
3. **一条实例只该有一条常驻长轮询**(`ready()` 登记后自动启,幂等)——它直接占平台连接额度,
|
||||
⛔ 别自己再写第二条。收尾用 `await host.close()`(会掐断在途长轮询,否则进程退不出去)。
|
||||
|
||||
## ⛔ 不做什么
|
||||
|
||||
- **不开端口**:所有调用都是插件**拨出去**的(HTTP 到平台);平台不打进实例、实例不需可寻址。
|
||||
- **不新增凭据**:复用实例凭据(`DSH_PLATFORM_IM_URL` / `DSH_IM_INSTANCE_TOKEN`,平台起实例时注入)。
|
||||
- **不把事件当可靠消息流**:事件队列**有界**(满则丢最旧并计数)⇒ 要可靠增量请用
|
||||
`host.subscribe()`(游标补齐,不会丢)。
|
||||
- **判不了就拒(fail-closed)要靠"返回 `{ok:false}`",⛔ 不是靠抛错** ——
|
||||
你的 `check` 抛错时,平台按"插件故障 ⇒ 放行"处理(与同进程语义逐字一致)。
|
||||
|
||||
## 接真平台
|
||||
|
||||
1. 把 `sdk/im-plugin-host/index.mjs` 拷进你自己的包(单文件、零依赖、可 vendoring),
|
||||
并把 `index.mjs` 顶部的 import 改成你包内的路径。
|
||||
2. 保证 `pluginId`(SDK 参数 = **包名**)与 `manifest.packageName` / `manifest.pluginId` **自洽**:
|
||||
`@dsh-local/im-plugin-demo` → 内部 id `im_plugin_demo`(去 scope、非字母数字 → `_`)。
|
||||
不自洽 ⇒ `plugin-id-mismatch`;**出向恒 `plugin-unavailable`**。
|
||||
3. 在实例内运行:`DSH_PLATFORM_IM_URL` / `DSH_IM_INSTANCE_TOKEN` 由平台注入,
|
||||
⛔ 不要手填、不要硬编码。出向发言前,bot **必须已是该房成员**(进房是房主 / 管理员动作)。
|
||||
|
||||
## 与同目录另外三个示例的关系
|
||||
|
||||
`mud-rate` / `office-tasks` / `trpg-turn` 写于反向通道落地**之前**,把 `check` / `onEvent`
|
||||
直接写在 `manifest` 里 —— 那是**同进程**形态,跨不了进程。它们仍有参考价值(声明面、
|
||||
业务角色、面板),但**函数侧请照本目录写**。
|
||||
|
||||
⚠️ 本示例**刻意不声明 `tables`**(零数据面):一是保持"最小",二是
|
||||
`package.json#dsh.data.schema` 与契约内 `tables:` 两种写法的**口径尚未裁定**(见接续包 §8-2)。
|
||||
需要数据面时先定口径,别照本示例推。
|
||||
|
||||
## 依据
|
||||
|
||||
- 契约:`dsh-server-docs/01-规范/09-IM插件SDK与扩展点契约.md`(§10.4 / §12)
|
||||
- 落地与验收:工作区 `接续包_IM反向通道实现_20260926.md`
|
||||
- 内核约束(⛔ 不得破):`store.ts` 零改动 / `hub.ts` 纯插入 / `ws.ts` 只动投递点 / `routes/im.ts` 纯插入
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @dsh-local/im-plugin-minimal-callbacks —— **可跑的最小示例(含跨进程反向回调)**。
|
||||
*
|
||||
* ## 它解决什么问题
|
||||
* 本目录是「插件作者从 0 到能跑」的**唯一落点**:`poc/business-plugins-im/` 下另外三个示例
|
||||
* (`mud-rate` / `office-tasks` / `trpg-turn`)写于**反向通道落地之前** ⇒ 它们把 `check` /
|
||||
* `onEvent` 直接写在 manifest 里,那是**同进程**形态,**跨不了进程**。
|
||||
* 本示例是补齐后的正确写法。
|
||||
*
|
||||
* ## 覆盖的两条反向回调(跨进程 · 平台 → 插件)
|
||||
* ① **同步发言规则** `host.onSpeakRule()` —— 平台**写之前**把待判定的发言交给长轮询,
|
||||
* 你在**自己进程内**判,`{ok:false, detail}` ⇒ 拒发。本示例 = **回合制**,
|
||||
* 🔴 并且**必须**在 manifest 里声明 `onTimeout: 'deny'`(理由见下)。
|
||||
* ② **异步事件订阅** `host.onEvent()` —— 平台把事件放进你的有界队列,长轮询取回后在本进程执行。
|
||||
*
|
||||
* ## 🔴 为什么回合制必须声明 `onTimeout: 'deny'`
|
||||
* `speakRules[].onTimeout` 的**缺省值是 `'allow'`**(放行)—— 这是为了与平台既有口径
|
||||
* 「无插件 / 插件故障 ⇒ 放行(回落自由并发)」**逐字一致**,不让插件把房间卡死。
|
||||
* 但对**回合制 / 限速型**来说,"放行"就是**规则失效**:你的实例不在线(重启 / 崩溃 / 部署窗口)
|
||||
* 时,非当前回合者的消息会**照常写入**,等于没装规则。
|
||||
* ⇒ **回合制 / 限速型必须显式声明 `'deny'`**,否则超时即破规则。
|
||||
*
|
||||
* ## ⛔ 三条纪律(本示例刻意遵守,照抄即可)
|
||||
* - **不开端口**:所有调用都是**你拨出去**的(HTTP 到平台);⛔ 插件不得监听端口。
|
||||
* - **manifest 里不许塞函数**:`check` / `validate` / `onEvent` / `render` / `onAction`
|
||||
* **跨不了进程**,SDK 会自动剔除 ⇒ 函数侧一律走 `onSpeakRule()` / `onEvent()` 注册。
|
||||
* - **一条实例只该有一条常驻长轮询**(`ready()` 自动启,⛔ 别自己再写第二条)——它占平台连接额度。
|
||||
*
|
||||
* @module @dsh-local/im-plugin-minimal-callbacks
|
||||
*/
|
||||
|
||||
// 示例内用**相对路径**引入 SDK(本仓自带单文件,零依赖、可 vendoring)。
|
||||
// 真实插件里请把 `sdk/im-plugin-host/index.mjs` 拷进你自己的包,再 `import ... from '<你的包内路径>'`。
|
||||
import { createImPluginHost } from '../../../sdk/im-plugin-host/index.mjs'
|
||||
|
||||
/** 你的包名(🔴 SDK 的 `pluginId` 参数 = 包名,不是平台内部 id)。 */
|
||||
export const PLUGIN_ID = '@dsh-local/im-plugin-minimal-callbacks'
|
||||
/** 本示例只用一种房间类型,便于一眼看懂。 */
|
||||
export const ROOM_TYPE = 'demo-turn'
|
||||
|
||||
const TIMEOUT_MS = 50
|
||||
const CIRCUIT = { threshold: 5, cooldownMs: 30_000 }
|
||||
|
||||
/**
|
||||
* 声明面(交给平台)—— ⛔ **不含任何函数**。
|
||||
*
|
||||
* 声明**照传**(平台靠它路由"该问哪个插件");函数侧在 `start()` 里用 `onSpeakRule()` / `onEvent()` 注册。
|
||||
*/
|
||||
export const manifest = {
|
||||
pluginId: 'im_plugin_minimal_callbacks',
|
||||
packageName: PLUGIN_ID,
|
||||
label: '最小回调示例',
|
||||
version: '0.1.0',
|
||||
|
||||
// ── 扩展点 1 · 房间类型 ──────────────────────────────────────────────────
|
||||
roomTypes: [
|
||||
{
|
||||
type: ROOM_TYPE,
|
||||
label: '回合制示例房',
|
||||
requiresReview: false,
|
||||
transport: 'chat',
|
||||
defaultConfig: { turnOf: '' },
|
||||
uses: ['roomTypes', 'speakRules', 'events'],
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
circuit: CIRCUIT,
|
||||
},
|
||||
],
|
||||
|
||||
// ── 扩展点 2 · bot(出向发言要借它的身份) ──────────────────────────────
|
||||
bots: [{ botId: 'bot_minimal', label: '示例助手', timeoutMs: TIMEOUT_MS, circuit: CIRCUIT }],
|
||||
|
||||
// ── 扩展点 3 · 出向声明(⛔ 未声明 ⇒ 出向恒 `plugin-unavailable`) ──────
|
||||
outbounds: [
|
||||
{ outboundId: 'minimal_out', label: '出向', roomTypes: [ROOM_TYPE], timeoutMs: TIMEOUT_MS, circuit: CIRCUIT },
|
||||
],
|
||||
|
||||
// ── 扩展点 4 · 发言规则(**声明**) ─────────────────────────────────────
|
||||
// 🔴 `onTimeout: 'deny'` 是**本示例的要点**:回合制下超时必须拒,否则规则形同不存在。
|
||||
speakRules: [
|
||||
{ rule: 'turn-based', roomTypes: [ROOM_TYPE], onTimeout: 'deny', timeoutMs: TIMEOUT_MS, circuit: CIRCUIT },
|
||||
],
|
||||
|
||||
// ── 扩展点 5 · 事件订阅(**声明**) ─────────────────────────────────────
|
||||
events: [{ events: ['message.created'], roomTypes: [ROOM_TYPE], timeoutMs: TIMEOUT_MS, circuit: CIRCUIT }],
|
||||
}
|
||||
|
||||
/**
|
||||
* 回合制判定(**纯函数**,导出以便单测直接断言 —— 跨进程后你更需要单测,因为平台侧不再有你的代码)。
|
||||
*
|
||||
* 规则:`config.turnOf` = 当前该行动的人。
|
||||
* - 空 ⇒ **视为自由**(未开局不该把房间卡死);
|
||||
* - 是本人 ⇒ 放行;
|
||||
* - 系统消息 / bot / agent 回写 ⇒ 放行(否则 agent 代答会把自己卡死);
|
||||
* - 其余 ⇒ 拒,并给**可读原因**(`detail` 会进平台的审计 / 回给调用方)。
|
||||
*
|
||||
* ⚠️ 本函数**抛错**时平台按"插件故障 ⇒ 放行"处理(与同进程语义逐字一致)——
|
||||
* 所以"判不了就拒"(fail-closed)要靠**返回** `{ok:false}`,⛔ 不是靠抛错。
|
||||
*/
|
||||
export function decideTurn(input) {
|
||||
const turnOf = input?.config?.turnOf
|
||||
if (typeof turnOf !== 'string' || turnOf === '') return { ok: true, reason: 'ok' }
|
||||
if (turnOf === input.authorId) return { ok: true, reason: 'ok' }
|
||||
if (input.authorKind === 'bot' || input.envelope?.via === 'system') return { ok: true, reason: 'ok' }
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'bad-args',
|
||||
detail: `还没轮到「${input.authorId}」行动(当前回合 = ${turnOf})`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 起插件:登记 + 挂两条回调 + 自动开始长轮询。
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.baseUrl] - 缺省读 `DSH_PLATFORM_IM_URL`(平台起实例时注入)。
|
||||
* @param {string} [options.token] - 缺省读 `DSH_IM_INSTANCE_TOKEN`。
|
||||
* @param {(e: object) => void} [options.onLog] - 具名日志出口(⛔ SDK 自己不 print)。
|
||||
* @returns {Promise<{host: object, reg: object, counters: () => object}>}
|
||||
*/
|
||||
export async function start(options = {}) {
|
||||
const onLog = typeof options.onLog === 'function' ? options.onLog : (e) => console.warn('[minimal-callbacks]', e)
|
||||
|
||||
const host = await createImPluginHost({
|
||||
pluginId: PLUGIN_ID,
|
||||
manifest,
|
||||
...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
|
||||
...(options.token === undefined ? {} : { token: options.token }),
|
||||
onLog,
|
||||
})
|
||||
|
||||
// ── ① 同步发言规则(跨进程)─────────────────────────────────────────────
|
||||
host.onSpeakRule({
|
||||
rule: 'turn-based',
|
||||
roomTypes: [ROOM_TYPE],
|
||||
onTimeout: 'deny', // 🔴 与 manifest 的声明**必须一致**;回合制 / 限速型写 'deny'
|
||||
check: (input) => decideTurn(input),
|
||||
})
|
||||
|
||||
// ── ② 异步事件订阅(跨进程 · 尽力送达)─────────────────────────────────
|
||||
let events = 0
|
||||
host.onEvent({
|
||||
events: ['message.created'],
|
||||
roomTypes: [ROOM_TYPE],
|
||||
onEvent: async (event) => {
|
||||
events += 1
|
||||
// ⚠️ 事件队列**有界**(满则丢最旧并计 `bridgeDropped`)⇒ ⛔ 别把这里当可靠消息流;
|
||||
// 要可靠性请用 `host.subscribe()`(游标增量,不会丢)。
|
||||
//
|
||||
// 本示例:把"有人行动了"广播成一帧(ephemeral:不落库 / 不进历史)。
|
||||
// 真实业务里这里可以写库、调模型、发公告 —— 都是**在你自己的进程内**跑。
|
||||
await host.frame({
|
||||
roomId: event.roomId,
|
||||
channel: 'turn.activity',
|
||||
data: { at: event.at, authorId: event.data?.authorId ?? '', seq: event.data?.seq ?? 0 },
|
||||
outboundId: 'minimal_out',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// 🔴 登记成功后,只要上面注册过回调 ⇒ `ready()` **自动开始长轮询**(幂等,别自己再启一条)。
|
||||
const reg = await host.ready()
|
||||
if (reg.ok !== true) onLog({ at: 'start', ok: false, reason: reg.reason, detail: reg.detail })
|
||||
|
||||
return {
|
||||
host,
|
||||
reg,
|
||||
/** 计数读数(⛔ 只回计数,不含内容)—— 上生产后靠它看"回调到底跑没跑"。 */
|
||||
counters: () => ({ ...host.callbacks, events }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 极简「平台侧」模拟实现 —— 只为让本示例**不接真平台也能一键跑起来**。
|
||||
*
|
||||
* ## 它模拟什么
|
||||
* 反向通道只用到三个端点(真实现见平台仓 `src/web/routes/im.ts` 的「纯插入」段):
|
||||
* | 端点 | 方向 | 作用 |
|
||||
* |---|---|---|
|
||||
* | `POST /api/im/plugins/register` | 插件 → 平台 | 交声明面 |
|
||||
* | `POST /api/im/plugins/bridge/pull` | 插件 → 平台(长轮询) | 取工作项;队列空 ⇒ 挂起到 `waitMs` 后回空 |
|
||||
* | `POST /api/im/plugins/bridge/result` | 插件 → 平台 | 回投结果(⛔ 幂等:过期 `callId` ⇒ `unknown-call-id`) |
|
||||
* 外加两个出向端点(`out/frame` / `out/message`),用来断言事件回调**真的跑到了**。
|
||||
*
|
||||
* ⚠️ **这不是平台的替代品**:它不校验房间成员、不落库、无鉴权语义,只做"协议形状对齐"。
|
||||
* 端到端真实验收仍要连真平台(见本目录 README「接真平台」)。
|
||||
*
|
||||
* @module mock-platform
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
/** 服务端长轮询硬上限(与平台 `ImBridgeCallbackBus.maxPollHoldMs = 20 s` 同一口径)。 */
|
||||
export const MAX_POLL_HOLD_MS = 20_000
|
||||
|
||||
/**
|
||||
* 起一个模拟平台。
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.holdCapMs] - **本模拟器**单次挂起上限(缺省 800)。⚠️ 只为让自检快跑;
|
||||
* 真平台是 20 s。它调小了 ⇒ 客户端会更快重拨,属预期(不会因此误判功能)。
|
||||
* @param {string} [options.token] - 期望的实例凭据(缺省 `dev-token`)。
|
||||
* @returns {Promise<object>} 模拟平台句柄(含排队 / 取结果 / 读帧的辅助方法)。
|
||||
*/
|
||||
export async function startMockPlatform(options = {}) {
|
||||
const holdCapMs = Number.isFinite(options.holdCapMs) ? options.holdCapMs : 800
|
||||
const token = options.token ?? 'dev-token'
|
||||
|
||||
/** @type {Array<object>} 待取的工作项 */
|
||||
const queue = []
|
||||
/** @type {Map<string, object>} 已回投的结果(按 callId) */
|
||||
const results = new Map()
|
||||
/** @type {Array<object>} 收到的帧 */
|
||||
const frames = []
|
||||
/** @type {Array<object>} 收到的落库发言 */
|
||||
const outMessages = []
|
||||
/** @type {Array<() => void>} 在挂起的长轮询(有货就唤醒) */
|
||||
let waiters = []
|
||||
let registered = null
|
||||
let pullCount = 0
|
||||
let lastToken = null
|
||||
|
||||
function wake() {
|
||||
const pending = waiters
|
||||
waiters = []
|
||||
for (const fn of pending) fn()
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
const text = JSON.stringify(body)
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) })
|
||||
res.end(text)
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = []
|
||||
for await (const c of req) chunks.push(c)
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
if (text === '') return {}
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return { __badJson: text }
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
void (async () => {
|
||||
const path = (req.url ?? '').split('?')[0]
|
||||
const body = req.method === 'POST' ? await readBody(req) : {}
|
||||
lastToken = req.headers['x-dsh-im-instance-token'] ?? null
|
||||
|
||||
if (path === '/api/im/plugins/register') {
|
||||
registered = { pluginId: req.headers['x-dsh-im-plugin-id'] ?? null, manifest: body }
|
||||
json(res, 200, { ok: true, pluginId: registered.pluginId })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/api/im/plugins/bridge/pull') {
|
||||
pullCount += 1
|
||||
const want = Number.isFinite(body?.waitMs) ? body.waitMs : 0
|
||||
const deadline = Date.now() + Math.max(0, Math.min(want, holdCapMs))
|
||||
while (queue.length === 0 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => {
|
||||
const t = setTimeout(resolve, Math.max(1, deadline - Date.now()))
|
||||
waiters.push(() => {
|
||||
clearTimeout(t)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
const max = Number.isFinite(body?.max) ? body.max : 32
|
||||
const items = queue.splice(0, Math.max(1, max))
|
||||
json(res, 200, { ok: true, items })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/api/im/plugins/bridge/result') {
|
||||
const callId = typeof body?.callId === 'string' ? body.callId : ''
|
||||
if (callId === '') {
|
||||
json(res, 400, { ok: false, error: 'call-id-required' })
|
||||
return
|
||||
}
|
||||
if (results.has(callId)) {
|
||||
// 真口径:幂等,过期 / 重复回投 ⇒ 具名拒绝,⛔ 不重复 resolve。
|
||||
json(res, 200, { ok: false, reason: 'unknown-call-id' })
|
||||
return
|
||||
}
|
||||
results.set(callId, { ok: body.ok === true, reason: body.reason, detail: body.detail })
|
||||
json(res, 200, { ok: true, accepted: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/api/im/plugins/out/frame') {
|
||||
frames.push(body)
|
||||
json(res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/api/im/plugins/out/message') {
|
||||
outMessages.push(body)
|
||||
json(res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/api/im/plugins/unregister') {
|
||||
registered = null
|
||||
json(res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/api/im/rooms') {
|
||||
json(res, 200, { ok: true, rooms: [] })
|
||||
return
|
||||
}
|
||||
|
||||
json(res, 404, { ok: false, error: 'not-found', detail: path })
|
||||
})().catch((err) => {
|
||||
try {
|
||||
json(res, 500, { ok: false, error: 'mock-failure', detail: String(err) })
|
||||
} catch {
|
||||
/* 已断开 */
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const port = server.address().port
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
token,
|
||||
|
||||
/** 平台收到的登记面(含 manifest 原文)—— 用来断言"函数没被送出去"。 */
|
||||
get registered() {
|
||||
return registered
|
||||
},
|
||||
get frames() {
|
||||
return frames
|
||||
},
|
||||
get outMessages() {
|
||||
return outMessages
|
||||
},
|
||||
get pullCount() {
|
||||
return pullCount
|
||||
},
|
||||
get lastToken() {
|
||||
return lastToken
|
||||
},
|
||||
|
||||
/** 造一条待判定的发言(模拟"用户在写")。 */
|
||||
queueSpeakCheck({ callId, input, timeoutMs = 50 }) {
|
||||
queue.push({ kind: 'speak.check', callId, pluginId: registered?.pluginId ?? '', timeoutMs, input })
|
||||
wake()
|
||||
},
|
||||
|
||||
/** 造一条事件(模拟"有人发言了")。 */
|
||||
queueEvent({ callId, event, timeoutMs = 50 }) {
|
||||
queue.push({ kind: 'event.deliver', callId, pluginId: registered?.pluginId ?? '', timeoutMs, event })
|
||||
wake()
|
||||
},
|
||||
|
||||
/** 等某个 `callId` 的回投结果(超时 ⇒ 回 `null`,由调用方断言失败)。 */
|
||||
async waitResult(callId, timeoutMs = 3000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (results.has(callId)) return results.get(callId)
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
async close() {
|
||||
wake()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@dsh-local/im-plugin-minimal-callbacks",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"exports": {
|
||||
".": "./index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"self-check": "node self-check.mjs"
|
||||
},
|
||||
"dependencies": {},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 自检(**一键跑**):`node self-check.mjs`
|
||||
*
|
||||
* 它证明本示例的五件事 —— 都不接真平台,靠 `mock-platform.mjs` 跑完整协议:
|
||||
* ① 登记成功,且送上平台的 manifest **不含函数**(跨进程只传数据);
|
||||
* ② 声明里 `onTimeout: 'deny'` 在位(回合制的命门);
|
||||
* ③ **同步发言规则**真的生效:非当前回合者 ⇒ 回 `{ok:false}`(**且原因可读**);
|
||||
* ④ **异步事件订阅**真的跑到:`event.deliver` ⇒ 你的 `onEvent` 执行 ⇒ 出了一帧;
|
||||
* ⑤ **未接线时行为不变**:缺 `baseUrl` / `token` ⇒ **不抛**、不启长轮询(插件其余功能不受影响)。
|
||||
*
|
||||
* 退出码:0 = 全绿;1 = 有红。⛔ 红就是红 —— 别把"没报错"当通过。
|
||||
*/
|
||||
|
||||
import { decideTurn, manifest, start, ROOM_TYPE, PLUGIN_ID } from './index.mjs'
|
||||
import { startMockPlatform } from './mock-platform.mjs'
|
||||
|
||||
let failed = 0
|
||||
let passed = 0
|
||||
|
||||
function check(name, condition, extra = '') {
|
||||
if (condition) {
|
||||
passed += 1
|
||||
console.log(` ✅ ${name}`)
|
||||
} else {
|
||||
failed += 1
|
||||
console.log(` ❌ ${name}${extra === '' ? '' : ` —— ${extra}`}`)
|
||||
}
|
||||
}
|
||||
|
||||
const ROOM_ID = 'room_demo_1'
|
||||
const eventOf = (authorId) => ({
|
||||
name: 'message.created',
|
||||
roomId: ROOM_ID,
|
||||
roomType: ROOM_TYPE,
|
||||
at: 1_758_000_000_000,
|
||||
data: { messageId: 'm1', seq: 1, authorId, authorKind: 'user', via: 'client', visibility: 'room', payload: { kind: 'text', text: 'hi' } },
|
||||
})
|
||||
|
||||
async function main() {
|
||||
console.log('── ① 纯函数判定(回合制语义,可单测)')
|
||||
check('未开局(turnOf 空)⇒ 放行', decideTurn({ config: { turnOf: '' }, authorId: 'u1' }).ok === true)
|
||||
check('轮到本人 ⇒ 放行', decideTurn({ config: { turnOf: 'u2' }, authorId: 'u2' }).ok === true)
|
||||
const rejected = decideTurn({ config: { turnOf: 'u1' }, authorId: 'u2' })
|
||||
check('没轮到 ⇒ 拒,且原因可读', rejected.ok === false && typeof rejected.detail === 'string' && rejected.detail.length > 0, JSON.stringify(rejected))
|
||||
check('bot / agent 代答不参与回合(⛔ 否则会自卡)', decideTurn({ config: { turnOf: 'u1' }, authorId: 'bot_x', authorKind: 'bot' }).ok === true)
|
||||
|
||||
console.log('\n── ② 声明面(不接平台也能看)')
|
||||
check(`清单里 onTimeout = 'deny'`, manifest.speakRules[0].onTimeout === 'deny')
|
||||
|
||||
const platform = await startMockPlatform()
|
||||
let host
|
||||
try {
|
||||
console.log('\n── ③ 接"平台",跑真实协议(登记 → 长轮询 → 回投)')
|
||||
const logLines = []
|
||||
const boot = await start({
|
||||
baseUrl: platform.url,
|
||||
token: platform.token,
|
||||
onLog: (e) => logLines.push(e),
|
||||
})
|
||||
host = boot.host
|
||||
|
||||
check('登记成功(reg.ok === true)', boot.reg.ok === true, JSON.stringify(boot.reg))
|
||||
check('拼上了实例凭据', platform.lastToken === platform.token)
|
||||
|
||||
// 送上平台的 manifest:有声明、但**没有函数**。
|
||||
const sent = platform.registered?.manifest ?? {}
|
||||
check('manifest 送上去了且声明在', Array.isArray(sent.speakRules) && sent.speakRules.length === 1)
|
||||
check('🔴 manifest 里没有函数(check / onEvent 已被 SDK 剔除)', sent.speakRules[0].check === undefined && sent.events[0].onEvent === undefined, JSON.stringify(Object.keys(sent.speakRules[0])))
|
||||
check('送上平台的 onTimeout 也是 deny', sent.speakRules[0].onTimeout === 'deny')
|
||||
|
||||
check('长轮询已启动(callbacks.started)', host.callbacks.started === true)
|
||||
|
||||
// ⑶ 同步发言规则:没轮到的发言必须被拒。
|
||||
platform.queueSpeakCheck({
|
||||
callId: 'c1',
|
||||
input: { roomId: ROOM_ID, roomType: ROOM_TYPE, authorId: 'u2', authorKind: 'user', config: { turnOf: 'u1' }, envelope: { via: 'client' } },
|
||||
})
|
||||
const r1 = await platform.waitResult('c1')
|
||||
check('⚠️ 非当前回合者 ⇒ 平台收到 ok:false(拒)', r1 !== null && r1.ok === false, JSON.stringify(r1))
|
||||
check('拒绝原因可读(不是空 detail)', r1 !== null && typeof r1.detail === 'string' && r1.detail.includes('还没轮到'), JSON.stringify(r1))
|
||||
|
||||
// 轮到本人 ⇒ 必须放行(否则规则把正常发言也卡死 = 净变差)。
|
||||
platform.queueSpeakCheck({
|
||||
callId: 'c2',
|
||||
input: { roomId: ROOM_ID, roomType: ROOM_TYPE, authorId: 'u1', authorKind: 'user', config: { turnOf: 'u1' }, envelope: { via: 'client' } },
|
||||
})
|
||||
const r2 = await platform.waitResult('c2')
|
||||
check('✅ 轮到本人 ⇒ 放行(ok:true)', r2 !== null && r2.ok === true, JSON.stringify(r2))
|
||||
|
||||
// ⑷ 异步事件订阅:事件送达 ⇒ onEvent 在本进程执行 ⇒ 出一帧。
|
||||
platform.queueEvent({ callId: 'e1', event: eventOf('u1') })
|
||||
const r3 = await platform.waitResult('e1')
|
||||
check('事件回投成功(ok:true)', r3 !== null && r3.ok === true, JSON.stringify(r3))
|
||||
const deadline = Date.now() + 2000
|
||||
while (platform.frames.length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10))
|
||||
check('⚠️ onEvent 真的执行了(平台收到 1 帧)', platform.frames.length === 1, `frames=${platform.frames.length}`)
|
||||
check('帧内容 = 事件里的作者', platform.frames[0]?.data?.authorId === 'u1', JSON.stringify(platform.frames[0]))
|
||||
check('出向带了 outboundId', platform.frames[0]?.outboundId === 'minimal_out')
|
||||
|
||||
check('读数可观测(items / settled > 0)', host.callbacks.items >= 3 && host.callbacks.settled >= 3, JSON.stringify(host.callbacks))
|
||||
check('整轮无 onLog 报错', logLines.length === 0, JSON.stringify(logLines))
|
||||
|
||||
await host.close()
|
||||
check('close() 后长轮询已停', host.callbacks.stopped === true)
|
||||
host = undefined
|
||||
} finally {
|
||||
await platform.close()
|
||||
}
|
||||
|
||||
console.log('\n── ④ 未接线(缺 baseUrl / token)⇒ 行为必须不变')
|
||||
const offline = await start({ baseUrl: '', token: '', onLog: () => {} })
|
||||
check('不抛(start 正常返回)', offline.host !== undefined)
|
||||
check('不启长轮询(callbacks.started === false)', offline.host.callbacks.started === false)
|
||||
check('登记失败回具名结论(not-configured)', offline.reg.ok === false && offline.reg.reason === 'not-configured', JSON.stringify(offline.reg))
|
||||
check('出向回具名结论而非抛错', (await offline.host.frame({ roomId: ROOM_ID, channel: 'x', data: {} })).reason === 'not-configured')
|
||||
await offline.host.close()
|
||||
|
||||
console.log(`\n══════ 结果:${passed} 例通过 / ${failed} 例失败 ══════`)
|
||||
console.log(`(插件身份 ${PLUGIN_ID} · 房间类型 ${ROOM_TYPE})`)
|
||||
process.exit(failed === 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('自检自身崩了:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
// @dsh-local/im-plugin-mud — bundle patch (host row only)
|
||||
//
|
||||
// D 单示例插件之二(MUD)。走**实时档**(档案 108 游戏口径,jitter 预算 20 ms),
|
||||
// 发言规则 = 限速。⛔ 无 client 面。
|
||||
|
||||
- insert:
|
||||
- id: im-plugin-mud
|
||||
name: '@dsh-local/im-plugin-mud'
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"@dsh-local/im-plugin-mud","version":"0.1.0","private":true,"type":"module","main":"lib/index.js","exports":{".":"./lib/index.js"},"dsh":{"bundle":{"patch":"./cordis.patch.yml"}},"dependencies":{},"license":"MIT"}
|
||||
@@ -0,0 +1,8 @@
|
||||
// @dsh-local/im-plugin-office — bundle patch (host row only)
|
||||
//
|
||||
// D 单示例插件之一(办公群)。⛔ 无 client 面:本插件只在**服务端**把房间类型 +
|
||||
// 任务卡载荷 + 面板插槽声明给 IM SDK,UI 成品在 E 单。
|
||||
|
||||
- insert:
|
||||
- id: im-plugin-office
|
||||
name: '@dsh-local/im-plugin-office'
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@dsh-local/im-plugin-office",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
},
|
||||
"dependencies": {},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// @dsh-local/im-plugin-trpg — bundle patch (host row only)
|
||||
//
|
||||
// D 单示例插件之三(跑团)。发言规则 = **回合制**(非当前回合者发言被拒),
|
||||
// 含角色卡表与 DM/玩家业务角色。⛔ 无 client 面。
|
||||
|
||||
- insert:
|
||||
- id: im-plugin-trpg
|
||||
name: '@dsh-local/im-plugin-trpg'
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"@dsh-local/im-plugin-trpg","version":"0.1.0","private":true,"type":"module","main":"lib/index.js","exports":{".":"./lib/index.js"},"dsh":{"bundle":{"patch":"./cordis.patch.yml"}},"dependencies":{},"license":"MIT"}
|
||||
Reference in new issue
Block a user