主人 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 条用例红。
166 lines
7.8 KiB
Go
166 lines
7.8 KiB
Go
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
|
||
}
|