feat: 公共平台最小化打扰(design/12 §6)——5条策略: 分级+轻思考+限流+审查剔噪
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

降噪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包)+交叉编译, 零新依赖, 前端无涉
This commit is contained in:
Aska committed 2026-09-07 12:18:21 +08:00
1 parent 764d592859
commit 8cde0162e6
16 files changed
+1750 -28

No files matched your search

+37 -5
View File
@@ -104,10 +104,14 @@ type Config struct {
}
// ConfigFromBackground maps a config.BackgroundConfig (the source of the
// heartbeat interval) into a backthink.Config, leaving the push/decide knobs at
// their defaults for normalize to fill in.
// heartbeat interval and the shared quiet-hours window) into a backthink.Config,
// leaving the push/decide knobs at their defaults for normalize to fill in.
func ConfigFromBackground(bg config.BackgroundConfig) Config {
return Config{IntervalSec: bg.IntervalSec}
return Config{
IntervalSec: bg.IntervalSec,
QuietStartHour: bg.QuietStartHour,
QuietEndHour: bg.QuietEndHour,
}
}
// Deps is the injected dependency set for a Backthink. Only Thinker and DB are
@@ -308,12 +312,40 @@ func (b *Backthink) subscribe(ctx context.Context) {
}
// onEvent is the bus handler for event-triggered thinking. It runs on the bus's
// own worker goroutine, so it must not block on another subscriber.
// own worker goroutine, so it must not block on another subscriber. It first
// applies the minimal-disturbance gate (design/12 §6 策略一): a low-value system
// event is downgraded to a registration-only record rather than waking a full
// thinking pass, so platform/system noise cannot flood the thinker.
func (b *Backthink) onEvent(ctx context.Context, e bus.Event) error {
b.think(ctx, triggerForEvent(e))
tr := triggerForEvent(e)
if !b.shouldThink(tr) {
b.log.Debug("backthink system event downgraded, no thinking",
"source", tr.Source, "summary", tr.Summary)
b.record(Record{
Trigger: tr.Source,
At: b.now(),
Conclusion: "system event: low value, not worth waking a thinking pass",
ShouldPush: false,
PushResult: PushResult{Route: RouteNone, Detail: "downgraded"},
})
return nil
}
b.think(ctx, tr)
return nil
}
// shouldThink gates event-triggered thinking (design/12 §6 策略一). On a public
// platform a generic system event is low value, so only a high-importance
// (owner-relevant) system event wakes a pass. IoT device updates and reminder
// triggers are owner-relevant / high value and are never gated, so the productive
// triggers stay fully functional.
func (b *Backthink) shouldThink(tr Trigger) bool {
if tr.Source == SubjectSystemEvent {
return tr.Import >= ImportanceHigh
}
return true
}
// tickLoop is the heartbeat goroutine. It fires one thinking pass per tick and
// exits when ctx is cancelled.
func (b *Backthink) tickLoop(ctx context.Context) {
@@ -136,6 +136,63 @@ func TestBackthink_EventTrigger_NoPush(t *testing.T) {
}
}
func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) {
t.Parallel()
// design/12 §6 策略一: a low-value system event on a public platform does not
// wake a full thinking pass — it is downgraded to a registration-only record.
thinker := &mockThinker{text: "should not be called"}
bs := bus.New()
b, _ := newBackthink(t, Config{}, Deps{
Bus: bs,
Thinker: thinker,
Now: func() time.Time { return dayAt() },
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
if err := bs.Publish(context.Background(), SubjectSystemEvent, SystemEvent{Summary: "member joined"}); err != nil {
t.Fatalf("Publish: %v", err)
}
eventually(t, 2*time.Second, func() bool {
recs, _ := b.List(context.Background(), 10)
return len(recs) >= 1 && recs[0].Trigger == SubjectSystemEvent
})
if got := thinker.count(); got != 0 {
t.Fatalf("thinker calls = %d, want 0 (low-value system event downgraded)", got)
}
recs, _ := b.List(context.Background(), 10)
if recs[0].ShouldPush {
t.Fatalf("ShouldPush = true for a downgraded system event")
}
if recs[0].PushResult.Route != RouteNone {
t.Fatalf("PushResult.Route = %q, want %q", recs[0].PushResult.Route, RouteNone)
}
}
func TestBackthink_SystemEventHighValueThinks(t *testing.T) {
t.Parallel()
// A high-importance (owner-relevant) system event still wakes a thinking pass —
// the denoise gate only downgrades low-value noise (design/12 §6 策略一).
thinker := &mockThinker{text: "important notice"}
bs := bus.New()
b, _ := newBackthink(t, Config{}, Deps{
Bus: bs,
Thinker: thinker,
Now: func() time.Time { return dayAt() },
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
if err := bs.Publish(context.Background(), SubjectSystemEvent, SystemEvent{Summary: "owner tagged", Import: ImportanceHigh}); err != nil {
t.Fatalf("Publish: %v", err)
}
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 })
}
func TestBackthink_List_NewestFirst(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "nothing to report"}
+19 -1
View File
@@ -17,6 +17,17 @@ const (
SubjectSystemEvent = "system.event"
)
// SystemEvent is the optional typed payload a caller may publish to
// SubjectSystemEvent to convey a system notice. Import defaults to Normal. Under
// design/12 §6 策略一, a low-value system notice (a join/leave ping, generic
// platform noise) is downgraded to a registration-only record and does not wake a
// full thinking pass; only a high-importance (owner-relevant) system event thinks,
// so a busy public platform cannot trigger a flood of expensive reflections.
type SystemEvent struct {
Summary string
Import Importance
}
// Trigger describes what woke a thinking pass: the source subsystem, a
// human-readable summary and the event's estimated importance.
type Trigger struct {
@@ -48,7 +59,14 @@ func triggerForEvent(e bus.Event) Trigger {
case SubjectReminderTrigger:
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceHigh}
case SubjectSystemEvent:
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal}
tr := Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal}
if sy, ok := e.Payload.(SystemEvent); ok {
if sy.Summary != "" {
tr.Summary = sy.Summary
}
tr.Import = sy.Import
}
return tr
default:
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal}
}
@@ -90,7 +90,30 @@ type Options struct {
// allow/deny black-or-white lists (design/16 §4.6 §6), so the composition root
// can route it into an audit trail. It is invoked with the rejected message and
// a short reason token ("group_denied", "user_not_allowed", ...). Nil = log only.
//
// It is also wired as the flow gate's OnPruned audit hook (design/12 §6 策略三/四),
// so a message dropped by the rate limiter or the review prune reaches the same
// audit trail with reason "rate_limited", "review_pruned" or "capped".
OnRejected func(adapter.UnifiedMessage, string)
// Review is the optional review model (design/12 §6 策略四) that prunes low-value
// queued messages. Nil falls back to FIFO eviction at the total cap. Use
// NewLLMReviewer to wrap a model.Adapter into this signature.
Review adapter.Reviewer
// Light is the optional light-response judge for non-owner group/stranger
// messages (design/12 §6 策略二/五). When nil the bridge uses its built-in
// rule-based judge and fails-closed to silence, so no chat message is sent to an
// LLM just to decide whether to bother the owner. It is only consulted when the
// rule judge is inconclusive.
Light LightJudge
// QuietStartHour and QuietEndHour bound the do-not-disturb window (local clock
// hours), shared with the backthink service so 策略五 reuses the same definition.
// Equal non-zero hours disable the window; both zero pick the backthink defaults
// (23-07). This is the price of keeping one source of truth across services.
QuietStartHour int
QuietEndHour int
// Now is the wall-clock source for quiet-hour judgement. Nil uses time.Now,
// which makes time-dependent behaviour injectable in tests.
Now func() time.Time
}
// Bridge owns the channel adapter lifecycle and the inbound message pipeline. It
@@ -103,12 +126,24 @@ type Bridge struct {
history history.HistoryStore
onPersisted func(history.Message)
onRejected func(adapter.UnifiedMessage, string)
review adapter.Reviewer
light LightJudge
quietStartHour int
quietEndHour int
now func() time.Time
mu sync.RWMutex
adapters map[string]adapter.PlatformAdapter
connected map[string]bool
closed bool
// gateMu guards the per-adapter flow gates (design/12 §6 策略三/四). They are
// built lazily on the first inbound message per account and are independent of
// the adapter/connected lifecycle, so the same gate survives reconnects.
gateMu sync.Mutex
gates map[string]*adapter.FlowGate
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
@@ -129,18 +164,34 @@ func New(opts Options) (*Bridge, error) {
if log == nil {
log = logger.New(logger.Options{Out: nil})
}
// Quiet hours default to the same window the backthink service uses, so the two
// services never disagree about the do-not-disturb window (design/12 §6 策略五).
qStart, qEnd := opts.QuietStartHour, opts.QuietEndHour
if qStart == 0 && qEnd == 0 {
qStart, qEnd = defaultQuietStartHour, defaultQuietEndHour
}
now := opts.Now
if now == nil {
now = time.Now
}
return &Bridge{
log: logger.WithSource(log, "services/channelbridge"),
run: opts.Loop,
sessions: opts.Sessions,
owner: opts.Owner,
history: opts.History,
onPersisted: opts.OnPersisted,
onRejected: opts.OnRejected,
adapters: make(map[string]adapter.PlatformAdapter),
connected: make(map[string]bool),
cancel: func() {},
sem: make(chan struct{}, maxConcurrent),
log: logger.WithSource(log, "services/channelbridge"),
run: opts.Loop,
sessions: opts.Sessions,
owner: opts.Owner,
history: opts.History,
onPersisted: opts.OnPersisted,
onRejected: opts.OnRejected,
review: opts.Review,
light: opts.Light,
quietStartHour: qStart,
quietEndHour: qEnd,
now: now,
adapters: make(map[string]adapter.PlatformAdapter),
connected: make(map[string]bool),
gates: make(map[string]*adapter.FlowGate),
cancel: func() {},
sem: make(chan struct{}, maxConcurrent),
}, nil
}
@@ -341,6 +392,23 @@ func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a ad
return
}
// design/12 §6 策略三/四: per-adapter minimal-disturbance gate. Owner-private
// messages bypass it entirely (the owner is never throttled or pruned); every
// other message passes a sliding-window rate limit (策略三) and the bounded
// pending set whose saturation triggers the review model to prune noise (策略四).
// Dropped messages are audited through the gate's OnPruned hook (wired to
// OnRejected) and never reach the pipeline, so a flood cannot saturate the loop.
actor := session.ResolveActor(msg, b.owner)
if !actor.IsOwner() {
verdict := b.gateFor(a).Admit(ctx, msg)
if !verdict.Proceed {
b.log.Warn("inbound message dropped by flow gate",
"platform", msg.Platform, "channel", msg.ChannelID,
"reason", verdict.Reason, "dropped", len(verdict.Pruned))
return
}
}
select {
case b.sem <- struct{}{}:
case <-ctx.Done():
@@ -415,6 +483,21 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/12 §6 策略一/二/五: minimal-disturbance gate for non-owner messages. A
// non-owner group/stranger message that the cheap light judgement deems not
// worth a response is registered to its isolated session log and silenced — it
// never drives the full agent loop. Owner messages are never gated here, so
// owner-private chat and owner group @/high-value replies are unchanged.
if !actor.IsOwner() {
if ok, reason := b.shouldRespond(ctx, msg, actor); !ok {
b.registerSilenced(ctx, msg, actor, sess)
b.log.Debug("non-owner message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "reason", reason)
return
}
}
// design/16 §4.4 + design/02 §3: an owner private chat is routed INTO the single
// main web session (shared across platforms) and persisted to chat_messages with
// the channel platform, so the Web client mirrors it; group chats and strangers
@@ -84,12 +84,17 @@ func (s *stubSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage
return s.sess, nil
}
// directMessage is an owner-private direct message: the sender is tagged as the
// owner (structural, not from text), so it exercises the owner-private path. The
// single-account stub session in newTestBridge reports Owner=true to match, since
// the production SessionManager derives Owner from the resolved actor.
func directMessage() adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram",
ChannelID: "100",
ChannelType: adapter.ChannelDirect,
SenderID: "100",
Sender: adapter.Actor{Role: adapter.RoleOwner},
Content: "hello",
ContentType: adapter.ContentText,
}
@@ -180,6 +185,8 @@ func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
t.Parallel()
b, chat, _, ad := newTestBridge(t)
// A non-owner group message that is addressed (mentioned) gets a response under
// design/12 §6 策略二, so it still exercises group reply targeting.
msg := adapter.UnifiedMessage{
Platform: "telegram",
ChannelID: "-20",
@@ -187,6 +194,8 @@ func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
GroupID: "-20",
GroupName: "Gang",
SenderID: "9",
Sender: adapter.Actor{Role: adapter.RoleMember},
Mentions: []adapter.Mention{{UserID: "9"}},
Content: "hello all",
ContentType: adapter.ContentText,
}
@@ -351,10 +360,13 @@ func TestGroupMessageNotPersistedToMainSession(t *testing.T) {
t.Fatalf("New: %v", err)
}
// Addressed (mentioned) group message → responds (design/12 §6 策略二), still
// isolated to the group session.
msg := adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: "9", Content: "hello all",
ContentType: adapter.ContentText,
GroupID: "-20", GroupName: "Gang", SenderID: "9",
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: []adapter.Mention{{UserID: "9"}},
Content: "hello all", ContentType: adapter.ContentText,
}
b.process(context.Background(), msg)
@@ -463,11 +475,13 @@ func TestGroupMessageThirdPartySenderNameAndBoundary(t *testing.T) {
t.Fatalf("New: %v", err)
}
// Addressed (mentioned) group message → responds (design/12 §6 策略二), so the
// third-party boundary and the [第三方 · 张三] prefix are injected.
msg := adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
Sender: adapter.Actor{Role: adapter.RoleMember}, Content: "hello all",
ContentType: adapter.ContentText,
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: []adapter.Mention{{UserID: "9"}},
Content: "hello all", ContentType: adapter.ContentText,
}
b.process(context.Background(), msg)
+178
View File
@@ -0,0 +1,178 @@
package channelbridge
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// gateFor returns the per-account flow gate for an adapter, building it lazily
// from the adapter's Config snapshot on first use. Gates are keyed by the adapter
// name so every account (platform:confId) gets an independent rate limit and
// pending-cap (design/12 §6 策略三/四), and they survive adapter reconnects.
func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate {
name := a.Name()
b.gateMu.Lock()
defer b.gateMu.Unlock()
if g, ok := b.gates[name]; ok {
return g
}
g := adapter.NewFlowGate(b.gateOptions(a))
b.gates[name] = g
return g
}
// gateOptions resolves the per-account flow-limits out of the adapter's Config
// snapshot into a FlowGate. An absent key (single-account env adapters) leaves the
// limit off, so existing channels run unchanged; a present key with value 0 follows
// the protective default (6 group/min, 50 cap) and a negative value disables it —
// the backward-compatible contract for CHANNELS_JSON accounts (design/12 §6 实现要点).
func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions {
opts := adapter.FlowGateOptions{
Review: b.review,
OnPruned: b.onRejected,
Now: b.now,
}
cfg := a.Config()
if grp, ok := cfgInt(cfg, "groupMsgPerMin"); ok {
opts.GroupMsgPerMin = flowOrDefault(grp, adapter.FlowLimitDefault)
}
if tot, ok := cfgInt(cfg, "msgPerMin"); ok && tot > 0 {
opts.MsgPerMin = tot
}
if max, ok := cfgInt(cfg, "maxMessages"); ok {
opts.MaxMessages = flowOrDefault(max, adapter.FlowCapDefault)
}
return opts
}
// flowOrDefault maps a configured limit value to an effective limit: a negative
// value disables the limit (0), zero follows the protective default, and a positive
// value pins it. This is the "old account without the field keeps prior behavior or
// gets the safe default" contract.
func flowOrDefault(v, def int) int {
switch {
case v < 0:
return 0
case v == 0:
return def
default:
return v
}
}
// cfgInt reads an integer field from an adapter config map, returning (0,false)
// when the key is absent or of an unexpected type (so an absent field stays "off").
func cfgInt(m map[string]any, key string) (int, bool) {
if m == nil {
return 0, false
}
switch v := m[key].(type) {
case int:
return v, true
case int64:
return int(v), true
case float64:
return int(v), true
default:
return 0, false
}
}
const (
// reviewMaxTokens bounds the review model output so a noisy queue listing never
// produces a giant completion (design/12 §6 策略四 cost guard).
reviewMaxTokens = 256
// reviewSnippetLen is how many bytes of each message's content are shown to the
// reviewer, enough to judge importance without leaking the whole queue.
reviewSnippetLen = 80
// reviewPrompt instructs the review model to pick which queued messages matter.
// It only ever decides noise/importance — never who the sender is (project red
// line: identity comes from actor metadata, not the LLM).
reviewPrompt = "你是群聊消息去噪助手。下面列出若干进入队列的消息(每条带编号)。" +
"请判断哪些消息没有保留价值(灌水、无关、低信息量),只返回你认为【有必要保留】的编号 JSON 数组。" +
"只判断信息价值,不判断发送者身份;不要编造编号;只输出 JSON 数组。"
)
// NewLLMReviewer wraps a model.Adapter into an adapter.Reviewer (design/12 §6 策略四).
// It asks the LLM, via a light single-turn prompt, which of the queued messages are
// worth keeping and routes the answer back to the gate. The gate only invokes it
// when a pending set nears its cap, so the LLM is never called per-message.
func NewLLMReviewer(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
if log == nil {
log = slog.Default()
}
return func(ctx context.Context, msgs []adapter.UnifiedMessage) ([]int, error) {
resp, err := llm.Chat(ctx, model.Request{
Messages: []model.Message{
model.TextMessage(model.RoleSystem, reviewPrompt),
model.TextMessage(model.RoleUser, buildReviewContent(msgs)),
},
Purpose: "noise_review",
MaxTokens: reviewMaxTokens,
}, model.Options{})
if err != nil {
return nil, fmt.Errorf("flowgate: review model: %w", err)
}
keep, err := parseKeepIndices(resp.Content, len(msgs))
if err != nil {
log.Warn("flow gate review output unparsable, falling back to FIFO",
"error", err)
return nil, err
}
return keep, nil
}
}
// buildReviewContent renders the queued messages as a numbered, truncated listing so
// the reviewer can judge importance cheaply. No identity or LLM judgement is made
// here — only a compact, index-labelled text summary.
func buildReviewContent(msgs []adapter.UnifiedMessage) string {
var b strings.Builder
for i, m := range msgs {
name := senderDisplayName(m, m.Sender)
if name == "" {
name = "(未标注)"
}
fmt.Fprintf(&b, "[%d] %s: %s\n", i, name, truncateText(m.Content, reviewSnippetLen))
}
return b.String()
}
// truncateText shortens s to at most n bytes (by rune count) for display.
func truncateText(s string, n int) string {
if n <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "…"
}
// parseKeepIndices decodes a JSON array of indices from the review model and keeps
// only in-range, de-duplicated values in ascending order. Out-of-range or duplicate
// indices are dropped so a hallucinated number can never evict a real message.
func parseKeepIndices(s string, bound int) ([]int, error) {
var idx []int
if err := json.Unmarshal([]byte(s), &idx); err != nil {
return nil, err
}
seen := make(map[int]bool, len(idx))
out := make([]int, 0, len(idx))
for _, i := range idx {
if i >= 0 && i < bound && !seen[i] {
seen[i] = true
out = append(out, i)
}
}
sort.Ints(out)
return out, nil
}
@@ -0,0 +1,212 @@
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")
}
}
+190
View File
@@ -0,0 +1,190 @@
package channelbridge
import (
"context"
"strings"
"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/history"
)
// This file holds the channel bridge's minimal-disturbance inbound classification
// for public platforms (DevDocs/design/12 §6 策略一/二/五). Non-owner group/stranger
// messages are judged cheaply before they reach the full agent loop, so a busy
// public group does not fling every message into an expensive LLM turn. The logic
// lives here to keep channelbridge.go under the module size bound; it never reads
// message content to decide identity — identity is already resolved structurally by
// the session/actor layer (a project red line).
// Quiet-hour defaults, mirroring the backthink service so the bridge and the
// background thinker share one do-not-disturb window (design/12 §6 策略五).
const (
defaultQuietStartHour = 23
defaultQuietEndHour = 7
)
// lightVerdict is the outcome of a rule-based light judgement before the optional
// injected LightJudge is consulted (design/12 §6 策略二/五).
type lightVerdict int
const (
// verdictRespond means the rule signals the message is worth a response.
verdictRespond lightVerdict = iota
// verdictSilent means the rule signals the message should be registered but not
// answered (e.g. during quiet hours with no strong signal).
verdictSilent
// verdictInconclusive means the rules cannot tell; defer to the LightJudge (or
// fail-closed to silence when none is wired).
verdictInconclusive
)
// LightInput is the set of signals a LightJudge receives to decide response
// worthiness. Identity is already resolved structurally by the session/actor
// layer, so the judge weighs only 值不值得回 — it never decides who the sender is.
type LightInput struct {
Msg adapter.UnifiedMessage
Actor adapter.Actor
SenderName string
InQuietHours bool
QuietStart int
QuietEnd int
}
// LightDecision is a LightJudge's verdict.
type LightDecision struct {
Respond bool
Reason string
}
// LightJudge is the optional cost-controlled light-response judge (design/12 §6
// 策略二/五). The bridge consults it only when the built-in rule judge is
// inconclusive, so a chat message is not repeatedly sent to an expensive LLM. A
// nil LightJudge (the default) keeps the bridge rule-only and fail-closed toward
// silence (不打扰为第一原则). The production wiring does not supply one yet, so the
// seam is ready but not token-consuming until it is deliberately wired.
type LightJudge interface {
Judge(ctx context.Context, in LightInput) (LightDecision, error)
}
// highValueKeywords is the small term list the rule judge matches to decide a
// message is worth a response even when the sender was not directly addressed
// (设计/12 §6 策略二: 关键词重要度). It is a cheap substring match — never an LLM call,
// and never used to infer identity.
var highValueKeywords = []string{
"紧急", "救命", "故障", "坏掉", "坏了", "报错", "出错", "崩溃",
"求助", "帮帮我", "help", "urgent", "error", "bug",
}
// isLowValueEvent reports whether a non-owner message is platform/system noise
// that must be registered but never drives the full agent loop (设计/12 §6 策略一).
// It is only consulted for non-owner messages: a system/synthetic sender is the
// canonical low-value signal. Messages with no meaningful chat text are dropped at
// the bridge head anyway, so this primarily gates system-actor notifications.
func isLowValueEvent(actor adapter.Actor) bool {
return actor.IsSystem()
}
// hasHighValueKeyword reports whether text carries a term worth responding to even
// when the sender was not directly addressed. Kept tiny so the rule path stays O(1)
// per message.
func hasHighValueKeyword(text string) bool {
lower := strings.ToLower(text)
for _, k := range highValueKeywords {
if strings.Contains(lower, k) {
return true
}
}
return false
}
// ruleLightJudge decides response-worthiness from cheap rule signals only
// (设计/12 §6 策略二/五). Priority: a direct mention, a mention of the owner's display
// name, a high-value keyword, then a quiet-hours bias toward silence; otherwise the
// verdict is inconclusive (defer to the injected LightJudge, else silence). It
// reads only already-resolved actor metadata and message content — never infers
// identity.
func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor, inQuiet bool) lightVerdict {
if len(msg.Mentions) > 0 {
return verdictRespond
}
if b.owner != nil && b.owner.DisplayName != "" &&
strings.Contains(msg.Content, b.owner.DisplayName) {
return verdictRespond
}
if hasHighValueKeyword(msg.Content) {
return verdictRespond
}
if inQuiet {
return verdictSilent
}
return verdictInconclusive
}
// shouldRespond implements the minimal-disturbance classification for a non-owner
// inbound message (设计/12 §6 策略一/二/五). It returns whether the message should
// drive the full agent loop and reply, or be registered-and-silenced. Owner
// messages never reach here (processFrom gates on !actor.IsOwner), so owner-private
// chat and owner group @/high-value replies are untouched.
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor) (bool, string) {
// 策略一: platform/system noise → register only, never the full loop.
if isLowValueEvent(actor) {
b.log.Debug("non-owner low-value system event, registered only",
"platform", msg.Platform, "channel", msg.ChannelID, "actor_role", actor.Role)
return false, "low-value system event; registered only"
}
// 策略二/五: rule-based light judgement, quiet-hours aware.
inQuiet := b.inQuietHours(b.now())
switch b.ruleLightJudge(msg, actor, inQuiet) {
case verdictRespond:
return true, "rule signal: worth responding"
case verdictSilent:
b.log.Debug("non-owner message silenced (quiet/no signal), registered only",
"platform", msg.Platform, "channel", msg.ChannelID, "quiet", inQuiet, "actor_role", actor.Role)
return false, "rule signal: not worth responding"
}
// Inconclusive → consult the optional injected light judge. Fail-closed to
// silence on error or absence (不打扰为第一原则).
if b.light != nil {
d, err := b.light.Judge(ctx, LightInput{
Msg: msg, Actor: actor, SenderName: senderDisplayName(msg, actor),
InQuietHours: inQuiet, QuietStart: b.quietStartHour, QuietEnd: b.quietEndHour,
})
if err == nil {
if d.Respond {
return true, "light judge: " + d.Reason
}
return false, "light judge: " + d.Reason
}
b.log.Warn("light judge failed; silencing", "error", err)
}
b.log.Debug("non-owner message has no rule signal, silenced",
"platform", msg.Platform, "channel", msg.ChannelID, "actor_role", actor.Role)
return false, "no rule signal; silenced (fail-closed)"
}
// registerSilenced persists a silenced non-owner inbound turn into its isolated
// session's platform log so the adapter log page still renders the non-owner
// stream (design/16 §4.6 §4), then stops — no loop, no reply. It is the 登记 side
// of the minimal-disturbance path.
func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session) {
b.persistIsolated(ctx, history.RoleUser, msg.Content, deviceIDFor(msg), sess, msg.Platform, senderDisplayName(msg, actor))
b.log.Debug("non-owner message silenced, registered only",
"platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID)
}
// inQuietHours reports whether at falls inside the configured do-not-disturb
// window (shared semantics with backthink: equal non-zero hours disable it, and a
// window that wraps midnight is handled). It is a pure method on Bridge so tests
// drive it deterministically via the Now source.
func (b *Bridge) inQuietHours(at time.Time) bool {
h := at.Hour()
start, end := b.quietStartHour, b.quietEndHour
if start <= end {
return h >= start && h < end
}
return h >= start || h < end
}
@@ -0,0 +1,285 @@
package channelbridge
import (
"context"
"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/history"
"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"
)
// stubLightJudge is a configurable LightJudge recording how often it was consulted.
type stubLightJudge struct {
resp LightDecision
err error
calls int
}
func (s *stubLightJudge) Judge(context.Context, LightInput) (LightDecision, error) {
s.calls++
return s.resp, s.err
}
// nonOwnerGroupMsg returns a third-party group message from a member sender, with
// the given mentions. Content is left untouched so tests control the signal.
func nonOwnerGroupMsg(content string, mentions ...adapter.Mention) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: mentions,
Content: content, ContentType: adapter.ContentText,
}
}
// nonOwnerSession is the restricted (group / stranger) session binding used by the
// non-owner tests: read-only memory, no owner.
func nonOwnerSession() session.Session {
return session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}
}
func TestNonOwnerSilencedRegisteredOnly(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)
}
// A non-owner message with no @/keyword/name signal is silenced (设计/12 §6 策略二):
// it is registered to the isolated session log but never drives the loop.
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("loop ran for a no-signal non-owner message: session=%q messages=%+v", chat.sessionID, chat.messages)
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("records = %d, want 1 (inbound registered, no assistant reply)", len(recs))
}
if recs[0].SessionID != "sess-g" || recs[0].Role != history.RoleUser || recs[0].Content != "hello all" {
t.Fatalf("registered record = %+v, want isolated sess-g user 'hello all'", recs[0])
}
}
func TestNonOwnerMentionResponds(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)
}
// An addressed (mentioned) non-owner message is worth a response (策略二), so it
// drives the full loop against the isolated group session.
b.process(context.Background(), nonOwnerGroupMsg("hello", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want isolated sess-g", chat.sessionID)
}
// Restricted group context → the third-party boundary system message plus the
// user message (design/16 §4.6 §1), mirroring TestGroupMessageThirdPartySenderNameAndBoundary.
if len(chat.messages) != 2 || chat.messages[1].Role != model.RoleUser {
t.Fatalf("loop messages = %+v, want boundary + user", chat.messages)
}
if chat.messages[1].Content[0].Text != "[第三方 · 张三] hello" {
t.Fatalf("loop user text = %q, want [第三方 · 张三] hello", chat.messages[1].Content[0].Text)
}
}
func TestNonOwnerHighValueKeywordResponds(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "我看看"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// A high-value keyword (求助) is worth a response even without an @ (策略二).
b.process(context.Background(), nonOwnerGroupMsg("有人在吗,紧急求助"))
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want isolated sess-g for a high-value keyword", chat.sessionID)
}
}
func TestNonOwnerQuietHoursSilenced(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,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 策略五: during quiet hours a no-signal non-owner message is silenced — it is
// registered but not answered.
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("loop ran during quiet hours for a no-signal message: session=%q messages=%+v", chat.sessionID, chat.messages)
}
if len(hist.records()) != 1 {
t.Fatalf("records = %d, want 1 (registered, no reply)", len(hist.records()))
}
}
func TestNonOwnerQuietHoursMentionResponds(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 策略五 is not a hard silence: a directly-addressed message during quiet hours
// still gets a response.
b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (mention should break quiet-hours silence)", chat.sessionID)
}
}
func TestOwnerUnaffectedInQuietHours(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "主人好"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: &captureHistory{},
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
// An owner message is never gated, even during quiet hours (策略五 only ever
// governs non-owner traffic): owner-private chat still routes to the main web
// session and replies.
b.process(context.Background(), directMessage())
if chat.sessionID != history.MainSessionID {
t.Fatalf("owner loop session = %q, want %q", chat.sessionID, history.MainSessionID)
}
if len(ad.sends) != 1 || ad.sends[0].Content != "主人好" {
t.Fatalf("owner reply = %+v, want '主人好'", ad.sends)
}
}
func TestLightJudgeConsultedWhenInconclusive(t *testing.T) {
t.Parallel()
// A no-signal daytime message is inconclusive to the rules, so the injected
// LightJudge gets a chance to decide (设计/12 §6 策略二: 规则不确定才轻 LLM).
judge := &stubLightJudge{resp: LightDecision{Respond: true, Reason: "值得回"}}
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
Light: judge,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1 (inconclusive rules escalated)", judge.calls)
}
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (judge said respond)", chat.sessionID)
}
}
func TestLightJudgeSilenceIsRespected(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: false, Reason: "不值得"}}
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,
Light: judge,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1", judge.calls)
}
if chat.sessionID != "" {
t.Fatalf("loop ran after the judge said silence: session=%q", chat.sessionID)
}
if len(hist.records()) != 1 {
t.Fatalf("records = %d, want 1 (registered, no reply)", len(hist.records()))
}
}
func TestLightJudgeErrorFailsClosedToSilence(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{err: context.DeadlineExceeded}
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,
Light: judge,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// A failing light judge is fail-closed toward silence (不打扰为第一原则).
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1", judge.calls)
}
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("loop ran after judge error: session=%q messages=%+v", chat.sessionID, chat.messages)
}
if len(hist.records()) != 1 {
t.Fatalf("records = %d, want 1 (registered, no reply)", len(hist.records()))
}
}