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 }