- 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN)
- 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6
- 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要
- 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行
- 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原
- ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0
332 lines
12 KiB
Go
332 lines
12 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/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||
)
|
||
|
||
func itoa(n int) string {
|
||
if n == 0 {
|
||
return "0"
|
||
}
|
||
var b [20]byte
|
||
i := len(b)
|
||
for n > 0 {
|
||
i--
|
||
b[i] = byte('0' + n%10)
|
||
n /= 10
|
||
}
|
||
return string(b[i:])
|
||
}
|
||
|
||
func groupSender(i int) adapter.UnifiedMessage {
|
||
return adapter.UnifiedMessage{
|
||
Platform: "kook",
|
||
ChannelID: "-100",
|
||
ChannelType: adapter.ChannelGroup,
|
||
GroupID: "-100",
|
||
SenderID: "u" + itoa(i),
|
||
Content: "spam " + itoa(i),
|
||
ContentType: adapter.ContentText,
|
||
}
|
||
}
|
||
|
||
func groupOwnerMsg(i int) adapter.UnifiedMessage {
|
||
m := groupSender(i)
|
||
m.Sender = adapter.Actor{Role: adapter.RoleOwner}
|
||
return m
|
||
}
|
||
|
||
// TestFlowGate_RateLimitDropsNonOwnerAndAudits 验证闸门已接入
|
||
// 入口(onMessage),且被限流的非主人消息会被丢弃并经
|
||
// OnRejected 审计钩子暴露原因。丢弃路径是同步的
|
||
// (不会派生工作 goroutine),因此断言是确定性的。
|
||
func TestFlowGate_RateLimitDropsNonOwnerAndAudits(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
chat := &stubChatter{res: &loop.Result{Text: "hi"}}
|
||
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
|
||
var rejected []string
|
||
b, err := New(Options{
|
||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
|
||
OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) },
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
ad := &fakeAdapter{name: "kook", cfg: map[string]any{"bot_token": "x", "msgPerMin": 1}}
|
||
b.Add(ad)
|
||
|
||
// 第一条消息被放行(异步工作项),只对第二条入审计,
|
||
// 它被每分钟总量上限拒绝。
|
||
b.onMessage(context.Background(), groupSender(0), ad)
|
||
b.onMessage(context.Background(), groupSender(1), ad)
|
||
|
||
if len(rejected) != 1 || rejected[0] != "rate_limited" {
|
||
t.Fatalf("rejected = %v, want [rate_limited]", rejected)
|
||
}
|
||
}
|
||
|
||
// TestFlowGate_OwnerPrivateBypassesGate 验证主人消息完全绕过流控
|
||
// 闸门:即使限流很激进,主人消息也绝不会以
|
||
// rate_limited / review_pruned / capped 被丢弃。
|
||
func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
chat := &stubChatter{res: &loop.Result{Text: "hi"}}
|
||
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
||
var rejected []string
|
||
b, err := New(Options{
|
||
Loop: chat, Sessions: sess, Owner: testOwnerProfile("kook", "u0", "u1", "u2"),
|
||
OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) },
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
ad := &fakeAdapter{name: "kook", cfg: map[string]any{"bot_token": "x", "groupMsgPerMin": 1, "maxMessages": 1}}
|
||
b.Add(ad)
|
||
|
||
// 三条主人消息:若闸门生效,第 2、3 条会被限流
|
||
// / 触及上限并到达 OnRejected。主人绕过则审计为空。
|
||
for i := 0; i < 3; i++ {
|
||
b.onMessage(context.Background(), groupOwnerMsg(i), ad)
|
||
}
|
||
if len(rejected) != 0 {
|
||
t.Fatalf("owner messages must bypass the gate, got rejected %v", rejected)
|
||
}
|
||
}
|
||
|
||
// TestFlowGate_GateOptionsDefaults 钉住「配置→限制」的解析规则,
|
||
// 它保证旧的 CHANNELS_JSON 账号向后兼容:键缺失 → 关闭,0 → 保护性
|
||
// 默认值,负值 → 关闭,正值 → 固定为该值。**私聊(陌生人)限流同一口径,
|
||
// 但缺省侧相反**:键缺失/非整数/0 一律取保护性默认 6(裁决③「陌生人必须有流控」
|
||
// +裁决 13「流控时间要可配置」)——绝不因为「没配」而静默关掉。
|
||
func TestFlowGate_GateOptionsDefaults(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
b, err := New(Options{Loop: &stubChatter{}, Sessions: &stubSessions{}})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
|
||
// direct 列用**字面量 6**(该常量由 adapter/flowgate_direct_test.go 的
|
||
// TestFlowDirectDefaultIsPinned 钉住):缺键时若解析成 0(= 关掉流控)本用例必须红。
|
||
cases := []struct {
|
||
name string
|
||
cfg map[string]any
|
||
group, max int
|
||
direct int
|
||
}{
|
||
{"absent keys off", map[string]any{"bot_token": "x"}, 0, 0, 6},
|
||
{"group 0 defaults 6", map[string]any{"bot_token": "x", "groupMsgPerMin": 0}, 6, 0, 6},
|
||
{"group -1 off", map[string]any{"bot_token": "x", "groupMsgPerMin": -1}, 0, 0, 6},
|
||
{"group 10 pinned", map[string]any{"bot_token": "x", "groupMsgPerMin": 10}, 10, 0, 6},
|
||
{"max 0 defaults 50", map[string]any{"bot_token": "x", "maxMessages": 0}, 0, 50, 6},
|
||
{"max -1 off", map[string]any{"bot_token": "x", "maxMessages": -1}, 0, 0, 6},
|
||
{"max 80 pinned", map[string]any{"bot_token": "x", "maxMessages": 80}, 0, 80, 6},
|
||
{"direct 0 defaults 6", map[string]any{"bot_token": "x", "directMsgPerMin": 0}, 0, 0, 6},
|
||
{"direct -1 off", map[string]any{"bot_token": "x", "directMsgPerMin": -1}, 0, 0, 0},
|
||
{"direct 12 pinned", map[string]any{"bot_token": "x", "directMsgPerMin": 12}, 0, 0, 12},
|
||
{"direct non-numeric defaults 6", map[string]any{"bot_token": "x", "directMsgPerMin": "abc"}, 0, 0, 6},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
opts := b.gateOptions(&fakeAdapter{name: "kook", cfg: c.cfg})
|
||
if opts.GroupMsgPerMin != c.group {
|
||
t.Fatalf("GroupMsgPerMin = %d, want %d", opts.GroupMsgPerMin, c.group)
|
||
}
|
||
if opts.MaxMessages != c.max {
|
||
t.Fatalf("MaxMessages = %d, want %d", opts.MaxMessages, c.max)
|
||
}
|
||
if opts.DirectMsgPerMin != c.direct {
|
||
t.Fatalf("DirectMsgPerMin = %d, want %d(缺失/0 走保护性默认 6,负值=关闭,正值=该值)",
|
||
opts.DirectMsgPerMin, c.direct)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestFlowGate_DirectLimitFromAccountConfig 验证 directMsgPerMin 从账号配置到闸门的
|
||
// **整条链**:键缺失 ⇒ 保护性默认 6 条/分真的拦下第 7 条;正值 ⇒ 该值;负值 ⇒ 关闭
|
||
// (全部放行)。计数按发送者独立(AllowDirect 的会话键含 sender)。
|
||
func TestFlowGate_DirectLimitFromAccountConfig(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
cfg map[string]any
|
||
// allowed 是限制内会被放行的条数;off = 限制被显式关闭(第 allowed+1 条不会被拒)。
|
||
allowed int
|
||
off bool
|
||
}{
|
||
{"absent key keeps the protective default 6", map[string]any{"bot_token": "x"}, 6, false},
|
||
{"pinned 2", map[string]any{"bot_token": "x", "directMsgPerMin": 2}, 2, false},
|
||
{"negative disables the limit", map[string]any{"bot_token": "x", "directMsgPerMin": -1}, 12, true},
|
||
}
|
||
for _, c := range cases {
|
||
c := c
|
||
t.Run(c.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
b, ad := newGateBridge(t, c.cfg)
|
||
gate := b.gateFor(ad)
|
||
if gate == nil {
|
||
t.Fatal("gateFor = nil, want a gate for the configured adapter")
|
||
}
|
||
for i := 0; i < c.allowed; i++ {
|
||
if !gate.AllowDirect(strangerDirectMsg("777", "第 "+itoa(i)+" 句")) {
|
||
t.Fatalf("额度内第 %d 条被拒,want allowed", i+1)
|
||
}
|
||
}
|
||
// 限制生效时第 allowed+1 条必须被拒(off=false);关闭时(off=true)必须放行。
|
||
if over := gate.AllowDirect(strangerDirectMsg("777", "第 N+1 句")); over != c.off {
|
||
t.Fatalf("第 %d 条的放行结果 = %v(off=%v),want %v",
|
||
c.allowed+1, over, c.off, c.off)
|
||
}
|
||
// 另一个发送者有独立额度:不被前一个发送者挤占。
|
||
if !gate.AllowDirect(strangerDirectMsg("888", "我是另一个人")) {
|
||
t.Fatal("另一个发送者的额度被挤占")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// newGateBridge 构建一条只用于流控解析的桥接器(telegram 适配器 + 给定账号配置),
|
||
// 使 directMsgPerMin 走与生产完全相同的「Config() → gateOptions」链。
|
||
func newGateBridge(t *testing.T, cfg map[string]any) (*Bridge, *fakeAdapter) {
|
||
t.Helper()
|
||
b, err := New(Options{Loop: &stubChatter{}, Sessions: &stubSessions{}})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
ad := &fakeAdapter{name: "telegram", cfg: cfg}
|
||
b.Add(ad)
|
||
return b, ad
|
||
}
|
||
|
||
// TestFlowGate_DirectLimitOffIsLogged 钉住「负值关闭必须留痕」:主人显式关掉熟人/
|
||
// 陌生人私聊流控时,日志里必须出现一条带 source 的 WARN(不许静默关闭);而正值或
|
||
// 缺省(保护性默认)绝不产生这条告警(否则主人会以为流控被关了)。
|
||
func TestFlowGate_DirectLimitOffIsLogged(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var buf bytes.Buffer
|
||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||
at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||
b, _, ad := newStrangerBridge(t, &stubChatter{}, &captureHistory{}, at, log)
|
||
|
||
ad.cfg = map[string]any{"bot_token": "x", "directMsgPerMin": -1}
|
||
if opts := b.gateOptions(ad); opts.DirectMsgPerMin != 0 {
|
||
t.Fatalf("DirectMsgPerMin = %d, want 0(负值 = 关闭)", opts.DirectMsgPerMin)
|
||
}
|
||
logged := buf.String()
|
||
if !strings.Contains(logged, "non-owner private rate limit turned off") ||
|
||
!strings.Contains(logged, "source=services/channelbridge/flowgate") ||
|
||
!strings.Contains(logged, "directMsgPerMin") {
|
||
t.Fatalf("负值关闭流控没有留下带 source 的可见告警:%q", logged)
|
||
}
|
||
|
||
// 正值:不告警,且额度就是该值。
|
||
buf.Reset()
|
||
ad.cfg = map[string]any{"bot_token": "x", "directMsgPerMin": 12}
|
||
if opts := b.gateOptions(ad); opts.DirectMsgPerMin != 12 {
|
||
t.Fatalf("DirectMsgPerMin = %d, want 12", opts.DirectMsgPerMin)
|
||
}
|
||
if got := buf.String(); got != "" {
|
||
t.Fatalf("正值不该产生「流控被关闭」告警:%q", got)
|
||
}
|
||
|
||
// 缺省(保护性默认):同样不告警 —— 默认不是「主人关掉了它」。
|
||
buf.Reset()
|
||
ad.cfg = map[string]any{"bot_token": "x"}
|
||
if opts := b.gateOptions(ad); opts.DirectMsgPerMin != adapter.FlowDirectDefault {
|
||
t.Fatalf("DirectMsgPerMin = %d, want the protective default %d",
|
||
opts.DirectMsgPerMin, adapter.FlowDirectDefault)
|
||
}
|
||
if got := buf.String(); got != "" {
|
||
t.Fatalf("缺省不该产生「流控被关闭」告警:%q", got)
|
||
}
|
||
}
|
||
|
||
// mockLLM 是返回固定补全的 model.Adapter 桩。
|
||
type mockLLM struct {
|
||
content string
|
||
err error
|
||
}
|
||
|
||
func (m *mockLLM) Name() string { return "mock-llm" }
|
||
func (m *mockLLM) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||
if m.err != nil {
|
||
return model.Response{}, m.err
|
||
}
|
||
return model.Response{Content: m.content, Message: model.TextMessage(model.RoleAssistant, m.content)}, nil
|
||
}
|
||
func (m *mockLLM) ChatStream(_ context.Context, _ model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||
return model.Response{}, nil
|
||
}
|
||
|
||
// TestNewLLMReviewer 验证审查模型被包装成 Reviewer:向
|
||
// LLM 询问哪些队列消息该保留,并过滤返回的索引。
|
||
func TestNewLLMReviewer(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
llm := &mockLLM{content: `[0,1,99,0]`}
|
||
review := NewLLMReviewer(llm, nil)
|
||
|
||
msgs := []adapter.UnifiedMessage{
|
||
{SenderID: "a", Content: "x", ContentType: adapter.ContentText},
|
||
{SenderID: "b", Content: "y", ContentType: adapter.ContentText},
|
||
{SenderID: "c", Content: "z", ContentType: adapter.ContentText},
|
||
}
|
||
keep, err := review(context.Background(), msgs)
|
||
if err != nil {
|
||
t.Fatalf("review: %v", err)
|
||
}
|
||
// 越界索引 99 与重复的 0 被丢弃;保留 = [0,1]。
|
||
if len(keep) != 2 || keep[0] != 0 || keep[1] != 1 {
|
||
t.Fatalf("keep = %v, want [0 1]", keep)
|
||
}
|
||
|
||
if llm.err != nil {
|
||
t.Fatal("unexpected mock error")
|
||
}
|
||
}
|
||
|
||
// TestNewLLMReviewerError 验证审查模型失败会暴露给闸门,
|
||
// 闸门随后回退为 FIFO 淘汰,而不是伪造一个成功。
|
||
func TestNewLLMReviewerError(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
review := NewLLMReviewer(&mockLLM{err: context.DeadlineExceeded}, nil)
|
||
if _, err := review(context.Background(), []adapter.UnifiedMessage{
|
||
{SenderID: "a", Content: "x", ContentType: adapter.ContentText},
|
||
}); err == nil {
|
||
t.Fatal("expected error from failed review model")
|
||
}
|
||
}
|
||
|
||
func TestParseKeepIndices(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got, err := parseKeepIndices(`[2,0,0,5]`, 3)
|
||
if err != nil {
|
||
t.Fatalf("parse: %v", err)
|
||
}
|
||
if len(got) != 2 || got[0] != 0 || got[1] != 2 {
|
||
t.Fatalf("keep = %v, want [0 2] (out-of-range/dup dropped)", got)
|
||
}
|
||
if _, err := parseKeepIndices(`not json`, 3); err == nil {
|
||
t.Fatal("expected error for non-JSON")
|
||
}
|
||
}
|