主人 2026-10-13 裁决「乙 C」= 默认按甲(群里不对她说就不开口)+ 可调旋钮。 - inbound.go: owner 不再无条件短路参与闸(去掉 `!actor.IsOwner()` 门), 主人群消息改走 shouldRespond 的 owner 车道分支;主人私聊恒回。 - ownerlane.go(新): owner 车道结构化闸 + 判据 `msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0)` (未接线渠道退回旧判据,保护 kook/webhook 的群 @);指向她本人必回(硬保留); 非指向消息按每群旋钮——默认只进上下文不回(registerSilenced 落隔离会话)。 「少说点」类关键词落成结构化降频配置,带 source 日志。 - channelbridge.go: Options.OwnerLaneFor/OwnerLaneSet 读写接缝 + Bridge 状态。 - engage.go: 冷避词表并入「少说点」(与既有「少说两句」同义)。 - runtimeconfig/channels_ownerlane.go(新): owner_lane.json 落盘面 (默认档 + 每群覆盖,可读可改,面板可写)。 - wire_channels.go: 注入 owner_lane.json 存储($DATA_DIR/config/)。 - 测试:不对她说就不回(进上下文零回复)/ @她必回(即使降频)/ 未接线退回 旧判据 / 降频生效且可恢复 + 频率闸 / 旋钮读写接缝 / 归属不变量闭合 / 陌生人私聊与主人私聊回归。变异自证:恢复 owner 短路 ⇒ 5 条用例红。
189 lines
7.1 KiB
Go
189 lines
7.1 KiB
Go
// 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 档。
|
||
// 内存先改(行为立即生效),再尽力落盘;落盘失败返回错误(调用方留可见 WARN),
|
||
// 但内存里的新档位仍然生效 —— 「改不动磁盘」不该让已经做出的行为决定回退。
|
||
func (s *OwnerLaneStore) Set(platform, groupID string, p OwnerLanePolicy) error {
|
||
s.mu.Lock()
|
||
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
|
||
}
|
||
snapshot := OwnerLaneFile{Default: s.cfg.Default, Groups: make(map[string]OwnerLanePolicy, len(s.cfg.Groups))}
|
||
for k, v := range s.cfg.Groups {
|
||
snapshot.Groups[k] = v
|
||
}
|
||
s.mu.Unlock()
|
||
|
||
if err := s.save(snapshot); err != nil {
|
||
return fmt.Errorf("runtimeconfig: save owner lane config %s: %w", s.path, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// save 原子写出配置(临时文件 + rename),使并发读者永远不会看到半份文件。
|
||
func (s *OwnerLaneStore) save(f OwnerLaneFile) 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(f, "", " ")
|
||
if err != nil {
|
||
return fmt.Errorf("marshal: %w", err)
|
||
}
|
||
tmp := s.path + ".tmp"
|
||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||
return fmt.Errorf("write %s: %w", tmp, err)
|
||
}
|
||
if err := os.Rename(tmp, s.path); err != nil {
|
||
return fmt.Errorf("rename: %w", err)
|
||
}
|
||
return nil
|
||
}
|