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/teams.js
CHANGED
|
@@ -64,7 +64,10 @@ export const DEFAULT_TEAM_ADMIN_POLICY = 'open'
|
|
|
64
64
|
const HUMAN_IDS = Object.freeze(['ui', 'user', 'person'])
|
|
65
65
|
|
|
66
66
|
/** 合法动作(含 setPolicy:策略变更本身也是要留痕的事件)。 */
|
|
67
|
-
const ACTIONS = Object.freeze(['create', 'addMember', 'removeMember', 'setRole', 'close', 'note', 'setPolicy', 'attachRoom'])
|
|
67
|
+
const ACTIONS = Object.freeze(['create', 'addMember', 'removeMember', 'setRole', 'close', 'note', 'setPolicy', 'attachRoom', 'route'])
|
|
68
|
+
|
|
69
|
+
/** 跨团队路由的最大 hop 数(>3 拒绝)。 */
|
|
70
|
+
const ROUTE_MAX_HOP = 3
|
|
68
71
|
|
|
69
72
|
/** 结构化错误。 */
|
|
70
73
|
export class TeamError extends Error {
|
|
@@ -192,6 +195,12 @@ export function createTeams(ctx, config = {}) {
|
|
|
192
195
|
if (rows.length === 0) { diagnostics.hydratedFrom = 'empty'; return }
|
|
193
196
|
let skipped = 0
|
|
194
197
|
for (const ev of rows) {
|
|
198
|
+
// route 事件是**审计记录**,不是团队状态变更 —— 没有 id/rev,仍要进 events
|
|
199
|
+
// 供 listRoutes 查询;只有团队状态变更事件才需要 id + rev 去重。
|
|
200
|
+
if (ev?.action === 'route') {
|
|
201
|
+
events.push(clone(ev))
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
195
204
|
if (!ev || typeof ev.id !== 'string' || !ev.after) { skipped += 1; continue }
|
|
196
205
|
const current = entries.get(ev.id)
|
|
197
206
|
if (current && Number.isFinite(ev.rev) && ev.rev <= current.rev) { skipped += 1; continue }
|
|
@@ -282,7 +291,7 @@ export function createTeams(ctx, config = {}) {
|
|
|
282
291
|
*/
|
|
283
292
|
function create(input = {}) {
|
|
284
293
|
assertAlive('create')
|
|
285
|
-
const { name, type, purpose, memberIds, leadId, adminPolicy, by, now } = input ?? {}
|
|
294
|
+
const { name, type, purpose, memberIds, leadId, adminPolicy, by, now, kind, nativeTeamId } = input ?? {}
|
|
286
295
|
const nameNorm = requireString(name, 'name', 'create')
|
|
287
296
|
if (nameNorm.length > 120) throw new TeamError(`teams.create:name ≤120 字符(收到 ${nameNorm.length})`, 'TEAM_BAD_FIELD', { field: 'name' })
|
|
288
297
|
const teamType = type ?? 'big'
|
|
@@ -301,18 +310,40 @@ export function createTeams(ctx, config = {}) {
|
|
|
301
310
|
throw new TeamError(`teams.create:leadId ${lead} 不能同时出现在 memberIds 中(会形成自引用)`, 'TEAM_BAD_FIELD', { field: 'memberIds' })
|
|
302
311
|
}
|
|
303
312
|
|
|
313
|
+
// ── kind:人类/智能体(服务层强制推导,防止伪造)─────────────────────────
|
|
314
|
+
// 由执行者身份(author)硬推导:HUMAN_IDS ⇒ 'human',其他 ⇒ 'agent'。
|
|
315
|
+
// 显式传 kind 但与推导矛盾 ⇒ 抛 TEAM_KIND_MISMATCH(不许伪造 Boss 建的团队)。
|
|
316
|
+
const derivedKind = HUMAN_IDS.includes(author) ? 'human' : 'agent'
|
|
317
|
+
if (kind !== undefined && kind !== null && kind !== derivedKind) {
|
|
318
|
+
throw new TeamError(
|
|
319
|
+
`teams.create:kind=${JSON.stringify(kind)} 与执行者身份矛盾(执行者 ${author} ⇒ '${derivedKind}')——不许伪造`,
|
|
320
|
+
'TEAM_KIND_MISMATCH',
|
|
321
|
+
{ author, derivedKind, requested: kind },
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
const teamKind = derivedKind
|
|
325
|
+
|
|
304
326
|
idCounter += 1
|
|
305
327
|
const id = `team-${idCounter}`
|
|
306
328
|
/** @type {Record<string, unknown>} */
|
|
307
329
|
const rec = {
|
|
308
|
-
id, rev: 1, name: nameNorm, type: teamType, purpose: purpose ?? '',
|
|
330
|
+
id, rev: 1, name: nameNorm, type: teamType, kind: teamKind, purpose: purpose ?? '',
|
|
309
331
|
leadId: lead, members,
|
|
332
|
+
// ── t-39 第2批 C:绑定 DSH 原生 team ──
|
|
333
|
+
// cs-bots 团队(teams.jsonl)与 DSH 原生 team(agentTeams)是**两套人模型**,
|
|
334
|
+
// 没有任何自动交集(原生 TeamId = root session id,跟我们的 team-N 毫无关系)
|
|
335
|
+
// ⇒ 必须显式绑定。这个字段是「Boss 在这个 cs-bots 房间发言时,该同步给哪个
|
|
336
|
+
// 原生团队」的唯一依据。
|
|
337
|
+
//
|
|
338
|
+
// 为什么从 cs-bots 侧配:原生侧**没有**「列出所有原生 team」的接口,
|
|
339
|
+
// 只有 `tryMembership(agent)`(从某个成员反查它所属团队)⇒ 反向枚举不可行。
|
|
340
|
+
nativeTeamId: typeof nativeTeamId === 'string' && nativeTeamId.length > 0 ? nativeTeamId : null,
|
|
310
341
|
adminPolicy: policy,
|
|
311
342
|
status: 'open',
|
|
312
343
|
createdAt: at, createdBy: author, updatedAt: at, updatedBy: author,
|
|
313
344
|
}
|
|
314
345
|
commit('create', at, author, null, rec)
|
|
315
|
-
info(`[cs-bots/teams] 团队 ${id}(${nameNorm},${teamType})创建 by ${author}`)
|
|
346
|
+
info(`[cs-bots/teams] 团队 ${id}(${nameNorm},${teamType},${teamKind})创建 by ${author}`)
|
|
316
347
|
return clone(rec)
|
|
317
348
|
}
|
|
318
349
|
|
|
@@ -502,6 +533,209 @@ export function createTeams(ctx, config = {}) {
|
|
|
502
533
|
return clone(next)
|
|
503
534
|
}
|
|
504
535
|
|
|
536
|
+
// ── A. 跨团队路由(t-39 第2批 · bot_team_route 的服务层本体)────────────────
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 解析并校验一条跨团队路由目标 —— **只做决定,不做投递**。
|
|
540
|
+
*
|
|
541
|
+
* 为什么拆开:投递要 dispatcher(wakes.jsonl / dedup / 背压),而 dispatcher 挂在
|
|
542
|
+
* `src/tools.js` 的组装层(`s.dispatcher`)。若把投递塞进 TeamsService,本服务就得
|
|
543
|
+
* 反向依赖 dispatcher —— 两个域服务互指会让装配顺序变成硬约束。所以这里只回答
|
|
544
|
+
* 「路由到谁」,投递由工具层完成,语义边界清晰。
|
|
545
|
+
*
|
|
546
|
+
* 失败一律**返回可读的 refused**,不抛裸错 —— 契约要求 `{ok:false, decision:'refused',
|
|
547
|
+
* reason}` 而不是异常(异常在工具层会被包成 `tool-crashed`,理由就丢了)。
|
|
548
|
+
*
|
|
549
|
+
* @param {{ fromTeam?: string, toTeam?: string, toBot?: string,
|
|
550
|
+
* taskId: string, text?: string, hop?: number, by: string }} input
|
|
551
|
+
* @returns {{ ok: true, decision: 'accepted', to: { kind: 'team'|'bot', id: string,
|
|
552
|
+
* leadId: string, name: string }, hop: number, dedupKey: string,
|
|
553
|
+
* fromTeam: string }
|
|
554
|
+
* | { ok: false, decision: 'refused', reason: string, to?: object, hop?: number }}
|
|
555
|
+
*/
|
|
556
|
+
function resolveRoute(input = {}) {
|
|
557
|
+
assertAlive('resolveRoute')
|
|
558
|
+
const { fromTeam, toTeam, toBot, taskId, hop, by } = input ?? {}
|
|
559
|
+
const author = requireString(by, 'by', 'resolveRoute')
|
|
560
|
+
|
|
561
|
+
// ── 参数校验:全部**可读拒绝** ──
|
|
562
|
+
if (typeof taskId !== 'string' || taskId.trim() === '') {
|
|
563
|
+
return { ok: false, decision: 'refused', reason: 'ROUTE_TASK_ID_REQUIRED' }
|
|
564
|
+
}
|
|
565
|
+
if (!toTeam && !toBot) {
|
|
566
|
+
return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_REQUIRED' }
|
|
567
|
+
}
|
|
568
|
+
if (toTeam && toBot) {
|
|
569
|
+
return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_AMBIGUOUS' }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ── hop 计数:>3 拒绝(环路防护第一道闸)──
|
|
573
|
+
const hopCount = Number.isFinite(Number(hop)) ? Math.max(0, Math.trunc(Number(hop))) : 0
|
|
574
|
+
if (hopCount > ROUTE_MAX_HOP) {
|
|
575
|
+
return { ok: false, decision: 'refused', reason: 'ROUTE_HOP_EXCEEDED', hop: hopCount }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ── 解析目标团队 / 目标 lead ──
|
|
579
|
+
let targetTeamId = null
|
|
580
|
+
let targetLeadId = null
|
|
581
|
+
let targetName = null
|
|
582
|
+
|
|
583
|
+
if (toTeam) {
|
|
584
|
+
const team = entries.get(toTeam)
|
|
585
|
+
if (!team) return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_NOT_FOUND', hop: hopCount }
|
|
586
|
+
targetTeamId = team.id
|
|
587
|
+
targetLeadId = team.leadId ?? null
|
|
588
|
+
targetName = team.name
|
|
589
|
+
} else {
|
|
590
|
+
// toBot:必须**属于**某个 cs-bots 团队(leadId 或成员),否则无法确定唤醒谁
|
|
591
|
+
for (const t of entries.values()) {
|
|
592
|
+
if (t.leadId === toBot || (Array.isArray(t.members) && t.members.includes(toBot))) {
|
|
593
|
+
targetTeamId = t.id
|
|
594
|
+
targetLeadId = t.leadId ?? null
|
|
595
|
+
targetName = t.name
|
|
596
|
+
break
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (!targetTeamId) {
|
|
600
|
+
return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_NOT_FOUND', hop: hopCount }
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 目标团队必须有人可唤 —— 契约:**只唤 leadId**,不 wake 全部成员
|
|
605
|
+
if (!targetLeadId) {
|
|
606
|
+
return {
|
|
607
|
+
ok: false,
|
|
608
|
+
decision: 'refused',
|
|
609
|
+
reason: 'ROUTE_NO_LEAD',
|
|
610
|
+
to: { kind: toTeam ? 'team' : 'bot', id: targetTeamId, name: targetName },
|
|
611
|
+
hop: hopCount,
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const fromKey = fromTeam ?? author
|
|
616
|
+
|
|
617
|
+
// 去重键:交给 dispatcher 的 dedupKey 机制(不自己造一套)。
|
|
618
|
+
// **不含 hop** —— 这样同一 (from→to, taskId) 的重复路由会命中 dispatcher 的
|
|
619
|
+
// IdempotencySet 而被判 skipped,正是「A→B→A→B 第 3 跳被拒」的实现路径。
|
|
620
|
+
const dedupKey = `route:${fromKey}:${targetTeamId}:${taskId}`
|
|
621
|
+
|
|
622
|
+
return {
|
|
623
|
+
ok: true,
|
|
624
|
+
decision: 'accepted',
|
|
625
|
+
to: { kind: toTeam ? 'team' : 'bot', id: targetTeamId, leadId: targetLeadId, name: targetName },
|
|
626
|
+
hop: hopCount,
|
|
627
|
+
dedupKey,
|
|
628
|
+
fromTeam: fromKey,
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* **一条龙路由**:决定 → 投递 → 留痕。工具层(`bot_team_route`)与 HTTP 层
|
|
634
|
+
* (`POST /teams/route`)**共用这一个方法**。
|
|
635
|
+
*
|
|
636
|
+
* 为什么必须共用:两条路各写一遍,行为就会漂 —— 工具层去重了、HTTP 层没去重,
|
|
637
|
+
* 或者一边先投后记、一边先记后投。契约要求「跨团队路由」只有一套语义,
|
|
638
|
+
* 所以序列编排只能有一处。
|
|
639
|
+
*
|
|
640
|
+
* 依赖注入:`dispatcher` 由调用方传入(TeamsService 不反向依赖 dispatcher,
|
|
641
|
+
* 否则两个域服务互指,装配顺序变成硬约束)。
|
|
642
|
+
*
|
|
643
|
+
* @param {{ fromTeam?: string, toTeam?: string, toBot?: string, taskId?: string,
|
|
644
|
+
* text?: string, hop?: number, by: string, now?: number,
|
|
645
|
+
* dispatcher: object }} input
|
|
646
|
+
* @returns {object} `{ok, decision, to, hop, wake, reason?, taskId}` —— DecisionKind 语义
|
|
647
|
+
*/
|
|
648
|
+
function deliverRoute(input = {}) {
|
|
649
|
+
assertAlive('deliverRoute')
|
|
650
|
+
const { dispatcher, taskId: rawTaskId, by, now, text, hop } = input ?? {}
|
|
651
|
+
const author = requireString(by, 'by', 'deliverRoute')
|
|
652
|
+
|
|
653
|
+
// taskId 缺省生成:页面上的「发协作请求」不一定挂任务(Boss 只是想跨团队喊一句)。
|
|
654
|
+
// 每轮生成独立 id ⇒ 不会被 dispatcher 误判重(同一轮重发才判重,由调用方复用同一 id)。
|
|
655
|
+
const taskId = typeof rawTaskId === 'string' && rawTaskId.trim() !== ''
|
|
656
|
+
? rawTaskId.trim()
|
|
657
|
+
: `route-${normalizeNow(now, 'deliverRoute')}`
|
|
658
|
+
|
|
659
|
+
const decision = resolveRoute({ ...input, taskId, by: author })
|
|
660
|
+
if (!decision.ok) return { ...decision, taskId }
|
|
661
|
+
|
|
662
|
+
if (!dispatcher || typeof dispatcher.notify !== 'function') {
|
|
663
|
+
return { ok: false, decision: 'refused', reason: 'ROUTE_NO_DISPATCHER', to: decision.to, hop: decision.hop, taskId }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// 投递:只唤对方 leadId;dedupKey 交给 dispatcher(不自己造去重);
|
|
667
|
+
// teamId 让 byTeam() 能按团队聚合唤醒成本。
|
|
668
|
+
const wakeText = text ?? `[cs-bots 跨团队路由] ${decision.fromTeam} → ${decision.to.id}`
|
|
669
|
+
+ `(lead=${decision.to.leadId}, hop=${decision.hop}, task=${taskId})`
|
|
670
|
+
const wake = dispatcher.notify({
|
|
671
|
+
target: decision.to.leadId,
|
|
672
|
+
reason: 'room-message',
|
|
673
|
+
dedupKey: decision.dedupKey,
|
|
674
|
+
text: wakeText,
|
|
675
|
+
teamId: decision.to.id,
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
// 留痕在投递之后(投递已有 dedupKey 记在 wakes.jsonl;这里记的是「路由决策」本身)。
|
|
679
|
+
// 落盘失败**必须显式报错**,不能吞 —— 「投了没记」会让环路统计漏跳。
|
|
680
|
+
try {
|
|
681
|
+
recordRoute({ to: decision.to, fromTeam: decision.fromTeam, taskId, text, hop: decision.hop, by: author, now, wake })
|
|
682
|
+
} catch (recordError) {
|
|
683
|
+
return {
|
|
684
|
+
ok: false, decision: 'refused', reason: 'ROUTE_RECORD_FAILED',
|
|
685
|
+
message: recordError?.message ?? String(recordError),
|
|
686
|
+
to: decision.to, hop: decision.hop, wake, taskId,
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return { ok: true, decision: 'delivered', to: decision.to, hop: decision.hop, wake, taskId }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* 把已经做出的路由决定**落盘留痕**(intent-first:先记后投)。
|
|
695
|
+
*
|
|
696
|
+
* 顺序不可颠倒:`记了没投` 是可见可重试的;`投了没记` 则绕过限额/审计,
|
|
697
|
+
* 让环路防护统计不到这一跳 —— 那正是要防的。
|
|
698
|
+
* @param {{ to: object, fromTeam: string, taskId: string, text?: string, hop: number,
|
|
699
|
+
* by: string, now?: number, wake?: object }} input
|
|
700
|
+
*/
|
|
701
|
+
function recordRoute(input = {}) {
|
|
702
|
+
assertAlive('recordRoute')
|
|
703
|
+
const { to, fromTeam, taskId, text, hop, by, now, wake } = input ?? {}
|
|
704
|
+
const author = requireString(by, 'by', 'recordRoute')
|
|
705
|
+
const at = normalizeNow(now, 'recordRoute')
|
|
706
|
+
const rec = {
|
|
707
|
+
toKind: to?.kind ?? 'team',
|
|
708
|
+
toId: to?.id ?? null,
|
|
709
|
+
toLeadId: to?.leadId ?? null,
|
|
710
|
+
toName: to?.name ?? null,
|
|
711
|
+
fromTeam: fromTeam ?? null,
|
|
712
|
+
taskId,
|
|
713
|
+
text: text ?? '',
|
|
714
|
+
hop: Number(hop ?? 0),
|
|
715
|
+
wake: wake ?? null,
|
|
716
|
+
at,
|
|
717
|
+
}
|
|
718
|
+
if (!appendJsonl(jsonlPath, { action: 'route', at, by: author, before: null, after: rec })) {
|
|
719
|
+
throw new TeamError(`teams.recordRoute:事件追加失败(${jsonlPath})—— 拒绝「投了没记」`, 'TEAM_APPEND_FAILED', { action: 'route' })
|
|
720
|
+
}
|
|
721
|
+
events.push({ action: 'route', at, by: author, before: null, after: clone(rec) })
|
|
722
|
+
info(`[cs-bots/teams] route ${rec.fromTeam} → ${rec.toId}(lead=${rec.toLeadId}, hop=${rec.hop}, task=${taskId})by ${author}`)
|
|
723
|
+
return clone(rec)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* 查 route 审计记录(按 toId / taskId / fromTeam 过滤)。
|
|
728
|
+
* @param {{ toId?: string, fromTeam?: string, taskId?: string }} [query]
|
|
729
|
+
*/
|
|
730
|
+
function listRoutes(query = {}) {
|
|
731
|
+
assertAlive('listRoutes')
|
|
732
|
+
let rows = events.filter((e) => e.action === 'route').map((e) => ({ ...e.after, by: e.by, at: e.at }))
|
|
733
|
+
if (query?.toId !== undefined && query?.toId !== null) rows = rows.filter((r) => r.toId === query.toId)
|
|
734
|
+
if (query?.fromTeam !== undefined && query?.fromTeam !== null) rows = rows.filter((r) => r.fromTeam === query.fromTeam)
|
|
735
|
+
if (query?.taskId !== undefined && query?.taskId !== null) rows = rows.filter((r) => r.taskId === query.taskId)
|
|
736
|
+
return clone(rows)
|
|
737
|
+
}
|
|
738
|
+
|
|
505
739
|
// ── API:读 ────────────────────────────────────────────────────────────────
|
|
506
740
|
|
|
507
741
|
function list(query = {}) {
|
|
@@ -513,6 +747,113 @@ export function createTeams(ctx, config = {}) {
|
|
|
513
747
|
return clone(rows.map((r) => (r.adminPolicy ? r : { ...r, adminPolicy: DEFAULT_TEAM_ADMIN_POLICY })))
|
|
514
748
|
}
|
|
515
749
|
|
|
750
|
+
/**
|
|
751
|
+
* 按房间 id 反查绑定它的团队(t-39 第2批 C)。
|
|
752
|
+
*
|
|
753
|
+
* 用途:Boss 在 cs-bots 团队的议事厅发言 ⇒ 知道该同步给哪个原生 team。
|
|
754
|
+
* 一个房间只可能被一个团队绑定(`attachRoom` 是 CAS 单值覆盖)。
|
|
755
|
+
*
|
|
756
|
+
* @param {string} roomId
|
|
757
|
+
* @returns {object|null} 团队记录(含 `nativeTeamId`),没绑定 ⇒ null
|
|
758
|
+
*/
|
|
759
|
+
function findByRoom(roomId) {
|
|
760
|
+
assertAlive('findByRoom')
|
|
761
|
+
if (typeof roomId !== 'string' || roomId.length === 0) return null
|
|
762
|
+
for (const t of entries.values()) {
|
|
763
|
+
if (t.roomId === roomId) return get(t.id)
|
|
764
|
+
}
|
|
765
|
+
return null
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* **发言同步给 DSH 原生 team 的 Lead**(t-39 第2批 C · Boss 体验 item 5)。
|
|
770
|
+
*
|
|
771
|
+
* ### 为什么要这个
|
|
772
|
+
* Boss 在 cs-bots 团队的议事厅发言,原生 team 的 Lead 感知不到 —— 因为两套
|
|
773
|
+
* 团队模型是**分离的**(cs-bots `teams.jsonl` vs DSH `agentTeam`),消息不会自动
|
|
774
|
+
* 跨过去。这个方法负责把这条链补上。
|
|
775
|
+
*
|
|
776
|
+
* ### 链路(lead 批准的实现方向,2026-11-11)
|
|
777
|
+
* Boss 在 cs-bots 房间发言 → `findByRoom(roomId)` 反查绑定团队
|
|
778
|
+
* → 取该团队的 `nativeTeamId` → 投递给原生 team 的 Lead。
|
|
779
|
+
*
|
|
780
|
+
* **为什么投递 target = nativeTeamId 就是投递给 Lead**:
|
|
781
|
+
* DSH 原生 `TeamId` 就是 root session id(`TeamId(id: SessionId|string)`,
|
|
782
|
+
* 且 `tryMembership(agent).root.id === membership.id`)。所以原生 team 的 Lead
|
|
783
|
+
* 会话 id 就等于这个 TeamId。投递 root = 投递 Lead,走的是 root 路径,**合法**。
|
|
784
|
+
*
|
|
785
|
+
* ### 为什么用 dispatcher 而不是 agentTeams.sendMessage
|
|
786
|
+
* 官方 `sendMessage(caller, ...)` 需要 Lead 身份的 `Agent` 作为凭证,且校验
|
|
787
|
+
* Lead→child 边 —— 队友不能给 Lead 发消息。cs-bots 是 root session,用
|
|
788
|
+
* `dispatcher.followup(root)` 投递是 root 路径,不冒充任何人。
|
|
789
|
+
*
|
|
790
|
+
* ### 诚实原则(硬要求)
|
|
791
|
+
* 同步失败**绝不**返回 `ok:false`,也**绝不**假装成功:
|
|
792
|
+
* `{ ok: true, synced: false, reason }` —— 房间消息本身已经成功落盘,
|
|
793
|
+
* 同步是附加动作,失败必须可见但不影响消息。
|
|
794
|
+
*
|
|
795
|
+
* reason 取值(给前端 A 的确定形状):
|
|
796
|
+
* - `NATIVE_TEAM_NOT_FOUND` 房间没绑任何 cs-bots 团队
|
|
797
|
+
* - `NATIVE_TEAM_NOT_BOUND` 团队存在但 `nativeTeamId` 为 null(要先配置绑定)
|
|
798
|
+
* - `SYNC_DISPATCHER_UNAVAILABLE` 装配层没注入 dispatcher
|
|
799
|
+
* - `SYNC_DEDUP_HIT` 这条消息之前已同步过(幂等,不算新同步也不算失败)
|
|
800
|
+
* - `SYNC_DELIVERY_FAILED` 投递失败(dispatcher 返回 failed)
|
|
801
|
+
* - `SYNCED` 同步成功
|
|
802
|
+
*
|
|
803
|
+
* @param {{ roomId: string, messageId?: string, text?: string, by: string,
|
|
804
|
+
* dispatcher: object }} input
|
|
805
|
+
* @returns {{ ok: true, synced: boolean, reason: string,
|
|
806
|
+
* teamId?: string, nativeTeamId?: string, leadId?: string, wake?: object }}
|
|
807
|
+
*/
|
|
808
|
+
function syncToNativeLead(input = {}) {
|
|
809
|
+
assertAlive('syncToNativeLead')
|
|
810
|
+
const { roomId, messageId, text, by, dispatcher } = input ?? {}
|
|
811
|
+
requireString(by, 'by', 'syncToNativeLead')
|
|
812
|
+
|
|
813
|
+
// ① 反查房间属于哪个 cs-bots 团队(一个房间只可能被一个团队绑定)
|
|
814
|
+
const team = findByRoom(roomId)
|
|
815
|
+
if (!team) {
|
|
816
|
+
return { ok: true, synced: false, reason: 'NATIVE_TEAM_NOT_FOUND', detail: '房间未绑定任何 cs-bots 团队' }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ② 团队有没有绑定 DSH 原生 team —— 这是同步的唯一依据
|
|
820
|
+
if (!team.nativeTeamId) {
|
|
821
|
+
return {
|
|
822
|
+
ok: true, synced: false, reason: 'NATIVE_TEAM_NOT_BOUND',
|
|
823
|
+
teamId: team.id, detail: '团队未绑定 DSH 原生 team,请先在团队详情里配置 nativeTeamId',
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ③ dispatcher 在不在(缺失 ⇒ 无法投递,但不是消息本身的错)
|
|
828
|
+
if (!dispatcher || typeof dispatcher.notify !== 'function') {
|
|
829
|
+
return {
|
|
830
|
+
ok: true, synced: false, reason: 'SYNC_DISPATCHER_UNAVAILABLE',
|
|
831
|
+
teamId: team.id, nativeTeamId: team.nativeTeamId,
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ④ 投递给原生 team 的 Lead(root session id == nativeTeamId)
|
|
836
|
+
// dedupKey 用消息 id —— 同一消息重复 post 不会重复同步(幂等)。
|
|
837
|
+
const dedupKey = `lead-sync:${messageId ?? roomId}:${team.id}`
|
|
838
|
+
const wake = dispatcher.notify({
|
|
839
|
+
target: team.nativeTeamId,
|
|
840
|
+
reason: 'room-message',
|
|
841
|
+
dedupKey,
|
|
842
|
+
text: `[cs-bots 团队发言同步] 「${team.name}」房间新消息:${String(text ?? '').slice(0, 200)}`,
|
|
843
|
+
teamId: team.id,
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
// ⑤ 诚实汇报:只有真的发出去才算 synced:true
|
|
847
|
+
const leadId = team.nativeTeamId
|
|
848
|
+
if (wake?.ok === true && wake?.result === 'sent') {
|
|
849
|
+
return { ok: true, synced: true, reason: 'SYNCED', teamId: team.id, nativeTeamId: leadId, leadId, wake }
|
|
850
|
+
}
|
|
851
|
+
if (wake?.ok === true && wake?.result === 'skipped') {
|
|
852
|
+
return { ok: true, synced: false, reason: 'SYNC_DEDUP_HIT', teamId: team.id, nativeTeamId: leadId, leadId, wake }
|
|
853
|
+
}
|
|
854
|
+
return { ok: true, synced: false, reason: 'SYNC_DELIVERY_FAILED', teamId: team.id, nativeTeamId: leadId, leadId, wake }
|
|
855
|
+
}
|
|
856
|
+
|
|
516
857
|
function get(id) {
|
|
517
858
|
assertAlive('get')
|
|
518
859
|
const t = entries.get(id)
|
|
@@ -548,8 +889,16 @@ export function createTeams(ctx, config = {}) {
|
|
|
548
889
|
// 契约 §3:把「议事厅」绑到团队上。api.js 的 ensureTeamRoom 会调它;
|
|
549
890
|
// 不导出 ⇒ 房间建好了但团队记不住 roomId,重启后自动配厅就永远重配一遍。
|
|
550
891
|
attachRoom,
|
|
892
|
+
// 契约 t-39:跨团队路由(只决定 + 留痕,投递由工具层用 dispatcher 完成)
|
|
893
|
+
resolveRoute,
|
|
894
|
+
recordRoute,
|
|
895
|
+
listRoutes,
|
|
896
|
+
// 一条龙:工具层与 HTTP 层共用的唯一序列编排
|
|
897
|
+
deliverRoute,
|
|
551
898
|
list,
|
|
552
899
|
get,
|
|
900
|
+
findByRoom,
|
|
901
|
+
syncToNativeLead,
|
|
553
902
|
stats,
|
|
554
903
|
replay,
|
|
555
904
|
diagnostics: () => clone(diagnostics),
|
package/src/tools.js
CHANGED
|
@@ -1132,7 +1132,7 @@ const DEFS = [
|
|
|
1132
1132
|
},
|
|
1133
1133
|
{
|
|
1134
1134
|
name: 'bot_teams_add',
|
|
1135
|
-
description: '向团队添加成员(需带 expectedRev 做 CAS
|
|
1135
|
+
description: '向团队添加成员(需带 expectedRev 做 CAS)。若该团队已绑定房间(roomId),自动把成员拉进房间。',
|
|
1136
1136
|
parameters: {
|
|
1137
1137
|
type: 'object',
|
|
1138
1138
|
properties: {
|
|
@@ -1146,7 +1146,18 @@ const DEFS = [
|
|
|
1146
1146
|
run: (s, a, exec) => {
|
|
1147
1147
|
if (!s.teams) return failure('teams-unavailable')
|
|
1148
1148
|
try {
|
|
1149
|
-
|
|
1149
|
+
const team = s.teams.addMember({ ...a, by: callerOf(exec) })
|
|
1150
|
+
// ── ③ bot_teams_add 补 attachRoom:若团队有 roomId,自动拉人进房间(失败不阻断主流程)──
|
|
1151
|
+
// 参照 api.js:1555 附近的逻辑:只回报 roomError,不让它把「加入团队成功」变成失败。
|
|
1152
|
+
let roomError
|
|
1153
|
+
if (team.roomId && s.room) {
|
|
1154
|
+
try {
|
|
1155
|
+
s.room.join({ roomId: team.roomId, sessionId: a.memberId, byId: callerOf(exec) })
|
|
1156
|
+
} catch (e) {
|
|
1157
|
+
roomError = `room.join 失败(${e?.code ?? 'room-join-failed'}):${e?.message ?? String(e)}`
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return success({ team, roomError })
|
|
1150
1161
|
} catch (error) {
|
|
1151
1162
|
return failure(error?.code ?? 'teams-add-failed', { message: error?.message ?? String(error) })
|
|
1152
1163
|
}
|
|
@@ -1223,6 +1234,41 @@ const DEFS = [
|
|
|
1223
1234
|
}
|
|
1224
1235
|
},
|
|
1225
1236
|
},
|
|
1237
|
+
// ── A. bot_team_route(t-39 第2批 · 跨团队定向路由)─────────────────────────
|
|
1238
|
+
{
|
|
1239
|
+
name: 'bot_team_route',
|
|
1240
|
+
description:
|
|
1241
|
+
'跨团队定向路由:把任务/消息路由给另一个团队或某个 Bot 所在团队的 lead。' +
|
|
1242
|
+
'只唤醒对方 leadId,不唤醒全部成员。先落 route 事件再投递(intent-first)。' +
|
|
1243
|
+
'失败一律返回可读的 {ok:false, decision:"refused", reason},绝不静默。',
|
|
1244
|
+
parameters: {
|
|
1245
|
+
type: 'object',
|
|
1246
|
+
properties: {
|
|
1247
|
+
fromTeam: { type: 'string', description: '发送方团队 id(可选;缺省按执行者会话 id 推导)' },
|
|
1248
|
+
toTeam: { type: 'string', description: '目标团队 id' },
|
|
1249
|
+
toBot: { type: 'string', description: '目标 Bot 的会话 id(toTeam 与 toBot 二选一)' },
|
|
1250
|
+
taskId: {
|
|
1251
|
+
type: 'string',
|
|
1252
|
+
description: '关联任务 id(可选;缺省自动生成 route-<时间戳>——页面「发协作请求」不一定挂任务)。'
|
|
1253
|
+
+ '同一轮重发请复用同一 taskId,否则会被当成新路由(去重键含 taskId)',
|
|
1254
|
+
},
|
|
1255
|
+
text: { type: 'string', description: '要投递的正文内容' },
|
|
1256
|
+
hop: { type: 'number', description: '当前跳数(>3 拒绝;缺省 0)' },
|
|
1257
|
+
},
|
|
1258
|
+
additionalProperties: false,
|
|
1259
|
+
},
|
|
1260
|
+
run: (s, a, exec) => {
|
|
1261
|
+
if (!s.teams) return failure('teams-unavailable')
|
|
1262
|
+
// 唯一的序列编排在 `teams.deliverRoute()`(决定 → 投递 → 留痕)。
|
|
1263
|
+
// 工具层与 `POST /teams/route` 共用它 —— 两路各写一遍行为就会漂
|
|
1264
|
+
// (一边去重了、一边没去重;一边先投后记、一边先记后投)。
|
|
1265
|
+
return s.teams.deliverRoute({
|
|
1266
|
+
...a,
|
|
1267
|
+
by: callerOf(exec),
|
|
1268
|
+
dispatcher: s.dispatcher,
|
|
1269
|
+
})
|
|
1270
|
+
},
|
|
1271
|
+
},
|
|
1226
1272
|
]
|
|
1227
1273
|
|
|
1228
1274
|
|