Files
Cyrene_v2/internal/services/channelbridge/engage.go
T
AskaEth 13a0519115 feat(channelbridge): 冷避期唯一的放行口——高价值关键词穿透(不解除冷却)
塔 2026-10-15 例外口径定稿落地。M18 主体(冷避优先于「@她必回」)保持不动,
本提交只补冷却期内的例外与它的守卫。

- engage.go:`coldShoulderLocked` 在冷却期内放行**高价值关键词**这一条
  (真故障不该被「让她少闲聊」一并压掉),且**不解除冷却**(不动 state / cooldownUntil);
  同句既有负面态度又有高价值关键词时**负面态度优先**(仍进冷却、仍静默);
  例外与顺序写进函数注释,并写明 owner 车道为什么不需要单独写例外(它在
  shouldRespond 第一步就分岔,写在这里是死代码)。文档面 design/18 §5/§9 由塔同步。
- light.go:shouldRespond 的判定顺序与冷避闸注释同步该口径。
- 测试:新增 engage_coldshoulder_exception_test.go(冷却期例外矩阵:真故障放行、
  @她/闲聊不穿透、穿透不解除冷却、同句负面态度优先;以及「冷却之外行为与基线一致」
  的护栏),并按文件 ≤500 行拆出该文件;engage_coldshoulder_test.go 增共享夹具。

红前绿证据(参照 = 本提交的父 M18 提交):例外矩阵两条「真故障放行」行在我的改动
之前为红(冷避闸会把它们静默),其余三行与基线护栏在父提交上已绿,已如实标注为护栏;
「同句负面态度优先」一条另做变异自证(把例外插到负面态度判定之前 ⇒ 该行立刻变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
channelbridge / channel / cmd/cyrene 单独跑绿;darwin/arm64 交叉编译通过。
2026-10-15 19:01:02 +08:00

485 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package channelbridge
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
)
// 本文件承载公开平台群聊/陌生人会话的参与度引擎
// (DevDocs/design/18):按会话的状态机
// (IDLE→PROBING→ACTIVE→COOLDOWN)、受成本控制的触发闸门(角色名提及
// / 概率)、带跟进复审的活跃窗口,以及群内出现负面
// 态度时的冷避。它按进程存于内存(重启即重置),
// 且只在真正触发时才咨询轻量 LLM 判定器,因此不会仅为「要不要
// 打扰某人」就把聊天消息发给 LLM(design/18 红线)。
//
// M18(主人 2026-10-14 裁决「给」):冷避**优先于**「@她必回」——
// 冷避闸(coldShoulderGate)由 shouldRespond 在廉价规则之前调用,
// 使群成员的一句「少说点」在 30 分钟时限内不被随后的 @ 作废。
// 冷却期内的唯一放行口是**高价值关键词穿透**(塔 2026-10-15 定稿,不解除冷却),
// 口径见 coldShoulderLocked。
// EngageConfig 承载 design/18 的按会话参与度调参。P2c 会让它
// 可按群配置 / 在面板编辑;目前由 DefaultEngageConfig 提供
// 常量,组合根中的 EngageFor 接缝保持 nil。
type EngageConfig struct {
// Enabled 是按会话开关(design/18 §6 engageEnabled)。为 false 时
// 只有角色名提醒词触发仍会探测;概率路径关闭。
Enabled bool
Probability float64 // 触发③ 概率,默认 0.10
ActivityWindow time.Duration // ACTIVE 窗口,默认 15m
ActivityMessages int // ACTIVE 窗口按消息条数计,默认 20
Cooldown time.Duration // COOLDOWN 时长,默认 30m
ContextMessages int // 探测时喂给判定器的近期消息条数
}
// 参数默认值(design/18 §6)。编译期常量,运行路径上就不会出现
// 裸魔法数字(项目规范 §10)。
const (
defaultEngageProbability = 0.10
defaultActivityWindow = 15 * time.Minute
defaultActivityMessages = 20
defaultCooldownDuration = 30 * time.Minute
defaultEngageContext = 12
// consecutiveNoInterestThreshold 是连续多少次跟进探测都返回「无
// 兴趣」/回避后,她从 ACTIVE 退回 IDLE(design/18 §4)。负面
// 态度则由下面的规则词表把她按进 COOLDOWN。
consecutiveNoInterestThreshold = 2
)
// errNoEngageJudge 表示参与度引擎已配置但未装配 LightJudge,
// 因此它失败关闭为静默。
var errNoEngageJudge = errors.New("channelbridge: no light judge wired for engage")
// DefaultEngageConfig 返回 design/18 的参数默认值(P2a 基准;P2c 会让它们
// 可按群配置/在面板编辑)。
func DefaultEngageConfig() EngageConfig {
return EngageConfig{
Enabled: true,
Probability: defaultEngageProbability,
ActivityWindow: defaultActivityWindow,
ActivityMessages: defaultActivityMessages,
Cooldown: defaultCooldownDuration,
ContextMessages: defaultEngageContext,
}
}
// engageState 是按会话状态机的一个阶段(design/18 §1)。
type engageState int
const (
engageIDLE engageState = iota
engagePROBING // 瞬态:某次触发刚把该消息送去判定
engageACTIVE // 她正在参与;跟进复审已开启
engageCOOLDOWN // 她被要求安静;计时结束后自动恢复
)
// engageGroupState 是一个参与度引擎实例,以群聊/陌生人
// 会话为键(design/18 §1)。它仅存于内存(进程重启即重置)。
type engageGroupState struct {
mu sync.Mutex
state engageState
lastTopic string
lastInteract time.Time
activeUntil time.Time
msgsInWindow int
cooldownUntil time.Time
consecutiveNoInterest int
}
// engageConfigured 报告该桥接器是否装配了 design/18 参与度引擎。
// 角色带触发词,或参与度参数启用引擎时为 true;
// 否则桥接器沿用引入参与度引擎之前的旧轻量判定
// (design/12 §6),不做改动。
func (b *Bridge) engageConfigured() bool {
return len(b.triggerWords) > 0 || b.engage.Enabled || b.engage.Probability > 0
}
// engageKey 返回该会话在参与度引擎里的状态键(design/18 §1/§6,裁决 06.4.3)。键口径与
// 入站节流**完全一致**:adapter.ConversationKey(platform+channelID+groupID/peerID),
// 所以同一会话在「滑窗计数」和「参与度状态机」里落在同一个桶——两个群并发互不挤占。
// 群聊缺 group id 时用 actor 上报的群 id 补齐(适配器可能只填了 Sender.GroupID)。
func engageKey(msg adapter.UnifiedMessage, actor adapter.Actor) string {
if msg.GroupID == "" && (msg.IsGroup() || actor.IsGroupChat() || actor.GroupID != "") {
msg.GroupID = actor.GroupID
}
return adapter.ConversationKey(msg)
}
// engageState 返回会话键对应的状态,首次使用时创建 IDLE
// 实例(内存态,可安全并发使用)。
func (b *Bridge) engageState(key string) *engageGroupState {
b.engageMu.Lock()
defer b.engageMu.Unlock()
st := b.engageGroups[key]
if st == nil {
st = &engageGroupState{state: engageIDLE}
b.engageGroups[key] = st
}
return st
}
// engageConfigFor 解析一条消息实际生效的参与度配置:装配了按群
// 覆盖(EngageFor,P2a 中为 nil)时用它,否则用基准配置,
// 并把所有零值字段归一化为默认值。
func (b *Bridge) engageConfigFor(msg adapter.UnifiedMessage, actor adapter.Actor) EngageConfig {
cfg := b.engage
if b.engageFor != nil {
g := msg.GroupID
if g == "" {
g = actor.GroupID
}
if ov := b.engageFor(msg.Platform, g); ov != nil {
cfg = *ov
}
}
// 把未设置的字段归一化为保守默认值,使只配了触发的配置仍能
// 限定活跃窗口与冷却,而不是以零值运行。
if cfg.ActivityWindow <= 0 {
cfg.ActivityWindow = defaultActivityWindow
}
if cfg.ActivityMessages <= 0 {
cfg.ActivityMessages = defaultActivityMessages
}
if cfg.Cooldown <= 0 {
cfg.Cooldown = defaultCooldownDuration
}
if cfg.ContextMessages <= 0 {
cfg.ContextMessages = defaultEngageContext
}
if cfg.Probability <= 0 {
cfg.Probability = defaultEngageProbability
}
return cfg
}
// engageDecision 是对(廉价规则无法判定)非主人消息的 design/18
// 参与度决策。仅在 engageConfigured() 为 true 时才会走到。
// 它返回该消息是否应驱动完整 agent 循环并回复,以及
// 供日志用的简短原因。调用方(shouldRespond)不持锁;
// 这里获取按键的状态互斥锁,使同一会话的并发消息串行化,
// 不同会话之间保持独立。
func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, inQuiet bool) (bool, string) {
cfg := b.engageConfigFor(msg, actor)
key := engageKey(msg, actor)
st := b.engageState(key)
st.mu.Lock()
defer st.mu.Unlock()
now := b.now()
// design/18 §5 冷避优先:群成员表达负面态度是明确的「安静」信号 →
// COOLDOWN,即使正处在参与中。判据本体与 shouldRespond 的冷避闸
// (coldShoulderGate)**只有这一份**;那里在廉价规则之前先跑它,因此
// @她 / 高价值关键词都拦不住冷避(M18,主人 2026-10-14 裁决「给」)。
// 这里保留调用是**纵深防御**:engageDecision 单看仍然自带冷避优先语义,
// 将来若多出别的调用点也不会漏掉它。
if quiet, reason := b.coldShoulderLocked(msg, actor, key, st, cfg, now); quiet {
return false, reason
}
// ACTIVE 跟进:活跃窗口未过期间,她持续轻量判断
// 群里延续的讨论,并倾向于保持参与(design/18 §4)。
if st.state == engageACTIVE {
if st.activityExpired(now, cfg) {
b.toIdle(st)
b.log.Debug("engage activity window expired, back to idle", "group", key)
} else {
return b.engageFollowUp(ctx, msg, actor, sess, key, st, cfg, inQuiet)
}
}
// IDLE:只有角色名提及或概率命中才会把该消息送入受成本控制的
// PROBING 判定(design/18 §2 触发②/③);否则它只被
// 登记并静默,不调用 LLM(一条红线)。
if !b.mentionsTriggerWord(msg.Content) && !b.engageProbabilityHit(cfg) {
b.log.Debug("engage no trigger, registered only", "group", key)
return false, "engage: no trigger"
}
return b.engageProbe(ctx, msg, actor, sess, key, st, cfg, inQuiet)
}
// engageProbe 是一次受成本控制的 PROBING 判定(design/18 §3):把
// 群上下文 + 角色提示词喂给轻量判定器,并决定 ACTIVE(回复)还是
// IDLE(仅登记、静默)。解析/LLM 失败时失败关闭为静默。
func (b *Bridge) engageProbe(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, key string, st *engageGroupState, cfg EngageConfig, inQuiet bool) (bool, string) {
now := b.now()
st.state = engagePROBING
d, err := b.judgeEngage(ctx, msg, actor, sess, cfg, inQuiet)
if err != nil {
b.toIdle(st)
b.log.Warn("engage probe failed; silenced", "group", key, "error", err)
return false, "engage: probe failed; silenced"
}
if d.AvoidTopic {
b.toIdle(st)
b.log.Debug("engage probe avoid topic, staying idle", "group", key, "reason", d.Reason)
return false, "engage: avoid topic"
}
if d.WorthReply || d.Interesting {
b.enterActive(st, cfg, now, msg)
b.log.Debug("engage probing → active (respond)", "group", key, "reason", d.Reason)
return true, "engage: " + d.Reason
}
b.toIdle(st)
b.log.Debug("engage probing → idle, no interest", "group", key, "reason", d.Reason)
return false, "engage: no interest"
}
// engageFollowUp 是 ACTIVE 窗口的跟进复审(design/18 §4):她仍
// 轻量判断讨论,但倾向于继续参与。连续无兴趣或
// 窗口结束会使她退出 ACTIVE;负面态度早已
// 被前面的冷避规则捕获。
func (b *Bridge) engageFollowUp(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, key string, st *engageGroupState, cfg EngageConfig, inQuiet bool) (bool, string) {
st.msgsInWindow++
now := b.now()
d, err := b.judgeEngage(ctx, msg, actor, sess, cfg, inQuiet)
if err != nil {
b.countNoInterest(st)
b.log.Warn("engage follow-up judge failed; counted as no interest", "group", key, "error", err)
return b.engageAfterFollowUp(st, cfg, now, key)
}
if d.AvoidTopic || (!d.WorthReply && !d.Interesting) {
b.countNoInterest(st)
b.log.Debug("engage follow-up no interest / avoid", "group", key, "reason", d.Reason)
return b.engageAfterFollowUp(st, cfg, now, key)
}
st.lastInteract = now
st.lastTopic = contentSnippet(msg.Content)
st.consecutiveNoInterest = 0
b.log.Debug("engage follow-up respond", "group", key, "reason", d.Reason)
return true, "engage follow-up: " + d.Reason
}
// engageAfterFollowUp 判定一次无兴趣/回避跟进探测的结果。
func (b *Bridge) engageAfterFollowUp(st *engageGroupState, cfg EngageConfig, now time.Time, key string) (bool, string) {
if st.consecutiveNoInterest >= consecutiveNoInterestThreshold || st.activityExpired(now, cfg) {
b.toIdle(st)
b.log.Debug("engage exited active (no interest / window ended)", "group", key)
return false, "engage: left active"
}
return false, "engage: not responding"
}
// countNoInterest 为一次跟进探测递增连续无兴趣计数。
func (b *Bridge) countNoInterest(st *engageGroupState) {
st.consecutiveNoInterest++
}
// judgeEngage 为一次受成本控制的参与度判定渲染 LightInput,并
// 咨询注入的 LightJudge。只有在这里才喂入群的近期上下文与
// 角色提示词——绝不在未触发的消息上喂。
func (b *Bridge) judgeEngage(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, cfg EngageConfig, inQuiet bool) (LightDecision, error) {
if b.light == nil {
return LightDecision{}, errNoEngageJudge
}
return b.light.Judge(ctx, LightInput{
Msg: msg, Actor: actor, SenderName: senderDisplayName(msg, actor),
InQuietHours: inQuiet, QuietStart: b.quietStartHour, QuietEnd: b.quietEndHour,
Engage: true,
PersonaPrompt: b.personaPrompt,
TriggerWords: b.triggerWords,
RecentContext: b.buildEngageContext(ctx, sess, cfg),
})
}
// buildEngageContext 为参与度判定器渲染该会话最近的 N 条消息(带发送者
// 名,最旧在前)。未装配历史存储或存储尚无数据时为空操作
// (返回空字符串),判定器因此退化为只读
// 那一条触发消息。
func (b *Bridge) buildEngageContext(ctx context.Context, sess session.Session, cfg EngageConfig) string {
if b.history == nil || cfg.ContextMessages <= 0 {
return ""
}
msgs, err := b.history.Recent(ctx, sess.SessionID, cfg.ContextMessages)
if err != nil || len(msgs) == 0 {
return ""
}
var sb strings.Builder
for _, m := range msgs {
name := m.SenderName
if name == "" {
name = "(未标注)"
}
role := m.Role
if role == "" {
role = "msg"
}
sb.WriteString(fmt.Sprintf("%s %s: %s\n", role, name, truncateText(m.Content, 80)))
}
if strings.TrimSpace(sb.String()) == "" {
return ""
}
return sb.String()
}
// engageProbabilityHit 报告该消息是否独立命中与角色名提醒词无关的
// 概率触发(design/18 §2 触发③)。它读取可注入的
// EngagRand 源,使测试具备确定性;随机源为 nil 时概率路径
// 关闭(失败关闭)。
func (b *Bridge) engageProbabilityHit(cfg EngageConfig) bool {
if !cfg.Enabled || cfg.Probability <= 0 || b.engageRand == nil {
return false
}
return b.engageRand() < cfg.Probability
}
// negativeAttitude 报告群是否在让她安静(design/18 §5
// 冷避)。这是一条廉价子串规则,兜住 LLM 可能漏掉的信号
// ——更安静是安全的方向(不打扰为第一原则)。绝不用于身份判定。
func (b *Bridge) negativeAttitude(text string) bool {
if text == "" {
return false
}
lower := strings.ToLower(text)
for _, k := range negativeAttitudeKeywords {
if strings.Contains(lower, k) {
return true
}
}
return false
}
// negativeAttitudeKeywords 是冷避规则词表(design/18 §5 词表兜底)。它只是
// 兜底;同一信号也会被判定器中的跟进复审通过 avoidTopic 读取。
// 「少说点」是主人 2026-10-13 裁决「乙 = C」里主人诉求的原话形态(design/18 §11 ②),
// 与既有「少说两句」同义,因此并入同一张词表 —— owner 车道据此把诉求落成结构化降频。
//
// 匹配是**子串**(strings.Contains),因此词条的选取标准是「够长、够专指」:短而
// 泛的词(「减少」「输出」「最少」「小声」)会在与她无关的日常对话里大量误伤,
// 把整群按进 30 分钟静默。末尾两条是 M18 依**真机原话**补的(2026-10-14 群
// 531082168「企鹅神明」两次报障的原文),刻意只取带「文本输出」这个专指锚点的
// 长短语 —— 见 engage_coldshoulder_test.go 的词表护栏用例。
var negativeAttitudeKeywords = []string{
"太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听",
"安静点", "少说两句", "少说点", "别说了", "别bb", "别逼逼", "烦死了", "好烦",
"打住", "住嘴", "shut up", "too loud", "stop spamming",
// 「减少文本输出(量)」:真机原话「减少文本输出量」的**去数量词**形式,
// 顺带接住「减少文本输出频率」一类同义说法。「文本输出」是专指锚点 ——
// 程序员闲聊里的「减少输出日志」「减少输出噪音」不含它,因此不误伤。
"减少文本输出",
// 「文本输出调到最少」:真机原话「在本群中文本输出调到最少」的尾部短语。
// 同样靠「文本输出」立住专指性;单取「输出调到最少」会误伤把别的输出
// (音箱/视频/日志)调低的话题,故不取。
"文本输出调到最少",
}
// coldShoulderGate 是非主人**群**消息进入廉价规则判定与参与度引擎**之前**的冷避闸
// (design/18 §5;M18 修的顺序偏离)。它返回 true 表示该消息只登记、不进循环:
// 该群正在让她安静(本条命中负面态度 → 进 COOLDOWN),或她仍处在冷却期内且本条
// 没有高价值关键词。
//
// **它优先于「@她必回」**(design/18 §2 触发①):被嫌吵后的这段时限里,@ 也拦不住
// 冷避 —— 否则群成员的一句「少说点」会被随后的任何一次 @ 立刻作废,行为零变化
// (2026-10-14 报障的病灶)。适用面严格限定如下:
//
// - **只对群聊**:私聊(含陌生人私聊)不是冷避车道(design/18 §11;调用点另按
// sess.IsGroup 把关),私聊的陌生人不因一句话被静默 30 分钟;
// - **只对非主人**:owner 车道在 shouldRespond 更早处已分岔,主人有自己那套
// 结构化旋钮(ownerlane.go),冷避绝不碰主人的必回语义;
// - **与参与度引擎是否装配无关**:未装配引擎(旧轻量判定车道)的部署顺序与
// 装配了的**完全一致** —— 闸本身不查 engageConfigured()。
//
// 例外口径(塔 2026-10-15 定稿,design/18 §5/§9 由塔同步)见 coldShoulderLocked。
func (b *Bridge) coldShoulderGate(msg adapter.UnifiedMessage, actor adapter.Actor) (bool, string) {
cfg := b.engageConfigFor(msg, actor)
key := engageKey(msg, actor)
st := b.engageState(key)
st.mu.Lock()
defer st.mu.Unlock()
return b.coldShoulderLocked(msg, actor, key, st, cfg, b.now())
}
// coldShoulderLocked 是冷避判定的本体(调用方须持有 st.mu)。它是该规则
// **唯一**的实现:shouldRespond 的冷避闸与 engageDecision 都调它。
// 命中负面态度时进 COOLDOWN 并留一条带来源的 INFO —— 判据只读正文关键词,
// 日志只带群键、发送者与截止时刻,**绝不打印正文**(design/18 §9.7)。
//
// 判定顺序(塔 2026-10-15 裁定;该口径的文档面 design/18 §5/§9 由塔同步,本片只写代码注释):
//
// ① 同句**负面态度优先**:一句里既有「安静点」又有「报错」也按负面态度处理
// —— 加个高价值词绝不能把「安静点」作废,仍进冷却、仍静默。
// ② 冷却期内 + **高价值关键词**(真故障)⇒ **放行这一条**,且**不解除冷却**
// (不动 st.state / st.cooldownUntil):「让她少**闲聊**」≠「让她漏掉**真故障**」,
// 技术群里的 `报错`/`error`/`bug` 是刚需;但这不等于撤诉 —— 紧接着的
// @她 / 闲聊消息仍在冷却里静默。
// ③ 冷却期内其余一切(含 @她 / @全体)⇒ 静默。
//
// **主人为什么不需要单独写一条例外**:owner 车道在 shouldRespond 的**第一步**就按
// `actor.IsOwner()` 分岔(见 ownerlane.go 的 ownerGroupShouldRespond),主人的消息
// 根本走不到这个闸;在这里再写一条「主人例外」是死代码,还会让人误以为主人
// 也可能被冷避压住。
func (b *Bridge) coldShoulderLocked(msg adapter.UnifiedMessage, actor adapter.Actor, key string, st *engageGroupState, cfg EngageConfig, now time.Time) (bool, string) {
if b.negativeAttitude(msg.Content) {
b.enterCooldown(st, cfg, now)
// INFO:主人要能看出「她为什么突然不说话了」(谁在哪个群、冷却到什么时候)。
b.log.Info("engage cooldown: group asked her to stay quiet",
"group", key, "sender", msg.SenderID, "sender_name", senderDisplayName(msg, actor),
"until", st.cooldownUntil)
return true, "engage: cooldown (negative attitude)"
}
if st.inCooldown(now) {
// 例外②:高价值关键词穿透冷避 —— 只放行这一条,冷却本身照旧。
if hasHighValueKeyword(msg.Content) {
b.log.Debug("engage cooldown: high-value keyword allowed through, cooldown kept",
"group", key, "until", st.cooldownUntil)
return false, ""
}
b.log.Debug("engage cooldown active, not responding",
"group", key, "until", st.cooldownUntil)
return true, "engage: cooldown active"
}
return false, ""
}
// contentSnippet 把消息截短为状态机所用的有界话题标记。
func contentSnippet(text string) string {
runes := []rune(strings.TrimSpace(text))
if len(runes) <= 40 {
return string(runes)
}
return string(runes[:40]) + "…"
}
func (st *engageGroupState) inCooldown(now time.Time) bool {
return st.state == engageCOOLDOWN && now.Before(st.cooldownUntil)
}
func (st *engageGroupState) activityExpired(now time.Time, cfg EngageConfig) bool {
return now.After(st.activeUntil) || st.msgsInWindow >= cfg.ActivityMessages
}
// toIdle 把会话重置回 IDLE(design/18:退出 ACTIVE → IDLE)。
func (b *Bridge) toIdle(st *engageGroupState) {
st.state = engageIDLE
st.msgsInWindow = 0
st.consecutiveNoInterest = 0
st.lastTopic = ""
}
// enterActive 打开活跃窗口(design/18 §4)。调用方须持有 st.mu。
func (b *Bridge) enterActive(st *engageGroupState, cfg EngageConfig, now time.Time, msg adapter.UnifiedMessage) {
st.state = engageACTIVE
st.activeUntil = now.Add(cfg.ActivityWindow)
st.msgsInWindow = 1
st.consecutiveNoInterest = 0
st.lastInteract = now
st.lastTopic = contentSnippet(msg.Content)
}
// enterCooldown 把会话按进安静期(design/18 §5)。调用方
// 须持有 st.mu。
func (b *Bridge) enterCooldown(st *engageGroupState, cfg EngageConfig, now time.Time) {
st.state = engageCOOLDOWN
st.cooldownUntil = now.Add(cfg.Cooldown)
st.msgsInWindow = 0
st.consecutiveNoInterest = 0
st.lastTopic = ""
}