cs-bots 0.1.0 → 0.7.4
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/CHANGELOG.md +533 -0
- package/README.md +65 -10
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
- package/docs/ARCHITECTURE.md +277 -0
- package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
- package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
- package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
- package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
- package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
- package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
- package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
- package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
- package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
- package/package.json +4 -2
- package/src/admission.js +365 -0
- package/src/api.js +4097 -420
- package/src/board.js +883 -14
- package/src/botprofile.js +190 -3
- package/src/client.js +6596 -1082
- package/src/cycle.js +516 -0
- package/src/delivery-watch.js +255 -0
- package/src/dirbrowse.js +20 -0
- package/src/discipline.js +1188 -0
- package/src/fence.js +142 -0
- package/src/index.js +387 -4
- package/src/kernel.js +636 -24
- package/src/library.js +1224 -11
- package/src/memory.js +617 -37
- package/src/metrics.js +339 -0
- package/src/prompts.js +1318 -0
- package/src/reviewer.js +709 -0
- package/src/room.js +655 -14
- package/src/roster.js +346 -26
- package/src/routing/complexity.js +320 -0
- package/src/routing/hints.js +707 -0
- package/src/scratch.js +477 -0
- package/src/settings.js +97 -0
- package/src/stage.js +263 -0
- package/src/store.js +133 -1
- package/src/subscription.js +571 -0
- package/src/suggest.js +1203 -0
- package/src/teams.js +527 -0
- package/src/todo.js +1301 -0
- package/src/tools.js +734 -10
- package/src/verify.js +356 -0
package/src/room.js
CHANGED
|
@@ -62,6 +62,30 @@
|
|
|
62
62
|
* 管理员退房 → 管理权**移交给最早加入的剩余成员**(写 `admin_transferred` 事件,可见);
|
|
63
63
|
* 房间空了则清空 `adminId`。⇒ 不留「管理员已走、房间被锁死」这种隐形死局。
|
|
64
64
|
*
|
|
65
|
+
* ── 房间配置与超级管理员(DESIGN-v2 特性 ①,2026-11-04)──
|
|
66
|
+
* `purpose` 用途/话题,缺省 `''`(**不是 undefined**:回放后形状必须与内存态逐字段一致)。
|
|
67
|
+
* `roles` `{ sessionId: 'admin'|'member'|'observer' }`,缺省按成员全 `'member'`,
|
|
68
|
+
* admin 模式的 `adminId` 记 `'admin'`。**roles 只描述、不参与权限判定**
|
|
69
|
+
* (权限仍只看 `adminId` —— 避免「改个角色就隐式夺权」)。
|
|
70
|
+
* `settings` `{ allowBotAutoPost, maxBotsPerRound, relayEnabled }`,缺省见 `DEFAULT_SETTINGS`;
|
|
71
|
+
* 未知键**抛错**(拒绝写入会被静默忽略的配置)。
|
|
72
|
+
* `archivedAt` 归档时间(软删)。**归档房缺省不出现在 `list()`**,
|
|
73
|
+
* `list({includeArchived:true})` 才含;`get()` 仍可读(否则归档后无法恢复)。
|
|
74
|
+
* `withBody` `listMessages({withBody:true})` 把长正文全文内联进每条 `body`,
|
|
75
|
+
* 避免页面为每条消息再发一次 `/rooms/body` 请求。
|
|
76
|
+
*
|
|
77
|
+
* **超级管理员 = `USER`('user')**:所有新老操作一律放行(DESIGN 权限行)。
|
|
78
|
+
* 非 USER 仍按原规则:
|
|
79
|
+
* - `update`:admin 模式看 `adminId`、everyone 模式看成员资格(既有行为,未改);
|
|
80
|
+
* - `archive/unarchiveRoom`:同上,另加**创建者恒可**——`members` 允许不显式包含创建者
|
|
81
|
+
* (见上文「成员语义」),不给创建者留门会导致「房间归档后再没人能恢复」;
|
|
82
|
+
* - `removeRoom`:仅 owner(=`createdBy`)或 USER(硬删不可恢复,权限必须收紧);
|
|
83
|
+
* - `setMemberRole/kickMember`:「管理员级」操作 —— USER / 创建者 / admin 模式的管理员;
|
|
84
|
+
* everyone 模式下的**普通成员一律拒绝**(DESIGN 原文「成员角色(super admin 专用)」)。
|
|
85
|
+
*
|
|
86
|
+
* CAS 沿用本模块既有的**可选 CAS**(见上文「CAS 取舍」):新函数签名里 DESIGN 没列
|
|
87
|
+
* `expectedRev` 的(`setMemberRole`/`kickMember`)也接受它 —— 传了就严格比对,不传不拦。
|
|
88
|
+
*
|
|
65
89
|
* 依赖面:node 内置(`node:crypto` / `node:fs` / `node:path`)+ `./store.js` + `./kernel.js`。
|
|
66
90
|
* **不 import 任何 `@deepseek-ai/*`**(`test/ci-zero-dep.sh` 检查 1 机械强制)。
|
|
67
91
|
*
|
|
@@ -93,6 +117,21 @@ import {
|
|
|
93
117
|
} from './store.js'
|
|
94
118
|
import * as kernel from './kernel.js'
|
|
95
119
|
|
|
120
|
+
// ── store.appendAudit 软导入(P1-D §4)───────────────────────────────────────
|
|
121
|
+
// 软用:拿不到就只 warn 一次 + 计数,绝不阻断主流程,也绝不自己另造审计格式。
|
|
122
|
+
// 为什么用动态 `await import` 而不是具名静态 import:store.js 正被另一工程师并行改,
|
|
123
|
+
// 静态具名 import 在对方重构掉该导出时会变成**加载期**失败(整个服务起不来);
|
|
124
|
+
// 动态判定只是「审计少一条」,主流程不受影响。顶层 await 一次性判定 ⇒ commit 内同步。
|
|
125
|
+
let storeAppendAudit = /** @type {null | ((dir: string, entry: object) => unknown)} */ (null)
|
|
126
|
+
let storeAuditMissing = false
|
|
127
|
+
try {
|
|
128
|
+
const storeMod = await import('./store.js')
|
|
129
|
+
if (typeof storeMod.appendAudit === 'function') storeAppendAudit = storeMod.appendAudit
|
|
130
|
+
else storeAuditMissing = true
|
|
131
|
+
} catch {
|
|
132
|
+
storeAuditMissing = true
|
|
133
|
+
}
|
|
134
|
+
|
|
96
135
|
// ── 常量 ────────────────────────────────────────────────────────────────────
|
|
97
136
|
|
|
98
137
|
/** `notice` 上限(§3.2)。 */
|
|
@@ -124,9 +163,30 @@ const EVENTS = [
|
|
|
124
163
|
'room_renamed',
|
|
125
164
|
'notice_changed',
|
|
126
165
|
'mode_changed',
|
|
166
|
+
'purpose_changed',
|
|
167
|
+
'settings_changed',
|
|
168
|
+
'room_archived',
|
|
169
|
+
'room_unarchived',
|
|
170
|
+
'room_removed',
|
|
171
|
+
'member_role_changed',
|
|
172
|
+
'member_kicked',
|
|
127
173
|
'relay',
|
|
128
174
|
'relay_result',
|
|
129
175
|
]
|
|
176
|
+
/** `purpose` 上限:与公告同口径(2000)—— 用途/话题是「一段短说明」,不是正文(正文走旁路)。 */
|
|
177
|
+
const MAX_PURPOSE = 2000
|
|
178
|
+
/** 合法成员角色(DESIGN-v2 ①)。 */
|
|
179
|
+
const MEMBER_ROLES = ['admin', 'member', 'observer']
|
|
180
|
+
/**
|
|
181
|
+
* 房间级开关缺省值(DESIGN-v2 ①)。
|
|
182
|
+
* **必须是建房间、update 合并、回放补全三处共用的唯一口径** ——
|
|
183
|
+
* 各写一份常量就会出现「新建房间缺省 true、回放后变成 false」这类分叉。
|
|
184
|
+
*/
|
|
185
|
+
const DEFAULT_SETTINGS = Object.freeze({
|
|
186
|
+
allowBotAutoPost: true,
|
|
187
|
+
maxBotsPerRound: 3,
|
|
188
|
+
relayEnabled: true,
|
|
189
|
+
})
|
|
130
190
|
/** `bodyRef` 的合法形态(防路径穿越:只允许 `bodies/<安全文件名>.txt`)。 */
|
|
131
191
|
const BODY_REF_RE = /^bodies\/[A-Za-z0-9._-]+\.txt$/
|
|
132
192
|
|
|
@@ -286,6 +346,126 @@ function normalizeNotice(value, where = 'createRoom') {
|
|
|
286
346
|
return value
|
|
287
347
|
}
|
|
288
348
|
|
|
349
|
+
/**
|
|
350
|
+
* `purpose` 校验(DESIGN-v2 ①,≤2000)。
|
|
351
|
+
*
|
|
352
|
+
* 与 `optionalString` 的区别:**空串是合法值**(表示「清空用途」),
|
|
353
|
+
* 所以不能把 `''` 归成「未提供」;但纯空白仍归一成 `''`(避免不可见的伪内容)。
|
|
354
|
+
*/
|
|
355
|
+
function normalizePurpose(value, where = 'createRoom') {
|
|
356
|
+
if (value === undefined || value === null) return undefined
|
|
357
|
+
if (typeof value !== 'string') {
|
|
358
|
+
throw new RoomError(
|
|
359
|
+
`room.${where}:purpose 必须是字符串,收到 ${JSON.stringify(value)}`,
|
|
360
|
+
'ROOM_BAD_FIELD',
|
|
361
|
+
{ field: 'purpose', where },
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
if (value.length > MAX_PURPOSE) {
|
|
365
|
+
throw new RoomError(
|
|
366
|
+
`room.${where}:purpose 超长(${value.length} > ${MAX_PURPOSE} 字符上限)——用途/话题只写一段短说明`,
|
|
367
|
+
'ROOM_PURPOSE_TOO_LONG',
|
|
368
|
+
{ length: value.length, max: MAX_PURPOSE },
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
return value.trim()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** `settings` 单个开关校验(按键分派;未知键抛错 —— 拒绝写入「怎么写都不会生效」的配置)。 */
|
|
375
|
+
function normalizeSettingValue(key, value, where) {
|
|
376
|
+
if (key === 'allowBotAutoPost' || key === 'relayEnabled') {
|
|
377
|
+
if (typeof value !== 'boolean') {
|
|
378
|
+
throw new RoomError(
|
|
379
|
+
`room.${where}:settings.${key} 必须是布尔值,收到 ${JSON.stringify(value)}`,
|
|
380
|
+
'ROOM_BAD_SETTING',
|
|
381
|
+
{ field: `settings.${key}`, where },
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
return value
|
|
385
|
+
}
|
|
386
|
+
if (key === 'maxBotsPerRound') {
|
|
387
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
388
|
+
throw new RoomError(
|
|
389
|
+
`room.${where}:settings.maxBotsPerRound 必须是非负整数,收到 ${JSON.stringify(value)}`,
|
|
390
|
+
'ROOM_BAD_SETTING',
|
|
391
|
+
{ field: 'settings.maxBotsPerRound', where },
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
return value
|
|
395
|
+
}
|
|
396
|
+
throw new RoomError(
|
|
397
|
+
`room.${where}:未知设置项 ${JSON.stringify(key)}(支持 ${Object.keys(DEFAULT_SETTINGS).join(' / ')})`
|
|
398
|
+
+ ' —— 拒绝写入会被静默忽略的配置',
|
|
399
|
+
'ROOM_BAD_SETTING',
|
|
400
|
+
{ field: `settings.${key}`, where },
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* `settings` 入参校验(**增量**:只校验调用方显式给出的键)。
|
|
406
|
+
* @returns {object|undefined} 校验后的增量;`undefined` 表示未提供
|
|
407
|
+
*/
|
|
408
|
+
function normalizeSettingsPatch(value, where = 'createRoom') {
|
|
409
|
+
if (value === undefined || value === null) return undefined
|
|
410
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
411
|
+
throw new RoomError(
|
|
412
|
+
`room.${where}:settings 必须是对象,收到 ${JSON.stringify(value)}`,
|
|
413
|
+
'ROOM_BAD_FIELD',
|
|
414
|
+
{ field: 'settings', where },
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
const out = {}
|
|
418
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
419
|
+
out[key] = normalizeSettingValue(key, raw, where)
|
|
420
|
+
}
|
|
421
|
+
return out
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 回放用的 `settings` **宽松补全**:缺省键补齐、非法值忽略。
|
|
426
|
+
*
|
|
427
|
+
* 为什么回放不能像入参那样抛错:磁盘上的老数据/手改快照是**既成事实**,
|
|
428
|
+
* 回放期抛错会让整个房间甚至整个服务起不来(一个坏字段炸掉全部房间)。
|
|
429
|
+
* 非法值不被静默采信(回落到缺省),并在 `diagnostics` 层面可由 `get()` 侧看出差异。
|
|
430
|
+
* @returns {{ allowBotAutoPost: boolean, maxBotsPerRound: number, relayEnabled: boolean }}
|
|
431
|
+
*/
|
|
432
|
+
function settingsFromState(value) {
|
|
433
|
+
const out = { ...DEFAULT_SETTINGS }
|
|
434
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return out
|
|
435
|
+
for (const key of Object.keys(DEFAULT_SETTINGS)) {
|
|
436
|
+
const v = value[key]
|
|
437
|
+
if (key === 'maxBotsPerRound') {
|
|
438
|
+
if (typeof v === 'number' && Number.isInteger(v) && v >= 0) out[key] = v
|
|
439
|
+
} else if (typeof v === 'boolean') {
|
|
440
|
+
out[key] = v
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return out
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* 成员的初始角色表(建房间用)。
|
|
448
|
+
* admin 模式的 `adminId` 记 `'admin'`;其余成员 `'member'`。
|
|
449
|
+
* **注意**:`adminId` 允许不是成员(既有 `createRoom` 行为),此时它不出现在 roles 里。
|
|
450
|
+
*/
|
|
451
|
+
function initialRoles(members, adminId) {
|
|
452
|
+
const roles = {}
|
|
453
|
+
for (const m of members) roles[m] = m === adminId ? 'admin' : 'member'
|
|
454
|
+
return roles
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** 角色值校验(DESIGN-v2 ①)。 */
|
|
458
|
+
function normalizeMemberRole(value, where = 'setMemberRole') {
|
|
459
|
+
if (!MEMBER_ROLES.includes(value)) {
|
|
460
|
+
throw new RoomError(
|
|
461
|
+
`room.${where}:role 必须是 ${MEMBER_ROLES.join(' | ')},收到 ${JSON.stringify(value)}`,
|
|
462
|
+
'ROOM_BAD_ROLE',
|
|
463
|
+
{ role: value, allowed: [...MEMBER_ROLES] },
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
return value
|
|
467
|
+
}
|
|
468
|
+
|
|
289
469
|
/** `meta` 校验:必须是普通对象(否则原样落盘会污染日志结构)。 */
|
|
290
470
|
function normalizeMeta(value, where = 'post') {
|
|
291
471
|
if (value === undefined || value === null) return undefined
|
|
@@ -416,6 +596,8 @@ export function createRoom(ctx, config = {}) {
|
|
|
416
596
|
orphanRoomDirs: 0,
|
|
417
597
|
snapshotWriteFailures: 0,
|
|
418
598
|
relayResultAppendFailures: 0,
|
|
599
|
+
auditWriteFailures: 0,
|
|
600
|
+
auditWarnedOnce: false,
|
|
419
601
|
lastReplayAt: /** @type {number|undefined} */ (undefined),
|
|
420
602
|
}
|
|
421
603
|
|
|
@@ -490,6 +672,50 @@ export function createRoom(ctx, config = {}) {
|
|
|
490
672
|
}
|
|
491
673
|
}
|
|
492
674
|
|
|
675
|
+
/**
|
|
676
|
+
* 「管理类」操作权限(归档 / 恢复):USER 超级管理员恒放行;
|
|
677
|
+
* 非 USER 按原规则 —— admin 模式看 `adminId`,everyone 模式看成员资格;
|
|
678
|
+
* **另加创建者**:`members` 语义允许创建者不在成员表里,不给创建者留门
|
|
679
|
+
* 会导致「房间归档后再没人能恢复」(DESIGN 权限行的必要补丁,已写进文件头)。
|
|
680
|
+
*/
|
|
681
|
+
function assertManage(room, actor, action) {
|
|
682
|
+
if (actor === USER) return
|
|
683
|
+
if (room.createdBy === actor) return
|
|
684
|
+
if (room.mode === 'admin') {
|
|
685
|
+
if (room.adminId === actor) return
|
|
686
|
+
throw new RoomError(
|
|
687
|
+
`room.${action}:房间 ${room.id} 是 admin 模式,只有管理员(${room.adminId ?? '未设置'})`
|
|
688
|
+
+ `或创建者(${room.createdBy})能执行该操作,调用者为 ${actor}`,
|
|
689
|
+
'ROOM_FORBIDDEN',
|
|
690
|
+
{ roomId: room.id, adminId: room.adminId ?? null, createdBy: room.createdBy, actor },
|
|
691
|
+
)
|
|
692
|
+
}
|
|
693
|
+
if (room.members.includes(actor)) return
|
|
694
|
+
throw new RoomError(
|
|
695
|
+
`room.${action}:${actor} 不是房间 ${room.id} 的成员(everyone 模式按成员资格判定)——无权操作`,
|
|
696
|
+
'ROOM_NOT_MEMBER',
|
|
697
|
+
{ roomId: room.id, actor, members: room.members },
|
|
698
|
+
)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* 「管理员级」操作权限(改成员角色 / 踢人)。
|
|
703
|
+
* DESIGN 对这两项标注「super admin 专用」,故 everyone 模式下的**普通成员一律拒绝**;
|
|
704
|
+
* 非 USER 的合法操作者只有:房间创建者、admin 模式的 `adminId`。
|
|
705
|
+
*/
|
|
706
|
+
function assertAdminPrivilege(room, actor, action) {
|
|
707
|
+
if (actor === USER) return
|
|
708
|
+
if (room.createdBy === actor) return
|
|
709
|
+
if (room.mode === 'admin' && room.adminId === actor) return
|
|
710
|
+
throw new RoomError(
|
|
711
|
+
`room.${action}:${actor} 无权执行管理员操作(需要超级管理员 ${JSON.stringify(USER)}、`
|
|
712
|
+
+ `房间创建者 ${room.createdBy}${room.mode === 'admin' ? ` 或管理员 ${room.adminId ?? '未设置'}` : ''})`
|
|
713
|
+
+ ' —— 成员角色/踢人属管理员级操作,普通成员被拒',
|
|
714
|
+
'ROOM_FORBIDDEN',
|
|
715
|
+
{ roomId: room.id, actor, createdBy: room.createdBy, adminId: room.adminId ?? null, mode: room.mode },
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
|
|
493
719
|
// ── 持久化 ────────────────────────────────────────────────────────────────
|
|
494
720
|
|
|
495
721
|
/** 索引快照内容(落盘形态)。 */
|
|
@@ -555,9 +781,41 @@ export function createRoom(ctx, config = {}) {
|
|
|
555
781
|
}
|
|
556
782
|
appendRoomEvent(roomId, event)
|
|
557
783
|
rooms.set(roomId, clone(after))
|
|
558
|
-
//
|
|
784
|
+
// 名字索引随房间状态一起维护(改名/归档都走这里 —— 保持单一维护点,避免索引漂移)
|
|
559
785
|
if (after?.name) nameIndex.set(nameKey(after.name), roomId)
|
|
560
786
|
snapshotIndex()
|
|
787
|
+
// ── 审计接线(P1-D §4)────────────────────────────────────────────────
|
|
788
|
+
// 软用 store.appendAudit:拿不到/写失败 → 只 warn 一次 + 计数,绝不阻断主流程。
|
|
789
|
+
if (storeAuditMissing) {
|
|
790
|
+
diagnostics.auditWriteFailures += 1
|
|
791
|
+
if (!diagnostics.auditWarnedOnce) {
|
|
792
|
+
diagnostics.auditWarnedOnce = true
|
|
793
|
+
warn('[cs-bots/room] store.appendAudit 尚不可用 → 审计接线跳过;主流程不受影响')
|
|
794
|
+
}
|
|
795
|
+
} else {
|
|
796
|
+
try {
|
|
797
|
+
const digest = (r) => (r
|
|
798
|
+
? { id: r.id, name: r.name ?? null, rev: r.rev, mode: r.mode ?? null, members: Array.isArray(r.members) ? r.members.length : 0 }
|
|
799
|
+
: null)
|
|
800
|
+
const result = storeAppendAudit(stateDir, {
|
|
801
|
+
ts: at, actor: byId, action: type, target: roomId, before: digest(before), after: digest(after),
|
|
802
|
+
})
|
|
803
|
+
const failed = result === false || Boolean(result && typeof result === 'object' && result.ok === false)
|
|
804
|
+
if (failed) {
|
|
805
|
+
diagnostics.auditWriteFailures += 1
|
|
806
|
+
if (!diagnostics.auditWarnedOnce) {
|
|
807
|
+
diagnostics.auditWarnedOnce = true
|
|
808
|
+
warn('[cs-bots/room] store.appendAudit 返回失败——主流程不受影响')
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
} catch (error) {
|
|
812
|
+
diagnostics.auditWriteFailures += 1
|
|
813
|
+
if (!diagnostics.auditWarnedOnce) {
|
|
814
|
+
diagnostics.auditWarnedOnce = true
|
|
815
|
+
warn(`[cs-bots/room] store.appendAudit 抛错:${error?.message ?? error}——主流程不受影响`)
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
561
819
|
return clone(after)
|
|
562
820
|
}
|
|
563
821
|
|
|
@@ -651,6 +909,21 @@ export function createRoom(ctx, config = {}) {
|
|
|
651
909
|
if (!MODES.includes(state.mode)) state.mode = DEFAULT_MODE
|
|
652
910
|
if (!Number.isFinite(state.relayLimit)) state.relayLimit = relayLimitForMembers(state.members)
|
|
653
911
|
if (!Number.isFinite(state.rev)) state.rev = 1
|
|
912
|
+
|
|
913
|
+
// ── DESIGN-v2 ① 新字段归一(老数据/手改过的快照也要能安全消费)────────────
|
|
914
|
+
// 目的:让「内存态形状 == 回放后形状」逐字段成立(既有 replay 测试就靠这条)。
|
|
915
|
+
if (typeof state.purpose !== 'string') state.purpose = ''
|
|
916
|
+
if (!state.roles || typeof state.roles !== 'object' || Array.isArray(state.roles)) {
|
|
917
|
+
state.roles = {}
|
|
918
|
+
} else {
|
|
919
|
+
// 非法角色值不采信,回落到 'member';键保留(键存在说明该 session 曾登记为成员角色,
|
|
920
|
+
// 删键会静默丢掉「这里原本有个人」这一事实,回放期只纠正值、不删证据)
|
|
921
|
+
for (const [sid, role] of Object.entries(state.roles)) {
|
|
922
|
+
if (!MEMBER_ROLES.includes(role)) state.roles[sid] = 'member'
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
state.settings = settingsFromState(state.settings)
|
|
926
|
+
if (state.archivedAt !== null && !Number.isFinite(state.archivedAt)) state.archivedAt = null
|
|
654
927
|
rooms.set(id, state)
|
|
655
928
|
if (state.name) nameIndex.set(nameKey(state.name), id)
|
|
656
929
|
|
|
@@ -730,7 +1003,7 @@ export function createRoom(ctx, config = {}) {
|
|
|
730
1003
|
* @throws {RoomError} 缺字段 / 重名 / notice 超长 / 非法 mode
|
|
731
1004
|
*/
|
|
732
1005
|
function createRoom_(input = {}) {
|
|
733
|
-
const { name, members, mode, adminId, notice, cwd, byId, now } = input ?? {}
|
|
1006
|
+
const { name, members, mode, adminId, notice, cwd, purpose, settings, byId, now } = input ?? {}
|
|
734
1007
|
const displayName = requireString(name, 'name', 'createRoom')
|
|
735
1008
|
const creator = requireString(byId, 'byId', 'createRoom')
|
|
736
1009
|
const key = nameKey(displayName)
|
|
@@ -752,6 +1025,8 @@ export function createRoom(ctx, config = {}) {
|
|
|
752
1025
|
const advisory = optionalString(adminId, 'adminId', 'createRoom')
|
|
753
1026
|
const noticeValue = normalizeNotice(notice, 'createRoom')
|
|
754
1027
|
const cwdValue = optionalString(cwd, 'cwd', 'createRoom')
|
|
1028
|
+
const purposeValue = normalizePurpose(purpose, 'createRoom')
|
|
1029
|
+
const settingsPatch = normalizeSettingsPatch(settings, 'createRoom')
|
|
755
1030
|
const at = normalizeNow(now, 'createRoom')
|
|
756
1031
|
|
|
757
1032
|
const roomId = `room-${randomUUID().slice(0, 8)}`
|
|
@@ -793,6 +1068,12 @@ export function createRoom(ctx, config = {}) {
|
|
|
793
1068
|
}
|
|
794
1069
|
if (noticeValue !== undefined) room.notice = noticeValue
|
|
795
1070
|
if (cwdValue !== undefined) room.cwd = cwdValue
|
|
1071
|
+
// DESIGN-v2 ① 新字段:**建房间时就把缺省值写进模型**(而不是读到 undefined 时临时兜底)——
|
|
1072
|
+
// 这样事件流/快照里的形状与内存态一致,回放后逐字段相等才是可验证的。
|
|
1073
|
+
room.purpose = purposeValue ?? ''
|
|
1074
|
+
room.roles = initialRoles(room.members, room.adminId)
|
|
1075
|
+
room.settings = { ...DEFAULT_SETTINGS, ...(settingsPatch ?? {}) }
|
|
1076
|
+
room.archivedAt = null
|
|
796
1077
|
|
|
797
1078
|
return commit(roomId, 'room_created', creator, at, null, room)
|
|
798
1079
|
}
|
|
@@ -805,11 +1086,27 @@ export function createRoom(ctx, config = {}) {
|
|
|
805
1086
|
}
|
|
806
1087
|
|
|
807
1088
|
/**
|
|
808
|
-
*
|
|
1089
|
+
* 列出房间(**创建序**)。
|
|
1090
|
+
*
|
|
1091
|
+
* DESIGN-v2 ①:归档房(`archivedAt` 非 null)**缺省不出现**;
|
|
1092
|
+
* 需要看归档区时传 `{includeArchived:true}`。`get()` 不受影响(归档房仍可读、可恢复)。
|
|
1093
|
+
*
|
|
1094
|
+
* @param {{ includeArchived?: boolean }} [options]
|
|
809
1095
|
* @returns {object[]}
|
|
1096
|
+
* @throws {RoomError} includeArchived 类型非法
|
|
810
1097
|
*/
|
|
811
|
-
function list() {
|
|
812
|
-
|
|
1098
|
+
function list(options = {}) {
|
|
1099
|
+
const { includeArchived } = options ?? {}
|
|
1100
|
+
if (includeArchived !== undefined && typeof includeArchived !== 'boolean') {
|
|
1101
|
+
throw new RoomError(
|
|
1102
|
+
`room.list:includeArchived 必须是布尔值,收到 ${JSON.stringify(includeArchived)}`,
|
|
1103
|
+
'ROOM_BAD_FIELD',
|
|
1104
|
+
{ field: 'includeArchived' },
|
|
1105
|
+
)
|
|
1106
|
+
}
|
|
1107
|
+
const rows = [...rooms.values()]
|
|
1108
|
+
const visible = includeArchived === true ? rows : rows.filter((r) => (r.archivedAt ?? null) === null)
|
|
1109
|
+
return visible.map(clone)
|
|
813
1110
|
}
|
|
814
1111
|
|
|
815
1112
|
/**
|
|
@@ -820,18 +1117,22 @@ export function createRoom(ctx, config = {}) {
|
|
|
820
1117
|
* @throws {RoomError} 房间不存在 / 已是成员 / CAS 冲突
|
|
821
1118
|
*/
|
|
822
1119
|
/**
|
|
823
|
-
* 改房间设置(改名 / 公告 / 模式 /
|
|
1120
|
+
* 改房间设置(改名 / 公告 / 模式 / 管理员 / 用途 / 房间开关)。
|
|
824
1121
|
*
|
|
825
1122
|
* 为什么要这个:原来只能建房间、不能改,建错名字只能废弃重来。
|
|
826
1123
|
* 权限:`admin` 模式下仅管理员可改;`everyone` 模式下任一成员可改。
|
|
827
1124
|
*
|
|
1125
|
+
* DESIGN-v2 ① 扩展:新增 `purpose`(用途/话题,空串=清空)与 `settings`(**增量**合并)。
|
|
1126
|
+
* `settings` 的未知键/类型错误一律抛错 —— 拒绝「写了但永远不会生效」的配置。
|
|
1127
|
+
*
|
|
828
1128
|
* @param {{ roomId: string, name?: string, notice?: string, mode?: string, adminId?: string,
|
|
1129
|
+
* purpose?: string, settings?: object,
|
|
829
1130
|
* expectedRev?: number, byId: string, now?: number }} input
|
|
830
|
-
* @returns {object} 变更后的 Room
|
|
1131
|
+
* @returns {object} 变更后的 Room(有变化时多带 `changed` 字段;无变化时原样返回)
|
|
831
1132
|
* @throws {RoomError} 房间不存在 / 非成员 / 无权限 / 重名 / CAS 冲突 / 字段非法
|
|
832
1133
|
*/
|
|
833
1134
|
function update(input = {}) {
|
|
834
|
-
const { roomId, name, notice, mode, adminId, expectedRev, byId, now } = input ?? {}
|
|
1135
|
+
const { roomId, name, notice, mode, adminId, purpose, settings, expectedRev, byId, now } = input ?? {}
|
|
835
1136
|
const room = requireRoom(roomId, 'update')
|
|
836
1137
|
const actor = requireString(byId, 'byId', 'update')
|
|
837
1138
|
assertRev(room, expectedRev, 'update')
|
|
@@ -842,6 +1143,17 @@ export function createRoom(ctx, config = {}) {
|
|
|
842
1143
|
{ roomId: room.id, adminId: room.adminId, actor },
|
|
843
1144
|
)
|
|
844
1145
|
}
|
|
1146
|
+
// ⚠️ 修复(2026-11-04 测试,P1-5):原实现只在 admin 模式校验管理员,
|
|
1147
|
+
// mode='everyone' 时**完全不校验调用者是否成员** ⇒ 任意人可改房间名/公告
|
|
1148
|
+
// (实测 `byId:'attacker-not-a-member'` 改名成功)。现在与 post() 口径一致:
|
|
1149
|
+
// 非 USER 的调用者必须是房间成员。
|
|
1150
|
+
if (actor !== USER && !room.members.includes(actor)) {
|
|
1151
|
+
throw new RoomError(
|
|
1152
|
+
`room.update:${actor} 不是房间 ${room.id} 的成员 —— 无权修改房间设置`,
|
|
1153
|
+
'ROOM_NOT_MEMBER',
|
|
1154
|
+
{ roomId: room.id, actor, members: room.members },
|
|
1155
|
+
)
|
|
1156
|
+
}
|
|
845
1157
|
|
|
846
1158
|
const at = normalizeNow(now, 'update')
|
|
847
1159
|
const before = clone(room)
|
|
@@ -877,7 +1189,11 @@ export function createRoom(ctx, config = {}) {
|
|
|
877
1189
|
if (m !== room.mode) {
|
|
878
1190
|
next.mode = m
|
|
879
1191
|
// admin 模式但没有管理员 → 由操作者接管(不留「有模式无管理员」的死局)
|
|
880
|
-
if (m === 'admin' && !next.adminId)
|
|
1192
|
+
if (m === 'admin' && !next.adminId) {
|
|
1193
|
+
next.adminId = actor
|
|
1194
|
+
// 角色表跟着描述实际管理员(roles 不参与权限判定,只为人看)
|
|
1195
|
+
next.roles = { ...(next.roles ?? {}), [actor]: 'admin' }
|
|
1196
|
+
}
|
|
881
1197
|
if (m === 'everyone') delete next.adminId
|
|
882
1198
|
changed.push('mode')
|
|
883
1199
|
}
|
|
@@ -897,23 +1213,49 @@ export function createRoom(ctx, config = {}) {
|
|
|
897
1213
|
)
|
|
898
1214
|
}
|
|
899
1215
|
next.adminId = target
|
|
1216
|
+
// 管理员变更同步到角色表(roles 只是描述,但描述与实际管理员不一致会误导 UI)
|
|
1217
|
+
next.roles = { ...(next.roles ?? {}), [target]: 'admin' }
|
|
900
1218
|
changed.push('adminId')
|
|
901
1219
|
}
|
|
902
1220
|
}
|
|
903
1221
|
|
|
1222
|
+
if (purpose !== undefined) {
|
|
1223
|
+
// 空串是合法值(清空用途)→ 不能走「空串视为未提供」的 optionalString
|
|
1224
|
+
const value = normalizePurpose(purpose, 'update')
|
|
1225
|
+
if (value !== (room.purpose ?? '')) {
|
|
1226
|
+
next.purpose = value
|
|
1227
|
+
changed.push('purpose')
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (settings !== undefined) {
|
|
1232
|
+
const patch = normalizeSettingsPatch(settings, 'update')
|
|
1233
|
+
const merged = { ...(room.settings ?? DEFAULT_SETTINGS), ...patch }
|
|
1234
|
+
if (Object.keys(patch).some((k) => merged[k] !== (room.settings ?? DEFAULT_SETTINGS)[k])) {
|
|
1235
|
+
next.settings = merged
|
|
1236
|
+
changed.push('settings')
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
904
1240
|
if (changed.length === 0) {
|
|
905
1241
|
// 没有任何变化 → 不写事件(避免污染审计流)
|
|
906
1242
|
return clone(room)
|
|
907
1243
|
}
|
|
908
1244
|
|
|
909
1245
|
next.rev = room.rev + 1
|
|
1246
|
+
// 事件类型只作审计标签:多字段同时变更时按固定优先级取一个(before/after 里是完整快照)。
|
|
1247
|
+
// 顺序与改造前保持一致(name > notice > mode > adminId),新字段接在后面。
|
|
910
1248
|
const type = changed.includes('name')
|
|
911
1249
|
? 'room_renamed'
|
|
912
1250
|
: changed.includes('notice')
|
|
913
1251
|
? 'notice_changed'
|
|
914
1252
|
: changed.includes('mode')
|
|
915
1253
|
? 'mode_changed'
|
|
916
|
-
: '
|
|
1254
|
+
: changed.includes('adminId')
|
|
1255
|
+
? 'admin_transferred'
|
|
1256
|
+
: changed.includes('purpose')
|
|
1257
|
+
? 'purpose_changed'
|
|
1258
|
+
: 'settings_changed'
|
|
917
1259
|
const committed = commit(room.id, type, actor, at, before, next)
|
|
918
1260
|
return { ...committed, changed }
|
|
919
1261
|
}
|
|
@@ -972,12 +1314,16 @@ export function createRoom(ctx, config = {}) {
|
|
|
972
1314
|
const next = clone(room)
|
|
973
1315
|
next.members = room.members.filter((m) => m !== sid)
|
|
974
1316
|
next.relayLimit = relayLimitForMembers(next.members)
|
|
1317
|
+
// 角色表只描述成员:退房后残留的角色条目会误导 UI(回放看老数据也一样)
|
|
1318
|
+
if (next.roles && Object.prototype.hasOwnProperty.call(next.roles, sid)) delete next.roles[sid]
|
|
975
1319
|
next.rev = room.rev + 1
|
|
976
1320
|
|
|
977
1321
|
let type = 'members_left'
|
|
978
1322
|
if (room.adminId === sid) {
|
|
979
1323
|
if (next.members.length > 0) {
|
|
980
1324
|
next.adminId = next.members[0]
|
|
1325
|
+
// 角色表跟着实际管理员走(描述性字段,但「管理员」标签必须真)
|
|
1326
|
+
next.roles = { ...(next.roles ?? {}), [next.members[0]]: 'admin' }
|
|
981
1327
|
type = 'admin_transferred'
|
|
982
1328
|
info(`[cs-bots/room] 房间 ${room.id} 管理权移交:${sid} → ${next.adminId}(最早加入的剩余成员)`)
|
|
983
1329
|
} else {
|
|
@@ -987,6 +1333,254 @@ export function createRoom(ctx, config = {}) {
|
|
|
987
1333
|
return commit(room.id, type, actor, at, before, next)
|
|
988
1334
|
}
|
|
989
1335
|
|
|
1336
|
+
// ── API:房间归档 / 硬删(DESIGN-v2 ①)──────────────────────────────────────
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* 归档房间(**软删**):只打 `archivedAt` 时间戳,消息与事件**一条不删**。
|
|
1340
|
+
*
|
|
1341
|
+
* DESIGN:归档房缺省不出现在 `list()`;`get()` 仍可读(否则归档后无法恢复)。
|
|
1342
|
+
* 重复归档**抛错**(不静默成功 —— 否则归档时间会被悄悄改写,审计不可信)。
|
|
1343
|
+
*
|
|
1344
|
+
* @param {{ roomId: string, expectedRev?: number, byId: string, now?: number }} input
|
|
1345
|
+
* @returns {object} 变更后的 Room(archivedAt=now)
|
|
1346
|
+
* @throws {RoomError} 房间不存在 / 无权限 / 已归档 / CAS 冲突
|
|
1347
|
+
*/
|
|
1348
|
+
function archiveRoom(input = {}) {
|
|
1349
|
+
const { roomId, expectedRev, byId, now } = input ?? {}
|
|
1350
|
+
const room = requireRoom(roomId, 'archiveRoom')
|
|
1351
|
+
const actor = requireString(byId, 'byId', 'archiveRoom')
|
|
1352
|
+
assertRev(room, expectedRev, 'archiveRoom')
|
|
1353
|
+
assertManage(room, actor, 'archiveRoom')
|
|
1354
|
+
if (room.archivedAt !== null && room.archivedAt !== undefined) {
|
|
1355
|
+
throw new RoomError(
|
|
1356
|
+
`room.archiveRoom:房间 ${room.id} 已归档(archivedAt=${room.archivedAt})——拒绝静默重复归档`,
|
|
1357
|
+
'ROOM_ALREADY_ARCHIVED',
|
|
1358
|
+
{ roomId: room.id, archivedAt: room.archivedAt },
|
|
1359
|
+
)
|
|
1360
|
+
}
|
|
1361
|
+
const at = normalizeNow(now, 'archiveRoom')
|
|
1362
|
+
const before = clone(room)
|
|
1363
|
+
const next = clone(room)
|
|
1364
|
+
next.archivedAt = at
|
|
1365
|
+
next.rev = room.rev + 1
|
|
1366
|
+
return commit(room.id, 'room_archived', actor, at, before, next)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* 恢复归档房间(`archivedAt=null`)。未归档的房间**抛错**(不静默无操作)。
|
|
1371
|
+
*
|
|
1372
|
+
* @param {{ roomId: string, expectedRev?: number, byId: string, now?: number }} input
|
|
1373
|
+
* @returns {object} 变更后的 Room(archivedAt=null)
|
|
1374
|
+
* @throws {RoomError} 房间不存在 / 无权限 / 未归档 / CAS 冲突
|
|
1375
|
+
*/
|
|
1376
|
+
function unarchiveRoom(input = {}) {
|
|
1377
|
+
const { roomId, expectedRev, byId, now } = input ?? {}
|
|
1378
|
+
const room = requireRoom(roomId, 'unarchiveRoom')
|
|
1379
|
+
const actor = requireString(byId, 'byId', 'unarchiveRoom')
|
|
1380
|
+
assertRev(room, expectedRev, 'unarchiveRoom')
|
|
1381
|
+
assertManage(room, actor, 'unarchiveRoom')
|
|
1382
|
+
if (room.archivedAt === null || room.archivedAt === undefined) {
|
|
1383
|
+
throw new RoomError(
|
|
1384
|
+
`room.unarchiveRoom:房间 ${room.id} 未归档(archivedAt=null)——拒绝静默无操作`,
|
|
1385
|
+
'ROOM_NOT_ARCHIVED',
|
|
1386
|
+
{ roomId: room.id },
|
|
1387
|
+
)
|
|
1388
|
+
}
|
|
1389
|
+
const at = normalizeNow(now, 'unarchiveRoom')
|
|
1390
|
+
const before = clone(room)
|
|
1391
|
+
const next = clone(room)
|
|
1392
|
+
next.archivedAt = null
|
|
1393
|
+
next.rev = room.rev + 1
|
|
1394
|
+
return commit(room.id, 'room_unarchived', actor, at, before, next)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* 硬删除房间(**不可恢复**):删目录(事件流 + 消息日志 + 旁路正文)并从内存/索引摘除。
|
|
1399
|
+
*
|
|
1400
|
+
* 闸门(DESIGN):「仅 owner 且 force,或无消息」:
|
|
1401
|
+
* - 权限:`USER` 或房间创建者(owner)——硬删不可恢复,不放开给普通成员;
|
|
1402
|
+
* - 有消息时必须显式 `force:true`(防误删整段讨论)。
|
|
1403
|
+
*
|
|
1404
|
+
* 顺序:**先落「删除意图」事件 → 再删目录 → 最后改内存**。
|
|
1405
|
+
* 若 `rmSync` 失败:抛 `ROOM_DELETE_FAILED`,内存与索引**保持原样**(房间还在,可重试);
|
|
1406
|
+
* 那条 `room_removed` 事件没有 `after`,回放时不会把房间状态改掉(最多留一条痕迹)。
|
|
1407
|
+
*
|
|
1408
|
+
* @param {{ roomId: string, expectedRev?: number, byId: string, force?: boolean, now?: number }} input
|
|
1409
|
+
* @returns {{ removed: string, messages: number }}
|
|
1410
|
+
* @throws {RoomError} 房间不存在 / 无权限 / 有消息且未 force / 删除失败 / CAS 冲突
|
|
1411
|
+
*/
|
|
1412
|
+
function removeRoom(input = {}) {
|
|
1413
|
+
const { roomId, expectedRev, byId, force, now } = input ?? {}
|
|
1414
|
+
const room = requireRoom(roomId, 'removeRoom')
|
|
1415
|
+
const actor = requireString(byId, 'byId', 'removeRoom')
|
|
1416
|
+
assertRev(room, expectedRev, 'removeRoom')
|
|
1417
|
+
if (force !== undefined && typeof force !== 'boolean') {
|
|
1418
|
+
throw new RoomError(
|
|
1419
|
+
`room.removeRoom:force 必须是布尔值,收到 ${JSON.stringify(force)}`,
|
|
1420
|
+
'ROOM_BAD_FIELD',
|
|
1421
|
+
{ field: 'force' },
|
|
1422
|
+
)
|
|
1423
|
+
}
|
|
1424
|
+
const owned = actor === USER || room.createdBy === actor
|
|
1425
|
+
if (!owned) {
|
|
1426
|
+
throw new RoomError(
|
|
1427
|
+
`room.removeRoom:只有超级管理员 ${JSON.stringify(USER)} 或房间创建者(${room.createdBy})`
|
|
1428
|
+
+ `能硬删房间 ${room.id},调用者为 ${actor} —— 硬删不可恢复,权限必须收紧`,
|
|
1429
|
+
'ROOM_FORBIDDEN',
|
|
1430
|
+
{ roomId: room.id, actor, createdBy: room.createdBy },
|
|
1431
|
+
)
|
|
1432
|
+
}
|
|
1433
|
+
const messages = messagesByRoom.get(room.id) ?? []
|
|
1434
|
+
if (messages.length > 0 && force !== true) {
|
|
1435
|
+
throw new RoomError(
|
|
1436
|
+
`room.removeRoom:房间 ${room.id} 有 ${messages.length} 条消息,硬删必须显式 force:true`
|
|
1437
|
+
+ '(消息日志 append-only 且无回收站,删了就没了)',
|
|
1438
|
+
'ROOM_NOT_EMPTY',
|
|
1439
|
+
{ roomId: room.id, messages: messages.length },
|
|
1440
|
+
)
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const at = normalizeNow(now, 'removeRoom')
|
|
1444
|
+
// 先落意图:删除失败时内存保持原样,但这次尝试在事件流里可见(不静默)
|
|
1445
|
+
appendRoomEvent(room.id, {
|
|
1446
|
+
type: 'room_removed',
|
|
1447
|
+
roomId: room.id,
|
|
1448
|
+
rev: room.rev,
|
|
1449
|
+
byId: actor,
|
|
1450
|
+
at,
|
|
1451
|
+
messages: messages.length,
|
|
1452
|
+
force: force === true,
|
|
1453
|
+
})
|
|
1454
|
+
|
|
1455
|
+
try {
|
|
1456
|
+
rmSync(roomDir(room.id), { recursive: true, force: false })
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
throw new RoomError(
|
|
1459
|
+
`room.removeRoom:房间目录删除失败(${roomDir(room.id)}):${error?.message ?? error}`
|
|
1460
|
+
+ ' —— 内存与索引保持不变,请核对文件权限后重试',
|
|
1461
|
+
'ROOM_DELETE_FAILED',
|
|
1462
|
+
{ roomId: room.id, dir: roomDir(room.id) },
|
|
1463
|
+
)
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// 目录已删 → 内存/索引同步摘除(内存清理**不可能失败**,放在落盘之后)
|
|
1467
|
+
const messageCount = messages.length
|
|
1468
|
+
rooms.delete(room.id)
|
|
1469
|
+
messagesByRoom.delete(room.id)
|
|
1470
|
+
eventsByRoom.delete(room.id)
|
|
1471
|
+
if (room.name && nameIndex.get(nameKey(room.name)) === room.id) nameIndex.delete(nameKey(room.name))
|
|
1472
|
+
for (const key of [...dedupIndex.keys()]) {
|
|
1473
|
+
if (key.startsWith(`${room.id}::`)) dedupIndex.delete(key)
|
|
1474
|
+
}
|
|
1475
|
+
for (const key of [...relayCounts.keys()]) {
|
|
1476
|
+
if (key.startsWith(`${room.id}::`)) relayCounts.delete(key)
|
|
1477
|
+
}
|
|
1478
|
+
for (const key of [...idem.keys]) {
|
|
1479
|
+
if (key.startsWith(`${room.id}::`)) idem.keys.delete(key)
|
|
1480
|
+
}
|
|
1481
|
+
snapshotIndex()
|
|
1482
|
+
info(`[cs-bots/room] 房间 ${room.id} 已硬删(${messageCount} 条消息随之删除,审计事件在删除前落盘)`)
|
|
1483
|
+
return { removed: room.id, messages: messageCount }
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// ── API:成员角色 / 踢人(DESIGN-v2 ①)──────────────────────────────────────
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* 设置成员角色(`'admin'|'member'|'observer'`)。
|
|
1490
|
+
*
|
|
1491
|
+
* **roles 只描述、不参与权限判定**(权限仍只看 `adminId`)——见文件头说明。
|
|
1492
|
+
* 目标必须是房间成员;`USER` 不可被改角色;无变化不写事件(与 `update` 同口径)。
|
|
1493
|
+
*
|
|
1494
|
+
* @param {{ roomId: string, sessionId: string, role: string, byId: string,
|
|
1495
|
+
* expectedRev?: number, now?: number }} input
|
|
1496
|
+
* @returns {object} 变更后的 Room
|
|
1497
|
+
* @throws {RoomError} 房间/成员不存在 / 无权限 / 非法角色 / 改 USER 角色 / CAS 冲突
|
|
1498
|
+
*/
|
|
1499
|
+
function setMemberRole(input = {}) {
|
|
1500
|
+
const { roomId, sessionId, role, expectedRev, byId, now } = input ?? {}
|
|
1501
|
+
const room = requireRoom(roomId, 'setMemberRole')
|
|
1502
|
+
const actor = requireString(byId, 'byId', 'setMemberRole')
|
|
1503
|
+
const sid = requireString(sessionId, 'sessionId', 'setMemberRole')
|
|
1504
|
+
// DESIGN 的签名没列 expectedRev,但传了就严格比(可选 CAS,与全模块同口径)
|
|
1505
|
+
assertRev(room, expectedRev, 'setMemberRole')
|
|
1506
|
+
assertAdminPrivilege(room, actor, 'setMemberRole')
|
|
1507
|
+
if (sid === USER) {
|
|
1508
|
+
throw new RoomError(
|
|
1509
|
+
`room.setMemberRole:不能修改超级管理员(${JSON.stringify(USER)})的角色 —— 人类入口不可被降权`,
|
|
1510
|
+
'ROOM_CANNOT_MODIFY_USER',
|
|
1511
|
+
{ roomId: room.id, sessionId: sid },
|
|
1512
|
+
)
|
|
1513
|
+
}
|
|
1514
|
+
if (!room.members.includes(sid)) {
|
|
1515
|
+
throw new RoomError(
|
|
1516
|
+
`room.setMemberRole:${sid} 不是房间 ${room.id} 的成员 —— 角色只能挂在成员身上`,
|
|
1517
|
+
'ROOM_NOT_MEMBER',
|
|
1518
|
+
{ roomId: room.id, sessionId: sid, members: room.members },
|
|
1519
|
+
)
|
|
1520
|
+
}
|
|
1521
|
+
const r = normalizeMemberRole(role, 'setMemberRole')
|
|
1522
|
+
if ((room.roles?.[sid] ?? 'member') === r) return clone(room)
|
|
1523
|
+
|
|
1524
|
+
const at = normalizeNow(now, 'setMemberRole')
|
|
1525
|
+
const before = clone(room)
|
|
1526
|
+
const next = clone(room)
|
|
1527
|
+
next.roles = { ...(room.roles ?? {}), [sid]: r }
|
|
1528
|
+
next.rev = room.rev + 1
|
|
1529
|
+
return commit(room.id, 'member_role_changed', actor, at, before, next)
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* 踢出成员(**管理员级操作**,DESIGN「super admin 专用」)。
|
|
1534
|
+
*
|
|
1535
|
+
* 不能踢 `USER`(人类入口);目标必须是成员。被踢者若是 admin 模式的管理员,
|
|
1536
|
+
* 管理权按「最早加入的剩余成员」移交(与 `leave()` 同款,不留锁死房间)。
|
|
1537
|
+
*
|
|
1538
|
+
* @param {{ roomId: string, sessionId: string, byId: string,
|
|
1539
|
+
* expectedRev?: number, now?: number }} input
|
|
1540
|
+
* @returns {object} 变更后的 Room
|
|
1541
|
+
* @throws {RoomError} 房间/成员不存在 / 无权限 / 踢 USER / CAS 冲突
|
|
1542
|
+
*/
|
|
1543
|
+
function kickMember(input = {}) {
|
|
1544
|
+
const { roomId, sessionId, expectedRev, byId, now } = input ?? {}
|
|
1545
|
+
const room = requireRoom(roomId, 'kickMember')
|
|
1546
|
+
const actor = requireString(byId, 'byId', 'kickMember')
|
|
1547
|
+
const sid = requireString(sessionId, 'sessionId', 'kickMember')
|
|
1548
|
+
assertRev(room, expectedRev, 'kickMember')
|
|
1549
|
+
assertAdminPrivilege(room, actor, 'kickMember')
|
|
1550
|
+
if (sid === USER) {
|
|
1551
|
+
throw new RoomError(
|
|
1552
|
+
`room.kickMember:不能踢出超级管理员(${JSON.stringify(USER)})—— 人类入口不可被移除`,
|
|
1553
|
+
'ROOM_CANNOT_KICK_USER',
|
|
1554
|
+
{ roomId: room.id, sessionId: sid },
|
|
1555
|
+
)
|
|
1556
|
+
}
|
|
1557
|
+
if (!room.members.includes(sid)) {
|
|
1558
|
+
throw new RoomError(
|
|
1559
|
+
`room.kickMember:${sid} 不是房间 ${room.id} 的成员(拒绝静默无操作)`,
|
|
1560
|
+
'ROOM_NOT_MEMBER',
|
|
1561
|
+
{ roomId: room.id, sessionId: sid, members: room.members },
|
|
1562
|
+
)
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
const at = normalizeNow(now, 'kickMember')
|
|
1566
|
+
const before = clone(room)
|
|
1567
|
+
const next = clone(room)
|
|
1568
|
+
next.members = room.members.filter((m) => m !== sid)
|
|
1569
|
+
next.relayLimit = relayLimitForMembers(next.members)
|
|
1570
|
+
if (next.roles && Object.prototype.hasOwnProperty.call(next.roles, sid)) delete next.roles[sid]
|
|
1571
|
+
next.rev = room.rev + 1
|
|
1572
|
+
if (room.adminId === sid) {
|
|
1573
|
+
if (next.members.length > 0) {
|
|
1574
|
+
next.adminId = next.members[0]
|
|
1575
|
+
next.roles = { ...(next.roles ?? {}), [next.members[0]]: 'admin' }
|
|
1576
|
+
info(`[cs-bots/room] 房间 ${room.id} 管理员被踢(${sid})→ 管理权移交 ${next.adminId}`)
|
|
1577
|
+
} else {
|
|
1578
|
+
delete next.adminId
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
return commit(room.id, 'member_kicked', actor, at, before, next)
|
|
1582
|
+
}
|
|
1583
|
+
|
|
990
1584
|
// ── API:消息 ──────────────────────────────────────────────────────────────
|
|
991
1585
|
|
|
992
1586
|
/**
|
|
@@ -1152,22 +1746,49 @@ export function createRoom(ctx, config = {}) {
|
|
|
1152
1746
|
/**
|
|
1153
1747
|
* 列出消息(**按 seq 升序**)。
|
|
1154
1748
|
*
|
|
1155
|
-
* @param {{ roomId: string, limit?: number, sinceSeq?: number }} input
|
|
1749
|
+
* @param {{ roomId: string, limit?: number, sinceSeq?: number, withBody?: boolean }} input
|
|
1156
1750
|
* - `sinceSeq`:只要 `seq > sinceSeq` 的(断点续传用)
|
|
1157
1751
|
* - `limit`:取**最新** N 条(不是最早 N 条);缺省不限(**永不截断**)
|
|
1752
|
+
* - `withBody`(DESIGN-v2 ①):true 时每条内联 `body` = 正文**全文**
|
|
1753
|
+
* (长正文从 `bodies/<msgId>.txt` 读回,页面不用再逐条发 `/rooms/body`)
|
|
1158
1754
|
* @returns {object[]}
|
|
1159
1755
|
* @throws {RoomError} 房间不存在 / 参数非法
|
|
1160
1756
|
*/
|
|
1161
1757
|
function listMessages(input = {}) {
|
|
1162
|
-
const { roomId, limit, sinceSeq } = input ?? {}
|
|
1758
|
+
const { roomId, limit, sinceSeq, withBody } = input ?? {}
|
|
1163
1759
|
const room = requireRoom(roomId, 'listMessages')
|
|
1164
1760
|
const since = optionalInteger(sinceSeq, 'sinceSeq', 'listMessages')
|
|
1165
1761
|
const cap = optionalInteger(limit, 'limit', 'listMessages')
|
|
1762
|
+
if (withBody !== undefined && typeof withBody !== 'boolean') {
|
|
1763
|
+
throw new RoomError(
|
|
1764
|
+
`room.listMessages:withBody 必须是布尔值,收到 ${JSON.stringify(withBody)}`,
|
|
1765
|
+
'ROOM_BAD_FIELD',
|
|
1766
|
+
{ field: 'withBody' },
|
|
1767
|
+
)
|
|
1768
|
+
}
|
|
1166
1769
|
|
|
1167
1770
|
let rows = messagesByRoom.get(room.id) ?? []
|
|
1168
1771
|
if (since !== undefined) rows = rows.filter((m) => m.seq > since)
|
|
1169
1772
|
if (cap !== undefined && cap > 0 && rows.length > cap) rows = rows.slice(-cap)
|
|
1170
|
-
|
|
1773
|
+
const out = rows.map(clone)
|
|
1774
|
+
if (withBody !== true) return out
|
|
1775
|
+
|
|
1776
|
+
// 单条正文读失败(旁路文件被清理/损坏)**不能让整页 500**,但也绝不静默:
|
|
1777
|
+
// `body` 回落到日志里的头(与不带 withBody 时一致)+ 显式 `bodyError`,
|
|
1778
|
+
// 调用方据此决定「提示用户」还是「重试」——「看着像全文其实只有头」才是不可接受的。
|
|
1779
|
+
return out.map((message) => {
|
|
1780
|
+
try {
|
|
1781
|
+
message.body = readBody({ roomId: room.id, messageId: message.id })
|
|
1782
|
+
} catch (error) {
|
|
1783
|
+
message.body = message.text
|
|
1784
|
+
message.bodyError = {
|
|
1785
|
+
code: error?.code ?? 'ROOM_BODY_UNREADABLE',
|
|
1786
|
+
message: error?.message ?? String(error),
|
|
1787
|
+
}
|
|
1788
|
+
warn(`[cs-bots/room] listMessages(withBody) 正文读回失败 ${room.id}/${message.id}:${message.bodyError.code}`)
|
|
1789
|
+
}
|
|
1790
|
+
return message
|
|
1791
|
+
})
|
|
1171
1792
|
}
|
|
1172
1793
|
|
|
1173
1794
|
/**
|
|
@@ -1481,10 +2102,17 @@ export function createRoom(ctx, config = {}) {
|
|
|
1481
2102
|
}
|
|
1482
2103
|
|
|
1483
2104
|
let members = 0
|
|
1484
|
-
|
|
2105
|
+
let archived = 0
|
|
2106
|
+
for (const room of rooms.values()) {
|
|
2107
|
+
members += room.members.length
|
|
2108
|
+
if (room.archivedAt !== null && room.archivedAt !== undefined) archived += 1
|
|
2109
|
+
}
|
|
1485
2110
|
|
|
1486
2111
|
return {
|
|
2112
|
+
// ⚠️ 修复(2026-11-04 测试,P2-2):UI 概览读 `stats.total`,此处之前没有 ⇒ 恒显示 `—`
|
|
2113
|
+
total: rooms.size,
|
|
1487
2114
|
rooms: rooms.size,
|
|
2115
|
+
archived,
|
|
1488
2116
|
messages,
|
|
1489
2117
|
bodies,
|
|
1490
2118
|
members,
|
|
@@ -1527,6 +2155,12 @@ export function createRoom(ctx, config = {}) {
|
|
|
1527
2155
|
relay,
|
|
1528
2156
|
readBody,
|
|
1529
2157
|
listMessages,
|
|
2158
|
+
// DESIGN-v2 ①:房间配置 / 归档 / 硬删 / 成员角色(签名见 DESIGN 契约,勿改名)
|
|
2159
|
+
archiveRoom,
|
|
2160
|
+
unarchiveRoom,
|
|
2161
|
+
removeRoom,
|
|
2162
|
+
setMemberRole,
|
|
2163
|
+
kickMember,
|
|
1530
2164
|
replay,
|
|
1531
2165
|
stats,
|
|
1532
2166
|
history,
|
|
@@ -1538,12 +2172,19 @@ export function createRoom(ctx, config = {}) {
|
|
|
1538
2172
|
/** 对外暴露的关键口径(测试与调用方算 relayLimit/阈值时用,避免各处硬编码漂移)。 */
|
|
1539
2173
|
export const ROOM_LIMITS = Object.freeze({
|
|
1540
2174
|
MAX_NOTICE,
|
|
2175
|
+
MAX_PURPOSE,
|
|
1541
2176
|
BODY_THRESHOLD,
|
|
1542
2177
|
BODY_HEAD_CHARS,
|
|
1543
2178
|
RELAY_MULTIPLIER,
|
|
1544
2179
|
RELAY_FLOOR,
|
|
1545
2180
|
})
|
|
1546
2181
|
|
|
2182
|
+
/** 合法成员角色(DESIGN-v2 ①;UI 下拉框直接用,避免前后端各写一份)。 */
|
|
2183
|
+
export const ROOM_MEMBER_ROLES = Object.freeze([...MEMBER_ROLES])
|
|
2184
|
+
|
|
2185
|
+
/** 房间级开关缺省值(DESIGN-v2 ①;UI/API 侧做表单缺省用)。 */
|
|
2186
|
+
export const ROOM_DEFAULT_SETTINGS = Object.freeze({ ...DEFAULT_SETTINGS })
|
|
2187
|
+
|
|
1547
2188
|
/** 用户(人类)发送者 id(`from` 允许的特殊值)。 */
|
|
1548
2189
|
export const ROOM_USER = USER
|
|
1549
2190
|
|