降噪A(策略一/二/五): - channelbridge/light.go: 非owner分级门——小事件当系统消息登记(不进loop)、非owner轻响应(她判断回/不回,规则优先mention/关键词+轻LLM兜底fail-closed静默) - backthink 触发降噪: SubjectSystemEvent 低价值不唤醒thinking(仅高价值/owner才think), iot/reminder不受影响 - quiet hours: 安静时段非owner无信号静默,被@才回(非硬性静默); config QUIET_START/END_HOUR 单源共用 限流B(策略三/四): - adapter/flowgate.go: per-adapter 滑窗限流器+总上限+审查剔噪闸门(Admit两连:速率+上限/审查), owner私聊绕过 - channelbridge/flowgate.go: onMessage入站最前挂闸, 超限/被剔不占worker槽; 审查模型NewLLMReviewer复用model.Adapter(仅>80%阈值触发+跨分钟节流,失败回退FIFO,绝不判身份) - config: ChannelAccount 加 GroupMsgPerMin(默认6)/MsgPerMin/MaxMessages(默认50), CHANNELS_JSON每账号, 向后兼容 - 被剔消息走OnRejected审计不静默丢 main wire接线: 加modelAdapter字段+Review注入(reviewFor nil-safe,无LLM回退FIFO) 全仓 go build/vet/test 绿(53包)+交叉编译, 零新依赖, 前端无涉
213 lines
7.0 KiB
Go
213 lines
7.0 KiB
Go
package channelbridge
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"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 verifies the gate is wired into the
|
|
// ingress (onMessage) and that a rate-limited non-owner message is dropped with the
|
|
// reason surfaced through the OnRejected audit hook. The dropped path is synchronous
|
|
// (no worker goroutine is spawned), so the assertion is deterministic.
|
|
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)
|
|
|
|
// First message is admitted (async worker), only auditing the second, which the
|
|
// total per-minute cap refuses.
|
|
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 verifies owner messages bypass the flow gate
|
|
// entirely: even with an aggressive rate limit, an owner message is never dropped as
|
|
// 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: &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", "groupMsgPerMin": 1, "maxMessages": 1}}
|
|
b.Add(ad)
|
|
|
|
// Three owner messages: if the gate applied, the 2nd and 3rd would be rate
|
|
// limited / capped and reach OnRejected. Owner bypass leaves the audit empty.
|
|
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 pins the config-to-limit resolution that keeps
|
|
// old CHANNELS_JSON accounts backward compatible: absent key → off, 0 → protective
|
|
// default, negative → off, positive → pinned.
|
|
func TestFlowGate_GateOptionsDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b, err := New(Options{Loop: &stubChatter{}, Sessions: &stubSessions{}})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
cfg map[string]any
|
|
group, max int
|
|
}{
|
|
{"absent keys off", map[string]any{"bot_token": "x"}, 0, 0},
|
|
{"group 0 defaults 6", map[string]any{"bot_token": "x", "groupMsgPerMin": 0}, 6, 0},
|
|
{"group -1 off", map[string]any{"bot_token": "x", "groupMsgPerMin": -1}, 0, 0},
|
|
{"group 10 pinned", map[string]any{"bot_token": "x", "groupMsgPerMin": 10}, 10, 0},
|
|
{"max 0 defaults 50", map[string]any{"bot_token": "x", "maxMessages": 0}, 0, 50},
|
|
{"max -1 off", map[string]any{"bot_token": "x", "maxMessages": -1}, 0, 0},
|
|
{"max 80 pinned", map[string]any{"bot_token": "x", "maxMessages": 80}, 0, 80},
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// mockLLM is a model.Adapter stub returning a fixed completion.
|
|
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 verifies the review model is wrapped into a Reviewer that asks
|
|
// the LLM which queued messages to keep and filters the returned indices.
|
|
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)
|
|
}
|
|
// Out-of-range index 99 and the duplicate 0 are dropped; keep = [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 verifies a review-model failure is surfaced to the gate,
|
|
// which then falls back to FIFO eviction rather than fabricating a success.
|
|
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")
|
|
}
|
|
}
|