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/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // 本文件覆盖主人 2026-10-05 裁决①②③的三条口径: // - 陌生人私聊 = 正常聊天:非群 + 非主人一律直接进入完整回合,任何时刻(含安静时段 // 23:30)都不受安静期或概率门影响; // - 安静时段的硬静默已从规则层摘除(断言在 light_test.go); // - 陌生人**必须有流控**:同一发送者每分钟 ≤ FlowDirectDefault 条、同时最多 1 个在跑的 // 回合;触发时只登记 + 一条带 source 的可见告警,绝不静默。 // strangerPrivateSession 是陌生人私聊的隔离会话绑定:非群、非主人、记忆只读。 func strangerPrivateSession() session.Session { return session.Session{SessionID: "sess-s", IsGroup: false, Owner: false, MemWritable: false} } // strangerDirectMsg 构造一条来自陌生人(非主人,由 owner 白名单结构化判定)的私聊消息。 // 私聊的会话键是 (platform, channel, sender),因此换一个 sender 就换一个限流桶。 func strangerDirectMsg(sender, text string) adapter.UnifiedMessage { return adapter.UnifiedMessage{ Platform: "telegram", ChannelID: "100", ChannelType: adapter.ChannelDirect, SenderID: sender, SenderName: "陌生人" + sender, Sender: adapter.Actor{Role: adapter.RoleMember}, Content: text, ContentType: adapter.ContentText, } } // newStrangerBridge 构建一条接好 telegram 适配器的陌生人私聊测试桥接器。 func newStrangerBridge(t *testing.T, chat *stubChatter, hist *captureHistory, at time.Time, log *slog.Logger) (*Bridge, *stubSessions, *fakeAdapter) { t.Helper() sess := &stubSessions{sess: strangerPrivateSession()} ad := &fakeAdapter{name: "telegram"} b, err := New(Options{ Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist, Log: log, QuietStartHour: 23, QuietEndHour: 7, Now: func() time.Time { return at }, }) if err != nil { t.Fatalf("New: %v", err) } b.Add(ad) return b, sess, ad } // TestStrangerPrivateChatRespondsIncludingQuietHours 钉住裁决①:陌生人私聊(非群 + 非主人) // 在任何时刻都是正常聊天 —— 白天如此,安静时段 23:30 也如此(安静时段不设在她与陌生人之间)。 // 变异验证:把「陌生人跳过概率门」那段摘掉,本用例立刻变红(shouldRespond 会落到规则/参与度 // 链上,无信号 ⇒ 静默、循环不跑)。 func TestStrangerPrivateChatRespondsIncludingQuietHours(t *testing.T) { t.Parallel() cases := []struct { name string at time.Time }{ {"daytime", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)}, {"quiet hours 23:30", time.Date(2026, 1, 1, 23, 30, 0, 0, time.UTC)}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: "你好呀"}} b, sess, ad := newStrangerBridge(t, chat, &captureHistory{}, tc.at, nil) msg := strangerDirectMsg("777", "在吗,普通的一句话") actor := session.ResolveActor(msg, b.owner) if ok, reason := b.shouldRespond(context.Background(), msg, actor, sess.sess, ad); !ok { t.Fatalf("shouldRespond = false (%s), want true(陌生人私聊一律正常聊天)", reason) } b.process(context.Background(), msg) if chat.sessionID != "sess-s" { t.Fatalf("loop session = %q, want the isolated sess-s", chat.sessionID) } if len(ad.sends) != 1 || ad.sends[0].Content != "你好呀" { t.Fatalf("stranger replies = %+v, want one text reply", ad.sends) } }) } } // TestStrangerPrivateFlowControlPerSender 钉住裁决③:陌生人私聊有强制流控。 // 同一发送者在额度内连发都会被回答;超出 FlowDirectDefault 的第 N+1 条**不回**、 // 只登记,并留一条**带 source 的可见告警**;另一条发送者的额度完全独立、不受挤占。 func TestStrangerPrivateFlowControlPerSender(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})) at := time.Date(2026, 1, 1, 23, 30, 0, 0, time.UTC) b, sess, ad := newStrangerBridge(t, chat, hist, at, log) for i := 0; i < adapter.FlowDirectDefault; i++ { chat.sessionID = "" b.process(context.Background(), strangerDirectMsg("777", "第 "+itoa(i)+" 句")) if chat.sessionID != "sess-s" { t.Fatalf("额度内第 %d 条被拒(session=%q)", i+1, chat.sessionID) } } // 第 N+1 条超限:不回,只登记,且有可见日志。 before := len(hist.records()) chat.sessionID = "" b.process(context.Background(), strangerDirectMsg("777", "刷屏第 N+1 句")) if chat.sessionID != "" { t.Fatalf("超限消息驱动了循环:session=%q", chat.sessionID) } if got := len(hist.records()) - before; got != 1 { t.Fatalf("超限消息新增登记 %d 条,want 1(只登记不回)", got) } logged := logBuf.String() if !strings.Contains(logged, "stranger private chat rate limited") || !strings.Contains(logged, "source=services/channelbridge") { t.Fatalf("被限流没有留下带 source 的可见日志:%q", logged) } // 另一条发送者:额度独立,第一条照常回答。 other := strangerDirectMsg("888", "我是另一个人") if ok, reason := b.shouldRespond(context.Background(), other, session.ResolveActor(other, b.owner), sess.sess, ad); !ok { t.Fatalf("另一个发送者被同一额度挤占:reason=%s", reason) } } // TestStrangerPrivateSecondTurnAnsweredNotThrottled 钉住 M23 对裁决③后半句的**改写**: // 「同一发送者同时最多 1 个在跑的回合」这道闸仍然保留(runBatch 开跑前取、回合结束后还), // 但「已经有回合在跑 ⇒ 这条只登记、不回」已经被 **排队**取代 —— 塔 2026-10-24 的裁定原文: // 「陌生人私聊 `AcquireTurn` 那段由 M23 步 3 改(把「已有回合 ⇒ 不回」改成排队)」。 // // 结构上「排队」由车道提供(同一会话一条车道、单消费者 ⇒ 不可能有两轮同时跑), // 因此能走到「槽被占」的只剩并发路径(例如本用例直接调 process 而另一条路正占着槽)。 // 这时**绝不静默**:留一条带 source 的 WARN,消息照答(宁可晚回,不许不回)。 // // 变异检验:把 runBatch 里的那个 WARN 分支改回 registerThrottled + return ⇒ 本用例必红。 func TestStrangerPrivateSecondTurnAnsweredNotThrottled(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, _, ad := newStrangerBridge(t, chat, hist, time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), log) msg := strangerDirectMsg("777", "在吗") gate := b.gateFor(ad) if gate == nil || !gate.AcquireTurn(msg) { t.Fatal("占位失败:测试前置条件不成立") } t.Cleanup(func() { gate.ReleaseTurn(msg) }) b.process(context.Background(), msg) if chat.sessionID == "" { t.Fatal("回合槽被占时消息没有回答:M23 的口径是排队回答,不是不回") } if got := len(hist.records()); got != 2 { t.Fatalf("登记 %d 条,want 2(user + assistant:这条被回答了)", got) } logged := logBuf.String() if !strings.Contains(logged, "answering anyway") || !strings.Contains(logged, "source=services/channelbridge") { t.Fatalf("槽被占照答没有留下带 source 的可见日志:%q", logged) } } // TestNonOwnerLowValueSystemEventRegisteredOnly 钉住既有行为不变:系统/合成发送者的 // 低价值事件只登记、绝不驱动完整循环(design/12 §6 策略一)。 func TestNonOwnerLowValueSystemEventRegisteredOnly(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: "不该回"}} sess := &stubSessions{sess: nonOwnerSession()} hist := &captureHistory{} b, err := New(Options{ Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist, Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) }, }) if err != nil { t.Fatalf("New: %v", err) } msg := nonOwnerGroupMsg("群成员变更通知") msg.Sender.Role = adapter.RoleSystem b.process(context.Background(), msg) if chat.sessionID != "" || len(chat.messages) != 0 { t.Fatalf("低价值系统事件驱动了循环:session=%q messages=%+v", chat.sessionID, chat.messages) } if got := len(hist.records()); got != 1 { t.Fatalf("records = %d, want 1(只登记,不回复)", got) } }