cs-bots 0.8.0 → 0.9.0
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 +186 -0
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +5 -5
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +3 -3
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +3 -3
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +2 -2
- package/package.json +2 -2
- package/src/api.js +100 -1
- package/src/board.js +61 -0
- package/src/client.js +194 -14
- package/src/dispatcher.js +70 -11
- package/src/index.js +8 -1
- package/src/kernel.js +15 -0
- package/src/room.js +160 -9
- package/src/roster.js +54 -0
- package/src/teams.js +353 -4
- package/src/tools.js +48 -2
package/src/room.js
CHANGED
|
@@ -154,6 +154,18 @@ const RELAY_MULTIPLIER = 3
|
|
|
154
154
|
const RELAY_FLOOR = 3
|
|
155
155
|
/** 人类发送者 id(§3.2:`from` 为 sessionId 或 `'user'`)。 */
|
|
156
156
|
const USER = 'user'
|
|
157
|
+
/**
|
|
158
|
+
* 人类身份全集 —— 与 `teams.js` 的 `HUMAN_IDS` 对齐(`ui` / `user` / `person`)。
|
|
159
|
+
*
|
|
160
|
+
* **绝不唤醒人**:人是「看页面的人」,不是会话;`kernel.followup` 对着这些 id 必然失败。
|
|
161
|
+
* 实测证据(2026-11-11):`wakes.jsonl` 里 `room-msg:m-1:ui → failed` ——
|
|
162
|
+
* 缺省广播把 Boss 自己也算成唤醒目标,每次必失败一条。
|
|
163
|
+
*/
|
|
164
|
+
const HUMAN_IDS = Object.freeze(['ui', 'user', 'person'])
|
|
165
|
+
/** 是否为人类身份(房间转发一律跳过)。 */
|
|
166
|
+
function isHumanId(id) {
|
|
167
|
+
return typeof id === 'string' && HUMAN_IDS.includes(id)
|
|
168
|
+
}
|
|
157
169
|
/** 房间事件类型集合(审计流可枚举)。 */
|
|
158
170
|
const EVENTS = [
|
|
159
171
|
'room_created',
|
|
@@ -186,6 +198,14 @@ const DEFAULT_SETTINGS = Object.freeze({
|
|
|
186
198
|
allowBotAutoPost: true,
|
|
187
199
|
maxBotsPerRound: 3,
|
|
188
200
|
relayEnabled: true,
|
|
201
|
+
// ── 定向唤醒(2026-11-11「唤醒风暴」止血)────────────────────────────────
|
|
202
|
+
// 缺省 **true**:房间消息**只在被 @ 时**才唤醒成员。
|
|
203
|
+
//
|
|
204
|
+
// 为什么缺省要改成「安静」:实测 `wakes.jsonl` 里一条消息产生 3 次唤醒尝试、
|
|
205
|
+
// 3 次全失败(`room-msg:m-1:ui` + 两个 bot 全 failed)—— 缺省广播「除自己外全部成员」
|
|
206
|
+
// 既吵又必然失败(`ui` 根本没有会话可唤醒)。改缺省 = 把最吵、最容易失败的路径关掉;
|
|
207
|
+
// 需要广播的房间显式 `mentionOnly:false` 打开。
|
|
208
|
+
mentionOnly: true,
|
|
189
209
|
})
|
|
190
210
|
/** `bodyRef` 的合法形态(防路径穿越:只允许 `bodies/<安全文件名>.txt`)。 */
|
|
191
211
|
const BODY_REF_RE = /^bodies\/[A-Za-z0-9._-]+\.txt$/
|
|
@@ -283,6 +303,55 @@ function stringList(value, field, where) {
|
|
|
283
303
|
return out
|
|
284
304
|
}
|
|
285
305
|
|
|
306
|
+
/**
|
|
307
|
+
* 从消息里收集「被 @ 的成员 id」(与 `admission.isAddressed` 的 `@id` 解析同口径)。
|
|
308
|
+
*
|
|
309
|
+
* 两条来源(取并集):
|
|
310
|
+
* 1. `message.mentions` —— 结构化数组,若有则优先
|
|
311
|
+
* 2. `message.text` 里的 `@id` —— 兜底解析(必须是 word-boundary 后的 `@`,避免误伤 `@bot-2` 当 `@bot`)
|
|
312
|
+
*
|
|
313
|
+
* 仅保留 **room.members 里实际存在的** id(非成员被 @ 了也不算唤醒目标)。
|
|
314
|
+
*
|
|
315
|
+
* ⚠️ 注意:`message.from` 即使被 @ 了也不进唤醒名单(自己不唤醒自己)。
|
|
316
|
+
* `HUMAN_IDS`(人类身份)也在 `wakeable()` 里统一过滤,这里不再二次过滤。
|
|
317
|
+
* @param {object} message
|
|
318
|
+
* @param {string[]} members
|
|
319
|
+
* @returns {string[]} 被 @ 的可唤醒成员 id(去重、保序)
|
|
320
|
+
*/
|
|
321
|
+
function collectMentions(message, members) {
|
|
322
|
+
if (!message || typeof message !== 'object') return []
|
|
323
|
+
const memberSet = new Set(members)
|
|
324
|
+
const out = []
|
|
325
|
+
const seen = new Set()
|
|
326
|
+
|
|
327
|
+
// ① 结构化 mentions 数组
|
|
328
|
+
if (Array.isArray(message.mentions)) {
|
|
329
|
+
for (const m of message.mentions) {
|
|
330
|
+
if (typeof m === 'string' && memberSet.has(m) && !seen.has(m)) {
|
|
331
|
+
seen.add(m)
|
|
332
|
+
out.push(m)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ② 文本里的 @id(与 admission.js 同口径:`@id` 后必须跟非标识符字符或结尾)
|
|
338
|
+
const text = typeof message.text === 'string' ? message.text : ''
|
|
339
|
+
if (text) {
|
|
340
|
+
for (const m of members) {
|
|
341
|
+
if (seen.has(m)) continue
|
|
342
|
+
const idx = text.indexOf(`@${m}`)
|
|
343
|
+
if (idx >= 0) {
|
|
344
|
+
const after = text.slice(idx + 1 + m.length)
|
|
345
|
+
if (after === '' || !/[A-Za-z0-9_-]/.test(after[0])) {
|
|
346
|
+
seen.add(m)
|
|
347
|
+
out.push(m)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return out
|
|
353
|
+
}
|
|
354
|
+
|
|
286
355
|
/** 时间参数归一:缺省 now;支持 ms 数字与 Date。 */
|
|
287
356
|
function normalizeNow(value, where) {
|
|
288
357
|
if (value === undefined || value === null) return Date.now()
|
|
@@ -373,7 +442,7 @@ function normalizePurpose(value, where = 'createRoom') {
|
|
|
373
442
|
|
|
374
443
|
/** `settings` 单个开关校验(按键分派;未知键抛错 —— 拒绝写入「怎么写都不会生效」的配置)。 */
|
|
375
444
|
function normalizeSettingValue(key, value, where) {
|
|
376
|
-
if (key === 'allowBotAutoPost' || key === 'relayEnabled') {
|
|
445
|
+
if (key === 'allowBotAutoPost' || key === 'relayEnabled' || key === 'mentionOnly') {
|
|
377
446
|
if (typeof value !== 'boolean') {
|
|
378
447
|
throw new RoomError(
|
|
379
448
|
`room.${where}:settings.${key} 必须是布尔值,收到 ${JSON.stringify(value)}`,
|
|
@@ -1880,28 +1949,109 @@ export function createRoom(ctx, config = {}) {
|
|
|
1880
1949
|
const message = requireMessage(room.id, mid, 'relay')
|
|
1881
1950
|
const actor = optionalString(byId, 'byId', 'relay')
|
|
1882
1951
|
|
|
1952
|
+
// ══ 目标计算(2026-11-11「唤醒风暴」止血)═══════════════════════════════
|
|
1953
|
+
// 四档,**顺序即优先级**:
|
|
1954
|
+
// ① 显式给了 targets → 原样采信(调用方说了算;仍强制排除人类 id)
|
|
1955
|
+
// ② mentionOnly=true 且消息 @ 了某人 → 只打给被 @ 的成员(不含发送者/人类)
|
|
1956
|
+
// ③ mentionOnly=true 但没 @ → **只落消息,不唤醒任何人**(relayed:0)
|
|
1957
|
+
// ④ mentionOnly=false(显式关掉定向)→ 回退「除自己外全部成员」,但仍排除人类
|
|
1958
|
+
//
|
|
1959
|
+
// 为什么③不抛错:「只记录不打扰」是合法结果(Boss 只是同步信息,不需要谁被叫醒)。
|
|
1960
|
+
// 抛错会把「安静」变成「失败」,页面反而要处理异常 —— 那是更差的设计。
|
|
1961
|
+
const settings = room.settings ?? DEFAULT_SETTINGS
|
|
1962
|
+
const mentionOnly = settings.mentionOnly !== false
|
|
1963
|
+
/** 唤醒排除表:人类一律不算唤醒目标(见 HUMAN_IDS 注释的实测证据)。 */
|
|
1964
|
+
const wakeable = (id) => id !== message.from && !isHumanId(id)
|
|
1883
1965
|
let targetList
|
|
1884
|
-
|
|
1885
|
-
|
|
1966
|
+
let targetSource
|
|
1967
|
+
let noTargetReason
|
|
1968
|
+
|
|
1969
|
+
if (targets !== undefined && targets !== null) {
|
|
1970
|
+
targetList = stringList(targets, 'targets', 'relay')
|
|
1886
1971
|
if (targetList.length === 0) {
|
|
1887
1972
|
throw new RoomError(
|
|
1888
|
-
`room.relay
|
|
1889
|
-
+ ' —— 拒绝空转发(静默无操作)',
|
|
1973
|
+
`room.relay:targets 是空数组 —— 拒绝空转发(静默无操作),请显式给出目标或省略该字段走缺省`,
|
|
1890
1974
|
'ROOM_NO_TARGETS',
|
|
1891
|
-
{ roomId: room.id, messageId: mid
|
|
1975
|
+
{ roomId: room.id, messageId: mid },
|
|
1892
1976
|
)
|
|
1893
1977
|
}
|
|
1978
|
+
targetSource = 'explicit'
|
|
1979
|
+
// 显式 targets 可能混进人类 —— 剔除,但这是**调用方的错**,如实 warn 不静默
|
|
1980
|
+
const humans = targetList.filter((t) => isHumanId(t))
|
|
1981
|
+
if (humans.length > 0) {
|
|
1982
|
+
warn(`[cs-bots/room] relay 显式 targets 含人类身份 ${humans.join(',')} —— 已剔除(人不可被唤醒)`)
|
|
1983
|
+
targetList = targetList.filter((t) => !isHumanId(t))
|
|
1984
|
+
}
|
|
1985
|
+
} else if (mentionOnly) {
|
|
1986
|
+
// 从消息里解析「被 @ 的人」:mentions 字段 + text 里的 `@id`(与 admission.isAddressed 同口径)
|
|
1987
|
+
const mentioned = collectMentions(message, room.members)
|
|
1988
|
+
targetList = mentioned.filter(wakeable)
|
|
1989
|
+
targetSource = 'mention'
|
|
1990
|
+
if (targetList.length === 0) {
|
|
1991
|
+
targetSource = 'mention-only-no-target'
|
|
1992
|
+
noTargetReason = 'mention-only-no-target'
|
|
1993
|
+
}
|
|
1894
1994
|
} else {
|
|
1895
|
-
targetList =
|
|
1995
|
+
targetList = room.members.filter(wakeable)
|
|
1996
|
+
targetSource = 'broadcast'
|
|
1896
1997
|
if (targetList.length === 0) {
|
|
1897
1998
|
throw new RoomError(
|
|
1898
|
-
`room.relay
|
|
1999
|
+
`room.relay:房间 ${room.id} 除发送者(${message.from})外没有其他可唤醒成员,且未显式指定 targets`
|
|
2000
|
+
+ ' —— 拒绝空转发(静默无操作)',
|
|
1899
2001
|
'ROOM_NO_TARGETS',
|
|
1900
|
-
{ roomId: room.id, messageId: mid },
|
|
2002
|
+
{ roomId: room.id, messageId: mid, from: message.from },
|
|
1901
2003
|
)
|
|
1902
2004
|
}
|
|
1903
2005
|
}
|
|
1904
2006
|
|
|
2007
|
+
// ── ③「没 @ 到谁」:合法空转发 —— 记事件、返回 relayed:0,**不抛错** ──────
|
|
2008
|
+
// 必须在后台账之前分流:否则每次安静发言都吃掉一次 relayLimit 配额。
|
|
2009
|
+
if (targetList.length === 0) {
|
|
2010
|
+
const at0 = normalizeNow(now, 'relay')
|
|
2011
|
+
appendRoomEvent(room.id, {
|
|
2012
|
+
type: 'relay',
|
|
2013
|
+
roomId: room.id,
|
|
2014
|
+
rev: room.rev,
|
|
2015
|
+
byId: actor ?? null,
|
|
2016
|
+
at: at0,
|
|
2017
|
+
messageId: mid,
|
|
2018
|
+
targets: [],
|
|
2019
|
+
attempted: 0,
|
|
2020
|
+
limit: room.relayLimit,
|
|
2021
|
+
targetSource,
|
|
2022
|
+
reason: noTargetReason ?? 'no-wakeable-target',
|
|
2023
|
+
})
|
|
2024
|
+
let audit0 = { written: true }
|
|
2025
|
+
try {
|
|
2026
|
+
appendRoomEvent(room.id, {
|
|
2027
|
+
type: 'relay_result',
|
|
2028
|
+
roomId: room.id,
|
|
2029
|
+
rev: room.rev,
|
|
2030
|
+
byId: actor ?? null,
|
|
2031
|
+
at: at0,
|
|
2032
|
+
messageId: mid,
|
|
2033
|
+
deliveries: [],
|
|
2034
|
+
})
|
|
2035
|
+
} catch (error) {
|
|
2036
|
+
audit0 = { written: false, reason: `relay-result-append-failed:${error?.message ?? error}` }
|
|
2037
|
+
diagnostics.relayResultAppendFailures += 1
|
|
2038
|
+
warn(`[cs-bots/room] 转发结果事件写入失败(投递已发生):${audit0.reason}`)
|
|
2039
|
+
}
|
|
2040
|
+
return {
|
|
2041
|
+
ok: true,
|
|
2042
|
+
roomId: room.id,
|
|
2043
|
+
messageId: mid,
|
|
2044
|
+
relayed: 0,
|
|
2045
|
+
attempted: 0,
|
|
2046
|
+
limit: room.relayLimit,
|
|
2047
|
+
remaining: Math.max(0, room.relayLimit - (relayCounts.get(relayKey(room.id, mid)) ?? 0)),
|
|
2048
|
+
deliveries: [],
|
|
2049
|
+
audit: audit0,
|
|
2050
|
+
targetSource,
|
|
2051
|
+
reason: noTargetReason ?? 'no-wakeable-target',
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
1905
2055
|
for (const t of targetList) {
|
|
1906
2056
|
if (!room.members.includes(t)) {
|
|
1907
2057
|
throw new RoomError(
|
|
@@ -1992,6 +2142,7 @@ export function createRoom(ctx, config = {}) {
|
|
|
1992
2142
|
remaining: Math.max(0, limit - (relayCounts.get(rkey) ?? 0)),
|
|
1993
2143
|
deliveries,
|
|
1994
2144
|
audit,
|
|
2145
|
+
targetSource,
|
|
1995
2146
|
}
|
|
1996
2147
|
if (failed.length > 0) {
|
|
1997
2148
|
result.reason = failed
|
package/src/roster.js
CHANGED
|
@@ -831,6 +831,59 @@ export function createRoster(ctx, config = {}) {
|
|
|
831
831
|
return commit(current.id, 'rename', by, at, before, next)
|
|
832
832
|
}
|
|
833
833
|
|
|
834
|
+
/**
|
|
835
|
+
* 改预制体(preset)—— 2026-11-11 bots04 补的能力缺口。
|
|
836
|
+
*
|
|
837
|
+
* ## 为什么需要它
|
|
838
|
+
*
|
|
839
|
+
* `preset` 此前**只能**在 `create()` 时设置,之后没有任何接口能改。
|
|
840
|
+
* 实测后果:`myfree调研员`(t-36 挂 `agent-not-live` 的那个)preset 为空,
|
|
841
|
+
* 而空 preset = 走宿主默认预设 = **工具面没有保证**(连 `bot_*` 工具都不一定有)。
|
|
842
|
+
* 想修只能删掉重建 —— 那会丢掉 sessionId 与全部历史关联,代价过高。
|
|
843
|
+
*
|
|
844
|
+
* ## 语义边界
|
|
845
|
+
*
|
|
846
|
+
* - **只改 preset,不重建会话**:已经起来的会话不会被换预设。
|
|
847
|
+
* 这是诚实的边界 —— 换预设真正生效要等该会话下次启动。
|
|
848
|
+
* 调用方要能看见这一点,所以返回值里带 `appliesOnNextSession: true`。
|
|
849
|
+
* - 退役 Bot 不可改(与 `rename`/`setCwd` 同口径)。
|
|
850
|
+
* - CAS:必须带 `expectedRev`。
|
|
851
|
+
* - 空串 / null ⇒ 清除 preset(回到宿主默认)。这与 `create` 里
|
|
852
|
+
* 「不传/空串则不写 preset 字段」是同一口径,避免出现 `preset: ''` 这种半状态。
|
|
853
|
+
*
|
|
854
|
+
* @param {{ id: string, preset: string|null, byId: string, expectedRev: number, now?: number }} input
|
|
855
|
+
* @returns {object} Bot(含 `appliesOnNextSession`)
|
|
856
|
+
* @throws {RosterError} 不存在 / 已退役 / 缺字段 / rev 不匹配
|
|
857
|
+
*/
|
|
858
|
+
function setPreset(input = {}) {
|
|
859
|
+
const { preset, byId, now } = input ?? {}
|
|
860
|
+
const current = loadForCas(input, 'setPreset')
|
|
861
|
+
const by = requireString(byId, 'byId', 'setPreset')
|
|
862
|
+
assertNotRetired(current, 'setPreset', '换预制体')
|
|
863
|
+
|
|
864
|
+
const raw = preset === undefined || preset === null ? '' : String(preset).trim()
|
|
865
|
+
const nextPreset = raw === '' ? undefined : raw
|
|
866
|
+
if ((current.preset ?? undefined) === nextPreset) {
|
|
867
|
+
throw new RosterError(
|
|
868
|
+
`roster.setPreset:Bot ${current.id} 的 preset 已是 ${JSON.stringify(nextPreset ?? null)}——无需变更`,
|
|
869
|
+
'ROSTER_NOTHING_TO_SET_PRESET',
|
|
870
|
+
{ id: current.id, preset: nextPreset ?? null },
|
|
871
|
+
)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const at = normalizeNow(now, 'setPreset')
|
|
875
|
+
const before = clone(current)
|
|
876
|
+
const next = clone(current)
|
|
877
|
+
next.rev = current.rev + 1
|
|
878
|
+
if (nextPreset === undefined) delete next.preset
|
|
879
|
+
else next.preset = nextPreset
|
|
880
|
+
next.presetUpdatedAt = at
|
|
881
|
+
next.presetUpdatedBy = by
|
|
882
|
+
const committed = commit(current.id, 'setPreset', by, at, before, next)
|
|
883
|
+
// 诚实告知:换 preset 不会立刻影响已存在的会话
|
|
884
|
+
return { ...committed, appliesOnNextSession: true }
|
|
885
|
+
}
|
|
886
|
+
|
|
834
887
|
/**
|
|
835
888
|
* 退役 → **终态,但释放名额**(名字可复用、cwd 可回收;§3.1 "关键" 段)。
|
|
836
889
|
*
|
|
@@ -1408,6 +1461,7 @@ export function createRoster(ctx, config = {}) {
|
|
|
1408
1461
|
hasSession,
|
|
1409
1462
|
rename,
|
|
1410
1463
|
retire,
|
|
1464
|
+
setPreset,
|
|
1411
1465
|
setCwd,
|
|
1412
1466
|
setPrepareCommand,
|
|
1413
1467
|
getProfile,
|