Files
Cyrene_v2/internal/services/channelbridge/ownerlane_test.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

395 lines
16 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 (
"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)
}
})
}