Files
Cyrene_v2/internal/services/channelbridge/ownerlane.go
T
AskaEth 537a8b7988 feat(channel): owner 车道结构化参与闸与可调旋钮(乙 = C)
主人 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 条用例红。
2026-10-13 20:48:05 +08:00

166 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}