// channels_ownerlane.go 承载 owner 车道结构化旋钮的**落盘面**(design/18 §11 // 「乙 = C」,主人 2026-10-13 裁决):主人在群里说「少说点」这类诉求不再靠她自己记, // 而是被写成一份可读可改的每群配置(默认值 + 每群覆盖),面板后续照这份形状写入。 // // 它落在 $DATA_DIR/config/owner_lane.json(与 backthink.json 同一目录口径、同一 // 「.env 冻结 ⇒ 新配置走 $DATA_DIR/config/**」纪律,design/13 §2.1),**不进 .env**。 // 本文件是纯存储层:它只负责读写与默认值归一,**不做任何行为判定** —— // 「说不说 / 多频繁」的判据在 channelbridge/ownerlane.go,身份权限仍只由 actor 元数据决定。 package runtimeconfig import ( "encoding/json" "fmt" "log/slog" "os" "path/filepath" "sync" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) const ( // ownerLaneConfigSubdir 与 backthink.json 共用 $DATA_DIR 下的 config 子目录口径。 ownerLaneConfigSubdir = "config" // ownerLaneFileName 是 owner 车道旋钮的落盘文件名。 ownerLaneFileName = "owner_lane.json" // ownerLaneLogSource 是本模块日志的来源标记,使「旋钮被谁改成什么」跨模块可定位 // (项目规范 §五:禁止无来源裸日志)。 ownerLaneLogSource = "config/runtimeconfig/ownerlane" ) // DefaultOwnerLanePath 返回 owner 车道旋钮文件在 dataDir 下的路径。 func DefaultOwnerLanePath(dataDir string) string { return filepath.Join(dataDir, ownerLaneConfigSubdir, ownerLaneFileName) } // OwnerLanePolicy 是 owner 车道的一档参与策略(design/18 §11 的可调旋钮): // 它**只**影响「说不说 / 多频繁」,绝不动权限、工具面或 actor 元数据(AGENTS §9)。 // // 零值 = 最保守的一档(只回指向她的消息),也就是裁决「甲」的默认口径。 type OwnerLanePolicy struct { // ReplyToUnaddressed 是「说不说」开关:true 时主人**非指向她**的群消息也参与回复; // false(默认)时只回指向她的(@她 / @全体 / 未接线渠道的旧判据)。 ReplyToUnaddressed bool `json:"replyToUnaddressed"` // MinReplyGapSec 是「多频繁」:owner 车道两条**非指向她**的回复之间的最小间隔(秒)。 // 0 = 不额外限制。指向她的消息一律必回,**不受本字段约束**(硬保留)。 MinReplyGapSec int `json:"minReplyGapSec"` } // DefaultOwnerLanePolicy 返回裁决「甲」的默认口径:不对她说就不开口。 func DefaultOwnerLanePolicy() OwnerLanePolicy { return OwnerLanePolicy{} } // OwnerLaneFile 是 owner_lane.json 的形状:一个全局 default(可被面板改) // 加一份每群覆盖表。键见 OwnerLaneGroupKey。 type OwnerLaneFile struct { Default OwnerLanePolicy `json:"default"` Groups map[string]OwnerLanePolicy `json:"groups,omitempty"` } // OwnerLaneGroupKey 是每群覆盖表的键:平台 + 群 id。群 id 为空表示**全局默认**那一档 // (面板把「所有群」的旋钮写进 default 时用它)。 func OwnerLaneGroupKey(platform, groupID string) string { return platform + "|" + groupID } // OwnerLaneStore 是 owner 车道旋钮的读写面:内存缓存 + 落盘。它可安全并发使用。 // 读缺失文件、坏文件都退回默认值(绝不因为配置坏了而阻断入站),但会留一条带来源的 WARN。 type OwnerLaneStore struct { path string log *slog.Logger mu sync.RWMutex cfg OwnerLaneFile } // NewOwnerLaneStore 构造 store 并尽力加载已有文件。path 为空或文件缺失时走默认值, // 不返回错误:配置坏了只影响旋钮取值,绝不该拦住渠道启动。 func NewOwnerLaneStore(path string, log *slog.Logger) *OwnerLaneStore { if log == nil { log = slog.Default() } s := &OwnerLaneStore{ path: path, log: logger.WithSource(log, ownerLaneLogSource), cfg: OwnerLaneFile{Default: DefaultOwnerLanePolicy(), Groups: map[string]OwnerLanePolicy{}}, } s.load() return s } // load 尽力从磁盘读入配置。文件不存在是正常情况(首次运行),静默走默认值; // 其它读/解析错误留一条带来源的 WARN 后仍走默认值。 func (s *OwnerLaneStore) load() { if s.path == "" { return } raw, err := os.ReadFile(s.path) if err != nil { if !os.IsNotExist(err) { s.log.Warn("owner lane config unreadable, using defaults", "path", s.path, "error", err) } return } var f OwnerLaneFile if err := json.Unmarshal(raw, &f); err != nil { s.log.Warn("owner lane config invalid, using defaults", "path", s.path, "error", err) return } if f.Groups == nil { f.Groups = map[string]OwnerLanePolicy{} } s.mu.Lock() s.cfg = f s.mu.Unlock() } // PolicyFor 返回某个 (platform, groupID) 生效的策略:有每群覆盖就用它,否则用全局 // default(default 缺省即「甲」)。它不返回「未设置」,因此调用方永远拿到一个可用的档位。 func (s *OwnerLaneStore) PolicyFor(platform, groupID string) OwnerLanePolicy { s.mu.RLock() defer s.mu.RUnlock() if p, ok := s.cfg.Groups[OwnerLaneGroupKey(platform, groupID)]; ok { return p } return s.cfg.Default } // Snapshot 返回配置的一份拷贝,供面板/测试读取(改动它不影响 store)。 func (s *OwnerLaneStore) Snapshot() OwnerLaneFile { s.mu.RLock() defer s.mu.RUnlock() out := OwnerLaneFile{Default: s.cfg.Default, Groups: make(map[string]OwnerLanePolicy, len(s.cfg.Groups))} for k, v := range s.cfg.Groups { out.Groups[k] = v } return out } // Set 写入某个 (platform, groupID) 的策略并落盘。groupID 为空表示写全局 default 档。 // // 内存改动与**落盘都在同一把锁内**(review p1):并发 Set 因此完全串行 —— 既不会两个 // 写者去写同一个临时文件把 JSON 写坏,也不会因为原子 rename 的完成先后与各快照的新旧 // 不一致而丢掉「后发生的那次更新」。落盘量是一个小 JSON,持锁期间的 I/O 代价可忽略; // 读侧(PolicyFor)读的是内存、不碰文件,因此不会被这次落盘抖动。 // // 内存先改、失败返回错误(调用方留可见 WARN),但内存里的新档位仍然生效 —— // 「改不动磁盘」不该让已经做出的行为决定回退。 func (s *OwnerLaneStore) Set(platform, groupID string, p OwnerLanePolicy) error { s.mu.Lock() defer s.mu.Unlock() if groupID == "" { s.cfg.Default = p } else { if s.cfg.Groups == nil { s.cfg.Groups = map[string]OwnerLanePolicy{} } s.cfg.Groups[OwnerLaneGroupKey(platform, groupID)] = p } if err := s.saveLocked(); err != nil { return fmt.Errorf("runtimeconfig: save owner lane config %s: %w", s.path, err) } return nil } // saveLocked 原子写出当前配置。调用方**必须持有 s.mu**。 // // 它每次都用**新建的临时文件**(`os.CreateTemp`,仓库同款先例见 internal/user/save.go) // 再原子 rename:即便有别的进程/goroutine 也在写同一个目录,也不会撞同一个临时文件名, // 而半份文件永远不会以 s.path 的名字出现。 func (s *OwnerLaneStore) 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() // CreateTemp 以 0600 打开;显式再 chmod 一次,使落盘文件不因 umask 变化而更宽。 // 任一步失败都删掉临时文件,绝不留垃圾。 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 }