cs-bots 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +164 -2
- package/cordis.patch.agent-team.yml +31 -0
- package/cordis.patch.yml +30 -0
- package/install.sh +218 -0
- package/package.json +57 -4
- package/src/api.js +706 -0
- package/src/board.js +1056 -0
- package/src/botprofile.js +331 -0
- package/src/client.js +2089 -0
- package/src/compat.js +196 -0
- package/src/dirbrowse.js +555 -0
- package/src/dispatcher.js +651 -0
- package/src/index.js +311 -0
- package/src/kernel.js +480 -0
- package/src/library.js +740 -0
- package/src/memory.js +1905 -0
- package/src/registry.js +34 -0
- package/src/room.js +1550 -0
- package/src/roster.js +1119 -0
- package/src/settings.js +107 -0
- package/src/store.js +243 -0
- package/src/tools.js +469 -0
package/src/room.js
ADDED
|
@@ -0,0 +1,1550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots RoomService —— 房间与群聊(**确定性写通路**)。
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:`multiagent/10-初步架构设计方案-v1.md` §3.2(RoomService)。
|
|
5
|
+
*
|
|
6
|
+
* ── 为什么必须自建(硬事实,不是重复造轮子)────────────────────────────────
|
|
7
|
+
* 官方**没有** peer 消息流。一手原文(`dsh-experimental-client-ui-agent-team/README.zh.md:88`):
|
|
8
|
+
* "没有 mailbox timeline"
|
|
9
|
+
* ⇒ 平台之间的「消息可见性」这件事,宿主管线上**结构上不存在**,只能自建。
|
|
10
|
+
* ⇒ 而且写通路**必须是确定性的**:像 `cs-bot-bea` 那样「把指令投给模型、让模型自己去调工具」
|
|
11
|
+
* 是非确定性的(模型可以理解错、可以不调、可以改写)——§3.2 硬约束 1 明确要求
|
|
12
|
+
* **走域服务直调,不经 HTTP、不经模型**。本文件的 `post()` 就是那条通路。
|
|
13
|
+
*
|
|
14
|
+
* ── 相对 §3.2 草案的**必要增量**(不改既有字段语义,纯增量)──
|
|
15
|
+
* `rev` —— 草案未列。加它是为了支持**可选 CAS**(见下「CAS 取舍」)。
|
|
16
|
+
* `createdAt` / `createdBy` —— 审计用(谁在何时建的房间),`list()` 直接可展示。
|
|
17
|
+
* `replyTo`(消息)—— 草案 `post` 签名列了该参数却没规定落点,本模块落在消息顶层。
|
|
18
|
+
* `fingerprint` —— 长正文的「长度 + 哈希前缀」,`readBody()` 用它做**读回校验**。
|
|
19
|
+
*
|
|
20
|
+
* ── CAS 取舍(**刻意与 board.js / roster.js 不同**,理由在此)──
|
|
21
|
+
* board/roster 的每次变更都强制 `expectedRev`;§3.2 给 `join` / `leave` /
|
|
22
|
+
* `post` 的签名里**没有**版本字段。硬塞一个必填 CAS 会破坏已冻结的接口。
|
|
23
|
+
* 故本模块取**可选 CAS**:传了 `expectedRev` 就严格比对(不匹配抛
|
|
24
|
+
* `ROOM_REV_CONFLICT`),不传则不拦。写通路本身是确定性的(单实例直调 + append-only),
|
|
25
|
+
* 房间状态的并发面远小于任务板,这个取舍是有意的,不是遗漏。
|
|
26
|
+
*
|
|
27
|
+
* ── 四条不变量(全模块机械保证)──
|
|
28
|
+
* 1. **事件流是真相源**:房间状态变更先 append `events.jsonl`(失败即抛错,
|
|
29
|
+
* 内存原封不动),再更新内存,最后原子写 `rooms/index.json`(快照失败只降级
|
|
30
|
+
* warn —— 事件流已落盘,`replay()` 能完整重建)。
|
|
31
|
+
* 2. **消息日志 append-only 且永不截断**(§3.2 硬约束 3):`log.jsonl` 一条一行,
|
|
32
|
+
* **没有任何环形上限**。对照 ds-bot 的 `ROOM_LOG_MAX = 60`(`groups.js:31`)——
|
|
33
|
+
* 环形截断正是「真相源分裂」的温床,本模块从结构上不给它留位置。
|
|
34
|
+
* 3. **seq 单调且连续无洞**:由消息日志推导(`store.nextSeq` + 内存自维护),
|
|
35
|
+
* 断点续传与回放都靠它。发现空洞**不静默**:计入 `stats().seqGaps` 并 warn。
|
|
36
|
+
* 4. **长正文落旁路文件**(K2):`text` 超过 `BODY_THRESHOLD` 时正文写
|
|
37
|
+
* `rooms/<roomId>/bodies/<msgId>.txt`,`log.jsonl` 里只留
|
|
38
|
+
* **头 + 指纹 + bodyRef**。这样日志可无限追加而不撞规模上限,
|
|
39
|
+
* **且不需要任何截断**(K2 的原话是「规模必须前置预算,不靠事后截断」)。
|
|
40
|
+
*
|
|
41
|
+
* ── 三条来自 DS-BOT 实测的纪律(`12-硬约束清单.md` §7.2)是怎么落地的 ──
|
|
42
|
+
* **K1(禁用阻塞式提问)**:投递一律 `kernel.followup`,**不用 `steer`** ——
|
|
43
|
+
* `followup` 是「新开一个回合」,适合「有新活要干」;`steer` 会插进正在跑的回合,
|
|
44
|
+
* 对有长任务的 Bot 语义不清。此处的选择与 `dispatcher.js:370` 一致。
|
|
45
|
+
* **K2(长正文前置旁路)**:见不变量 4。**这条是本模块相对 ds-bot 的核心改进**。
|
|
46
|
+
* **K4(投递必须有背压)**:`relayLimit`(默认 3×成员数)+ 每次转发**先落意图事件再投递**
|
|
47
|
+
* —— 「记录了但没投出去」可以重试,「投出去了但没记录」会导致限额被绕过。
|
|
48
|
+
*
|
|
49
|
+
* ── 成员语义(**易踩,特意写清**)──
|
|
50
|
+
* `room.members` **就是**房间成员全集,**创建者不会自动入房**。
|
|
51
|
+
* 为什么不做「自动把 creator 加进去」:那会让 `members` 与入参不一致,
|
|
52
|
+
* 连带 `relayLimit = 3 × 成员数` 也不再等于调用方的算术 —— 这是隐蔽的语义漂移。
|
|
53
|
+
* ⇒ 例外只有一处:**`members` 缺省未传**时,缺省为 `[byId]`(创建者自成一房)。
|
|
54
|
+
* 显式传了 `members` 就**原样采信**,不增不减。
|
|
55
|
+
* ⇒ 要让创建者能发言,请把 `byId` 放进 `members`,或走 `mode:'admin'`(见下)。
|
|
56
|
+
*
|
|
57
|
+
* ── `mode` 的语义(草案只给了枚举没给规则,此处定死并写清)──
|
|
58
|
+
* `'everyone'`(缺省):**成员都可以发言**。
|
|
59
|
+
* `'admin'`:**只有 `adminId` 能发言**(公告型房间)。成员仍可 join/leave/收转发。
|
|
60
|
+
* `'user'` 是特殊发送者 id:**永远可发言**(人类从平台 API 面发言的入口)。
|
|
61
|
+
* `mode:'admin'` 且 `adminId` 缺省 → 取 `byId`(创建者即管理员)。
|
|
62
|
+
* 管理员退房 → 管理权**移交给最早加入的剩余成员**(写 `admin_transferred` 事件,可见);
|
|
63
|
+
* 房间空了则清空 `adminId`。⇒ 不留「管理员已走、房间被锁死」这种隐形死局。
|
|
64
|
+
*
|
|
65
|
+
* 依赖面:node 内置(`node:crypto` / `node:fs` / `node:path`)+ `./store.js` + `./kernel.js`。
|
|
66
|
+
* **不 import 任何 `@deepseek-ai/*`**(`test/ci-zero-dep.sh` 检查 1 机械强制)。
|
|
67
|
+
*
|
|
68
|
+
* `kernel.js` 用**静态 import**:它自身对宿主包是惰性 `createRequire`(`kernel.js:17-25`),
|
|
69
|
+
* 裸检出(无 node_modules)下照样能加载 —— 与 `src/roster.js:56` 的既有做法一致。
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
73
|
+
import {
|
|
74
|
+
existsSync,
|
|
75
|
+
readdirSync,
|
|
76
|
+
readFileSync,
|
|
77
|
+
renameSync,
|
|
78
|
+
rmSync,
|
|
79
|
+
statSync,
|
|
80
|
+
writeFileSync,
|
|
81
|
+
} from 'node:fs'
|
|
82
|
+
import { dirname, join } from 'node:path'
|
|
83
|
+
|
|
84
|
+
import {
|
|
85
|
+
appendJsonl,
|
|
86
|
+
ensureDir,
|
|
87
|
+
IdempotencySet,
|
|
88
|
+
nextSeq,
|
|
89
|
+
readJson,
|
|
90
|
+
readJsonl,
|
|
91
|
+
statePath,
|
|
92
|
+
writeJsonAtomic,
|
|
93
|
+
} from './store.js'
|
|
94
|
+
import * as kernel from './kernel.js'
|
|
95
|
+
|
|
96
|
+
// ── 常量 ────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/** `notice` 上限(§3.2)。 */
|
|
99
|
+
const MAX_NOTICE = 2000
|
|
100
|
+
/** 长正文阈值:**超过**它才落旁路文件(§3.2 硬约束 4 / K2)。 */
|
|
101
|
+
const BODY_THRESHOLD = 2000
|
|
102
|
+
/** 落旁路时 `log.jsonl` 里保留的**头**长度(「只存头」的落地口径)。 */
|
|
103
|
+
const BODY_HEAD_CHARS = 200
|
|
104
|
+
/** 合法房间模式(§3.2)。 */
|
|
105
|
+
const MODES = ['admin', 'everyone']
|
|
106
|
+
/** 缺省模式:人人可发言(最不意外)。 */
|
|
107
|
+
const DEFAULT_MODE = 'everyone'
|
|
108
|
+
/** 合法消息类型(§3.2)。 */
|
|
109
|
+
const KINDS = ['normal', 'pass', 'notice', 'late', 'steer']
|
|
110
|
+
/** 缺省消息类型。 */
|
|
111
|
+
const DEFAULT_KIND = 'normal'
|
|
112
|
+
/** `relayLimit` = `RELAY_MULTIPLIER × 成员数`(§3.2)。 */
|
|
113
|
+
const RELAY_MULTIPLIER = 3
|
|
114
|
+
/** `relayLimit` 下限:空房(0 成员)时若取 0,则**任何**转发都非法 —— 那是死配置,故给地板。 */
|
|
115
|
+
const RELAY_FLOOR = 3
|
|
116
|
+
/** 人类发送者 id(§3.2:`from` 为 sessionId 或 `'user'`)。 */
|
|
117
|
+
const USER = 'user'
|
|
118
|
+
/** 房间事件类型集合(审计流可枚举)。 */
|
|
119
|
+
const EVENTS = [
|
|
120
|
+
'room_created',
|
|
121
|
+
'members_joined',
|
|
122
|
+
'members_left',
|
|
123
|
+
'admin_transferred',
|
|
124
|
+
'room_renamed',
|
|
125
|
+
'notice_changed',
|
|
126
|
+
'mode_changed',
|
|
127
|
+
'relay',
|
|
128
|
+
'relay_result',
|
|
129
|
+
]
|
|
130
|
+
/** `bodyRef` 的合法形态(防路径穿越:只允许 `bodies/<安全文件名>.txt`)。 */
|
|
131
|
+
const BODY_REF_RE = /^bodies\/[A-Za-z0-9._-]+\.txt$/
|
|
132
|
+
|
|
133
|
+
// ── 错误 ────────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/** 房间域结构化错误。所有失败都走它,**绝不静默**。 */
|
|
136
|
+
export class RoomError extends Error {
|
|
137
|
+
/**
|
|
138
|
+
* @param {string} message 人类可读、含定位信息(房间 id / 消息 id / 字段名)
|
|
139
|
+
* @param {string} [code]
|
|
140
|
+
* @param {object} [details]
|
|
141
|
+
*/
|
|
142
|
+
constructor(message, code = 'ROOM_ERROR', details) {
|
|
143
|
+
super(message)
|
|
144
|
+
this.name = 'RoomError'
|
|
145
|
+
this.code = code
|
|
146
|
+
if (details !== undefined) this.details = details
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── 小工具(风格对齐 board.js / roster.js)──────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 深拷贝 + **JSON 归一**(去掉值为 undefined 的键)。
|
|
154
|
+
* 必须走 JSON 而不是 structuredClone:否则内存态会比磁盘态多出 `{adminId: undefined}`
|
|
155
|
+
* 这类键,导致 `replay()` 重建后的 `get()` 与原值不等(`board.js:94-102` 同类踩坑)。
|
|
156
|
+
*/
|
|
157
|
+
function clone(value) {
|
|
158
|
+
if (value === undefined || value === null) return value
|
|
159
|
+
return JSON.parse(JSON.stringify(value))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 非空字符串字段校验。 */
|
|
163
|
+
function requireString(value, field, where) {
|
|
164
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
165
|
+
throw new RoomError(
|
|
166
|
+
`room.${where}:字段 ${field} 缺失或类型错误(要求非空字符串,收到 ${JSON.stringify(value)})`,
|
|
167
|
+
'ROOM_MISSING_FIELD',
|
|
168
|
+
{ field, where },
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
return value
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 可选字符串字段校验(空串视为未提供)。 */
|
|
175
|
+
function optionalString(value, field, where) {
|
|
176
|
+
if (value === undefined || value === null) return undefined
|
|
177
|
+
if (typeof value !== 'string') {
|
|
178
|
+
throw new RoomError(
|
|
179
|
+
`room.${where}:字段 ${field} 类型错误(要求字符串,收到 ${JSON.stringify(value)})`,
|
|
180
|
+
'ROOM_BAD_FIELD',
|
|
181
|
+
{ field, where },
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
const v = value.trim()
|
|
185
|
+
return v === '' ? undefined : v
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 可选数字字段校验(整数)。 */
|
|
189
|
+
function optionalInteger(value, field, where) {
|
|
190
|
+
if (value === undefined || value === null) return undefined
|
|
191
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
192
|
+
throw new RoomError(
|
|
193
|
+
`room.${where}:字段 ${field} 类型错误(要求非负整数,收到 ${JSON.stringify(value)})`,
|
|
194
|
+
'ROOM_BAD_FIELD',
|
|
195
|
+
{ field, where },
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
return value
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 字符串数组归一(去空、去重、保序)。 */
|
|
202
|
+
function stringList(value, field, where) {
|
|
203
|
+
if (value === undefined || value === null) return []
|
|
204
|
+
if (!Array.isArray(value)) {
|
|
205
|
+
throw new RoomError(
|
|
206
|
+
`room.${where}:字段 ${field} 类型错误(要求字符串数组,收到 ${JSON.stringify(value)})`,
|
|
207
|
+
'ROOM_BAD_FIELD',
|
|
208
|
+
{ field, where },
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
const out = []
|
|
212
|
+
for (const item of value) {
|
|
213
|
+
if (typeof item !== 'string' || item.trim() === '') {
|
|
214
|
+
throw new RoomError(
|
|
215
|
+
`room.${where}:字段 ${field} 的元素必须是非空字符串,收到 ${JSON.stringify(item)}`,
|
|
216
|
+
'ROOM_BAD_FIELD',
|
|
217
|
+
{ field, where },
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
const v = item.trim()
|
|
221
|
+
if (!out.includes(v)) out.push(v)
|
|
222
|
+
}
|
|
223
|
+
return out
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 时间参数归一:缺省 now;支持 ms 数字与 Date。 */
|
|
227
|
+
function normalizeNow(value, where) {
|
|
228
|
+
if (value === undefined || value === null) return Date.now()
|
|
229
|
+
if (value instanceof Date) return value.getTime()
|
|
230
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
231
|
+
throw new RoomError(
|
|
232
|
+
`room.${where}:now 必须是毫秒时间戳或 Date,收到 ${JSON.stringify(value)}`,
|
|
233
|
+
'ROOM_BAD_NOW',
|
|
234
|
+
{ where },
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 名字归一键(与 `roster.js` 的 `nameKey` 同口径:trim + NFKC + 小写 ⇒ 大小写不敏感)。 */
|
|
239
|
+
function nameKey(name) {
|
|
240
|
+
return String(name ?? '').trim().normalize('NFKC').toLowerCase()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 房间模式校验。 */
|
|
244
|
+
function normalizeMode(value, where = 'createRoom') {
|
|
245
|
+
if (value === undefined || value === null) return DEFAULT_MODE
|
|
246
|
+
if (!MODES.includes(value)) {
|
|
247
|
+
throw new RoomError(
|
|
248
|
+
`room.${where}:mode 必须是 ${MODES.join(' | ')},收到 ${JSON.stringify(value)}`,
|
|
249
|
+
'ROOM_BAD_MODE',
|
|
250
|
+
{ mode: value },
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
return value
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 消息类型校验。 */
|
|
257
|
+
function normalizeKind(value, where = 'post') {
|
|
258
|
+
if (value === undefined || value === null) return DEFAULT_KIND
|
|
259
|
+
if (!KINDS.includes(value)) {
|
|
260
|
+
throw new RoomError(
|
|
261
|
+
`room.${where}:kind 必须是 ${KINDS.join(' | ')},收到 ${JSON.stringify(value)}`,
|
|
262
|
+
'ROOM_BAD_KIND',
|
|
263
|
+
{ kind: value },
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
return value
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** `notice` 校验(≤2000,§3.2)。 */
|
|
270
|
+
function normalizeNotice(value, where = 'createRoom') {
|
|
271
|
+
if (value === undefined || value === null) return undefined
|
|
272
|
+
if (typeof value !== 'string') {
|
|
273
|
+
throw new RoomError(
|
|
274
|
+
`room.${where}:notice 必须是字符串,收到 ${JSON.stringify(value)}`,
|
|
275
|
+
'ROOM_BAD_FIELD',
|
|
276
|
+
{ field: 'notice' },
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
if (value.length > MAX_NOTICE) {
|
|
280
|
+
throw new RoomError(
|
|
281
|
+
`room.${where}:notice 超长(${value.length} > ${MAX_NOTICE} 字符上限)——请把长正文落旁路文件,公告只存摘要`,
|
|
282
|
+
'ROOM_NOTICE_TOO_LONG',
|
|
283
|
+
{ length: value.length, max: MAX_NOTICE },
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
return value
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** `meta` 校验:必须是普通对象(否则原样落盘会污染日志结构)。 */
|
|
290
|
+
function normalizeMeta(value, where = 'post') {
|
|
291
|
+
if (value === undefined || value === null) return undefined
|
|
292
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
293
|
+
throw new RoomError(
|
|
294
|
+
`room.${where}:meta 必须是对象,收到 ${JSON.stringify(value)}`,
|
|
295
|
+
'ROOM_BAD_FIELD',
|
|
296
|
+
{ field: 'meta' },
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
return clone(value)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** `relayLimit` 口径(§3.2:默认 3×成员数;空房取地板,见常量注释)。 */
|
|
303
|
+
function relayLimitForMembers(members) {
|
|
304
|
+
const n = Array.isArray(members) ? members.length : 0
|
|
305
|
+
return Math.max(RELAY_FLOOR, RELAY_MULTIPLIER * n)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 长正文指纹:**长度 + 哈希前缀**(§3.2 硬约束 4)。
|
|
310
|
+
* 它有两个用处:① `readBody()` 读回时校验完整性;② 日志里可自证「这条消息的正文有多长」。
|
|
311
|
+
*/
|
|
312
|
+
function fingerprint(text) {
|
|
313
|
+
return {
|
|
314
|
+
length: text.length,
|
|
315
|
+
sha256: createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 12),
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 原子写**纯文本**文件:`<file>.tmp-<pid>-<rand>` → rename(K3:状态/快照类走 rename)。
|
|
321
|
+
* 为什么不用 `store.writeJsonAtomic`:那是 JSON 序列化,会把正文包成带引号的 JSON 串,
|
|
322
|
+
* 旁路文件的口径是**原文**(`bodies/<msgId>.txt` 即原文本身)。
|
|
323
|
+
* @returns {boolean} 是否成功
|
|
324
|
+
*/
|
|
325
|
+
function writeTextAtomic(file, text) {
|
|
326
|
+
try {
|
|
327
|
+
ensureDir(dirname(file))
|
|
328
|
+
const tmp = `${file}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`
|
|
329
|
+
writeFileSync(tmp, text, 'utf8')
|
|
330
|
+
renameSync(tmp, file)
|
|
331
|
+
return true
|
|
332
|
+
} catch {
|
|
333
|
+
return false
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 目录可写性探测(构造期用,**不覆盖任何既有文件**)。 */
|
|
338
|
+
function probeWritable(dir) {
|
|
339
|
+
try {
|
|
340
|
+
if (!ensureDir(dir)) return false
|
|
341
|
+
const probe = join(dir, `.write-probe-${process.pid}`)
|
|
342
|
+
writeFileSync(probe, 'ok', 'utf8')
|
|
343
|
+
rmSync(probe, { force: true })
|
|
344
|
+
return true
|
|
345
|
+
} catch {
|
|
346
|
+
return false
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── 工厂 ────────────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 解析状态目录:`config.stateDir` > `kernel.stateDir()` > 抛错。
|
|
354
|
+
* **不做静默兜底**:两条路都不通时必须让调用方看见(与 `board.js:243-256` 同口径)。
|
|
355
|
+
* @param {{ stateDir?: string }} config
|
|
356
|
+
* @returns {string}
|
|
357
|
+
*/
|
|
358
|
+
function resolveStateDir(config) {
|
|
359
|
+
if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') {
|
|
360
|
+
return config.stateDir.trim()
|
|
361
|
+
}
|
|
362
|
+
if (typeof kernel?.stateDir === 'function') return kernel.stateDir()
|
|
363
|
+
throw new RoomError(
|
|
364
|
+
'room.createRoom:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir。'
|
|
365
|
+
+ '裸检出(无宿主包)下必须显式传 config.stateDir。',
|
|
366
|
+
'ROOM_NO_STATE_DIR',
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 创建一个房间服务实例。
|
|
372
|
+
*
|
|
373
|
+
* @param {{ logger?: { info?: Function, warn?: Function } }} [ctx] 宿主上下文
|
|
374
|
+
* (用于日志 + **透传给 `kernel.followup`** 做投递;缺失即降级为 no-op 日志)
|
|
375
|
+
* @param {{ stateDir?: string, idempotencyMax?: number, name?: string }} [config]
|
|
376
|
+
* @returns {object} rooms
|
|
377
|
+
*/
|
|
378
|
+
export function createRoom(ctx, config = {}) {
|
|
379
|
+
const stateDir = resolveStateDir(config)
|
|
380
|
+
const roomsDir = statePath(stateDir, 'rooms')
|
|
381
|
+
const indexPath = statePath(roomsDir, 'index.json')
|
|
382
|
+
|
|
383
|
+
const info = (...args) => { try { ctx?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
|
|
384
|
+
const warn = (...args) => { try { ctx?.logger?.warn?.(...args) } catch { /* 同上 */ } }
|
|
385
|
+
|
|
386
|
+
/** @type {Map<string, object>} roomId → Room(创建序 = Map 插入序) */
|
|
387
|
+
const rooms = new Map()
|
|
388
|
+
/** @type {Map<string, object[]>} roomId → RoomMessage[](按 seq 升序) */
|
|
389
|
+
const messagesByRoom = new Map()
|
|
390
|
+
/** @type {Map<string, object[]>} roomId → 房间事件流(审计流,按落盘顺序) */
|
|
391
|
+
const eventsByRoom = new Map()
|
|
392
|
+
/** @type {Map<string, string>} nameKey(name) → roomId(名字唯一性索引) */
|
|
393
|
+
const nameIndex = new Map()
|
|
394
|
+
/** @type {Map<string, string>} `<roomId>::<dedupKey>` → messageId(幂等取回) */
|
|
395
|
+
const dedupIndex = new Map()
|
|
396
|
+
/** @type {Map<string, number>} `<roomId>::<messageId>` → 已投递次数(relayLimit 记账,回放时从事件流重建) */
|
|
397
|
+
const relayCounts = new Map()
|
|
398
|
+
/** 幂等去重器(store 提供;`replay()` 时从消息日志重建) */
|
|
399
|
+
const idem = new IdempotencySet(
|
|
400
|
+
Number.isFinite(config.idempotencyMax) ? config.idempotencyMax : 10_000,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
const durable = probeWritable(roomsDir)
|
|
404
|
+
|
|
405
|
+
const diagnostics = {
|
|
406
|
+
stateDir,
|
|
407
|
+
roomsDir,
|
|
408
|
+
indexPath,
|
|
409
|
+
kernel: kernel ? 'loaded' : 'unavailable',
|
|
410
|
+
hydratedFrom: /** @type {null|'events'|'snapshot'|'empty'} */ (null),
|
|
411
|
+
replayedRooms: 0,
|
|
412
|
+
replayedMessages: 0,
|
|
413
|
+
replayedEvents: 0,
|
|
414
|
+
badLines: 0,
|
|
415
|
+
seqGaps: 0,
|
|
416
|
+
orphanRoomDirs: 0,
|
|
417
|
+
snapshotWriteFailures: 0,
|
|
418
|
+
relayResultAppendFailures: 0,
|
|
419
|
+
lastReplayAt: /** @type {number|undefined} */ (undefined),
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── 路径 ──────────────────────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
const roomDir = (roomId) => statePath(roomsDir, roomId)
|
|
425
|
+
const eventsPath = (roomId) => statePath(roomDir(roomId), 'events.jsonl')
|
|
426
|
+
const logPath = (roomId) => statePath(roomDir(roomId), 'log.jsonl')
|
|
427
|
+
|
|
428
|
+
// ── 内存态小工具 ───────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
const dedupKeyOf = (roomId, dedupKey) => `${roomId}::${dedupKey}`
|
|
431
|
+
|
|
432
|
+
/** 取房间内存态(原始引用,**不要**外传)。 */
|
|
433
|
+
const roomRef = (roomId) => rooms.get(roomId)
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* relayLimit 记账键。
|
|
437
|
+
*
|
|
438
|
+
* ⚠️ **必须带 roomId**:消息 id 形如 `m-<seq>`,是**房间内**编号 ——
|
|
439
|
+
* 每个房间都有 `m-1`。若只按 messageId 记账,A 房间的转发次数会算到 B 房间头上,
|
|
440
|
+
* 导致 B 房间被凭空判超限(K4 的限额被跨房间泄漏)。这是 review 时实测踩到的缺陷。
|
|
441
|
+
*/
|
|
442
|
+
const relayKey = (roomId, messageId) => `${roomId}::${messageId}`
|
|
443
|
+
|
|
444
|
+
/** 取房间(抛错版,写操作用)。 */
|
|
445
|
+
function requireRoom(roomId, where) {
|
|
446
|
+
const rid = requireString(roomId, 'roomId', where)
|
|
447
|
+
const room = rooms.get(rid)
|
|
448
|
+
if (!room) {
|
|
449
|
+
throw new RoomError(
|
|
450
|
+
`room.${where}:房间不存在 roomId=${rid}`,
|
|
451
|
+
'ROOM_NOT_FOUND',
|
|
452
|
+
{ roomId: rid },
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
return room
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 取消息(找不到抛错,**不返回 undefined 让调用方踩空**)。 */
|
|
459
|
+
function requireMessage(roomId, messageId, where) {
|
|
460
|
+
const list = messagesByRoom.get(roomId) ?? []
|
|
461
|
+
const found = list.find((m) => m.id === messageId)
|
|
462
|
+
if (!found) {
|
|
463
|
+
throw new RoomError(
|
|
464
|
+
`room.${where}:消息不存在 messageId=${messageId}(房间 ${roomId} 共 ${list.length} 条消息)`,
|
|
465
|
+
'ROOM_MESSAGE_NOT_FOUND',
|
|
466
|
+
{ roomId, messageId },
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
return found
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** 可选 CAS 校验(见文件头「CAS 取舍」)。 */
|
|
473
|
+
function assertRev(room, expectedRev, action) {
|
|
474
|
+
if (expectedRev === undefined || expectedRev === null) return
|
|
475
|
+
if (typeof expectedRev !== 'number' || !Number.isInteger(expectedRev)) {
|
|
476
|
+
throw new RoomError(
|
|
477
|
+
`room.${action}:expectedRev 必须是整数(CAS 需要显式期望版本,房间 ${room.id} 当前 rev=${room.rev},`
|
|
478
|
+
+ `收到 ${JSON.stringify(expectedRev)})`,
|
|
479
|
+
'ROOM_MISSING_EXPECTED_REV',
|
|
480
|
+
{ roomId: room.id, rev: room.rev },
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
if (room.rev !== expectedRev) {
|
|
484
|
+
throw new RoomError(
|
|
485
|
+
`room.${action} CAS 冲突:房间 ${room.id} 期望 rev=${expectedRev},实际 rev=${room.rev} —— `
|
|
486
|
+
+ '拒绝静默覆盖,请重新 get() 后按最新 rev 重试',
|
|
487
|
+
'ROOM_REV_CONFLICT',
|
|
488
|
+
{ roomId: room.id, expectedRev, rev: room.rev },
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── 持久化 ────────────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
/** 索引快照内容(落盘形态)。 */
|
|
496
|
+
const indexSnapshot = () => ({
|
|
497
|
+
version: 1,
|
|
498
|
+
savedAt: Date.now(),
|
|
499
|
+
rooms: [...rooms.values()],
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
/** 写索引快照(**原子**;失败只降级 warn —— 事件流已落盘,回放可完整重建)。 */
|
|
503
|
+
function snapshotIndex() {
|
|
504
|
+
if (!durable) {
|
|
505
|
+
diagnostics.snapshotWriteFailures += 1
|
|
506
|
+
warn(`[cs-bots/room] 状态目录不可写(${roomsDir})——索引快照未落盘`)
|
|
507
|
+
return false
|
|
508
|
+
}
|
|
509
|
+
if (!writeJsonAtomic(indexPath, indexSnapshot())) {
|
|
510
|
+
diagnostics.snapshotWriteFailures += 1
|
|
511
|
+
warn(`[cs-bots/room] 索引快照写入失败(${indexPath})——事件流已落盘,replay() 可完整恢复`)
|
|
512
|
+
return false
|
|
513
|
+
}
|
|
514
|
+
return true
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** 低层 append(房间事件;失败即抛错,**绝不「只改内存不落盘」**)。 */
|
|
518
|
+
function appendRoomEvent(roomId, event) {
|
|
519
|
+
const file = eventsPath(roomId)
|
|
520
|
+
if (!appendJsonl(file, event)) {
|
|
521
|
+
throw new RoomError(
|
|
522
|
+
`room.${event.type}:事件追加失败(${file})——拒绝「只改内存不落盘」`,
|
|
523
|
+
'ROOM_APPEND_FAILED',
|
|
524
|
+
{ roomId, type: event.type },
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
if (!eventsByRoom.has(roomId)) eventsByRoom.set(roomId, [])
|
|
528
|
+
eventsByRoom.get(roomId).push(event)
|
|
529
|
+
return true
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 提交一次**房间状态**变更:**先落事件,再改内存,最后写快照**。
|
|
534
|
+
*
|
|
535
|
+
* 顺序不可颠倒:append 失败时内存必须原封不动(否则内存与磁盘分叉,
|
|
536
|
+
* 重启回放后状态凭空回退,属于最恶劣的静默失败)。
|
|
537
|
+
*
|
|
538
|
+
* @param {string} roomId
|
|
539
|
+
* @param {string} type 见 `EVENTS`
|
|
540
|
+
* @param {string|null} byId
|
|
541
|
+
* @param {number} at
|
|
542
|
+
* @param {object|null} before 变更前 Room(新建为 null)
|
|
543
|
+
* @param {object} after 变更后 Room
|
|
544
|
+
* @returns {object} 克隆后的 after
|
|
545
|
+
*/
|
|
546
|
+
function commit(roomId, type, byId, at, before, after) {
|
|
547
|
+
const event = {
|
|
548
|
+
type,
|
|
549
|
+
roomId,
|
|
550
|
+
rev: Number.isFinite(after?.rev) ? after.rev : null,
|
|
551
|
+
byId: byId ?? null,
|
|
552
|
+
at,
|
|
553
|
+
before: before ? clone(before) : null,
|
|
554
|
+
after: clone(after),
|
|
555
|
+
}
|
|
556
|
+
appendRoomEvent(roomId, event)
|
|
557
|
+
rooms.set(roomId, clone(after))
|
|
558
|
+
// 名字索引随房间状态一起维护(改名不在本期 API 内,但保持单一维护点)
|
|
559
|
+
if (after?.name) nameIndex.set(nameKey(after.name), roomId)
|
|
560
|
+
snapshotIndex()
|
|
561
|
+
return clone(after)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── 回放 ──────────────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
/** 扫描磁盘上的房间目录(`room-*`)。 */
|
|
567
|
+
function listRoomDirs() {
|
|
568
|
+
try {
|
|
569
|
+
if (!existsSync(roomsDir)) return []
|
|
570
|
+
return readdirSync(roomsDir, { withFileTypes: true })
|
|
571
|
+
.filter((d) => d.isDirectory() && d.name.startsWith('room-'))
|
|
572
|
+
.map((d) => d.name)
|
|
573
|
+
.sort()
|
|
574
|
+
} catch {
|
|
575
|
+
return []
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* 从磁盘重建内存态。
|
|
581
|
+
*
|
|
582
|
+
* 优先级:**房间事件流(真相源)> 索引快照(缓存)> 空**。
|
|
583
|
+
* 消息一律从 `log.jsonl` 重建(消息的真相源只有它,索引里不存消息)。
|
|
584
|
+
* 坏行 / 空洞**不静默丢弃**:计入 diagnostics 并 warn。
|
|
585
|
+
*/
|
|
586
|
+
function hydrate() {
|
|
587
|
+
rooms.clear()
|
|
588
|
+
messagesByRoom.clear()
|
|
589
|
+
eventsByRoom.clear()
|
|
590
|
+
nameIndex.clear()
|
|
591
|
+
dedupIndex.clear()
|
|
592
|
+
relayCounts.clear()
|
|
593
|
+
idem.keys.clear()
|
|
594
|
+
diagnostics.replayedRooms = 0
|
|
595
|
+
diagnostics.replayedMessages = 0
|
|
596
|
+
diagnostics.replayedEvents = 0
|
|
597
|
+
diagnostics.badLines = 0
|
|
598
|
+
diagnostics.seqGaps = 0
|
|
599
|
+
diagnostics.orphanRoomDirs = 0
|
|
600
|
+
|
|
601
|
+
const snap = readJson(indexPath, null)
|
|
602
|
+
const snapById = new Map()
|
|
603
|
+
if (Array.isArray(snap?.rooms)) {
|
|
604
|
+
for (const raw of snap.rooms) {
|
|
605
|
+
if (raw && typeof raw.id === 'string') snapById.set(raw.id, raw)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const ids = new Set([...snapById.keys(), ...listRoomDirs()])
|
|
610
|
+
let fromEvents = 0
|
|
611
|
+
let fromSnapshot = 0
|
|
612
|
+
let orphan = 0
|
|
613
|
+
|
|
614
|
+
for (const id of ids) {
|
|
615
|
+
const { rows, bad } = readJsonl(eventsPath(id))
|
|
616
|
+
diagnostics.badLines += bad
|
|
617
|
+
if (bad > 0) {
|
|
618
|
+
warn(`[cs-bots/room] ${eventsPath(id)} 有 ${bad} 行无法解析(撕裂写或文件损坏)——已跳过,请核对`)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
let state = null
|
|
622
|
+
for (const ev of rows) {
|
|
623
|
+
if (!ev || typeof ev !== 'object') continue
|
|
624
|
+
if (!eventsByRoom.has(id)) eventsByRoom.set(id, [])
|
|
625
|
+
eventsByRoom.get(id).push(clone(ev))
|
|
626
|
+
if (ev.type === 'relay') {
|
|
627
|
+
// 转发意图事件:relayLimit 的记账基础(先落意图再投递,见 K4 落地说明)
|
|
628
|
+
const n = Array.isArray(ev.targets) ? ev.targets.length : 0
|
|
629
|
+
const key = relayKey(id, ev.messageId)
|
|
630
|
+
relayCounts.set(key, (relayCounts.get(key) ?? 0) + n)
|
|
631
|
+
continue
|
|
632
|
+
}
|
|
633
|
+
if (ev.after && typeof ev.after.id === 'string') state = clone(ev.after)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (!state && snapById.has(id)) {
|
|
637
|
+
// 事件流「整段缺失」(不是「回放后为空」)才回落快照 —— 与 board.js:378-400 同口径
|
|
638
|
+
state = clone(snapById.get(id))
|
|
639
|
+
fromSnapshot += 1
|
|
640
|
+
}
|
|
641
|
+
if (!state) {
|
|
642
|
+
orphan += 1
|
|
643
|
+
diagnostics.orphanRoomDirs += 1
|
|
644
|
+
warn(`[cs-bots/room] 目录 ${roomDir(id)} 存在但没有任何可回放的房间事件 —— 已跳过,请核对`)
|
|
645
|
+
continue
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// 字段归一(老数据/手改过的快照也要能安全消费)
|
|
649
|
+
if (!Array.isArray(state.members)) state.members = []
|
|
650
|
+
state.members = [...new Set(state.members.filter((m) => typeof m === 'string' && m !== ''))]
|
|
651
|
+
if (!MODES.includes(state.mode)) state.mode = DEFAULT_MODE
|
|
652
|
+
if (!Number.isFinite(state.relayLimit)) state.relayLimit = relayLimitForMembers(state.members)
|
|
653
|
+
if (!Number.isFinite(state.rev)) state.rev = 1
|
|
654
|
+
rooms.set(id, state)
|
|
655
|
+
if (state.name) nameIndex.set(nameKey(state.name), id)
|
|
656
|
+
|
|
657
|
+
// ── 消息(真相源 = log.jsonl)────────────────────────────────────────
|
|
658
|
+
const log = readJsonl(logPath(id))
|
|
659
|
+
diagnostics.badLines += log.bad
|
|
660
|
+
if (log.bad > 0) {
|
|
661
|
+
warn(`[cs-bots/room] ${logPath(id)} 有 ${log.bad} 行无法解析 —— 已跳过,请核对`)
|
|
662
|
+
}
|
|
663
|
+
const msgs = []
|
|
664
|
+
for (const row of log.rows) {
|
|
665
|
+
if (!row || typeof row.id !== 'string') continue
|
|
666
|
+
const m = clone(row)
|
|
667
|
+
if (typeof m.roomId !== 'string') m.roomId = id
|
|
668
|
+
if (!Number.isFinite(m.seq)) m.seq = 0
|
|
669
|
+
if (!KINDS.includes(m.kind)) m.kind = DEFAULT_KIND
|
|
670
|
+
if (typeof m.text !== 'string') m.text = ''
|
|
671
|
+
msgs.push(m)
|
|
672
|
+
}
|
|
673
|
+
msgs.sort((a, b) => a.seq - b.seq)
|
|
674
|
+
|
|
675
|
+
// seq 空洞检测(**绝不静默**:断点续传的调用方依赖这个连续性)
|
|
676
|
+
for (let i = 0; i < msgs.length; i += 1) {
|
|
677
|
+
if (msgs[i].seq !== i + 1) {
|
|
678
|
+
diagnostics.seqGaps += 1
|
|
679
|
+
warn(
|
|
680
|
+
`[cs-bots/room] 房间 ${id} 的消息 seq 不连续:第 ${i + 1} 条位置的 seq=${msgs[i].seq}`
|
|
681
|
+
+ '(应为 1..N 连续)——已保留原值,未静默重排',
|
|
682
|
+
)
|
|
683
|
+
break
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// `room.seq` 的口径 = 日志里的最大 seq(Truth = 日志;索引快照里的 seq 允许滞后)
|
|
688
|
+
const fileMaxSeq = nextSeq(logPath(id)) - 1
|
|
689
|
+
const lastSeq = msgs.length ? msgs[msgs.length - 1].seq : 0
|
|
690
|
+
if (fileMaxSeq !== lastSeq) {
|
|
691
|
+
warn(
|
|
692
|
+
`[cs-bots/room] 房间 ${id} 日志最大 seq=${fileMaxSeq} 与可解析消息最大 seq=${lastSeq} 不一致`
|
|
693
|
+
+ '(存在无法解析/残缺的行)——seq 取下限以确保不回退、不复用',
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
state.seq = Math.max(fileMaxSeq, lastSeq, Number.isFinite(state.seq) ? state.seq : 0)
|
|
697
|
+
|
|
698
|
+
messagesByRoom.set(id, msgs)
|
|
699
|
+
diagnostics.replayedRooms += 1
|
|
700
|
+
diagnostics.replayedMessages += msgs.length
|
|
701
|
+
if (rows.length > 0) fromEvents += 1
|
|
702
|
+
|
|
703
|
+
for (const m of msgs) {
|
|
704
|
+
if (m.dedupKey) {
|
|
705
|
+
const key = dedupKeyOf(id, m.dedupKey)
|
|
706
|
+
dedupIndex.set(key, m.id)
|
|
707
|
+
idem.check(key)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
diagnostics.hydratedFrom = fromEvents > 0 ? 'events' : (fromSnapshot > 0 ? 'snapshot' : 'empty')
|
|
713
|
+
if (orphan > 0) {
|
|
714
|
+
warn(`[cs-bots/room] 回放时有 ${orphan} 个房间目录无可用事件(见上文逐条 warn),未被静默忽略`)
|
|
715
|
+
}
|
|
716
|
+
info(
|
|
717
|
+
`[cs-bots/room] 回放完成:${diagnostics.replayedRooms} 个房间 / `
|
|
718
|
+
+ `${diagnostics.replayedMessages} 条消息 / from=${diagnostics.hydratedFrom}`,
|
|
719
|
+
)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ── API:房间生命周期 ──────────────────────────────────────────────────────
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* 建房间。名字**唯一**(trim + NFKC + 小写 归一,大小写不敏感)——重名抛错,不静默覆盖。
|
|
726
|
+
*
|
|
727
|
+
* @param {{ name: string, members?: string[], mode?: 'admin'|'everyone', adminId?: string,
|
|
728
|
+
* notice?: string, cwd?: string, byId: string, now?: number }} input
|
|
729
|
+
* @returns {object} Room
|
|
730
|
+
* @throws {RoomError} 缺字段 / 重名 / notice 超长 / 非法 mode
|
|
731
|
+
*/
|
|
732
|
+
function createRoom_(input = {}) {
|
|
733
|
+
const { name, members, mode, adminId, notice, cwd, byId, now } = input ?? {}
|
|
734
|
+
const displayName = requireString(name, 'name', 'createRoom')
|
|
735
|
+
const creator = requireString(byId, 'byId', 'createRoom')
|
|
736
|
+
const key = nameKey(displayName)
|
|
737
|
+
if (nameIndex.has(key)) {
|
|
738
|
+
const existingId = nameIndex.get(key)
|
|
739
|
+
throw new RoomError(
|
|
740
|
+
`room.createRoom:房间名 ${JSON.stringify(displayName)} 已被占用(大小写/空白归一后冲突)`
|
|
741
|
+
+ ` → 现存房间 ${existingId}。房间名必须唯一,拒绝静默重名`,
|
|
742
|
+
'ROOM_NAME_TAKEN',
|
|
743
|
+
{ name: displayName, existingId },
|
|
744
|
+
)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// members **显式传入就原样采信**;缺省才用 [byId](见文件头「成员语义」)
|
|
748
|
+
const memberList = members === undefined || members === null
|
|
749
|
+
? [creator]
|
|
750
|
+
: stringList(members, 'members', 'createRoom')
|
|
751
|
+
const m = normalizeMode(mode, 'createRoom')
|
|
752
|
+
const advisory = optionalString(adminId, 'adminId', 'createRoom')
|
|
753
|
+
const noticeValue = normalizeNotice(notice, 'createRoom')
|
|
754
|
+
const cwdValue = optionalString(cwd, 'cwd', 'createRoom')
|
|
755
|
+
const at = normalizeNow(now, 'createRoom')
|
|
756
|
+
|
|
757
|
+
const roomId = `room-${randomUUID().slice(0, 8)}`
|
|
758
|
+
if (rooms.has(roomId)) {
|
|
759
|
+
throw new RoomError(
|
|
760
|
+
`room.createRoom:房间 id 冲突 ${roomId}(同一毫秒内重复生成的极小概率)——请重试`,
|
|
761
|
+
'ROOM_ID_COLLISION',
|
|
762
|
+
{ roomId },
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
if (!ensureDir(statePath(roomDir(roomId), 'bodies'))) {
|
|
766
|
+
throw new RoomError(
|
|
767
|
+
`room.createRoom:房间目录创建失败(${roomDir(roomId)})——状态目录不可写时拒绝建一个「写不进去」的房间`,
|
|
768
|
+
'ROOM_DIR_CREATE_FAILED',
|
|
769
|
+
{ roomId, roomsDir },
|
|
770
|
+
)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** @type {Record<string, unknown>} */
|
|
774
|
+
const room = {
|
|
775
|
+
id: roomId,
|
|
776
|
+
name: displayName,
|
|
777
|
+
mode: m,
|
|
778
|
+
members: memberList,
|
|
779
|
+
relayLimit: relayLimitForMembers(memberList),
|
|
780
|
+
seq: 0,
|
|
781
|
+
rev: 1,
|
|
782
|
+
createdAt: at,
|
|
783
|
+
createdBy: creator,
|
|
784
|
+
}
|
|
785
|
+
if (m === 'admin') room.adminId = advisory ?? creator
|
|
786
|
+
else if (advisory) {
|
|
787
|
+
throw new RoomError(
|
|
788
|
+
`room.createRoom:mode=${JSON.stringify(m)} 的房间不接受 adminId=${JSON.stringify(advisory)}`
|
|
789
|
+
+ '(adminId 只在 mode:\'admin\' 下有语义)——拒绝写入会被忽略的配置',
|
|
790
|
+
'ROOM_ADMIN_ID_NOT_APPLICABLE',
|
|
791
|
+
{ mode: m, adminId: advisory },
|
|
792
|
+
)
|
|
793
|
+
}
|
|
794
|
+
if (noticeValue !== undefined) room.notice = noticeValue
|
|
795
|
+
if (cwdValue !== undefined) room.cwd = cwdValue
|
|
796
|
+
|
|
797
|
+
return commit(roomId, 'room_created', creator, at, null, room)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** @param {string} roomId @returns {object | undefined} Room(不存在返回 undefined,与 board.get 同口径) */
|
|
801
|
+
function get(roomId) {
|
|
802
|
+
if (typeof roomId !== 'string' || roomId === '') return undefined
|
|
803
|
+
const room = rooms.get(roomId)
|
|
804
|
+
return room ? clone(room) : undefined
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* 列出全部房间(**创建序**)。
|
|
809
|
+
* @returns {object[]}
|
|
810
|
+
*/
|
|
811
|
+
function list() {
|
|
812
|
+
return [...rooms.values()].map(clone)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* 加入房间。重复加入**抛错**(不静默成功 —— 否则「谁真的加了」不可审计)。
|
|
817
|
+
*
|
|
818
|
+
* @param {{ roomId: string, sessionId: string, byId?: string, expectedRev?: number, now?: number }} input
|
|
819
|
+
* @returns {object} 变更后的 Room
|
|
820
|
+
* @throws {RoomError} 房间不存在 / 已是成员 / CAS 冲突
|
|
821
|
+
*/
|
|
822
|
+
/**
|
|
823
|
+
* 改房间设置(改名 / 公告 / 模式 / 管理员)。
|
|
824
|
+
*
|
|
825
|
+
* 为什么要这个:原来只能建房间、不能改,建错名字只能废弃重来。
|
|
826
|
+
* 权限:`admin` 模式下仅管理员可改;`everyone` 模式下任一成员可改。
|
|
827
|
+
*
|
|
828
|
+
* @param {{ roomId: string, name?: string, notice?: string, mode?: string, adminId?: string,
|
|
829
|
+
* expectedRev?: number, byId: string, now?: number }} input
|
|
830
|
+
* @returns {object} 变更后的 Room
|
|
831
|
+
* @throws {RoomError} 房间不存在 / 非成员 / 无权限 / 重名 / CAS 冲突 / 字段非法
|
|
832
|
+
*/
|
|
833
|
+
function update(input = {}) {
|
|
834
|
+
const { roomId, name, notice, mode, adminId, expectedRev, byId, now } = input ?? {}
|
|
835
|
+
const room = requireRoom(roomId, 'update')
|
|
836
|
+
const actor = requireString(byId, 'byId', 'update')
|
|
837
|
+
assertRev(room, expectedRev, 'update')
|
|
838
|
+
if (room.mode === 'admin' && room.adminId && actor !== room.adminId && actor !== USER) {
|
|
839
|
+
throw new RoomError(
|
|
840
|
+
`room.update:房间 ${room.id} 是 admin 模式,只有管理员(${room.adminId})能改设置,调用者为 ${actor}`,
|
|
841
|
+
'ROOM_FORBIDDEN',
|
|
842
|
+
{ roomId: room.id, adminId: room.adminId, actor },
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const at = normalizeNow(now, 'update')
|
|
847
|
+
const before = clone(room)
|
|
848
|
+
const next = clone(room)
|
|
849
|
+
const changed = []
|
|
850
|
+
|
|
851
|
+
if (name !== undefined && name !== null) {
|
|
852
|
+
const displayName = requireString(name, 'name', 'update')
|
|
853
|
+
const key = nameKey(displayName)
|
|
854
|
+
if (key !== nameKey(room.name)) {
|
|
855
|
+
if (nameIndex.has(key)) {
|
|
856
|
+
const existingId = nameIndex.get(key)
|
|
857
|
+
throw new RoomError(
|
|
858
|
+
`room.update:房间名 ${JSON.stringify(displayName)} 已被占用(归一后冲突)→ 现存房间 ${existingId}`,
|
|
859
|
+
'ROOM_NAME_TAKEN',
|
|
860
|
+
{ name: displayName, existingId },
|
|
861
|
+
)
|
|
862
|
+
}
|
|
863
|
+
// 释放旧名索引
|
|
864
|
+
if (nameIndex.get(nameKey(room.name)) === room.id) nameIndex.delete(nameKey(room.name))
|
|
865
|
+
next.name = displayName
|
|
866
|
+
changed.push('name')
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (notice !== undefined) {
|
|
871
|
+
next.notice = normalizeNotice(notice)
|
|
872
|
+
changed.push('notice')
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (mode !== undefined && mode !== null) {
|
|
876
|
+
const m = normalizeMode(mode, 'update')
|
|
877
|
+
if (m !== room.mode) {
|
|
878
|
+
next.mode = m
|
|
879
|
+
// admin 模式但没有管理员 → 由操作者接管(不留「有模式无管理员」的死局)
|
|
880
|
+
if (m === 'admin' && !next.adminId) next.adminId = actor
|
|
881
|
+
if (m === 'everyone') delete next.adminId
|
|
882
|
+
changed.push('mode')
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (adminId !== undefined) {
|
|
887
|
+
const target = optionalString(adminId, 'adminId', 'update')
|
|
888
|
+
if (target === undefined || target === '') {
|
|
889
|
+
delete next.adminId
|
|
890
|
+
changed.push('adminId')
|
|
891
|
+
} else {
|
|
892
|
+
if (!next.members.includes(target)) {
|
|
893
|
+
throw new RoomError(
|
|
894
|
+
`room.update:管理员必须是房间成员,${target} 不在 ${room.id} 的成员表里`,
|
|
895
|
+
'ROOM_NOT_MEMBER',
|
|
896
|
+
{ roomId: room.id, sessionId: target },
|
|
897
|
+
)
|
|
898
|
+
}
|
|
899
|
+
next.adminId = target
|
|
900
|
+
changed.push('adminId')
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (changed.length === 0) {
|
|
905
|
+
// 没有任何变化 → 不写事件(避免污染审计流)
|
|
906
|
+
return clone(room)
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
next.rev = room.rev + 1
|
|
910
|
+
const type = changed.includes('name')
|
|
911
|
+
? 'room_renamed'
|
|
912
|
+
: changed.includes('notice')
|
|
913
|
+
? 'notice_changed'
|
|
914
|
+
: changed.includes('mode')
|
|
915
|
+
? 'mode_changed'
|
|
916
|
+
: 'admin_transferred'
|
|
917
|
+
const committed = commit(room.id, type, actor, at, before, next)
|
|
918
|
+
return { ...committed, changed }
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function join(input = {}) {
|
|
922
|
+
const { roomId, sessionId, byId, expectedRev, now } = input ?? {}
|
|
923
|
+
const room = requireRoom(roomId, 'join')
|
|
924
|
+
const sid = requireString(sessionId, 'sessionId', 'join')
|
|
925
|
+
const actor = optionalString(byId, 'byId', 'join') ?? sid
|
|
926
|
+
assertRev(room, expectedRev, 'join')
|
|
927
|
+
if (room.members.includes(sid)) {
|
|
928
|
+
throw new RoomError(
|
|
929
|
+
`room.join:${sid} 已经是房间 ${room.id} 的成员(重复加入被拒 —— 拒绝静默成功导致成员表不可审计)`,
|
|
930
|
+
'ROOM_ALREADY_MEMBER',
|
|
931
|
+
{ roomId: room.id, sessionId: sid },
|
|
932
|
+
)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const at = normalizeNow(now, 'join')
|
|
936
|
+
const before = clone(room)
|
|
937
|
+
const next = clone(room)
|
|
938
|
+
next.members = [...room.members, sid]
|
|
939
|
+
next.relayLimit = relayLimitForMembers(next.members)
|
|
940
|
+
next.rev = room.rev + 1
|
|
941
|
+
// admin 模式的空位补上(管理员退房后房间不该被锁死,见文件头)
|
|
942
|
+
if (next.mode === 'admin' && !next.adminId) next.adminId = sid
|
|
943
|
+
return commit(room.id, 'members_joined', actor, at, before, next)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* 离开房间。非成员**抛错**(不静默无操作)。
|
|
948
|
+
*
|
|
949
|
+
* 管理员退房 → 管理权移交给**最早加入的剩余成员**(写 `admin_transferred` 事件);
|
|
950
|
+
* 房间空则清空 `adminId`。⇒ 不留「管理员已走、房间锁死」的隐形死局。
|
|
951
|
+
*
|
|
952
|
+
* @param {{ roomId: string, sessionId: string, byId?: string, expectedRev?: number, now?: number }} input
|
|
953
|
+
* @returns {object} 变更后的 Room
|
|
954
|
+
* @throws {RoomError} 房间不存在 / 非成员 / CAS 冲突
|
|
955
|
+
*/
|
|
956
|
+
function leave(input = {}) {
|
|
957
|
+
const { roomId, sessionId, byId, expectedRev, now } = input ?? {}
|
|
958
|
+
const room = requireRoom(roomId, 'leave')
|
|
959
|
+
const sid = requireString(sessionId, 'sessionId', 'leave')
|
|
960
|
+
const actor = optionalString(byId, 'byId', 'leave') ?? sid
|
|
961
|
+
assertRev(room, expectedRev, 'leave')
|
|
962
|
+
if (!room.members.includes(sid)) {
|
|
963
|
+
throw new RoomError(
|
|
964
|
+
`room.leave:${sid} 不是房间 ${room.id} 的成员(拒绝静默无操作)`,
|
|
965
|
+
'ROOM_NOT_MEMBER',
|
|
966
|
+
{ roomId: room.id, sessionId: sid },
|
|
967
|
+
)
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const at = normalizeNow(now, 'leave')
|
|
971
|
+
const before = clone(room)
|
|
972
|
+
const next = clone(room)
|
|
973
|
+
next.members = room.members.filter((m) => m !== sid)
|
|
974
|
+
next.relayLimit = relayLimitForMembers(next.members)
|
|
975
|
+
next.rev = room.rev + 1
|
|
976
|
+
|
|
977
|
+
let type = 'members_left'
|
|
978
|
+
if (room.adminId === sid) {
|
|
979
|
+
if (next.members.length > 0) {
|
|
980
|
+
next.adminId = next.members[0]
|
|
981
|
+
type = 'admin_transferred'
|
|
982
|
+
info(`[cs-bots/room] 房间 ${room.id} 管理权移交:${sid} → ${next.adminId}(最早加入的剩余成员)`)
|
|
983
|
+
} else {
|
|
984
|
+
delete next.adminId
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return commit(room.id, type, actor, at, before, next)
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ── API:消息 ──────────────────────────────────────────────────────────────
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* 发言权校验。
|
|
994
|
+
*
|
|
995
|
+
* 规则(见文件头「`mode` 的语义」):`'user'` 永远可发言;其余必须是成员;
|
|
996
|
+
* `mode:'admin'` 时只有 `adminId` 能发言。**任何一条不满足都抛错,不静默接受。**
|
|
997
|
+
*/
|
|
998
|
+
function assertCanPost(room, sender) {
|
|
999
|
+
if (sender === USER) return
|
|
1000
|
+
if (!room.members.includes(sender)) {
|
|
1001
|
+
throw new RoomError(
|
|
1002
|
+
`room.post:${sender} 不是房间 ${room.id} 的成员(房间共 ${room.members.length} 名成员)`
|
|
1003
|
+
+ ' —— 非成员发言被拒,绝不静默接受',
|
|
1004
|
+
'ROOM_NOT_MEMBER',
|
|
1005
|
+
{ roomId: room.id, sessionId: sender, members: room.members },
|
|
1006
|
+
)
|
|
1007
|
+
}
|
|
1008
|
+
if (room.mode === 'admin' && room.adminId && sender !== room.adminId) {
|
|
1009
|
+
throw new RoomError(
|
|
1010
|
+
`room.post:房间 ${room.id} 是 admin 模式(adminId=${room.adminId}),成员 ${sender} 无发言权`
|
|
1011
|
+
+ '(成员仍可 join/leave/收转发)',
|
|
1012
|
+
'ROOM_POST_FORBIDDEN',
|
|
1013
|
+
{ roomId: room.id, sessionId: sender, adminId: room.adminId, mode: room.mode },
|
|
1014
|
+
)
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* 发言(**确定性写通路**:域服务直调 → append 落盘,不经 HTTP、不经模型)。
|
|
1020
|
+
*
|
|
1021
|
+
* - **seq 单调且连续无洞**:由上一条消息的 seq + 1 得到(见不变量 3)。
|
|
1022
|
+
* - **长正文旁路**(K2):`text.length > BODY_THRESHOLD` 时正文写
|
|
1023
|
+
* `bodies/<msgId>.txt`,日志只留头 + 指纹 + `bodyRef`。
|
|
1024
|
+
* - **幂等**:`dedupKey` 命中即返回既有消息,**不产生第二条**、不消耗新 seq。
|
|
1025
|
+
*
|
|
1026
|
+
* @param {{ roomId: string, from: string, text: string, kind?: string, to?: string,
|
|
1027
|
+
* dedupKey?: string, replyTo?: string, meta?: object, now?: number }} input
|
|
1028
|
+
* @returns {object} RoomMessage
|
|
1029
|
+
* @throws {RoomError} 房间不存在 / 非成员 / 缺字段 / 非法 kind / replyTo 悬空 / 落盘失败
|
|
1030
|
+
*/
|
|
1031
|
+
function post(input = {}) {
|
|
1032
|
+
const { roomId, from, text, kind, to, dedupKey, replyTo, meta, now } = input ?? {}
|
|
1033
|
+
const room = requireRoom(roomId, 'post')
|
|
1034
|
+
const sender = requireString(from, 'from', 'post')
|
|
1035
|
+
const body = requireString(text, 'text', 'post')
|
|
1036
|
+
const k = normalizeKind(kind, 'post')
|
|
1037
|
+
const toId = optionalString(to, 'to', 'post')
|
|
1038
|
+
const dk = optionalString(dedupKey, 'dedupKey', 'post')
|
|
1039
|
+
const reply = optionalString(replyTo, 'replyTo', 'post')
|
|
1040
|
+
const metaObj = normalizeMeta(meta, 'post')
|
|
1041
|
+
|
|
1042
|
+
assertCanPost(room, sender)
|
|
1043
|
+
|
|
1044
|
+
if (reply !== undefined) {
|
|
1045
|
+
const exist = (messagesByRoom.get(room.id) ?? []).find((mm) => mm.id === reply)
|
|
1046
|
+
if (!exist) {
|
|
1047
|
+
throw new RoomError(
|
|
1048
|
+
`room.post:replyTo=${JSON.stringify(reply)} 在本房间不存在 —— 拒绝写入永远悬空的引用`
|
|
1049
|
+
+ '(与 board.js 对 blockedBy 的处理同口径)',
|
|
1050
|
+
'ROOM_REPLY_TARGET_NOT_FOUND',
|
|
1051
|
+
{ roomId: room.id, replyTo: reply },
|
|
1052
|
+
)
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// ── 幂等(**全部校验通过后**才判定,避免校验失败污染幂等键)──
|
|
1057
|
+
if (dk !== undefined) {
|
|
1058
|
+
const key = dedupKeyOf(room.id, dk)
|
|
1059
|
+
const existingId = dedupIndex.get(key)
|
|
1060
|
+
if (existingId) {
|
|
1061
|
+
const hit = (messagesByRoom.get(room.id) ?? []).find((mm) => mm.id === existingId)
|
|
1062
|
+
if (hit) {
|
|
1063
|
+
idem.check(key)
|
|
1064
|
+
info(`[cs-bots/room] post 幂等命中 dedupKey=${dk} → 复用 ${existingId}`)
|
|
1065
|
+
return clone(hit)
|
|
1066
|
+
}
|
|
1067
|
+
// 索引指向的消息不在内存里 → 内部状态不一致,**绝不猜测**,直接暴露
|
|
1068
|
+
throw new RoomError(
|
|
1069
|
+
`room.post:dedupKey=${dk} 已登记但找不到对应消息 ${existingId}(幂等索引不一致)——拒绝静默新建`,
|
|
1070
|
+
'ROOM_IDEMPOTENCY_INDEX_MISSING',
|
|
1071
|
+
{ roomId: room.id, dedupKey: dk, messageId: existingId },
|
|
1072
|
+
)
|
|
1073
|
+
}
|
|
1074
|
+
if (idem.has(key)) {
|
|
1075
|
+
throw new RoomError(
|
|
1076
|
+
`room.post:dedupKey=${dk} 已在去重器登记但索引缺失(幂等索引不一致)——拒绝静默新建`,
|
|
1077
|
+
'ROOM_IDEMPOTENCY_INDEX_MISSING',
|
|
1078
|
+
{ roomId: room.id, dedupKey: dk },
|
|
1079
|
+
)
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const at = normalizeNow(now, 'post')
|
|
1084
|
+
|
|
1085
|
+
// seq:以**消息日志的末条**为准(内存与磁盘分叉时以日志为准,且可见)
|
|
1086
|
+
const msgs = messagesByRoom.get(room.id) ?? []
|
|
1087
|
+
const lastSeq = msgs.length ? msgs[msgs.length - 1].seq : 0
|
|
1088
|
+
if (Number.isFinite(room.seq) && room.seq !== lastSeq) {
|
|
1089
|
+
warn(
|
|
1090
|
+
`[cs-bots/room] 房间 ${room.id} 内存 seq=${room.seq} 与末条消息 seq=${lastSeq} 不一致`
|
|
1091
|
+
+ '——以消息日志为准(seq 不复用)',
|
|
1092
|
+
)
|
|
1093
|
+
room.seq = lastSeq
|
|
1094
|
+
}
|
|
1095
|
+
const seq = Math.max(lastSeq, Number.isFinite(room.seq) ? room.seq : 0) + 1
|
|
1096
|
+
const messageId = `m-${seq}`
|
|
1097
|
+
|
|
1098
|
+
/** @type {Record<string, unknown>} */
|
|
1099
|
+
const message = {
|
|
1100
|
+
id: messageId,
|
|
1101
|
+
roomId: room.id,
|
|
1102
|
+
seq,
|
|
1103
|
+
from: sender,
|
|
1104
|
+
kind: k,
|
|
1105
|
+
text: body,
|
|
1106
|
+
at,
|
|
1107
|
+
}
|
|
1108
|
+
if (toId !== undefined) message.to = toId
|
|
1109
|
+
if (reply !== undefined) message.replyTo = reply
|
|
1110
|
+
if (metaObj !== undefined) message.meta = metaObj
|
|
1111
|
+
if (dk !== undefined) message.dedupKey = dk
|
|
1112
|
+
|
|
1113
|
+
// ── 长正文旁路(K2):**先写正文,再写日志头** ──────────────────────────
|
|
1114
|
+
if (body.length > BODY_THRESHOLD) {
|
|
1115
|
+
const ref = `bodies/${messageId}.txt`
|
|
1116
|
+
const abs = statePath(roomDir(room.id), ref)
|
|
1117
|
+
if (!writeTextAtomic(abs, body)) {
|
|
1118
|
+
throw new RoomError(
|
|
1119
|
+
`room.post:长正文(${body.length} 字符)落旁路文件失败(${abs})`
|
|
1120
|
+
+ ' —— 拒绝「正文丢了却记一条日志」',
|
|
1121
|
+
'ROOM_BODY_WRITE_FAILED',
|
|
1122
|
+
{ roomId: room.id, messageId, bytes: body.length, ref },
|
|
1123
|
+
)
|
|
1124
|
+
}
|
|
1125
|
+
message.bodyRef = ref
|
|
1126
|
+
message.fingerprint = fingerprint(body)
|
|
1127
|
+
// 日志里的 `text` 只留**头**(§3.2:log 只存头 + 指纹 + bodyRef)
|
|
1128
|
+
message.text = body.slice(0, BODY_HEAD_CHARS)
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ── 落盘(**先落盘,再改内存**:不变量 1)────────────────────────────────
|
|
1132
|
+
if (!appendJsonl(logPath(room.id), message)) {
|
|
1133
|
+
throw new RoomError(
|
|
1134
|
+
`room.post:消息日志追加失败(${logPath(room.id)})——拒绝「只改内存不落盘」`,
|
|
1135
|
+
'ROOM_APPEND_FAILED',
|
|
1136
|
+
{ roomId: room.id, messageId, seq },
|
|
1137
|
+
)
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
msgs.push(clone(message))
|
|
1141
|
+
messagesByRoom.set(room.id, msgs)
|
|
1142
|
+
room.seq = seq
|
|
1143
|
+
if (dk !== undefined) {
|
|
1144
|
+
const key = dedupKeyOf(room.id, dk)
|
|
1145
|
+
dedupIndex.set(key, messageId)
|
|
1146
|
+
idem.check(key)
|
|
1147
|
+
}
|
|
1148
|
+
// 消息不触发索引快照:`log.jsonl` 已是消息的真相源,`room.seq` 由日志在回放时重算
|
|
1149
|
+
return clone(message)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* 列出消息(**按 seq 升序**)。
|
|
1154
|
+
*
|
|
1155
|
+
* @param {{ roomId: string, limit?: number, sinceSeq?: number }} input
|
|
1156
|
+
* - `sinceSeq`:只要 `seq > sinceSeq` 的(断点续传用)
|
|
1157
|
+
* - `limit`:取**最新** N 条(不是最早 N 条);缺省不限(**永不截断**)
|
|
1158
|
+
* @returns {object[]}
|
|
1159
|
+
* @throws {RoomError} 房间不存在 / 参数非法
|
|
1160
|
+
*/
|
|
1161
|
+
function listMessages(input = {}) {
|
|
1162
|
+
const { roomId, limit, sinceSeq } = input ?? {}
|
|
1163
|
+
const room = requireRoom(roomId, 'listMessages')
|
|
1164
|
+
const since = optionalInteger(sinceSeq, 'sinceSeq', 'listMessages')
|
|
1165
|
+
const cap = optionalInteger(limit, 'limit', 'listMessages')
|
|
1166
|
+
|
|
1167
|
+
let rows = messagesByRoom.get(room.id) ?? []
|
|
1168
|
+
if (since !== undefined) rows = rows.filter((m) => m.seq > since)
|
|
1169
|
+
if (cap !== undefined && cap > 0 && rows.length > cap) rows = rows.slice(-cap)
|
|
1170
|
+
return rows.map(clone)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* 读长正文**全文**(§3.2)。
|
|
1175
|
+
*
|
|
1176
|
+
* 无 `bodyRef` 的消息直接返回其 `text`;有 `bodyRef` 的从 `bodies/<msgId>.txt` 读,
|
|
1177
|
+
* 并**按指纹校验**完整性(读了残缺内容却不报错 = 静默数据损坏,不允许)。
|
|
1178
|
+
*
|
|
1179
|
+
* @param {{ roomId: string, messageId: string }} input
|
|
1180
|
+
* @returns {string} 全文
|
|
1181
|
+
* @throws {RoomError} 房间/消息不存在 / bodyRef 非法 / 正文缺失或损坏
|
|
1182
|
+
*/
|
|
1183
|
+
function readBody(input = {}) {
|
|
1184
|
+
const { roomId, messageId } = input ?? {}
|
|
1185
|
+
const room = requireRoom(roomId, 'readBody')
|
|
1186
|
+
const mid = requireString(messageId, 'messageId', 'readBody')
|
|
1187
|
+
const message = requireMessage(room.id, mid, 'readBody')
|
|
1188
|
+
|
|
1189
|
+
if (!message.bodyRef) return message.text
|
|
1190
|
+
if (!BODY_REF_RE.test(message.bodyRef)) {
|
|
1191
|
+
throw new RoomError(
|
|
1192
|
+
`room.readBody:消息 ${mid} 的 bodyRef 形态非法(${JSON.stringify(message.bodyRef)})`
|
|
1193
|
+
+ ' —— 拒绝按可疑路径读文件',
|
|
1194
|
+
'ROOM_BODY_REF_INVALID',
|
|
1195
|
+
{ roomId: room.id, messageId: mid, bodyRef: message.bodyRef },
|
|
1196
|
+
)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const abs = statePath(roomDir(room.id), message.bodyRef)
|
|
1200
|
+
if (!existsSync(abs)) {
|
|
1201
|
+
throw new RoomError(
|
|
1202
|
+
`room.readBody:正文文件缺失(${abs})——拒绝返回残缺内容,请核对旁路文件是否被清理`,
|
|
1203
|
+
'ROOM_BODY_MISSING',
|
|
1204
|
+
{ roomId: room.id, messageId: mid, bodyRef: message.bodyRef },
|
|
1205
|
+
)
|
|
1206
|
+
}
|
|
1207
|
+
let text
|
|
1208
|
+
try {
|
|
1209
|
+
text = readFileSync(abs, 'utf8')
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
throw new RoomError(
|
|
1212
|
+
`room.readBody:正文文件读取失败(${abs}):${error?.message ?? error}`,
|
|
1213
|
+
'ROOM_BODY_MISSING',
|
|
1214
|
+
{ roomId: room.id, messageId: mid, bodyRef: message.bodyRef },
|
|
1215
|
+
)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const fp = message.fingerprint
|
|
1219
|
+
if (fp && Number.isFinite(fp.length) && typeof fp.sha256 === 'string') {
|
|
1220
|
+
const actual = fingerprint(text)
|
|
1221
|
+
if (actual.length !== fp.length || actual.sha256 !== fp.sha256) {
|
|
1222
|
+
throw new RoomError(
|
|
1223
|
+
`room.readBody:消息 ${mid} 的正文与指纹不符(期望 length=${fp.length}/sha256=${fp.sha256},`
|
|
1224
|
+
+ `实读 length=${actual.length}/sha256=${actual.sha256})——正文已损坏,拒绝返回可疑内容`,
|
|
1225
|
+
'ROOM_BODY_CORRUPT',
|
|
1226
|
+
{ roomId: room.id, messageId: mid, expected: fp, actual },
|
|
1227
|
+
)
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return text
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// ── API:转发(投递)────────────────────────────────────────────────────────
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* 把一条消息**投递**给指定成员(用 `kernel.followup`,K1:不用 `steer`)。
|
|
1237
|
+
*
|
|
1238
|
+
* 投递的是**正文全文**(长正文走 `readBody()`),不是日志里的「头」——
|
|
1239
|
+
* 这正是 K2 的价值:接收方拿到的是完整内容。
|
|
1240
|
+
*
|
|
1241
|
+
* 背压(K4):一条消息累计被投递的次数不得超过 `room.relayLimit`(默认 3×成员数),
|
|
1242
|
+
* 超限**抛错**。**先落「转发意图」事件再投递**——「记录了但没投出去」可以重试,
|
|
1243
|
+
* 「投出去了但没记录」会让限额在重启后被绕过。
|
|
1244
|
+
*
|
|
1245
|
+
* @param {{ roomId: string, messageId: string, targets?: string[], byId?: string,
|
|
1246
|
+
* now?: number }} input
|
|
1247
|
+
* `targets` 缺省 = 房间全部成员(**不含发送者自己**)
|
|
1248
|
+
* @returns {{ ok: boolean, roomId: string, messageId: string, relayed: number,
|
|
1249
|
+
* attempted: number, limit: number, remaining: number,
|
|
1250
|
+
* reason?: string, deliveries: object[], audit: {written: boolean, reason?: string} }}
|
|
1251
|
+
* `ok:false` + `reason` 表示**有投递失败**(逐条原因在 `deliveries[]` 里)——
|
|
1252
|
+
* **绝不静默**。注意:投递失败**不抛错**(那是运行期事实,不是调用方契约违反)。
|
|
1253
|
+
* @throws {RoomError} 房间/消息不存在 / targets 为空 / 目标不是成员 / **超过 relayLimit**
|
|
1254
|
+
*/
|
|
1255
|
+
function relay(input = {}) {
|
|
1256
|
+
const { roomId, messageId, targets, byId, now } = input ?? {}
|
|
1257
|
+
const room = requireRoom(roomId, 'relay')
|
|
1258
|
+
const mid = requireString(messageId, 'messageId', 'relay')
|
|
1259
|
+
const message = requireMessage(room.id, mid, 'relay')
|
|
1260
|
+
const actor = optionalString(byId, 'byId', 'relay')
|
|
1261
|
+
|
|
1262
|
+
let targetList
|
|
1263
|
+
if (targets === undefined || targets === null) {
|
|
1264
|
+
targetList = room.members.filter((m) => m !== message.from)
|
|
1265
|
+
if (targetList.length === 0) {
|
|
1266
|
+
throw new RoomError(
|
|
1267
|
+
`room.relay:房间 ${room.id} 除发送者(${message.from})外没有其他成员,且未显式指定 targets`
|
|
1268
|
+
+ ' —— 拒绝空转发(静默无操作)',
|
|
1269
|
+
'ROOM_NO_TARGETS',
|
|
1270
|
+
{ roomId: room.id, messageId: mid, from: message.from },
|
|
1271
|
+
)
|
|
1272
|
+
}
|
|
1273
|
+
} else {
|
|
1274
|
+
targetList = stringList(targets, 'targets', 'relay')
|
|
1275
|
+
if (targetList.length === 0) {
|
|
1276
|
+
throw new RoomError(
|
|
1277
|
+
`room.relay:targets 是空数组 —— 拒绝空转发(静默无操作),请显式给出目标或省略该字段走缺省`,
|
|
1278
|
+
'ROOM_NO_TARGETS',
|
|
1279
|
+
{ roomId: room.id, messageId: mid },
|
|
1280
|
+
)
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
for (const t of targetList) {
|
|
1285
|
+
if (!room.members.includes(t)) {
|
|
1286
|
+
throw new RoomError(
|
|
1287
|
+
`room.relay:目标 ${t} 不是房间 ${room.id} 的成员 —— 拒绝向非成员投递`,
|
|
1288
|
+
'ROOM_NOT_MEMBER',
|
|
1289
|
+
{ roomId: room.id, messageId: mid, target: t, members: room.members },
|
|
1290
|
+
)
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// ── 背压:relayLimit(K4)──────────────────────────────────────────────
|
|
1295
|
+
const rkey = relayKey(room.id, mid)
|
|
1296
|
+
const attemptedBefore = relayCounts.get(rkey) ?? 0
|
|
1297
|
+
const limit = room.relayLimit
|
|
1298
|
+
if (attemptedBefore + targetList.length > limit) {
|
|
1299
|
+
throw new RoomError(
|
|
1300
|
+
`room.relay:消息 ${mid} 转发超限(已投 ${attemptedBefore} + 本次 ${targetList.length} > `
|
|
1301
|
+
+ `relayLimit=${limit}=${RELAY_MULTIPLIER}×${room.members.length} 成员)——`
|
|
1302
|
+
+ '这是防无限转发的硬闸,超出即拒绝',
|
|
1303
|
+
'ROOM_RELAY_LIMIT',
|
|
1304
|
+
{
|
|
1305
|
+
roomId: room.id,
|
|
1306
|
+
messageId: mid,
|
|
1307
|
+
attempted: attemptedBefore,
|
|
1308
|
+
adding: targetList.length,
|
|
1309
|
+
limit,
|
|
1310
|
+
},
|
|
1311
|
+
)
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const at = normalizeNow(now, 'relay')
|
|
1315
|
+
|
|
1316
|
+
// 先落意图(**投递前**):这样「投出去了但没记录」不可能发生
|
|
1317
|
+
appendRoomEvent(room.id, {
|
|
1318
|
+
type: 'relay',
|
|
1319
|
+
roomId: room.id,
|
|
1320
|
+
rev: room.rev,
|
|
1321
|
+
byId: actor ?? null,
|
|
1322
|
+
at,
|
|
1323
|
+
messageId: mid,
|
|
1324
|
+
targets: [...targetList],
|
|
1325
|
+
attempted: attemptedBefore + targetList.length,
|
|
1326
|
+
limit,
|
|
1327
|
+
})
|
|
1328
|
+
relayCounts.set(rkey, attemptedBefore + targetList.length)
|
|
1329
|
+
|
|
1330
|
+
// 投递:正文全文(K2)
|
|
1331
|
+
const fullText = readBody({ roomId: room.id, messageId: mid })
|
|
1332
|
+
const payload = relayText(room, message, fullText)
|
|
1333
|
+
const deliveries = []
|
|
1334
|
+
for (const target of targetList) {
|
|
1335
|
+
const attempt = kernel.followup(ctx, target, payload)
|
|
1336
|
+
if (attempt?.ok) deliveries.push({ target, ok: true })
|
|
1337
|
+
else deliveries.push({ target, ok: false, reason: attempt?.reason ?? 'relay-unknown-failure' })
|
|
1338
|
+
if (!attempt?.ok) {
|
|
1339
|
+
warn(`[cs-bots/room] 转发 ${mid} → ${target} 失败:${attempt?.reason ?? '未知原因'}(已记录,未静默)`)
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const relayed = deliveries.filter((d) => d.ok).length
|
|
1344
|
+
const failed = deliveries.filter((d) => !d.ok)
|
|
1345
|
+
|
|
1346
|
+
// 结果事件(投递已发生,写失败只降级 + 在返回值里可见,**不假装成功**)
|
|
1347
|
+
let audit = { written: true }
|
|
1348
|
+
try {
|
|
1349
|
+
appendRoomEvent(room.id, {
|
|
1350
|
+
type: 'relay_result',
|
|
1351
|
+
roomId: room.id,
|
|
1352
|
+
rev: room.rev,
|
|
1353
|
+
byId: actor ?? null,
|
|
1354
|
+
at,
|
|
1355
|
+
messageId: mid,
|
|
1356
|
+
deliveries: clone(deliveries),
|
|
1357
|
+
})
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
audit = { written: false, reason: `relay-result-append-failed:${error?.message ?? error}` }
|
|
1360
|
+
diagnostics.relayResultAppendFailures += 1
|
|
1361
|
+
warn(`[cs-bots/room] 转发结果事件写入失败(投递已发生):${audit.reason}`)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const result = {
|
|
1365
|
+
ok: failed.length === 0,
|
|
1366
|
+
roomId: room.id,
|
|
1367
|
+
messageId: mid,
|
|
1368
|
+
relayed,
|
|
1369
|
+
attempted: deliveries.length,
|
|
1370
|
+
limit,
|
|
1371
|
+
remaining: Math.max(0, limit - (relayCounts.get(rkey) ?? 0)),
|
|
1372
|
+
deliveries,
|
|
1373
|
+
audit,
|
|
1374
|
+
}
|
|
1375
|
+
if (failed.length > 0) {
|
|
1376
|
+
result.reason = failed
|
|
1377
|
+
.map((d) => `${d.target}:${d.reason}`)
|
|
1378
|
+
.join('; ')
|
|
1379
|
+
}
|
|
1380
|
+
return result
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/** 转发载荷模板(**确定性**:同样的房间/消息恒产出同一段文本,便于断言与排障)。 */
|
|
1384
|
+
function relayText(room, message, fullText) {
|
|
1385
|
+
const to = message.to ? ` → ${message.to}` : ''
|
|
1386
|
+
const header = `[cs-bots 房间「${room.name}」 ${room.id}] 消息 ${message.id} (#${message.seq}) `
|
|
1387
|
+
+ `← ${message.from}${to} [${message.kind}]`
|
|
1388
|
+
return `${header}\n${fullText}`
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// ── API:审计与运维 ─────────────────────────────────────────────────────────
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* 房间事件流查询(审计)。
|
|
1395
|
+
* @param {{ roomId?: string }} [input] 省略 roomId 则返回**全部房间**的事件
|
|
1396
|
+
* @returns {object[]}
|
|
1397
|
+
*/
|
|
1398
|
+
function history(input = {}) {
|
|
1399
|
+
const { roomId } = input ?? {}
|
|
1400
|
+
if (roomId === undefined || roomId === null) {
|
|
1401
|
+
const all = []
|
|
1402
|
+
for (const id of rooms.keys()) all.push(...(eventsByRoom.get(id) ?? []))
|
|
1403
|
+
all.sort((a, b) => (a.at ?? 0) - (b.at ?? 0))
|
|
1404
|
+
return clone(all)
|
|
1405
|
+
}
|
|
1406
|
+
const room = requireRoom(roomId, 'history')
|
|
1407
|
+
return clone(eventsByRoom.get(room.id) ?? [])
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* 内存中已回放的房间事件总数**实测值**。
|
|
1412
|
+
*
|
|
1413
|
+
* ⚠️ 不能用 `diagnostics.replayedEvents` 充当它:该计数在 hydrate() 第 1 行就被清零、
|
|
1414
|
+
* 之后**不再被写入**,于是 `replay()` 返回的 `events` 恒为 0、
|
|
1415
|
+
* `stats().events` 在重启后归零 —— 那是「看着像 0 其实很多」的静默误报。
|
|
1416
|
+
* 故这里直接数内存(`eventsByRoom` 是唯一的真相源副本)。
|
|
1417
|
+
* @returns {number}
|
|
1418
|
+
*/
|
|
1419
|
+
function eventsInMemory() {
|
|
1420
|
+
let n = 0
|
|
1421
|
+
for (const rows of eventsByRoom.values()) n += rows.length
|
|
1422
|
+
return n
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* 从磁盘重建内存态(重启回放一致性)。
|
|
1427
|
+
* @returns {{ from: string, rooms: number, messages: number, events: number, bad: number, seqGaps: number }}
|
|
1428
|
+
*/
|
|
1429
|
+
function replay() {
|
|
1430
|
+
hydrate()
|
|
1431
|
+
diagnostics.lastReplayAt = Date.now()
|
|
1432
|
+
return {
|
|
1433
|
+
from: diagnostics.hydratedFrom ?? 'empty',
|
|
1434
|
+
rooms: rooms.size,
|
|
1435
|
+
messages: diagnostics.replayedMessages,
|
|
1436
|
+
events: eventsInMemory(),
|
|
1437
|
+
bad: diagnostics.badLines,
|
|
1438
|
+
seqGaps: diagnostics.seqGaps,
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/** 单文件字节数(不存在记 0,**不抛错**——统计用途)。 */
|
|
1443
|
+
function fileBytes(file) {
|
|
1444
|
+
try {
|
|
1445
|
+
return statSync(file).size
|
|
1446
|
+
} catch {
|
|
1447
|
+
return 0
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* 运行口径统计(排障与验收用;数字**实测**,不估算)。
|
|
1453
|
+
* @returns {object}
|
|
1454
|
+
*/
|
|
1455
|
+
function stats() {
|
|
1456
|
+
let messages = 0
|
|
1457
|
+
let bodies = 0
|
|
1458
|
+
let relays = 0
|
|
1459
|
+
let relayResults = 0
|
|
1460
|
+
let relayDeliveries = 0
|
|
1461
|
+
let logBytes = 0
|
|
1462
|
+
let bodyBytes = 0
|
|
1463
|
+
|
|
1464
|
+
for (const [id, list] of messagesByRoom.entries()) {
|
|
1465
|
+
messages += list.length
|
|
1466
|
+
logBytes += fileBytes(logPath(id))
|
|
1467
|
+
for (const m of list) {
|
|
1468
|
+
if (m.bodyRef) {
|
|
1469
|
+
bodies += 1
|
|
1470
|
+
bodyBytes += fileBytes(statePath(roomDir(id), m.bodyRef))
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
for (const rows of eventsByRoom.values()) {
|
|
1475
|
+
for (const ev of rows) {
|
|
1476
|
+
if (ev?.type === 'relay') {
|
|
1477
|
+
relays += 1
|
|
1478
|
+
relayDeliveries += Array.isArray(ev.targets) ? ev.targets.length : 0
|
|
1479
|
+
} else if (ev?.type === 'relay_result') relayResults += 1
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
let members = 0
|
|
1484
|
+
for (const room of rooms.values()) members += room.members.length
|
|
1485
|
+
|
|
1486
|
+
return {
|
|
1487
|
+
rooms: rooms.size,
|
|
1488
|
+
messages,
|
|
1489
|
+
bodies,
|
|
1490
|
+
members,
|
|
1491
|
+
relays,
|
|
1492
|
+
relayResults,
|
|
1493
|
+
relayDeliveries,
|
|
1494
|
+
events: eventsInMemory(),
|
|
1495
|
+
dedupKeys: dedupIndex.size,
|
|
1496
|
+
badLines: diagnostics.badLines,
|
|
1497
|
+
seqGaps: diagnostics.seqGaps,
|
|
1498
|
+
orphanRoomDirs: diagnostics.orphanRoomDirs,
|
|
1499
|
+
snapshotWriteFailures: diagnostics.snapshotWriteFailures,
|
|
1500
|
+
relayResultAppendFailures: diagnostics.relayResultAppendFailures,
|
|
1501
|
+
logBytes,
|
|
1502
|
+
bodyBytes,
|
|
1503
|
+
durable,
|
|
1504
|
+
hydratedFrom: diagnostics.hydratedFrom ?? 'empty',
|
|
1505
|
+
lastReplayAt: diagnostics.lastReplayAt ?? null,
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// ── 收尾 ──────────────────────────────────────────────────────────────────
|
|
1510
|
+
|
|
1511
|
+
/** 首次构造即回放,保证 `list()` / `listMessages()` 在重启后马上可用。 */
|
|
1512
|
+
hydrate()
|
|
1513
|
+
|
|
1514
|
+
const service = {
|
|
1515
|
+
name: config.name ?? 'room',
|
|
1516
|
+
paths: Object.freeze({ stateDir, roomsDir, index: indexPath }),
|
|
1517
|
+
durable,
|
|
1518
|
+
/** §3.2 的工厂方法名(`createRoom_`);`create` 是等价别名,便于阅读与调用。 */
|
|
1519
|
+
createRoom_,
|
|
1520
|
+
create: createRoom_,
|
|
1521
|
+
get,
|
|
1522
|
+
list,
|
|
1523
|
+
update,
|
|
1524
|
+
join,
|
|
1525
|
+
leave,
|
|
1526
|
+
post,
|
|
1527
|
+
relay,
|
|
1528
|
+
readBody,
|
|
1529
|
+
listMessages,
|
|
1530
|
+
replay,
|
|
1531
|
+
stats,
|
|
1532
|
+
history,
|
|
1533
|
+
diagnostics: () => clone(diagnostics),
|
|
1534
|
+
}
|
|
1535
|
+
return service
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/** 对外暴露的关键口径(测试与调用方算 relayLimit/阈值时用,避免各处硬编码漂移)。 */
|
|
1539
|
+
export const ROOM_LIMITS = Object.freeze({
|
|
1540
|
+
MAX_NOTICE,
|
|
1541
|
+
BODY_THRESHOLD,
|
|
1542
|
+
BODY_HEAD_CHARS,
|
|
1543
|
+
RELAY_MULTIPLIER,
|
|
1544
|
+
RELAY_FLOOR,
|
|
1545
|
+
})
|
|
1546
|
+
|
|
1547
|
+
/** 用户(人类)发送者 id(`from` 允许的特殊值)。 */
|
|
1548
|
+
export const ROOM_USER = USER
|
|
1549
|
+
|
|
1550
|
+
export default createRoom
|