# IM 插件 SDK 与扩展点契约 > 面向要给 IM 房间加场景能力的插件作者。内核只提供通用地基,**场景差异全部下沉到这里**。 > 依据:`05-交接单/IM群组-04-插件SDK与扩展点契约.md`|需求基线:`04-调整方案/142-IM群组对话-需求基线与方案.md §3.6` ## 1. 这是什么 IM 房间内核(`src/im/{types,db,store,clock,hub,ws}.ts`)只定义**消息信封**(`id` / `seq` / `ts` / 作者 / `via` / `visibility`)与房间生命周期。办公群的任务卡、MUD 的指令流、跑团的回合制, **都不写进内核** —— 写法是注册一个插件,声明它要用到哪些扩展点。 SDK = `src/im/sdk/`: | 文件 | 内容 | |---|---| | `types.ts` | 七个扩展点的契约类型 + 两档传输 + 数据面声明 | | `host.ts` | 宿主实现:注册面、超时熔断、预算继承、声明式建表、审计 | | `index.ts` | 对外 Facade(插件 `import` 这一个就够) | ## 2. 七个扩展点 | # | 扩展点 | 契约 | 一句话 | |---|---|---|---| | 1 | 房间类型注册 | `RoomTypeContract` | 声明 `room_type` + 默认配置 + 走哪一档传输 | | 2 | 消息载荷 | `PayloadContract` | 插件自己定义 `payload` 结构与校验 | | 3 | 成员角色 | `RoleContract` | 业务角色(DM / 玩家 / 负责人);⛔ 不提升内核权限 | | 4 | 发言规则 hook | `SpeakRuleContract` | 自由并发 / 回合制 / 限速 | | 5 | 能力机器人 | `BotContract` | 注册 bot + @ 路由(形态 A 的来源) | | 6 | 事件订阅 | `EventSubscriber` | **拨出式**:宿主调插件,⛔ 插件不开监听端口 | | 7 | 房间内 UI | `PanelContract` | 声明面板并挂 dsh 既有 slots | **无插件 ⇒ 默认自由并发**:不注册发言规则时,房间行为与改造前一致,⛔ 不报错。 ## 3. 三条平台保护约束 写在契约里、由宿主强制执行 —— 不是文档约定: 1. **插件不直连 DB**:要读写数据只能经注入的 `ImDataPort`(`insert` / `update` / `remove` / `find` / `count`)。插件侧代码里出现 SQL 即违约。 2. **插件故障不拖垮房间**:每个扩展点**必须**声明 `timeoutMs` + `circuit`(缺一注册不通过)。 回调挂起 ⇒ 超时记失败并把该次交给内核默认行为;连续失败 ⇒ 开熔断,冷却期内不再调用插件。 3. **插件能力吃预算**:发言判定走**内核**提供的 `ImBudgetPort`。插件只能申请,⛔ 不得自带一套限速。 ## 4. 写一个插件 最小形状 = 一个 `manifest` 对象 + 一个 `apply(ctx, host)`: ```js export const manifest = { pluginId: 'im_plugin_demo', // = 包名去 scope、小写、连字符转下划线 label: '示例', packageName: '@dsh-local/im-plugin-demo', version: '0.1.0', roomTypes: [{ type: 'demo', label: '示例房', transport: 'chat', // 或 'realtime' defaultConfig: { topic: '' }, timeoutMs: 50, circuit: { threshold: 5, cooldownMs: 30_000 }, // 🔴 必填 }], payloads: [{ kind: 'ping', validate: (p) => typeof p?.text === 'string', timeoutMs: 50, circuit: { threshold: 5, cooldownMs: 30_000 }, }], tables: [{ name: 'pings', scope: 'room', schemaVersion: 1, columns: [{ name: 'text', type: 'text' }], // 归属列 room_id / user_id 由内核自动补 }], } export function apply(ctx, host) { host.register(manifest) ctx?.on?.('dispose', () => host.unregister(manifest.pluginId)) } ``` 打包与投递**复用既有通道**(`cordis.patch.yml` + `package.json#dsh` → 门户候选池 → 实例「能力管理」自助启用)。⛔ SDK 不新造分发机制。 ## 5. 两档传输 | 档 | `fanoutBatch` | `heartbeatMs` | `maxJitterMs` | `latencyBudgetMs` | |---|---|---|---|---| | `chat`(档案 107 口径) | 200 | 25 000 | 1 000 | 1 000 | | `realtime`(档案 108 游戏口径) | 50 | 5 000 | **20** | 100 | 房间类型声明自己走哪一档;**两档参数不同源**,⛔ 不是同一份参数改个名。 未声明 ⇒ 回落 `chat`(⛔ 不默认给实时档)。 ## 6. 数据面 **判落点,再谈建表**(`03-数据库/DB-03-插件数据面规范.md`): | 数据种类 | 落点 | 归属列 | |---|---|---| | 跟着**房间**走的(角色卡 / 地图 / 任务卡) | 该插件自己的库 `dshs_pl_`,表 `p__*` | `room_id` | | 只属于**某用户自己**的 | 同一个库 | `user_id` | | **缓存 / 临时**(丢了可重建) | 实例本地 | — ⛔ 不得当权威 | 规则: - **建表 = 声明式**:字段只用中性类型(`text` / `integer` / `bigint` / `real` / `boolean` / `json` / `timestamp` / `uuid`),内核翻译成 sqlite + pg 两套 DDL。⛔ 禁裸 `CREATE TABLE`、 厂商专有类型、触发器、存储过程、跨前缀 `JOIN`。 - **迁移只增不减**:`schemaVersion` 必填;只允许加列(带默认值)/ 加表 / 加索引。 任一步失败 ⇒ **拒绝启用**(⛔ 不做半迁移)。 - **访问必走内核 API**:房间维度**自动注入 ACL**(复用 `canSee`);跨前缀 / 内核表 **看不到也查不了**;事务仅限同前缀多表。 - **卸载数据默认保留 30 天**;`DROP TABLE` 不可逆 ⇒ 需 admin 显式确认 + 先出受影响清单。 ## 7. 三个示例插件 `poc/business-plugins-im/` 下三类场景各一个,可直接照抄: | 目录 | 包名 | 类型 | 演示的扩展点 | |---|---|---|---| | `office-tasks/` | `@dsh-local/im-plugin-office` | `office`(聊天档) | 任务卡载荷 + 面板 + 事件落库 | | `mud-rate/` | `@dsh-local/im-plugin-mud` | `mud`(**实时档**) | **限速**发言规则 + 指令流载荷 | | `trpg-turn/` | `@dsh-local/im-plugin-trpg` | `trpg`(**实时档**) | **回合制**发言规则 + 业务角色 + 骰子载荷 | ## 8. 怎么验 ```bash export PATH="E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3:$PATH" npm run build node --test test/im-sdk.test.mjs # 期望:退出码 0(66 用例全过) git diff -- src/im/store.ts src/im/hub.ts src/web/routes/im.ts # 期望:无输出 ``` 最后一条是**硬判据**:非聊天场景跑通**不改内核**(142 §五-6)。若必须改内核 ⇒ 扩展点没设计对, 回头改扩展点设计,⛔ 不是改内核。