// channels_coalesce.go 承载**渠道静默窗口合并**(M23)的落盘面(M23 蓝图 §7)。 // // 它回答的是主人 2026-10-23/24 的原话「要让群聊聊天很自然,很流畅的做法」:群里 // 「一鼓作气打三句」不该让她回三次。窗口/上限这些旋钮属于**调优**,因此按 // design/13 §2.1「`.env` 冻结 ⇒ 新配置走 `$DATA_DIR/config/**` + 代码默认值」落在 // $DATA_DIR/config/coalesce.json,**零新 env 键**。 // // 本文件是纯存储层:它只负责读写、默认值归一与 clamp,**不做任何行为判定** —— // 「哪些消息进窗口」「主人私聊为什么没有窗口」的判据在 // internal/services/channelbridge/coalesce*.go(车道类型由 actor 元数据决定, // **配置无权翻转**:这里既没有「对主人私聊生效」的键,也没有能改车道类型的开关)。 package runtimeconfig import ( "encoding/json" "fmt" "log/slog" "os" "path/filepath" "strings" "sync" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) const ( // coalesceConfigSubdir 与 owner_lane.json / backthink.json 共用 $DATA_DIR 下的 // config 子目录口径。 coalesceConfigSubdir = "config" // coalesceFileName 是静默窗口合并旋钮的落盘文件名。 coalesceFileName = "coalesce.json" // coalesceLogSource 是本模块日志的来源标记(AGENTS §10:禁止无来源裸日志)。 coalesceLogSource = "config/runtimeconfig/coalesce" // clamp 上下限(M23 蓝图 §4 + 塔 2026-10-24 裁定):窗口 300–15000ms、 // 绝对上限 ≥ 窗口且 ≤ 30000ms、单批 1–50 条。越界值**clamp + WARN**, // 绝不静默采信一个不可能的值,也绝不因此拦住入站。 CoalesceWindowMinMs = 300 CoalesceWindowMaxMs = 15000 CoalesceMaxWaitMinMs = CoalesceWindowMinMs CoalesceMaxWaitMaxMs = 30000 CoalesceBatchMin = 1 CoalesceBatchMax = 50 coalesceDefaultWindow = 2000 coalesceDefaultWait = 6000 coalesceDefaultBatch = 24 ) // DefaultCoalescePath 返回静默窗口合并配置文件在 dataDir 下的路径。 func DefaultCoalescePath(dataDir string) string { return filepath.Join(dataDir, coalesceConfigSubdir, coalesceFileName) } // CoalesceConversationKey 把**车道键**(`platform \x00 channelID`,与会话键同源) // 渲染成落盘/面板用的可读键:NUL 换成 `|`。 // // 为什么需要这一层:车道键里的分隔符是 NUL(`session.Key` 的唯一实现点决定), // 直接写进 JSON 会变成 `\u0000` —— 面板的输入框里看不见、复制不出来,主人根本 // 没法给某个群配覆盖。反向不歧义:平台名与渠道 id 都不含 `|`,所以这个渲染是 // 单射的;桥只按「同一个函数」把车道键映射成配置键,绝不在别处另拼一份。 func CoalesceConversationKey(laneKey string) string { return strings.ReplaceAll(laneKey, "\x00", "|") } // CoalescePolicy 是一组**生效**的静默窗口参数(已归一、已 clamp)。 // // 零值**不是**「关闭」的等价物(Enabled=false 才是):读盘缺失时走 // DefaultCoalescePolicy,绝不因为「没写这个键」就把特性当成关闭。 type CoalescePolicy struct { // Enabled 是群聊窗口的总开关;false ⇒ 行为逐字回到 M22(到达即投递、一条一轮)。 Enabled bool `json:"enabled"` // WindowMs 是**静默窗口**:滑动语义,新消息重置计时(「等她把话说完」)。 WindowMs int `json:"windowMs"` // MaxWaitMs 是**绝对上限**,锚定在批内第一条的到达时刻:一个不停说话的群 // 因此不会让计时永不归零(宁可晚回,不许不回)。 MaxWaitMs int `json:"maxWaitMs"` // MaxBatchMessages 是单批消息条数上限;超出的**不是丢弃**,是由车道回插队首 // 成为下一批(见 channelbridge/coalesce.go 的 split)。 MaxBatchMessages int `json:"maxBatchMessages"` // ApplyToStrangers 决定陌生人私聊是否也走窗口;false ⇒ 陌生人私聊逐条跑。 ApplyToStrangers bool `json:"applyToStrangers"` // StrangerWindowMs 是陌生人私聊的独立窗口(v1 与 WindowMs 同值,纯预留: // 蓝图 §3 明确「现状没有证据支撑两个值」,先留字段避免日后改结构)。 StrangerWindowMs int `json:"strangerWindowMs"` } // DefaultCoalescePolicy 返回代码默认值(也是面板「留空 ⇒ 跟随」的那一档)。 func DefaultCoalescePolicy() CoalescePolicy { return CoalescePolicy{ Enabled: true, WindowMs: coalesceDefaultWindow, MaxWaitMs: coalesceDefaultWait, MaxBatchMessages: coalesceDefaultBatch, ApplyToStrangers: true, StrangerWindowMs: coalesceDefaultWindow, } } // CoalescePolicyPatch 是**三态补丁**:键缺位(nil)= 不动;有值 = 采用。 // `null` 在文件层没有清除语义(每个键都有代码默认值兜底),因此无需 Clear 集合。 type CoalescePolicyPatch struct { Enabled *bool `json:"enabled,omitempty"` WindowMs *int `json:"windowMs,omitempty"` MaxWaitMs *int `json:"maxWaitMs,omitempty"` MaxBatchMessages *int `json:"maxBatchMessages,omitempty"` ApplyToStrangers *bool `json:"applyToStrangers,omitempty"` StrangerWindowMs *int `json:"strangerWindowMs,omitempty"` } // apply 把补丁里**出现过的**键叠到 p 上(缺位键保持 p 的原值)。 func (patch CoalescePolicyPatch) apply(p CoalescePolicy) CoalescePolicy { if patch.Enabled != nil { p.Enabled = *patch.Enabled } if patch.WindowMs != nil { p.WindowMs = *patch.WindowMs } if patch.MaxWaitMs != nil { p.MaxWaitMs = *patch.MaxWaitMs } if patch.MaxBatchMessages != nil { p.MaxBatchMessages = *patch.MaxBatchMessages } if patch.ApplyToStrangers != nil { p.ApplyToStrangers = *patch.ApplyToStrangers } if patch.StrangerWindowMs != nil { p.StrangerWindowMs = *patch.StrangerWindowMs } return p } // merge 返回自身与后到补丁的合并(后者出现过的键优先)。写盘时用它保留 // 「这次没提到的键」的旧值 —— 三态里的「不动」在文件层就落成这个合并。 func (patch CoalescePolicyPatch) merge(next CoalescePolicyPatch) CoalescePolicyPatch { out := patch if next.Enabled != nil { out.Enabled = next.Enabled } if next.WindowMs != nil { out.WindowMs = next.WindowMs } if next.MaxWaitMs != nil { out.MaxWaitMs = next.MaxWaitMs } if next.MaxBatchMessages != nil { out.MaxBatchMessages = next.MaxBatchMessages } if next.ApplyToStrangers != nil { out.ApplyToStrangers = next.ApplyToStrangers } if next.StrangerWindowMs != nil { out.StrangerWindowMs = next.StrangerWindowMs } return out } // Empty 报告补丁是否一个键都没有(写盘时据此跳过空改写)。 func (patch CoalescePolicyPatch) Empty() bool { return patch.Enabled == nil && patch.WindowMs == nil && patch.MaxWaitMs == nil && patch.MaxBatchMessages == nil && patch.ApplyToStrangers == nil && patch.StrangerWindowMs == nil } // CoalesceFile 是 coalesce.json 的形状:一个全局 default 加一份每会话覆盖表 // (键 = CoalesceConversationKey(车道键))。 type CoalesceFile struct { Default CoalescePolicyPatch `json:"default"` Conversations map[string]CoalescePolicyPatch `json:"conversations,omitempty"` } // EffectiveDefault 返回「代码默认 + 盘上 default 段」归一后的**生效值**(已 clamp)。 // 面板据此显示「当前值」,而 Values() 显示「你写进去的键」—— 两者刻意分开: // 一个是运行期事实,一个是盘上原文。 func (f CoalesceFile) EffectiveDefault() CoalescePolicy { p, _ := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy())) return p } // EffectiveConversation 返回某条会话覆盖的生效值:全局生效值 → 该会话覆盖 → clamp。 func (f CoalesceFile) EffectiveConversation(key string) (CoalescePolicy, bool) { conv, ok := f.Conversations[key] if !ok { return CoalescePolicy{}, false } p, _ := ClampCoalescePolicy(conv.apply(f.EffectiveDefault())) return p, true } // Values 返回该补丁里**出现过的**键(键缺位 ⇒ 不出现),供面板「留空 ⇒ 跟随默认」 // 如实回显。值是该键在盘上的原值(未经 clamp)。 func (patch CoalescePolicyPatch) Values() map[string]any { out := map[string]any{} if patch.Enabled != nil { out["enabled"] = *patch.Enabled } if patch.WindowMs != nil { out["windowMs"] = *patch.WindowMs } if patch.MaxWaitMs != nil { out["maxWaitMs"] = *patch.MaxWaitMs } if patch.MaxBatchMessages != nil { out["maxBatchMessages"] = *patch.MaxBatchMessages } if patch.ApplyToStrangers != nil { out["applyToStrangers"] = *patch.ApplyToStrangers } if patch.StrangerWindowMs != nil { out["strangerWindowMs"] = *patch.StrangerWindowMs } return out } // ClampCoalescePolicy 把一组参数夹进可运行区间,并返回**被改动过**的说明 // (调用方按 WARN 落地:越界不是错误,但必须留痕,否则「我配了 100ms」会变成 // 一个没人知道的 300ms)。 func ClampCoalescePolicy(p CoalescePolicy) (CoalescePolicy, []string) { var notes []string if p.WindowMs != 0 && p.WindowMs < CoalesceWindowMinMs { notes = append(notes, fmt.Sprintf("windowMs=%d 太小,取 %d", p.WindowMs, CoalesceWindowMinMs)) p.WindowMs = CoalesceWindowMinMs } if p.WindowMs > CoalesceWindowMaxMs { notes = append(notes, fmt.Sprintf("windowMs=%d 太大,取 %d", p.WindowMs, CoalesceWindowMaxMs)) p.WindowMs = CoalesceWindowMaxMs } if p.WindowMs == 0 { // 蓝图 §4:`0` 与 enabled:false 同义(「窗口 0 毫秒」不可能是有意的调优)。 notes = append(notes, "windowMs=0 视为关闭静默窗口") p.Enabled = false p.WindowMs = coalesceDefaultWindow } if p.StrangerWindowMs <= 0 { p.StrangerWindowMs = p.WindowMs } if p.StrangerWindowMs < CoalesceWindowMinMs || p.StrangerWindowMs > CoalesceWindowMaxMs { notes = append(notes, fmt.Sprintf("strangerWindowMs=%d 越界,取 windowMs=%d", p.StrangerWindowMs, p.WindowMs)) p.StrangerWindowMs = p.WindowMs } if p.MaxWaitMs < p.WindowMs { notes = append(notes, fmt.Sprintf("maxWaitMs=%d 小于窗口,取 %d", p.MaxWaitMs, p.WindowMs)) p.MaxWaitMs = p.WindowMs } if p.MaxWaitMs > CoalesceMaxWaitMaxMs { notes = append(notes, fmt.Sprintf("maxWaitMs=%d 太大,取 %d", p.MaxWaitMs, CoalesceMaxWaitMaxMs)) p.MaxWaitMs = CoalesceMaxWaitMaxMs } if p.MaxBatchMessages < CoalesceBatchMin { notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太小,取 %d", p.MaxBatchMessages, CoalesceBatchMin)) p.MaxBatchMessages = CoalesceBatchMin } if p.MaxBatchMessages > CoalesceBatchMax { notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太大,取 %d", p.MaxBatchMessages, CoalesceBatchMax)) p.MaxBatchMessages = CoalesceBatchMax } return p, notes } // ValidateCoalescePatch 拒绝那些**不是调优、而是敲错**的补丁(负数;类型错误已由 // JSON 解码挡掉)。返回错误 ⇒ 调用方回 400 且**盘上一个字节都不动**。 func ValidateCoalescePatch(p CoalescePolicyPatch) error { fields := []struct { name string v *int }{ {"windowMs", p.WindowMs}, {"maxWaitMs", p.MaxWaitMs}, {"maxBatchMessages", p.MaxBatchMessages}, {"strangerWindowMs", p.StrangerWindowMs}, } for _, f := range fields { if f.v != nil && *f.v < 0 { return fmt.Errorf("%s 不能为负数(%d)", f.name, *f.v) } } return nil } // CoalescePatch 是 PUT 请求体的形状(三态):`default` 缺位 ⇒ 全局不动、 // 出现过的键采用;`conversations` 里出现过的键逐键采用,值为 `null` ⇒ // **删掉**该会话覆盖(回到全局 default),没提到的会话一概不动。 type CoalescePatch struct { Default *CoalescePolicyPatch `json:"default,omitempty"` Conversations map[string]*CoalescePolicyPatch `json:"conversations,omitempty"` } // Empty 报告补丁是否什么都没提(面板「没改任何键就点保存」不该在盘上留一次改写)。 func (p CoalescePatch) Empty() bool { if p.Default != nil && !p.Default.Empty() { return false } return len(p.Conversations) == 0 } // CoalesceStore 是静默窗口旋钮的读写面:内存缓存 + 原子落盘。它可安全并发使用。 // 读缺失文件、坏文件都退回默认值(绝不因为配置坏了而阻断入站),但留带来源的 WARN。 type CoalesceStore struct { path string log *slog.Logger mu sync.RWMutex cfg CoalesceFile } // NewCoalesceStore 构造 store 并尽力加载已有文件。path 为空或文件缺失时走默认值, // 不返回错误:配置坏了只影响窗口取值,绝不该拦住渠道启动。 func NewCoalesceStore(path string, log *slog.Logger) *CoalesceStore { if log == nil { log = slog.Default() } s := &CoalesceStore{ path: path, log: logger.WithSource(log, coalesceLogSource), cfg: CoalesceFile{Default: CoalescePolicyPatch{}, Conversations: map[string]CoalescePolicyPatch{}}, } s.load() return s } // Path 返回这份配置的落点(面板如实回显「改的是哪个文件」用)。 func (s *CoalesceStore) Path() string { return s.path } // Present 报告配置文件是否已存在(false = 还没写过,运行期正走代码默认)。 func (s *CoalesceStore) Present() bool { if s.path == "" { return false } _, err := os.Stat(s.path) return err == nil } // load 尽力从磁盘读入配置。文件不存在是正常情况(首次运行),静默走默认值; // 其它读/解析错误留 WARN 后仍走默认值。 func (s *CoalesceStore) load() { if s.path == "" { return } raw, err := os.ReadFile(s.path) if err != nil { if !os.IsNotExist(err) { s.log.Warn("coalesce config unreadable, using defaults", "path", s.path, "error", err) } return } var f CoalesceFile if err := json.Unmarshal(raw, &f); err != nil { s.log.Warn("coalesce config invalid, using defaults", "path", s.path, "error", err) return } if f.Conversations == nil { f.Conversations = map[string]CoalescePolicyPatch{} } s.mu.Lock() s.cfg = f s.mu.Unlock() s.logClamp(f) } // logClamp 把 disk 上那份的越界值一次性记进日志(每次 load/Set 一次,不是每次发车)。 func (s *CoalesceStore) logClamp(f CoalesceFile) { if _, notes := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy())); len(notes) > 0 { s.log.Warn("coalesce default clamped", "path", s.path, "notes", strings.Join(notes, "; ")) } for key, patch := range f.Conversations { if _, notes := ClampCoalescePolicy(patch.apply(DefaultCoalescePolicy())); len(notes) > 0 { s.log.Warn("coalesce conversation override clamped", "path", s.path, "conversation", key, "notes", strings.Join(notes, "; ")) } } } // PolicyFor 返回某条车道键**生效**的窗口参数:代码默认 → 全局 default → 该会话覆盖, // 逐键叠完后 clamp。它永不返回「未设置」:调用方永远拿到一个可运行的值。 // // 它读的是内存(不碰磁盘),因此面板保存后**下一次发车**就生效(热生效), // 也与入站路径一样不会被一次落盘抖动卡住。 func (s *CoalesceStore) PolicyFor(laneKey string) CoalescePolicy { s.mu.RLock() def := s.cfg.Default conv, ok := s.cfg.Conversations[CoalesceConversationKey(laneKey)] s.mu.RUnlock() p, _ := ClampCoalescePolicy(def.apply(DefaultCoalescePolicy())) if ok { p, _ = ClampCoalescePolicy(conv.apply(p)) } return p } // Snapshot 返回配置的一份拷贝(**盘上原值**,未经 clamp),供面板/测试读取。 // 与 PolicyFor 的区别是刻意的:面板要如实回显「你写进去的是什么」, // 而不是「运行期把它夹成了什么」。 func (s *CoalesceStore) Snapshot() CoalesceFile { s.mu.RLock() defer s.mu.RUnlock() out := CoalesceFile{Default: s.cfg.Default, Conversations: make(map[string]CoalescePolicyPatch, len(s.cfg.Conversations))} for k, v := range s.cfg.Conversations { out.Conversations[k] = v } return out } // Apply 应用一份三态补丁:出现过的键采用,缺位的键保持原值;conversations 里 // 值为 nil 的键 = **删掉**该会话覆盖(回到全局 default)。内存与落盘在同一把锁内, // 因此并发写入不会互相覆盖,也不会留下半份文件。返回 (是否真的改了盘上的字节)。 // // 校验(ValidateCoalescePatch)由调用方在**进本函数之前**做完:走到这里意味着 // 「这份补丁合法」,落盘因此不会出现「写了一半才发现非法」。 func (s *CoalesceStore) Apply(patch CoalescePatch) (bool, error) { s.mu.Lock() defer s.mu.Unlock() before, _ := json.Marshal(s.cfg) if patch.Default != nil { s.cfg.Default = s.cfg.Default.merge(*patch.Default) } if s.cfg.Conversations == nil { s.cfg.Conversations = map[string]CoalescePolicyPatch{} } for key, conv := range patch.Conversations { if conv == nil { delete(s.cfg.Conversations, key) continue } s.cfg.Conversations[key] = s.cfg.Conversations[key].merge(*conv) } after, _ := json.Marshal(s.cfg) if string(before) == string(after) { return false, nil } if err := s.saveLocked(); err != nil { return false, fmt.Errorf("runtimeconfig: save coalesce config %s: %w", s.path, err) } return true, nil } // saveLocked 原子写出当前配置(临时文件 + rename)。调用方**必须持有 s.mu**。 // 0600 与 owner_lane.json 同口径:这份文件只被后端自己读写。 func (s *CoalesceStore) saveLocked() error { if s.path == "" { return nil } dir := filepath.Dir(s.path) if dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("mkdir: %w", err) } } raw, err := json.MarshalIndent(s.cfg, "", " ") if err != nil { return fmt.Errorf("marshal: %w", err) } tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".*.tmp") if err != nil { return fmt.Errorf("create temp in %s: %w", dir, err) } tmpName := tmp.Name() if err := tmp.Chmod(0o600); err != nil { _ = tmp.Close() _ = os.Remove(tmpName) return fmt.Errorf("chmod %s: %w", tmpName, err) } if _, err := tmp.Write(raw); err != nil { _ = tmp.Close() _ = os.Remove(tmpName) return fmt.Errorf("write %s: %w", tmpName, err) } if err := tmp.Close(); err != nil { _ = os.Remove(tmpName) return fmt.Errorf("close %s: %w", tmpName, err) } if err := os.Rename(tmpName, s.path); err != nil { _ = os.Remove(tmpName) return fmt.Errorf("rename: %w", err) } return nil }