package channelbridge import ( "bytes" "context" "io" "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/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // 本文件覆盖 M18(主人 2026-10-14 裁决「给」:群成员也能让她少说话)在 // channelbridge 里的落点 —— **冷避优先于「@她必回」**(design/18 §5): // // - 非主人**群**消息先过冷避闸(命中负面态度 → COOLDOWN;冷却期内一律静默), // 顺序排在廉价规则判定(ruleLightJudge)**之前**,因此 @她 / @全体 / // 高价值关键词都拦不住它; // - 边界:私聊(含陌生人私聊)与 owner 车道**不受影响**;闸与是否装配 // 参与度引擎**无关**; // - 被冷避静默的消息**照样进上下文**(registerSilenced / persistIsolated), // 只是不进 loop —— M13 红线,端到端钉住。 // coldShoulderSelfID 是 2026-10-14 报障现场那个号(她自己的 self_id),用于把 // 「@她」判据按**结构化字段**接上线(与 ownerlane.go 的 ownerAddressedToMe 同一口径)。 const coldShoulderSelfID = "3443115524" // coldShoulderMention 构造一条**指向她本人**的成员群消息:SelfID 已接线 ⇒ // AddressedToMe 由结构化字段算出(不是靠正文里的 "@<数字>" 猜)。修前的实现会把它 // 当成「必回」,这正是本片要改的顺序偏离。 func coldShoulderMention(content string) adapter.UnifiedMessage { m := nonOwnerGroupMsg(content, adapter.Mention{UserID: coldShoulderSelfID}) m.SelfID = coldShoulderSelfID m.AddressedToMe = m.AddressedToSelf() return m } // coldShoulderBridge 构建一条成员群消息用的桥接器:**未装配**参与度引擎与 LightJudge // (规则得不出结论时失败关闭为静默),时钟可注入 —— 因此「必不必回」只由规则判据与 // 冷避闸决定,用例完全确定。 func coldShoulderBridge(t *testing.T, chat *stubChatter, hist *captureHistory, now func() time.Time) *Bridge { t.Helper() b, err := New(Options{ Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, Owner: &user.OwnerProfile{}, History: hist, Now: now, }) if err != nil { t.Fatalf("New: %v", err) } return b } // TestColdShoulderPrecedesAddressedToMe 是本片的主断言(任务⑤①,**修前红 / 修后绿**): // 群成员 @她 并说「减少文本输出量」时 —— // // - 修前:@她 命中 ruleLightJudge 的 verdictRespond ⇒ shouldRespond 直接 return true, // 冷避根本走不到(红:循环被驱动,她只会口头答应、行为零变化,即报障现场); // - 修后:冷避闸排在「@她必回」之前 ⇒ 静默,但**照样登记**(静默 ≠ 丢弃)。 // // 两种部署都断言:装配了参与度引擎的(原本走 engageDecision)与未装配的 // (原本走旧轻量判定车道)—— 顺序修复**与引擎装配无关**(任务①的边界要求)。 func TestColdShoulderPrecedesAddressedToMe(t *testing.T) { t.Parallel() cases := []struct { name string engage bool }{ {name: "engage engine wired", engage: true}, {name: "no engage engine (legacy light lane)", engage: false}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() judge := &engageScriptJudge{} chat := &stubChatter{res: &loop.Result{Text: "好嘛,人家少说点♪"}} hist := &captureHistory{} opts := Options{ Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, Owner: &user.OwnerProfile{}, History: hist, Now: dayNow(), } if tc.engage { opts.Light = judge opts.TriggerWords = []string{"昔涟"} opts.Engage = DefaultEngageConfig() opts.EngageRand = func() float64 { return 0.5 } } b, err := New(opts) if err != nil { t.Fatalf("New: %v", err) } b.process(context.Background(), coldShoulderMention("减少文本输出量")) if chat.sessionID != "" { t.Fatalf("循环被驱动 = %q, want \"\"(冷避必须先于 @她必回)", chat.sessionID) } recs := hist.records() if len(recs) != 1 || recs[0].Content != "减少文本输出量" { t.Fatalf("records = %+v, want 恰好 1 条登记(静默 ≠ 丢弃)", recs) } // 只有装配了引擎的那一路才够得着判定器;旧车道根本没有它, // 因此这一条只在装配时断言(不给旧车道记一笔假功劳)。 if tc.engage && judge.calls != 0 { t.Fatalf("judge calls = %d, want 0(冷避命中不得咨询轻量 LLM:成本红线)", judge.calls) } }) } } // TestColdShoulderSecondReportedPhraseAndCooldownWindow 覆盖报障的**第二句原话**,并钉住 // 冷却的**时限性**:进冷却后连普通 @她 也不回(M18 口径 —— 旧口径「@ 打断冷却」正是本片 // 修掉的偏离);时钟推过冷却时长后她重新回到正常判定,**不是**永久闭嘴。 func TestColdShoulderSecondReportedPhraseAndCooldownWindow(t *testing.T) { t.Parallel() // 真机时刻:2026-10-14 00:27(群 531082168,成员「企鹅神明」)。 now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC) chat := &stubChatter{res: &loop.Result{Text: "好♪"}} b, err := New(Options{ Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, Owner: &user.OwnerProfile{}, History: &captureHistory{}, Now: func() time.Time { return now }, }) if err != nil { t.Fatalf("New: %v", err) } b.process(context.Background(), coldShoulderMention("在本群中文本输出调到最少")) if chat.sessionID != "" { t.Fatalf("原话②之后循环被驱动 = %q, want \"\"", chat.sessionID) } // 冷却期内:普通 @她 也不回(冷避优先于「@她必回」)。 b.process(context.Background(), coldShoulderMention("晚上有空吗")) if chat.sessionID != "" { t.Fatalf("冷却期内 @她 被驱动 = %q, want \"\"(@ 不得作废冷避)", chat.sessionID) } // 推过默认冷却时长(30 分钟):她恢复到「@她必回」的常态。 now = now.Add(defaultCooldownDuration + time.Minute) b.process(context.Background(), coldShoulderMention("晚上有空吗")) if chat.sessionID != "sess-g" { t.Fatalf("冷却过期后 @她 仍未回:session = %q, want sess-g(冷避是**有时限**的)", chat.sessionID) } } // TestColdShoulderNormalMentionNotCooled 是反例护栏(任务⑤②):不含负面态度的普通 // @她 消息**不得**被误冷却 —— 回得照常,且不把该群留在冷却态里(第二条普通 @她 仍回)。 // 诚实声明:这条在修前也是绿的,它是**护栏而非红前红**;红前红的证据是上面两条。 func TestColdShoulderNormalMentionNotCooled(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: "在的"}} hist := &captureHistory{} b, err := New(Options{ Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, Owner: &user.OwnerProfile{}, History: hist, Now: dayNow(), }) if err != nil { t.Fatalf("New: %v", err) } b.process(context.Background(), coldShoulderMention("晚上有空吗")) if chat.sessionID != "sess-g" { t.Fatalf("普通 @她 被拦下了:session = %q, want sess-g", chat.sessionID) } // 第二条紧接着的普通 @她:若第一条被误判成负面态度,这条会落进冷却而静默。 chat.sessionID = "" b.process(context.Background(), coldShoulderMention("在不在")) if chat.sessionID != "sess-g" { t.Fatalf("第二条普通 @她 未回:session = %q(首条被误冷却了吗)", chat.sessionID) } } // TestColdShoulderGroupOnlyBoundaries 钉住冷避的**适用面**(任务①的三条边界): // // - 陌生人**私聊**说「闭嘴」照样正常聊天(冷避只存在于群聊车道,design/18 §11); // - 主人**私聊**说「闭嘴」照样恒回(owner 车道在更早处分岔); // - 主人**群聊**里 @她 说「安静点」仍必回(owner 车道的必回不受冷避影响,主人 // 有自己那套旋钮)。 // // 三条在修前也是绿的(护栏),但它们钉住的是「不许把闸开大」的边界 —— 少了它们, // 一次把闸挪到 shouldRespond 最前端就能悄悄改掉私聊与主人的行为而无人察觉。 func TestColdShoulderGroupOnlyBoundaries(t *testing.T) { t.Parallel() t.Run("stranger private still chats", func(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: "好呀"}} b, err := New(Options{ Loop: chat, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-p", IsGroup: false, Owner: false}}, Owner: &user.OwnerProfile{}, Now: dayNow(), }) if err != nil { t.Fatalf("New: %v", err) } b.process(context.Background(), adapter.UnifiedMessage{ Platform: "telegram", ChannelID: "9", ChannelType: adapter.ChannelDirect, SenderID: "9", Sender: adapter.Actor{Role: adapter.RoleMember}, Content: "闭嘴", ContentType: adapter.ContentText, }) if chat.sessionID != "sess-p" { t.Fatalf("陌生人私聊被冷避拦下:session = %q, want sess-p(冷避是群机制)", chat.sessionID) } }) t.Run("owner private still 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{}, Now: dayNow(), }) if err != nil { t.Fatalf("New: %v", err) } b.Add(ad) m := directMessage() m.Content = "闭嘴" b.process(context.Background(), m) if chat.sessionID != history.MainSessionID { t.Fatalf("主人私聊被冷避拦下:session = %q, want %q", chat.sessionID, history.MainSessionID) } }) t.Run("owner group addressed still replies", func(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: "好嘛"}} b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, dayNow(), slog.New(slog.NewTextHandler(io.Discard, nil))) b.process(context.Background(), ownerLaneGroupMsg("安静点", adapter.Mention{UserID: "10001"})) if chat.sessionID != "sess-og" { t.Fatalf("主人群 @她 被冷避拦下:session = %q, want sess-og(owner 车道不受冷避影响)", chat.sessionID) } }) } // TestColdShoulderSilencedMessageStillEntersContext 是 M13 红线的**端到端**守卫(任务②): // 被冷避静默的消息照样进上下文,只是不进 loop。断言不走返回值,而走真实链路: // // ① 静默那条经 registerSilenced → persistIsolated 落进该群隔离会话(历史可读); // ② 冷却结束后下一条 @她 真正驱动回合时,**该回合的模型可见消息里含那条被静默的话** // —— 她看得见「刚才有人让她少说话」,不是被蒙在鼓里(transcript.go 的播种)。 func TestColdShoulderSilencedMessageStillEntersContext(t *testing.T) { t.Parallel() now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC) chat := &stubChatter{res: &loop.Result{Text: "知道啦♪"}} hist := &recentHistory{} b, err := New(Options{ Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, Owner: &user.OwnerProfile{}, History: hist, Now: func() time.Time { return now }, }) if err != nil { t.Fatalf("New: %v", err) } b.process(context.Background(), coldShoulderMention("减少文本输出量")) if chat.sessionID != "" { t.Fatalf("循环被驱动 = %q, want \"\"", chat.sessionID) } // ① 静默那条已被登记(不是被丢掉)。 registered := false for _, r := range hist.records() { if r.SessionID == "sess-g" && r.Role == history.RoleUser && r.Content == "减少文本输出量" { registered = true } } if !registered { t.Fatalf("被冷避的消息没有登记:records = %+v", hist.records()) } // ② 冷却过期后 @她 驱动回合 → 该回合上下文里读得到那条被静默的话。 now = now.Add(defaultCooldownDuration + time.Minute) b.process(context.Background(), coldShoulderMention("在吗")) if chat.sessionID != "sess-g" { t.Fatalf("冷却过期后 @她 未驱动回合:session = %q", chat.sessionID) } if !loopMessagesContain(chat.messages, "减少文本输出量") { t.Fatalf("被静默登记的消息没进上下文:%+v", chat.messages) } } // TestNegativeAttitudeKeywordList 逐条钉住 M18 的词表增补(任务③):认识**真机原话**, // 且**不**认识只在字面上擦边的日常说法(子串匹配的误伤护栏 —— 词条选取见 // engage.go 的 negativeAttitudeKeywords 注释)。 func TestNegativeAttitudeKeywordList(t *testing.T) { t.Parallel() b := &Bridge{} cases := []struct { name string text string want bool }{ {"real report 1", "@3443115524 减少文本输出量", true}, {"real report 2", "@3443115524 在本群中文本输出调到最少", true}, {"same wording, no quantifier", "减少文本输出频率", true}, {"pre-existing owner wording", "少说点", true}, {"dev talk about logs", "我在写脚本,减少输出日志", false}, {"praising output quality", "这个模型的输出质量不错", false}, {"another kind of output", "把音箱输出调到最少", false}, {"plain chatter", "今天天气不错", false}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := b.negativeAttitude(tc.text); got != tc.want { t.Fatalf("negativeAttitude(%q) = %v, want %v", tc.text, got, tc.want) } }) } } // TestColdShoulderLogsVisibleInfo 钉住可见性(任务④):进冷却时必须有**带来源的 INFO** // (哪个群 / 被谁的话 / 冷却到什么时候),使主人能看出「她为什么突然不说话了」。 // 日志绝不打印正文(design/18 §9.7),因此这里只断言键值。 func TestColdShoulderLogsVisibleInfo(t *testing.T) { t.Parallel() var buf bytes.Buffer log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) chat := &stubChatter{res: &loop.Result{Text: "好嘛"}} b, err := New(Options{ Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()}, Owner: &user.OwnerProfile{}, Log: log, Now: dayNow(), }) if err != nil { t.Fatalf("New: %v", err) } b.process(context.Background(), coldShoulderMention("减少文本输出量")) out := buf.String() for _, want := range []string{ "level=INFO", "source=services/channelbridge", "group asked her to stay quiet", "group=telegram", "sender=9", "sender_name=张三", "until=2026-01-01T12:30:00.000Z", } { if !strings.Contains(out, want) { t.Fatalf("INFO 日志缺少 %q;got:\n%s", want, out) } } if strings.Contains(out, "减少文本输出量") { t.Fatalf("日志打印了正文(design/18 §9.7 禁止):\n%s", out) } } // loopMessagesContain 报告本回合的模型可见消息里是否出现了 want(用于断言 // 「被静默登记的消息确实进了上下文」,而不是只看落库记录)。 func loopMessagesContain(msgs []model.Message, want string) bool { for _, m := range msgs { for _, c := range m.Content { if strings.Contains(c.Text, want) { return true } } } return false } // recentHistory 是 captureHistory 的读取侧补齐:Recent 返回本会话已登记的消息, // 使 seedTurnTranscript 真能把「被静默登记」的那条话播进下一个回合的上下文。 type recentHistory struct { captureHistory } func (h *recentHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) { var out []history.Message for _, m := range h.records() { if m.SessionID == sessionID { out = append(out, m) } } if n > 0 && len(out) > n { out = out[len(out)-n:] } return out, nil }