主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」 此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答 (批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。 四条不可走样: - 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器 工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定, 配置无权翻转; - 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点 (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前; - 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复; - 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom), 被静默的消息进不了批。 实现落点: - lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记; - coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数; - coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车; - coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch; - runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键); - gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 + writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批); - 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。 M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条 按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。 回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build + 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
211 lines
8.8 KiB
Go
211 lines
8.8 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/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)
|
||
}
|
||
}
|