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) } }) }