- 只翻注释/文档字符串:剥掉注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态) - 术语对齐既有译法(owner→主人、persona→角色、engage→参与度判定、quiet hours→安静时段、backoff→退避) - 复核:go build 绿 + channelbridge 包测试绿;gofmt 干净
213 lines
6.9 KiB
Go
213 lines
6.9 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 验证闸门已接入
|
|
// 入口(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 → 保护性
|
|
// 默认值,负值 → 关闭,正值 → 固定为该值。
|
|
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 是返回固定补全的 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")
|
|
}
|
|
}
|