主人 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 条用例红。
395 lines
16 KiB
Go
395 lines
16 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|