From 537a8b7988b1fc6dfbb47ea591aa0d2e447138df Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 20:48:05 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(channel):=20owner=20=E8=BD=A6=E9=81=93?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E5=8F=82=E4=B8=8E=E9=97=B8=E4=B8=8E?= =?UTF-8?q?=E5=8F=AF=E8=B0=83=E6=97=8B=E9=92=AE=EF=BC=88=E4=B9=99=20=3D=20?= =?UTF-8?q?C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 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 条用例红。 --- cmd/cyrene/wire_channels.go | 23 + .../runtimeconfig/channels_ownerlane.go | 188 +++++++++ .../runtimeconfig/channels_ownerlane_test.go | 81 ++++ .../services/channelbridge/channelbridge.go | 26 ++ .../channelbridge/channelbridge_test.go | 7 +- internal/services/channelbridge/engage.go | 4 +- internal/services/channelbridge/inbound.go | 19 +- internal/services/channelbridge/light.go | 25 +- internal/services/channelbridge/ownerlane.go | 165 ++++++++ .../services/channelbridge/ownerlane_test.go | 394 ++++++++++++++++++ .../services/channelbridge/persona_test.go | 6 +- 11 files changed, 917 insertions(+), 21 deletions(-) create mode 100644 internal/config/runtimeconfig/channels_ownerlane.go create mode 100644 internal/config/runtimeconfig/channels_ownerlane_test.go create mode 100644 internal/services/channelbridge/ownerlane.go create mode 100644 internal/services/channelbridge/ownerlane_test.go diff --git a/cmd/cyrene/wire_channels.go b/cmd/cyrene/wire_channels.go index 6ae5e04..9bb5e33 100644 --- a/cmd/cyrene/wire_channels.go +++ b/cmd/cyrene/wire_channels.go @@ -108,6 +108,12 @@ func (a *app) wireChannels() error { // 入站附件的目录口径与文档摄取层(wire_runtime.go 的 docResolver)同源, // 两处必须指向同一棵目录树,否则附件会落在一个地方、抽取去另一个地方找。 docDataDir := resolveDataDir(a.cfg) + // owner 车道结构化旋钮(design/18 §11「乙 = C」)的落盘面:主人在群里说「少说点」 + // 这类诉求被写成 $DATA_DIR/config/owner_lane.json(可读可改,面板后续照此写入)。 + // 它只承载「说不说 / 多频繁」,不承载任何身份/权限信息。 + ownerLane := runtimeconfig.NewOwnerLaneStore( + runtimeconfig.DefaultOwnerLanePath(docDataDir), + logger.WithSource(a.log, "services/channelbridge/ownerlane")) bridge, berr := channelbridge.New(channelbridge.Options{ Log: logger.WithSource(a.log, "services/channelbridge"), Loop: a.loop, @@ -145,6 +151,23 @@ func (a *app) wireChannels() error { PersonaPrompt: a.defaultInstructions(), Engage: channelbridge.DefaultEngageConfig(), // EngageFor 留空(P2a);按群覆盖随 P2c 到来。 + // OwnerLaneFor / OwnerLaneSet 是 owner 车道结构化旋钮(design/18 §11「乙 = C」) + // 的读取/落盘接缝:读接口按 (platform, groupID) 返回该群生效策略(缺省 = 不对她说 + // 就不开口),写接口把「少说点」这类结构化诉求落进 owner_lane.json,也是面板后续 + // 写入的那一份。它只影响说不说/多频繁,绝不触碰权限与 actor 元数据。 + OwnerLaneFor: func(platform, groupID string) *channelbridge.OwnerLanePolicy { + p := ownerLane.PolicyFor(platform, groupID) + return &channelbridge.OwnerLanePolicy{ + ReplyToUnaddressed: p.ReplyToUnaddressed, + MinReplyGapSec: p.MinReplyGapSec, + } + }, + OwnerLaneSet: func(platform, groupID string, p channelbridge.OwnerLanePolicy) error { + return ownerLane.Set(platform, groupID, runtimeconfig.OwnerLanePolicy{ + ReplyToUnaddressed: p.ReplyToUnaddressed, + MinReplyGapSec: p.MinReplyGapSec, + }) + }, // Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。 Factory: factory, // Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线 diff --git a/internal/config/runtimeconfig/channels_ownerlane.go b/internal/config/runtimeconfig/channels_ownerlane.go new file mode 100644 index 0000000..f1b5f0c --- /dev/null +++ b/internal/config/runtimeconfig/channels_ownerlane.go @@ -0,0 +1,188 @@ +// 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 +} diff --git a/internal/config/runtimeconfig/channels_ownerlane_test.go b/internal/config/runtimeconfig/channels_ownerlane_test.go new file mode 100644 index 0000000..df8a2bf --- /dev/null +++ b/internal/config/runtimeconfig/channels_ownerlane_test.go @@ -0,0 +1,81 @@ +package runtimeconfig + +import ( + "os" + "path/filepath" + "testing" +) + +// TestOwnerLaneStoreDefaultsWhenFileMissing 钉住默认档(裁决「甲」): +// 文件不存在时读到的就是最保守的一档 —— 不对她说就不开口,且没有额外频率闸。 +func TestOwnerLaneStoreDefaultsWhenFileMissing(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config", "owner_lane.json") + s := NewOwnerLaneStore(path, nil) + + got := s.PolicyFor("qq-obv11", "672432767") + if got.ReplyToUnaddressed || got.MinReplyGapSec != 0 { + t.Fatalf("默认档 = %+v, want 零值(不对她说就不开口)", got) + } +} + +// TestOwnerLaneStoreSetPersistsAndReloads 钉住「可读可改」:Set 落盘后,新建的 +// store 能读回同一个每群覆盖;未覆盖的群仍走全局 default。 +func TestOwnerLaneStoreSetPersistsAndReloads(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config", "owner_lane.json") + s := NewOwnerLaneStore(path, nil) + want := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 120} + if err := s.Set("qq-obv11", "672432767", want); err != nil { + t.Fatalf("Set: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("owner_lane.json 未落盘:%v", err) + } + + reloaded := NewOwnerLaneStore(path, nil) + if got := reloaded.PolicyFor("qq-obv11", "672432767"); got != want { + t.Fatalf("重载后该群策略 = %+v, want %+v", got, want) + } + // 未被覆盖的群仍走全局 default(零值)。 + if got := reloaded.PolicyFor("qq-obv11", "another-group"); got != DefaultOwnerLanePolicy() { + t.Fatalf("未覆盖群策略 = %+v, want 默认档", got) + } +} + +// TestOwnerLaneStoreGlobalDefault 钉住面板可改的「全局 default」那一档: +// groupID 为空即写默认,所有未单独覆盖的群都跟着变。 +func TestOwnerLaneStoreGlobalDefault(t *testing.T) { + t.Parallel() + + s := NewOwnerLaneStore(filepath.Join(t.TempDir(), "config", "owner_lane.json"), nil) + global := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 60} + if err := s.Set("", "", global); err != nil { + t.Fatalf("Set default: %v", err) + } + if got := s.PolicyFor("telegram", "-20"); got != global { + t.Fatalf("全局默认生效值 = %+v, want %+v", got, global) + } +} + +// TestOwnerLaneStoreCorruptFileFallsBackToDefaults 钉住「配置坏了也不阻断」: +// 畸形文件只退回默认档并留 WARN,绝不 panic、绝不返回错误阻断渠道启动。 +func TestOwnerLaneStoreCorruptFileFallsBackToDefaults(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "config", "owner_lane.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte("{ this is not json "), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + s := NewOwnerLaneStore(path, nil) + if got := s.PolicyFor("qq-obv11", "g"); got != DefaultOwnerLanePolicy() { + t.Fatalf("坏配置下策略 = %+v, want 默认档", got) + } +} diff --git a/internal/services/channelbridge/channelbridge.go b/internal/services/channelbridge/channelbridge.go index 59eb047..861b174 100644 --- a/internal/services/channelbridge/channelbridge.go +++ b/internal/services/channelbridge/channelbridge.go @@ -139,6 +139,14 @@ type Options struct { // Now 是安静时段判定所用的墙上时钟源。为 nil 时用 time.Now, // 使依赖时间的行为在测试中可注入。 Now func() time.Time + // OwnerLaneFor 是 owner 车道结构化旋钮(design/18 §11「乙 = C」)的**读取**接缝: + // 按 (platform, groupID) 返回该群生效的参与策略,返回 nil 表示没有覆盖 + // (桥用自己的默认档:不对她说就不开口)。为 nil 时全部走默认档 + 桥内内存覆盖。 + OwnerLaneFor func(platform, groupID string) *OwnerLanePolicy + // OwnerLaneSet 是旋钮的**落盘**接缝:主人在群里说「少说点」这类诉求被结构化后, + // 桥通过它把新策略写进外部配置(面板可读可改的那一份)。为 nil 时只更新桥内内存 + // 覆盖(进程重启即回到默认档)。 + OwnerLaneSet func(platform, groupID string, p OwnerLanePolicy) error // Factory 依据某平台生效的账号配置重建其适配器 // (channels.json → 环境变量回退)。非 nil 时桥接器支持热重载; // 为 nil 时桥接器仅支持启动期装配(Reload 返回 ErrNilFactory)。 @@ -250,6 +258,19 @@ type Bridge struct { engageMu sync.Mutex engageGroups map[string]*engageGroupState + // ownerLane 是 owner 车道结构化旋钮(design/18 §11)的**内存覆盖表**, + // 键 = platform|groupID。ownerLaneFor 从外部配置读取(组合根注入的 owner_lane.json), + // ownerLaneSet 把改动回写落盘;ownerLaneLast 记录每条 owner 车道回复的时间戳,供 + // 「多频繁」闸使用。四者都由 ownerLaneMu 保护。 + ownerLane map[string]OwnerLanePolicy + ownerLaneFor func(platform, groupID string) *OwnerLanePolicy + ownerLaneSet func(platform, groupID string, p OwnerLanePolicy) error + ownerLaneLast map[string]time.Time + ownerLaneMu sync.Mutex + // ownerLaneLog 是 owner 闸门/旋钮的子功能日志器(source=services/channelbridge/ownerlane), + // 使「这条为什么没回」与「旋钮被改成什么」在日志里可追溯。 + ownerLaneLog *slog.Logger + mu sync.RWMutex adapters map[string]adapter.PlatformAdapter connected map[string]bool @@ -356,6 +377,11 @@ func New(opts Options) (*Bridge, error) { engageRand: engageRand, bubbleGapFn: bubbleGap, engageGroups: make(map[string]*engageGroupState), + ownerLane: make(map[string]OwnerLanePolicy), + ownerLaneFor: opts.OwnerLaneFor, + ownerLaneSet: opts.OwnerLaneSet, + ownerLaneLast: make(map[string]time.Time), + ownerLaneLog: logger.WithSource(log, ownerLaneLogSource), adapters: make(map[string]adapter.PlatformAdapter), connected: make(map[string]bool), readCancels: make(map[string]context.CancelFunc), diff --git a/internal/services/channelbridge/channelbridge_test.go b/internal/services/channelbridge/channelbridge_test.go index 026ec95..6a215bb 100644 --- a/internal/services/channelbridge/channelbridge_test.go +++ b/internal/services/channelbridge/channelbridge_test.go @@ -566,9 +566,12 @@ func TestOwnerInGroupIsIsolatedButNotLabeledThirdParty(t *testing.T) { msg := adapter.UnifiedMessage{ Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, GroupID: "-20", SenderID: "owner_uid", SenderName: "主人", - Sender: adapter.Actor{Role: adapter.RoleOwner}, Content: "我在群里", - ContentType: adapter.ContentText, + Sender: adapter.Actor{Role: adapter.RoleOwner}, Mentions: []adapter.Mention{{UserID: "owner_uid"}}, + Content: "我在群里", ContentType: adapter.ContentText, } + // 这条主人群消息**指向她本人**(含提及):owner 车道默认「不对她说就不开口」 + // (design/18 §11),不带指向信号的主人群消息只进上下文、不驱动回合; + // 带 @ 信号才会进循环,本用例因此仍在断言「进循环时主人不被标为第三方」。 b.process(context.Background(), msg) // 受限上下文 → 仍会注入第三方边界系统消息。 diff --git a/internal/services/channelbridge/engage.go b/internal/services/channelbridge/engage.go index 4be3f0d..a1ed09d 100644 --- a/internal/services/channelbridge/engage.go +++ b/internal/services/channelbridge/engage.go @@ -349,9 +349,11 @@ func (b *Bridge) negativeAttitude(text string) bool { // negativeAttitudeKeywords 是冷避规则词表(design/18 §5 词表兜底)。它只是 // 兜底;同一信号也会被判定器中的跟进复审通过 avoidTopic 读取。 +// 「少说点」是主人 2026-10-13 裁决「乙 = C」里主人诉求的原话形态(design/18 §11 ②), +// 与既有「少说两句」同义,因此并入同一张词表 —— owner 车道据此把诉求落成结构化降频。 var negativeAttitudeKeywords = []string{ "太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听", - "安静点", "少说两句", "别说了", "别bb", "别逼逼", "烦死了", "好烦", + "安静点", "少说两句", "少说点", "别说了", "别bb", "别逼逼", "烦死了", "好烦", "打住", "住嘴", "shut up", "too loud", "stop spamming", } diff --git a/internal/services/channelbridge/inbound.go b/internal/services/channelbridge/inbound.go index 3dd501d..a9677b8 100644 --- a/internal/services/channelbridge/inbound.go +++ b/internal/services/channelbridge/inbound.go @@ -192,22 +192,23 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a "session", sess.SessionID, "actor_role", sess.ActorRole, "env", sess.Env(), "mem_writable", sess.MemWritable) - // design/12 §6 策略一/二:面向非主人消息的最小打扰闸门。 - // 被廉价轻量判定认为不值得回复的非主人**群聊**消息,只登记到 - // 其隔离会话日志并静默——绝不驱动完整 agent 循环;**陌生人私聊**则 - // 一律走完整回合(裁决①,见 shouldRespond),只受流控约束。 - // 主人消息绝不在此被拦,因此主人私聊与主人在群里的 - // @/高价值回复不受影响。 + // design/12 §6 策略一/二 + design/18 §11「乙 = C」(主人 2026-10-13 裁决): + // owner 车道不再**无条件短路**这道闸 —— 主人群消息也按结构化判据决定说不说。 + // - 主人**私聊/非群**:恒回,不受影响; + // - 主人**群聊**:只有指向她本人的(@她 / @全体 / 未接线渠道旧判据)才必回, + // 其余**仍进上下文但不触发回复**(落进该群隔离会话,见 ownerlane.go); + // - 非主人:走既有的最小打扰分级(shouldRespond)。 + // 被拦下的消息只登记到其隔离会话日志并静默——绝不驱动完整 agent 循环。 // // 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一 // 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向 // 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。 - if !actor.IsOwner() && !notice.Applicable { + if !notice.Applicable { if ok, reason := b.shouldRespond(ctx, msg, actor, sess, a); !ok { b.registerSilenced(ctx, msg, actor, sess, a) - b.log.Debug("non-owner message silenced (minimal disturbance)", + b.log.Debug("inbound message silenced (minimal disturbance)", "platform", msg.Platform, "channel", msg.ChannelID, - "session", sess.SessionID, "reason", reason) + "session", sess.SessionID, "actor_role", actor.Role, "reason", reason) return } } diff --git a/internal/services/channelbridge/light.go b/internal/services/channelbridge/light.go index 8c91a3e..59ef9e8 100644 --- a/internal/services/channelbridge/light.go +++ b/internal/services/channelbridge/light.go @@ -160,15 +160,23 @@ func isStrangerPrivate(sess session.Session) bool { return !sess.IsGroup && !sess.Owner } -// shouldRespond 实现非主人入站消息的最小打扰分级 -// (设计/12 §6 策略一/二 + design/18 §2/§4/§5)。它返回该消息 +// shouldRespond 实现入站消息的最小打扰分级 +// (设计/12 §6 策略一/二 + design/18 §2/§4/§5/§11)。它返回该消息 // 应驱动完整 agent 循环并回复,还是只登记并静默。 -// 主人消息绝不会到这里(processFrom 以 !actor.IsOwner 为门), -// 因此主人私聊与主人在群里的 @/高价值回复不受影响。sess 是 +// **主人消息**由此处的 owner 车道分支处理(design/18 §11「乙 = C」): +// 主人私聊恒回;主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说 +// (见 ownerlane.go);因此主人私聊与主人在群里的 @/@全体仍必回, +// 而主人群里不对她说的消息改为**进上下文不回**。sess 是 // 该消息解析出的隔离群聊/陌生人会话绑定,使参与度 // 引擎探测时能读到群的近期上下文;a 是收到该消息的账号适配器, // 用于取该账号的流控闸门(陌生人私聊的强制限流)。 func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) (bool, string) { + // owner 车道:结构化参与闸(design/18 §11)。身份由 actor 元数据判定, + // 这里只决定「说不说 / 多频繁」,绝不动权限面。 + if actor.IsOwner() { + return b.ownerGroupShouldRespond(msg, sess) + } + // 策略一:平台/系统噪声 → 只登记,绝不进完整循环。 if isLowValueEvent(actor) { b.log.Debug("non-owner low-value system event, registered only", @@ -251,11 +259,11 @@ func (b *Bridge) mentionsTriggerWord(text string) bool { return false } -// registerSilenced 把一次被静默的非主人入站对话持久化进其隔离 +// registerSilenced 把一次被静默的入站对话持久化进其隔离 // 会话的平台日志,使适配器日志页仍能渲染非主人 // 消息流(design/16 §4.6 §4),随后就此结束——不进循环、不回复。a 是 // 收到该消息的账号适配器,日志因此带上适配器配置标签。 -// 它是最小打扰路径中「登记」的那一侧。 +// 它是最小打扰路径(以及 owner 车道「进上下文不回」)中「登记」的那一侧。 func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) { // 被静默的消息不摄取附件(摄取在闸门之后),因此这两列恒为空 —— // 绝不写一条指向不存在文件的引用,也绝不写一张没进过上下文的图片。 @@ -268,8 +276,9 @@ func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessag // senderDisplayName 会在无昵称时写出空串,把一条陌生人消息渲染成 // 「来自主人(管理员账户)本人」(review p1:本次修掉的 bug 的镜像)。 b.persistIsolated(ctx, history.RoleUser, msg.Content, "", "", deviceIDFor(msg), sess, msg.Platform, thirdPartySenderName(msg, actor), adapterName(a), "") - b.log.Debug("non-owner message silenced, registered only", - "platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID) + b.log.Debug("inbound message silenced, registered only", + "platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID, + "actor_role", actor.Role) } // registerThrottled 是一次被强制流控(同一发送者已有回合在跑)拦下的 diff --git a/internal/services/channelbridge/ownerlane.go b/internal/services/channelbridge/ownerlane.go new file mode 100644 index 0000000..74e5a16 --- /dev/null +++ b/internal/services/channelbridge/ownerlane.go @@ -0,0 +1,165 @@ +package channelbridge + +import ( + "fmt" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" +) + +// 本文件承载 owner 车道(主人在**群聊**里的发言)的结构化参与闸与它的可调旋钮 +// (design/18 §11「乙 = C」,主人 2026-10-13 裁决「乙 C」): +// +// - 默认「不对她说就不开口」:只有**指向她本人**的主人群消息(@她 / @全体, +// 或未接线渠道上「有提及」的旧判据)才触发回复;其余主人群消息**仍进上下文** +// (经 registerSilenced 落进该群隔离会话,后续回合的转录照常读到),但**不驱动回合**。 +// - 可调旋钮:把主人在群里说的「少说点」这类诉求**落成结构化配置**(每群 / 主人的 +// 开关与频率,默认值 + 覆盖),可读可改 —— **不靠她自己记住**。 +// +// 两条红线(design/18 §11 写死):判据只认**结构化字段 + 正文关键词**,绝不调 LLM 判身份; +// 旋钮只影响「说不说 / 多频繁」,绝不动权限、工具面或 actor 元数据(AGENTS §9)。 +// 它同时**不碰** M10 的发言人归属车道(inbound_speaker.go):主人恒无标注这条不变量不变。 + +const ( + // ownerLaneLogSource 是本文件日志的来源标记,使「owner 闸门/旋钮怎么变的」跨模块 + // 可定位(项目规范 §五:禁止无来源裸日志)。 + ownerLaneLogSource = "services/channelbridge/ownerlane" + // defaultOwnerLaneQuietGapSec 是「少说点」把旋钮降到保守档时的最小回复间隔(5 分钟): + // 即便她仍回非指向她的主人消息,也不会连珠炮。 + defaultOwnerLaneQuietGapSec = 300 +) + +// OwnerLanePolicy 是 owner 车道的一档参与策略(与 runtimeconfig.OwnerLanePolicy 同形, +// 由组合根在接缝处转换,使 channelbridge 不依赖 config 包)。零值 = 最保守的一档。 +type OwnerLanePolicy struct { + // ReplyToUnaddressed 是「说不说」开关。 + ReplyToUnaddressed bool + // MinReplyGapSec 是「多频繁」:非指向她的回复之间的最小间隔(秒),0 = 不限制。 + MinReplyGapSec int +} + +// DefaultOwnerLanePolicy 返回默认档位:不对她说就不开口。 +func DefaultOwnerLanePolicy() OwnerLanePolicy { + return OwnerLanePolicy{} +} + +// ownerLaneQuietPolicy 是「少说点」把旋钮降到的保守档:非指向她的一律不回, +// 并保留一个频率闸(供别人把开关打开后仍受间隔约束)。 +func ownerLaneQuietPolicy() OwnerLanePolicy { + return OwnerLanePolicy{ReplyToUnaddressed: false, MinReplyGapSec: defaultOwnerLaneQuietGapSec} +} + +// ownerAddressedToMe 是 owner 车道的「指向她本人」判据(M11 reviewer 给死的写法): +// +// msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0) +// +// 后半句是**未接线渠道的旧判据**(现在只有 qq-obv11 填 SelfID/Mentions):对未接线渠道 +// AddressedToMe 恒 false,若只认前半句,kook / webhook 的群 @ 会不再必回。 +// ⇒ 「没有信号」绝不能被当成「没在对我说话」。判据全部来自结构化字段,绝不匹配正文。 +func ownerAddressedToMe(msg adapter.UnifiedMessage) bool { + return msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0) +} + +// ownerLaneKey 是旋钮内存覆盖表的键(平台 + 群)。 +func ownerLaneKey(platform, groupID string) string { + return platform + "|" + groupID +} + +// OwnerLanePolicy 返回某个群当前生效的 owner 车道策略:外部配置(组合根注入的 +// OwnerLaneFor)优先,其次桥内内存覆盖,最后是默认档。它是**可读**面 —— 面板/运维 +// 据此显示该群当前的旋钮位置。 +func (b *Bridge) OwnerLanePolicy(platform, groupID string) OwnerLanePolicy { + if b.ownerLaneFor != nil { + if p := b.ownerLaneFor(platform, groupID); p != nil { + return *p + } + } + b.ownerLaneMu.Lock() + defer b.ownerLaneMu.Unlock() + if p, ok := b.ownerLane[ownerLaneKey(platform, groupID)]; ok { + return p + } + return DefaultOwnerLanePolicy() +} + +// SetOwnerLanePolicy 写入某个群的 owner 车道策略(**可改**面:面板写入与「少说点」 +// 自动降频共用它)。内存先改(行为立即生效),再经注入的 OwnerLaneSet 尽力落盘; +// 每次变化都留一条带 source 的 INFO 日志,使「旋钮怎么变的」可观测(design/18 §11)。 +func (b *Bridge) SetOwnerLanePolicy(platform, groupID string, p OwnerLanePolicy) error { + b.ownerLaneMu.Lock() + b.ownerLane[ownerLaneKey(platform, groupID)] = p + b.ownerLaneMu.Unlock() + + var persistErr error + if b.ownerLaneSet != nil { + if err := b.ownerLaneSet(platform, groupID, p); err != nil { + persistErr = fmt.Errorf("channelbridge: persist owner lane policy: %w", err) + } + } + b.ownerLaneLog.Info("owner lane policy set", + "platform", platform, "group", groupID, + "reply_to_unaddressed", p.ReplyToUnaddressed, "min_reply_gap_sec", p.MinReplyGapSec) + return persistErr +} + +// ownerGroupShouldRespond 是 owner 车道的结构化参与闸(design/18 §11)。 +// 它只对**主人**消息调用(非主人走 shouldRespond 的既有分级),返回该消息是否应驱动 +// 完整 agent 循环并回复: +// +// - 主人**私聊/非群**:恒回(旋钮只管群,design/18 §11); +// - 群聊里命中「少说点」这类关键词:先把它落成结构化配置(降频),再照常判定; +// - 指向她本人(@她 / @全体 / 未接线旧判据):**必回**,不受旋钮约束(硬保留); +// - 其余:按该群旋钮 —— 开关关(默认)⇒ 只进上下文不回;开关开⇒再受频率闸约束。 +func (b *Bridge) ownerGroupShouldRespond(msg adapter.UnifiedMessage, sess session.Session) (bool, string) { + if !sess.IsGroup { + return true, "owner private: always replies" + } + b.applyOwnerLaneRequest(msg, sess) + if ownerAddressedToMe(msg) { + return true, "owner group: addressed to her" + } + p := b.OwnerLanePolicy(msg.Platform, msg.GroupID) + if !p.ReplyToUnaddressed { + return false, "owner lane: not addressed to her" + } + if !b.ownerLaneGapPassed(msg.Platform, msg.GroupID, p.MinReplyGapSec) { + return false, "owner lane: within reply gap" + } + return true, "owner lane: unaddressed reply allowed" +} + +// applyOwnerLaneRequest 把主人在群里表达的「少说点」这类诉求落成结构化配置 +// (design/18 §11 ②)。词表沿用 engage.go 的 negativeAttitudeKeywords(已有「少说两句」 +// 等),仍是廉价子串匹配 —— 绝不调用 LLM,也绝不读正文判身份。**日志不打印正文** +// (design/18 §9.7)。落盘失败只留可见 WARN,内存里的降频仍已生效。 +func (b *Bridge) applyOwnerLaneRequest(msg adapter.UnifiedMessage, sess session.Session) { + if !sess.IsGroup || !b.negativeAttitude(msg.Content) { + return + } + if err := b.SetOwnerLanePolicy(msg.Platform, msg.GroupID, ownerLaneQuietPolicy()); err != nil { + b.ownerLaneLog.Warn("owner lane quiet request could not be persisted", + "platform", msg.Platform, "group", msg.GroupID, "error", err) + return + } + b.ownerLaneLog.Info("owner lane quietened by owner request", + "platform", msg.Platform, "group", msg.GroupID) +} + +// ownerLaneGapPassed 施加「多频繁」闸:间隔未到返回 false(该消息只进上下文不回); +// 通过则记录本次时刻并返回 true。gapSec <= 0 表示不限制。只有**非指向她**的消息会走到 +// 这里,因此指向她的必回语义不受影响。调用方不持锁,这里取内存覆盖表同一把锁。 +func (b *Bridge) ownerLaneGapPassed(platform, groupID string, gapSec int) bool { + if gapSec <= 0 { + return true + } + b.ownerLaneMu.Lock() + defer b.ownerLaneMu.Unlock() + key := ownerLaneKey(platform, groupID) + now := b.now() + if last, ok := b.ownerLaneLast[key]; ok && now.Sub(last) < time.Duration(gapSec)*time.Second { + return false + } + b.ownerLaneLast[key] = now + return true +} diff --git a/internal/services/channelbridge/ownerlane_test.go b/internal/services/channelbridge/ownerlane_test.go new file mode 100644 index 0000000..06623e6 --- /dev/null +++ b/internal/services/channelbridge/ownerlane_test.go @@ -0,0 +1,394 @@ +package channelbridge + +import ( + "bytes" + "context" + "log/slog" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/user" +) + +// 本文件覆盖 design/18 §11「乙 = C」(主人 2026-10-13 裁决「乙 C」): +// - owner 车道默认「不对她说就不开口」:主人群里不对她说的消息**仍进上下文**(落进该群 +// 隔离会话,转录读得到)但**不驱动回复**; +// - 指向她本人(@她 / @全体 / 未接线渠道旧判据)**必回**,不被旋钮关掉; +// - 结构化旋钮(每群开关 + 频率)可读可改、可观测,且只影响说不说/多频繁。 + +// ownerLaneGroupMsg 构造一条来自主人(由白名单结构化判主)的群消息。 +// Sender.Role 故意留 member:判主只看白名单(裁决 06.4.2)。 +func ownerLaneGroupMsg(content string, mentions ...adapter.Mention) adapter.UnifiedMessage { + return adapter.UnifiedMessage{ + Platform: "qq-obv11", ChannelID: "g1", ChannelType: adapter.ChannelGroup, + GroupID: "g1", GroupName: "测试群", SenderID: "10001", SenderName: "主人", + Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: mentions, + Content: content, ContentType: adapter.ContentText, + } +} + +// ownerGroupSession 是主人在群里的受限会话绑定(群 + 主人 + 记忆只读)。 +func ownerGroupSession() session.Session { + return session.Session{SessionID: "sess-og", IsGroup: true, Owner: true, MemWritable: false} +} + +// newOwnerLaneBridge 构建一条接了 qq-obv11 适配器、主人白名单命中的测试桥接器。 +// now 为 nil 时用固定时钟。 +func newOwnerLaneBridge(t *testing.T, chat *stubChatter, sess *stubSessions, hist *captureHistory, now func() time.Time, log *slog.Logger) (*Bridge, *fakeAdapter) { + t.Helper() + if now == nil { + at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + now = func() time.Time { return at } + } + ad := &fakeAdapter{name: "qq-obv11"} + b, err := New(Options{ + Loop: chat, Sessions: sess, Owner: testOwnerProfile("qq-obv11", "10001"), + History: hist, Log: log, Now: now, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + b.Add(ad) + return b, ad +} + +// TestOwnerLaneUnaddressedGroupMessageRegisteredNotReplied 钉住默认口径「不对她说就不开口」: +// 主人群里一句不对她说的普通消息**只进上下文**(落进该群隔离会话)、**不驱动回合**。 +// 变异自证:把 inbound.go 的 owner 短路恢复(`!actor.IsOwner() && !notice.Applicable`)⇒ +// 本用例立刻变红(循环会被驱动、chat.sessionID 非空)。 +func TestOwnerLaneUnaddressedGroupMessageRegisteredNotReplied(t *testing.T) { + t.Parallel() + + chat := &stubChatter{res: &loop.Result{Text: "不该回"}} + hist := &captureHistory{} + b, ad := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, hist, nil, nil) + + b.process(context.Background(), ownerLaneGroupMsg("大家好啊,我今天在家")) + + if chat.sessionID != "" || len(chat.messages) != 0 { + t.Fatalf("不对她说的主人群消息驱动了循环:session=%q messages=%+v", chat.sessionID, chat.messages) + } + if len(ad.sends) != 0 { + t.Fatalf("不对她说的主人群消息被回复了:%+v", ad.sends) + } + recs := hist.records() + if len(recs) != 1 { + t.Fatalf("records = %d, want 1(进上下文但不回)", len(recs)) + } + if recs[0].SessionID != "sess-og" || recs[0].Role != history.RoleUser || recs[0].Content != "大家好啊,我今天在家" { + t.Fatalf("登记记录 = %+v, want 该群隔离会话的 user 原样正文", recs[0]) + } + if recs[0].SenderName != "" { + t.Fatalf("主人被标了发送者名 %q(主人恒无标注,design/16 §4.6)", recs[0].SenderName) + } +} + +// TestOwnerLaneAddressedToHerAlwaysRepliesEvenWhenThrottled 钉住硬保留: +// 主人**明确 @ 她本人 / @全体** → 必回,**即使该群旋钮已降到最保守档(降频)**。 +func TestOwnerLaneAddressedToHerAlwaysRepliesEvenWhenThrottled(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + msg func() adapter.UnifiedMessage + }{ + {"direct mention", func() adapter.UnifiedMessage { + m := ownerLaneGroupMsg("在吗", adapter.Mention{UserID: "bot-10001"}) + m.SelfID = "bot-10001" + m.AddressedToMe = m.AddressedToSelf() + return m + }}, + {"mention all", func() adapter.UnifiedMessage { + m := ownerLaneGroupMsg("都来看一眼") + m.SelfID = "bot-10001" + m.MentionAll = true + m.AddressedToMe = m.AddressedToSelf() + return m + }}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + chat := &stubChatter{res: &loop.Result{Text: "在的"}} + b, ad := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, nil, nil) + // 事先把该群压到最保守档 + 一小时频率闸。 + if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: false, MinReplyGapSec: 3600}); err != nil { + t.Fatalf("SetOwnerLanePolicy: %v", err) + } + + b.process(context.Background(), tc.msg()) + + if chat.sessionID != "sess-og" { + t.Fatalf("指向她的主人群消息被旋钮挡住了:session=%q", chat.sessionID) + } + if len(ad.sends) != 1 || ad.sends[0].Content != "在的" { + t.Fatalf("指向她的主人群消息未回复:%+v", ad.sends) + } + }) + } +} + +// TestOwnerLaneUnwiredChannelFallsBackToMentionsCriterion 钉住判据的后半句: +// - 未接线渠道(SelfID 为空)上「有提及」按**旧判据**算指向她 —— 保证 kook / webhook +// 的群 @ 仍然必回(不能把「没有信号」当成「没在对我说话」); +// - 已接线渠道上「提及的不是她」→ 不指向她 → 不回。 +func TestOwnerLaneUnwiredChannelFallsBackToMentionsCriterion(t *testing.T) { + t.Parallel() + + t.Run("unwired channel with mention replies", func(t *testing.T) { + t.Parallel() + chat := &stubChatter{res: &loop.Result{Text: "在的"}} + b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, nil, nil) + + m := ownerLaneGroupMsg("hello", adapter.Mention{UserID: "someone-else"}) + // SelfID 留空 = 未接线;AddressedToMe 恒 false(fail-closed)。 + b.process(context.Background(), m) + + if chat.sessionID != "sess-og" { + t.Fatalf("未接线渠道的群提及未回退到旧判据:session=%q", chat.sessionID) + } + }) + + t.Run("wired channel mentioning someone else is not addressed", func(t *testing.T) { + t.Parallel() + chat := &stubChatter{res: &loop.Result{Text: "不该回"}} + b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, nil, nil) + + m := ownerLaneGroupMsg("hello", adapter.Mention{UserID: "someone-else"}) + m.SelfID = "bot-10001" + m.AddressedToMe = m.AddressedToSelf() // 提及的不是她 ⇒ false + b.process(context.Background(), m) + + if chat.sessionID != "" { + t.Fatalf("已接线渠道里 @ 别人被当成指向她:session=%q", chat.sessionID) + } + }) +} + +// TestOwnerLaneThrottleEffectiveAndRestorable 钉住结构化旋钮的**生效与可恢复**: +// 打开该群开关 → 非指向消息回;主人说「少说点」→ 落结构化配置、降频生效(不回); +// 写回打开档 → 又回。同时断言旋钮变化留下带 source 的可见日志。 +func TestOwnerLaneThrottleEffectiveAndRestorable(t *testing.T) { + t.Parallel() + + chat := &stubChatter{res: &loop.Result{Text: "嗯"}} + hist := &captureHistory{} + var logBuf bytes.Buffer + log := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})) + b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, hist, nil, log) + + // 1) 打开该群开关(= 主人/面板要求「多说点」):非指向消息也回。 + if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: true}); err != nil { + t.Fatalf("SetOwnerLanePolicy: %v", err) + } + b.process(context.Background(), ownerLaneGroupMsg("大家今天都在忙什么")) + if chat.sessionID != "sess-og" { + t.Fatalf("打开开关后非指向消息仍不回:session=%q", chat.sessionID) + } + + // 2) 主人说「少说点」→ 落成结构化配置(降频),这一条自己也不再驱动回复。 + chat.sessionID = "" + b.process(context.Background(), ownerLaneGroupMsg("少说点")) + if chat.sessionID != "" { + t.Fatalf("降频后仍回:session=%q", chat.sessionID) + } + if p := b.OwnerLanePolicy("qq-obv11", "g1"); p.ReplyToUnaddressed { + t.Fatalf("「少说点」未把旋钮落到保守档:%+v", p) + } + + // 3) 可恢复:写回打开档 → 又回。 + if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: true}); err != nil { + t.Fatalf("SetOwnerLanePolicy restore: %v", err) + } + chat.sessionID = "" + b.process(context.Background(), ownerLaneGroupMsg("我再说一句普通的话")) + if chat.sessionID != "sess-og" { + t.Fatalf("恢复旋钮后仍不回:session=%q", chat.sessionID) + } + + // 4) 可观测:旋钮变化留下带 source 的日志。 + if logged := logBuf.String(); !strings.Contains(logged, "owner lane") || + !strings.Contains(logged, "source=services/channelbridge/ownerlane") { + t.Fatalf("旋钮变化没有带 source 的可见日志:%q", logged) + } +} + +// TestOwnerLaneReplyGapThrottlesUnaddressedReplies 钉住「多频繁」那一档: +// 开关打开后,非指向消息之间还要过最小间隔闸;但指向她的消息**不受间隔约束**(必回)。 +func TestOwnerLaneReplyGapThrottlesUnaddressedReplies(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + clock := func() time.Time { return now } + chat := &stubChatter{res: &loop.Result{Text: "嗯"}} + b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, clock, nil) + if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 300}); err != nil { + t.Fatalf("SetOwnerLanePolicy: %v", err) + } + + // 第一条非指向消息:过闸、回。 + b.process(context.Background(), ownerLaneGroupMsg("第一句普通的话")) + if chat.sessionID != "sess-og" { + t.Fatalf("第一条非指向消息未回:session=%q", chat.sessionID) + } + + // 间隔内第二条:被频率闸挡下,只进上下文。 + chat.sessionID = "" + b.process(context.Background(), ownerLaneGroupMsg("第二条普通的话")) + if chat.sessionID != "" { + t.Fatalf("间隔内的非指向消息仍回:session=%q", chat.sessionID) + } + + // 间隔内**指向她**的消息:仍必回(硬保留不受频率闸约束)。 + chat.sessionID = "" + addressed := ownerLaneGroupMsg("在吗", adapter.Mention{UserID: "bot-10001"}) + addressed.SelfID = "bot-10001" + addressed.AddressedToMe = addressed.AddressedToSelf() + b.process(context.Background(), addressed) + if chat.sessionID != "sess-og" { + t.Fatalf("间隔内指向她的消息被频率闸挡住:session=%q", chat.sessionID) + } + + // 时钟推进过间隔:非指向消息恢复。 + now = now.Add(301 * time.Second) + chat.sessionID = "" + b.process(context.Background(), ownerLaneGroupMsg("过了五分钟再说一句")) + if chat.sessionID != "sess-og" { + t.Fatalf("过了间隔后非指向消息仍未回:session=%q", chat.sessionID) + } +} + +// TestOwnerLanePolicySeamReadsAndPersists 钉住面板写入留好的形状(读写接缝): +// OwnerLaneFor 决定该群生效档位,OwnerLaneSet 收到每次写入(组合根据此落盘)。 +func TestOwnerLanePolicySeamReadsAndPersists(t *testing.T) { + t.Parallel() + + var saved []OwnerLanePolicy + var savedGroup string + chat := &stubChatter{res: &loop.Result{Text: "嗯"}} + b, err := New(Options{ + Loop: chat, Sessions: &stubSessions{sess: ownerGroupSession()}, + Owner: testOwnerProfile("qq-obv11", "10001"), + History: &captureHistory{}, + Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) }, + OwnerLaneFor: func(platform, groupID string) *OwnerLanePolicy { + return &OwnerLanePolicy{ReplyToUnaddressed: true} + }, + OwnerLaneSet: func(platform, groupID string, p OwnerLanePolicy) error { + saved = append(saved, p) + savedGroup = groupID + return nil + }, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + // 读接缝:外部配置决定该群档位 ⇒ 非指向消息也回。 + b.process(context.Background(), ownerLaneGroupMsg("普通的一句话")) + if chat.sessionID != "sess-og" { + t.Fatalf("读接缝未生效:session=%q", chat.sessionID) + } + + // 写接缝:主人说「少说点」⇒ 写入被外部存储收到。 + chat.sessionID = "" + b.process(context.Background(), ownerLaneGroupMsg("少说点")) + if len(saved) != 1 || savedGroup != "g1" || saved[0].ReplyToUnaddressed { + t.Fatalf("写接缝未收到结构化降频:saved=%+v group=%q", saved, savedGroup) + } +} + +// TestOwnerLanePrivateChatsUnaffected 钉住回归:旋钮只管**群**。 +// 主人私聊恒回;未进白名单的群消息走既有非主人分级(被 @ 仍回)。 +func TestOwnerLanePrivateChatsUnaffected(t *testing.T) { + t.Parallel() + + t.Run("owner private always replies", func(t *testing.T) { + t.Parallel() + chat := &stubChatter{res: &loop.Result{Text: "主人好"}} + ad := &fakeAdapter{name: "telegram"} + b, err := New(Options{ + Loop: chat, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}, + Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{}, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + b.Add(ad) + if err := b.SetOwnerLanePolicy("telegram", "-20", OwnerLanePolicy{ReplyToUnaddressed: false, MinReplyGapSec: 3600}); err != nil { + t.Fatalf("SetOwnerLanePolicy: %v", err) + } + + b.process(context.Background(), directMessage()) + + if chat.sessionID != history.MainSessionID || len(ad.sends) != 1 { + t.Fatalf("主人私聊被 owner 车道旋钮影响:session=%q sends=%+v", chat.sessionID, ad.sends) + } + }) + + t.Run("non-owner group mention still replies", func(t *testing.T) { + t.Parallel() + chat := &stubChatter{res: &loop.Result{Text: "收到"}} + b, err := New(Options{ + Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, + Owner: &user.OwnerProfile{}, History: &captureHistory{}, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + b.process(context.Background(), nonOwnerGroupMsg("hello", adapter.Mention{UserID: "bot"})) + + if chat.sessionID != "sess-g" { + t.Fatalf("非主人群消息的既有分级被 owner 车道影响:session=%q", chat.sessionID) + } + }) +} + +// TestOwnerLaneSilenceKeepsSpeakerLabelInvariant 钉住 M10 的归属不变量在 owner 车道 +// 静默路径上仍闭合:**主人静默行恒无标注**(受限会话里空标注 = 主人本人), +// **非主人静默行恒有标注**(第三方绝不被读成主人)。 +func TestOwnerLaneSilenceKeepsSpeakerLabelInvariant(t *testing.T) { + t.Parallel() + + t.Run("owner silenced row has no label", func(t *testing.T) { + t.Parallel() + hist := &captureHistory{} + chat := &stubChatter{res: &loop.Result{Text: "不该回"}} + b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, hist, nil, nil) + + b.process(context.Background(), ownerLaneGroupMsg("不对她说的主人群消息")) + + recs := hist.records() + if len(recs) != 1 || recs[0].SenderName != "" { + t.Fatalf("主人静默行 = %+v, want 无标注(主人本人)", recs) + } + }) + + t.Run("non-owner silenced row keeps a label", func(t *testing.T) { + t.Parallel() + hist := &captureHistory{} + chat := &stubChatter{res: &loop.Result{Text: "不该回"}} + b, err := New(Options{ + Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, + Owner: &user.OwnerProfile{}, History: hist, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + b.process(context.Background(), nonOwnerGroupMsg("群里随手一句普通的话")) + + recs := hist.records() + if len(recs) != 1 || recs[0].SenderName != "张三" { + t.Fatalf("非主人静默行 = %+v, want 带发送者标注「张三」", recs) + } + }) +} diff --git a/internal/services/channelbridge/persona_test.go b/internal/services/channelbridge/persona_test.go index 40ec9a4..4349090 100644 --- a/internal/services/channelbridge/persona_test.go +++ b/internal/services/channelbridge/persona_test.go @@ -123,6 +123,9 @@ func TestInboundChannelPersonaPrecedence(t *testing.T) { // TestInboundGroupForcesDefaultRole 是 design/22 §7-4:群聊——即使 // 群里是主人自己的消息——即使账号绑定了角色也仍用默认 // 角色,因此隔离的群会话绝不会被移入某个角色的作用域。 +// 这条主人群消息**指向她本人**(含提及):owner 车道默认「不对她说就不开口」 +// (design/18 §11),不带指向信号的主人群消息只进上下文、不驱动回合, +// 因此这里刻意带上 @ 信号,让本用例仍只验证角色解析这件事。 func TestInboundGroupForcesDefaultRole(t *testing.T) { t.Parallel() chat := &envChatter{} @@ -143,7 +146,8 @@ func TestInboundGroupForcesDefaultRole(t *testing.T) { b.process(context.Background(), adapter.UnifiedMessage{ Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, GroupID: "-20", - SenderID: "100", Sender: adapter.Actor{Role: adapter.RoleOwner}, Content: "我在群里", ContentType: adapter.ContentText, + SenderID: "100", Sender: adapter.Actor{Role: adapter.RoleOwner}, Mentions: []adapter.Mention{{UserID: "100"}}, + Content: "我在群里", ContentType: adapter.ContentText, }) if !chat.got { From 1d49d266f0fe62d3de1ed878cf685cb20aec8c39 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 20:50:31 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(channel):=20message=5Fsent=20=E5=9B=9E?= =?UTF-8?q?=E5=A3=B0=E5=8D=87=20T2=20=E8=90=BD=E8=A1=8C=EF=BC=8C=E5=87=BA?= =?UTF-8?q?=E7=AB=99=E6=B6=88=E6=81=AF=20id=20=E4=B8=8D=E5=86=8D=E8=A2=AB?= =?UTF-8?q?=E4=B8=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 塔转 M12 登记的缺口(塔裁定「归你」,文件都在本片 scope 内):她自己发出消息 的回声(message_sent)被当成「帧级链路遥测」一并豁免 self 升档 ⇒ 停在 T1, 只进计数、不落行,`message_id` 被丢,「她最近发了哪些消息」在库里查不到。 - events.go: 把 MessageSent 从 isFrameTelemetry 的豁免里摘出(它不是帧级遥测: 一帧 = 她真发出的一条消息,回声带的是那条出站消息的 id)。 心跳 / 生命周期的豁免**保持不变**(其登记的迁移债注释原样保留)。 - events_mapping_test.go: 两条 MessageSent 断言同步更新 (:52 自身回声命中自己 ⇒ 由 T1 改 T2;:33 无 self 信号仍走基础档 T1,标签改精确)。 - events_test.go: 新增端到端用例(真 SQLite):self 命中 ⇒ 落 1 行、tier=t2、 MessageID=out-msg-42 留在库里、不进上下文、不驱动 loop。 --- internal/services/channelbridge/events.go | 17 +++++--- .../channelbridge/events_mapping_test.go | 6 +-- .../services/channelbridge/events_test.go | 42 +++++++++++++++++++ 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/internal/services/channelbridge/events.go b/internal/services/channelbridge/events.go index b5abbff..56046f3 100644 --- a/internal/services/channelbridge/events.go +++ b/internal/services/channelbridge/events.go @@ -259,10 +259,16 @@ func kindTier(kind adapter.NoticeKind) events.Tier { } } -// isFrameTelemetry 报告该类别是否属于**帧级链路遥测**:心跳(~15s 一帧)、生命周期、 -// 她自己发出消息的回声。这三类的 self 命中是**恒定**的(帧帧都命中 self_id),所以 -// 按 §2.2 升档等于「每帧写一行 channel_events」—— 而 §4.3 明令「心跳 / 在线状态逐帧 -// 写库是纯浪费」(计数表存在的理由),§3.1 给这三类的档位也正是 T1。 +// isFrameTelemetry 报告该类别是否属于**帧级链路遥测**:心跳(~15s 一帧)与生命周期。 +// 这两类的 self 命中是**恒定**的(帧帧都命中 self_id),所以按 §2.2 升档等于「每帧写一行 +// channel_events」—— 而 §4.3 明令「心跳 / 在线状态逐帧写库是纯浪费」(计数表存在的理由), +// §3.1 给这两类的档位也正是 T1。 +// +// 🔴 **`message_sent`(她自己发出消息的回声)不在此列**(切 M13;原 M12 登记的缺口): +// 它**不是**帧级遥测 —— 一帧对应她真发出的一条消息,回声带的是那条出站消息的 id +// (`RefMessage`)。此前把它一并豁免,会让 self 命中**不升档** ⇒ 整条回声只进计数, +// `message_id` 被丢,「她自己最近发了哪些消息」在库里查不到(M12 的 A 层出站 id 因此 +// 断在这里)。摘出后它按 §2.2 正常 T1 → T2 逐条落行。 // // ⚠️ **由此登记的债**(design/26 §2.6「她自己账号的在线 / 离线」= T2 高价值): // 那是**状态迁移**(在线→离线 / 离线→在线),不是每一帧。判定它需要迁移信号 @@ -272,8 +278,7 @@ func kindTier(kind adapter.NoticeKind) events.Tier { // 迁移事件(T2)留待后续切片;已在切片报告「未做 / 存疑」中列明。 func isFrameTelemetry(cat adapter.NoticeCategory) bool { switch cat { - case adapter.NoticeCategoryHeartbeat, adapter.NoticeCategoryLifecycle, - adapter.NoticeCategoryMessageSent: + case adapter.NoticeCategoryHeartbeat, adapter.NoticeCategoryLifecycle: return true default: return false diff --git a/internal/services/channelbridge/events_mapping_test.go b/internal/services/channelbridge/events_mapping_test.go index d99dc52..cb909ff 100644 --- a/internal/services/channelbridge/events_mapping_test.go +++ b/internal/services/channelbridge/events_mapping_test.go @@ -30,7 +30,7 @@ func TestEventTier_两轴映射_正常(t *testing.T) { }{ {"心跳是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryHeartbeat}, events.TierT1}, {"生命周期是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryLifecycle}, events.TierT1}, - {"自身回声是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent}, events.TierT1}, + {"自身回声(无 self 信号)是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent}, events.TierT1}, {"他人在线状态是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryClientStatus}, events.TierT1}, {"未知类型是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryUnknown}, events.TierT1}, {"解析失败是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryDecodeError}, events.TierT1}, @@ -48,8 +48,8 @@ func TestEventTier_两轴映射_正常(t *testing.T) { adapter.SystemNotice{Category: adapter.NoticeCategoryHeartbeat, SelfRelated: true}, events.TierT1}, {"生命周期命中自己 ⇒ 不升档(帧级遥测)", adapter.SystemNotice{Category: adapter.NoticeCategoryLifecycle, SelfRelated: true}, events.TierT1}, - {"自身回声命中自己 ⇒ 不升档(帧级遥测)", - adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent, SelfRelated: true}, events.TierT1}, + {"自身回声命中自己 ⇒ 升档 T2(出站 id 回声须落行)", + adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent, SelfRelated: true}, events.TierT2}, {"类别缺失按 Kind 兜底:join ⇒ T2", adapter.SystemNotice{Kind: adapter.NoticeJoin}, events.TierT2}, {"类别缺失按 Kind 兜底:request ⇒ T2", diff --git a/internal/services/channelbridge/events_test.go b/internal/services/channelbridge/events_test.go index 137510b..46eff5a 100644 --- a/internal/services/channelbridge/events_test.go +++ b/internal/services/channelbridge/events_test.go @@ -205,6 +205,48 @@ func TestRecordEvent_unknown不进上下文但落行_正常(t *testing.T) { } } +// TestRecordEvent_自身回声升T2落行带出站id_正常 钉住切 M13 修掉的缺口(原 M12 登记): +// 她自己发出消息的回声(message_sent)**不是帧级遥测** —— self 命中必须把它从 T1 升到 T2 +// 逐条落行,`message_id`(出站消息 id)随之留在库里;此前它被帧级豁免挡回 T1,id 被丢。 +func TestRecordEvent_自身回声升T2落行带出站id_正常(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestEventStore(t) + + ad := eventAdapter("qq-obv11:conf0", adapter.SystemNotice{ + Kind: adapter.NoticeOther, + Category: adapter.NoticeCategoryMessageSent, + PlatformType: "message_sent", + Ignorable: true, + SelfRelated: true, + RefMessage: "out-msg-42", + }) + b, chat, hist, _ := eventBridgeFixture(t, ad, store) + + b.processFrom(ctx, groupNoticeMessage(), ad) + + if chat.sessionID != "" { + t.Fatalf("出站回声驱动了 loop:session=%q", chat.sessionID) + } + if recs := hist.records(); len(recs) != 0 { + t.Fatalf("出站回声被登记进会话历史:%+v", recs) + } + rows, err := store.List(ctx, events.Filter{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(rows) != 1 { + t.Fatalf("出站回声落行数 = %d, want 1(self 命中 ⇒ T2 逐条落行)", len(rows)) + } + ev := rows[0] + if ev.Tier != events.TierT2 || ev.Category != events.Category(adapter.NoticeCategoryMessageSent) { + t.Fatalf("落行档位/类别 = (%s,%s), want (t2,message_sent)", ev.Tier, ev.Category) + } + if ev.MessageID != "out-msg-42" { + t.Fatalf("出站消息 id = %q, want out-msg-42(id 必须留在库里)", ev.MessageID) + } +} + // TestRecordEvent_未接线时入站照常_异常:`Options.EventStore == nil` 时降级为 // 「内存计数 + 首次一条 DEBUG」,**绝不**中断入站 —— 高价值事件的升级真会话行为必须 // 逐字不变(fail-visible 而非 fail-crash,§4.3)。 From 48a5bb89f2f5e0a0d30a1d5a9f7433e4f54e1fa4 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 21:07:46 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(config):=20owner=5Flane.json=20?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E8=90=BD=E7=9B=98=E4=B8=8D=E5=86=8D=E6=8D=9F?= =?UTF-8?q?=E5=9D=8F/=E4=B8=A2=E6=9B=B4=E6=96=B0=EF=BC=88review=20p1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review p1:Set 的固定 tmp 名(path+".tmp")+ 落盘发生在 mu.Unlock() 之后 ⇒ 并发 Set 撞同一临时文件把 JSON 写坏(load 退回默认「甲」、主人刚说的「少说点」 白说且无提示),或 rename 完成顺序与新快照先后不一致而丢更新。 修法(两条都取,各司其职): - 落盘挪进锁内(Set 全程持 s.mu):并发 Set 完全串行 ⇒ 快照与 rename 顺序一致, 不可能丢更新。落盘是一个小 JSON,持锁 I/O 代价可忽略;读侧读内存、不读文件。 - 每次用 os.CreateTemp 新建临时文件(仓库同款先例 internal/user/save.go), 不再撞固定 tmp 名;CreateTemp 以 0600 打开并再 chmod 一次,任一步失败都清掉 tmp。 - 旧 save(快照) 换成 saveLocked()(调用方须持锁,直接 marshal s.cfg)。 测试:新增并发用例 TestOwnerLaneStoreConcurrentSetKeepsAllUpdates(24 轮 × 24 写者), 断言 ① 文件始终是合法 JSON、② 每一次成功的 Set 都能在最终文件读到(不丢更新)。 修前红(原实现在同一用例上):5/5 轮复现,两种失败模式都抓到 —— "并发落盘把配置写坏了(文件不是合法 JSON):invalid character '\"' after top-level value" 与 "丢更新 —— 键 qq-obv11|g-N-M 不在最终文件里";修后连续 3 次绿,-race 下亦 ok。 Bridge.SetOwnerLanePolicy 无需改动:它在 ownerLaneMu 下设内存后调注入的 store, 串行化由 store 侧保证。 --- .../runtimeconfig/channels_ownerlane.go | 55 +++++++++++++------ .../runtimeconfig/channels_ownerlane_test.go | 53 ++++++++++++++++++ 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/internal/config/runtimeconfig/channels_ownerlane.go b/internal/config/runtimeconfig/channels_ownerlane.go index f1b5f0c..8833ac6 100644 --- a/internal/config/runtimeconfig/channels_ownerlane.go +++ b/internal/config/runtimeconfig/channels_ownerlane.go @@ -138,10 +138,17 @@ func (s *OwnerLaneStore) Snapshot() OwnerLaneFile { } // Set 写入某个 (platform, groupID) 的策略并落盘。groupID 为空表示写全局 default 档。 -// 内存先改(行为立即生效),再尽力落盘;落盘失败返回错误(调用方留可见 WARN), -// 但内存里的新档位仍然生效 —— 「改不动磁盘」不该让已经做出的行为决定回退。 +// +// 内存改动与**落盘都在同一把锁内**(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 { @@ -150,20 +157,18 @@ func (s *OwnerLaneStore) Set(platform, groupID string, p OwnerLanePolicy) error } 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 { + if err := s.saveLocked(); 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 { +// saveLocked 原子写出当前配置。调用方**必须持有 s.mu**。 +// +// 它每次都用**新建的临时文件**(`os.CreateTemp`,仓库同款先例见 internal/user/save.go) +// 再原子 rename:即便有别的进程/goroutine 也在写同一个目录,也不会撞同一个临时文件名, +// 而半份文件永远不会以 s.path 的名字出现。 +func (s *OwnerLaneStore) saveLocked() error { if s.path == "" { return nil } @@ -173,15 +178,33 @@ func (s *OwnerLaneStore) save(f OwnerLaneFile) error { return fmt.Errorf("mkdir: %w", err) } } - raw, err := json.MarshalIndent(f, "", " ") + raw, err := json.MarshalIndent(s.cfg, "", " ") 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) + tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".*.tmp") + if err != nil { + return fmt.Errorf("create temp in %s: %w", dir, err) } - if err := os.Rename(tmp, s.path); err != nil { + 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 diff --git a/internal/config/runtimeconfig/channels_ownerlane_test.go b/internal/config/runtimeconfig/channels_ownerlane_test.go index df8a2bf..1ce0001 100644 --- a/internal/config/runtimeconfig/channels_ownerlane_test.go +++ b/internal/config/runtimeconfig/channels_ownerlane_test.go @@ -1,8 +1,11 @@ package runtimeconfig import ( + "encoding/json" + "fmt" "os" "path/filepath" + "sync" "testing" ) @@ -79,3 +82,53 @@ func TestOwnerLaneStoreCorruptFileFallsBackToDefaults(t *testing.T) { t.Fatalf("坏配置下策略 = %+v, want 默认档", got) } } + +// TestOwnerLaneStoreConcurrentSetKeepsAllUpdates 钉住并发 Set 的安全性(review p1): +// 落盘必须与内存改动**同一把锁**、且用**新建的临时文件**,否则两个写者会撞同一个 +// tmp 名把 JSON 写坏(⇒ load 退回默认「甲」、主人刚说的「少说点」白说且无提示), +// 或者 rename 的完成顺序与新快照的先后不一致,丢掉后发生的更新。 +// +// 修前必红(原实现:固定 `path+".tmp"` + mu.Unlock() 之后落盘),断言: +// ① 每一轮结束后文件都是**合法 JSON**; +// ② 本轮写过的**每一个**键都能在最终文件里读到(不丢更新)。 +func TestOwnerLaneStoreConcurrentSetKeepsAllUpdates(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config", "owner_lane.json") + s := NewOwnerLaneStore(path, nil) + + const ( + rounds = 24 + writers = 24 + ) + for round := 0; round < rounds; round++ { + var wg sync.WaitGroup + for i := 0; i < writers; i++ { + key := fmt.Sprintf("g-%d-%d", round, i) + wg.Add(1) + go func(i int) { + defer wg.Done() + // 写者的返回值故意不看:修前它会因 tmp 被别的写者 rename 走而报错, + // 但那正是本用例要靠**文件内容**抓住的失败。 + _ = s.Set("qq-obv11", key, OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: i}) + }(i) + } + wg.Wait() + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("round %d: 读回 owner_lane.json 失败:%v", round, err) + } + var f OwnerLaneFile + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatalf("round %d: 并发落盘把配置写坏了(文件不是合法 JSON):%v\n内容=%q", round, err, raw) + } + for i := 0; i < writers; i++ { + key := OwnerLaneGroupKey("qq-obv11", fmt.Sprintf("g-%d-%d", round, i)) + if _, ok := f.Groups[key]; !ok { + t.Fatalf("round %d: 丢更新 —— 键 %s 不在最终文件里(并发的某次 Set 没落盘)", round, key) + } + } + } +} +