Files
Cyrene_v2/internal/services/channelbridge/stranger_test.go
T
AskaEth 7ae4298871
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 陌生人私聊正常聊天(跳概率门)+ 强制流控 6条/分与回合槽位 + 安静时段不再硬静默 + 受限车道边界行前置
- 陌生人私聊:不再走「值不值得回」的概率门,唯一闸门 = 每发送者 6 条/分滑窗 + 同会话 1 个在跑回合
- 被限流只登记不回,且留带 source 的可见告警(不静默)
- 安静时段在回复侧只作信号(规则层不再静默),群聊取消夜间静默
- 锁定流控常量:FlowDirectDefault 加字面量钉桩测试(放大它 = 静默关流控,且会让额度循环跑百万轮)
2026-10-05 13:26:15 +08:00

204 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}