- 陌生人私聊:不再走「值不值得回」的概率门,唯一闸门 = 每发送者 6 条/分滑窗 + 同会话 1 个在跑回合 - 被限流只登记不回,且留带 source 的可见告警(不静默) - 安静时段在回复侧只作信号(规则层不再静默),群聊取消夜间静默 - 锁定流控常量:FlowDirectDefault 加字面量钉桩测试(放大它 = 静默关流控,且会让额度循环跑百万轮)
204 lines
8.1 KiB
Go
204 lines
8.1 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)
|
||
}
|
||
}
|
||
|
||
// TestStrangerPrivateSecondTurnRegisteredWhileOneRuns 钉住裁决③的后半句:
|
||
// 「同一发送者同时最多 1 个在跑的回合」。先用 AcquireTurn 占住该发送者的槽位(模拟
|
||
// 一个回合正在跑),再让下一条消息过门 —— 它必须只登记、不回,并留可见告警。
|
||
func TestStrangerPrivateSecondTurnRegisteredWhileOneRuns(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.Fatalf("已有回合在跑时仍开了第二个回合:session=%q", chat.sessionID)
|
||
}
|
||
if got := len(hist.records()); got != 1 {
|
||
t.Fatalf("被并发拦下的消息登记 %d 条,want 1", got)
|
||
}
|
||
logged := logBuf.String()
|
||
if !strings.Contains(logged, "throttled by flow gate") ||
|
||
!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)
|
||
}
|
||
}
|