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

+9
View File
@@ -69,6 +69,10 @@ STT_PROVIDER=dashscope
ENABLE_BACKGROUND_THINKING=true ENABLE_BACKGROUND_THINKING=true
THINK_INTERVAL_SEC=300 THINK_INTERVAL_SEC=300
THINK_SILENCE_TIMEOUT_SEC=120 THINK_SILENCE_TIMEOUT_SEC=120
# 安静时段(本地时钟小时,默认 23:00-07:00;两端相等且非 0 则关闭)。
# 同时用于 backthink 后台思考与 channelbridge 公共平台安静时段轻思考(design/12 §6 策略五)。
QUIET_START_HOUR=23
QUIET_END_HOUR=7
# ---- WebSocket ---- # ---- WebSocket ----
WS_MAX_CONNECTIONS=1000 WS_MAX_CONNECTIONS=1000
@@ -105,4 +109,9 @@ ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
# 每个 account 字段:id(平台内唯一)、config(平台特有凭据 map)、 # 每个 account 字段:id(平台内唯一)、config(平台特有凭据 map)、
# allowGroups/denyGroups(群黑白名单,allowGroups 非空则成为白名单)、 # allowGroups/denyGroups(群黑白名单,allowGroups 非空则成为白名单)、
# allowUsers/denyUsers(私聊用户黑白名单)。未配置时按单账号 env 路径处理。 # allowUsers/denyUsers(私聊用户黑白名单)。未配置时按单账号 env 路径处理。
# 群/总限流与总上限(可选,design/12 §6 策略三/四,公共平台防刷屏/最小化打扰):
# groupMsgPerMin 群聊每分钟上限(0=用默认6,-1=不限,正整数=该值)
# msgPerMin 非主人消息每分钟总上限(>0 生效,0/-1=不限)
# maxMessages 待处理上限,接近上限时由审查模型剔除低价值消息(0=用默认50,-1=不限,正整数=该值)
# 旧账号不配这些字段默认走「保护性默认/不限」,保持向后兼容。
# CHANNELS_JSON= # CHANNELS_JSON=
+13 -1
View File
@@ -110,13 +110,19 @@ type namedAdapter struct {
denyGroups []string denyGroups []string
allowUsers []string allowUsers []string
denyUsers []string denyUsers []string
// flow limits (design/12 §6 策略三/四). They are always surfaced through
// Config() so the bridge can build a per-account ingress gate; the bridge maps
// the zero default to the protective values and a negative value to "off".
groupMsgPerMin int
msgPerMin int
maxMessages int
} }
func (n *namedAdapter) Name() string { return n.name } func (n *namedAdapter) Name() string { return n.name }
func (n *namedAdapter) Config() map[string]any { func (n *namedAdapter) Config() map[string]any {
cfg := n.PlatformAdapter.Config() cfg := n.PlatformAdapter.Config()
out := make(map[string]any, len(cfg)+4) out := make(map[string]any, len(cfg)+7)
for k, v := range cfg { for k, v := range cfg {
out[k] = v out[k] = v
} }
@@ -124,6 +130,9 @@ func (n *namedAdapter) Config() map[string]any {
out["denyGroups"] = n.denyGroups out["denyGroups"] = n.denyGroups
out["allowUsers"] = n.allowUsers out["allowUsers"] = n.allowUsers
out["denyUsers"] = n.denyUsers out["denyUsers"] = n.denyUsers
out["groupMsgPerMin"] = n.groupMsgPerMin
out["msgPerMin"] = n.msgPerMin
out["maxMessages"] = n.maxMessages
return out return out
} }
@@ -182,6 +191,9 @@ func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.
denyGroups: acct.DenyGroups, denyGroups: acct.DenyGroups,
allowUsers: acct.AllowUsers, allowUsers: acct.AllowUsers,
denyUsers: acct.DenyUsers, denyUsers: acct.DenyUsers,
groupMsgPerMin: acct.GroupMsgPerMin,
msgPerMin: acct.MsgPerMin,
maxMessages: acct.MaxMessages,
}, nil }, nil
} }
+24 -5
View File
@@ -76,6 +76,7 @@ type app struct {
log *slog.Logger log *slog.Logger
db *sql.DB db *sql.DB
bus bus.Bus bus bus.Bus
modelAdapter model.Adapter
personas *personareg.Registry personas *personareg.Registry
caps *registry.Registry caps *registry.Registry
dispatcher *tool.Dispatcher dispatcher *tool.Dispatcher
@@ -263,6 +264,9 @@ func (a *app) wireRuntime() error {
a.log.Warn("llm adapter unavailable", "error", aerr) a.log.Warn("llm adapter unavailable", "error", aerr)
adapter = nil adapter = nil
} }
// Keep the model adapter for cross-function use (e.g. the channel-bridge
// review model that prunes low-value messages, design/12 §6 策略四).
a.modelAdapter = adapter
router := model.NewRouter(model.DefaultModelForProvider(a.cfg.LLM), router := model.NewRouter(model.DefaultModelForProvider(a.cfg.LLM),
model.WithVisionModel(a.cfg.LLM.VisionModel)) model.WithVisionModel(a.cfg.LLM.VisionModel))
@@ -484,11 +488,16 @@ func (a *app) wireChannels() error {
} }
bridge, berr := channelbridge.New(channelbridge.Options{ bridge, berr := channelbridge.New(channelbridge.Options{
Log: logger.WithSource(a.log, "services/channelbridge"), Log: logger.WithSource(a.log, "services/channelbridge"),
Loop: a.loop, Loop: a.loop,
Sessions: a.sessions, Sessions: a.sessions,
Owner: a.owner, Owner: a.owner,
History: a.history, History: a.history,
QuietStartHour: a.cfg.Background.QuietStartHour,
QuietEndHour: a.cfg.Background.QuietEndHour,
// Optional review model prunes low-value queued messages near the per-adapter
// cap (design/12 §6 策略四). Nil adapter → the gate falls back to FIFO evict.
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),
}) })
if berr != nil { if berr != nil {
return fmt.Errorf("channel bridge: %w", berr) return fmt.Errorf("channel bridge: %w", berr)
@@ -652,3 +661,13 @@ func resolveSQLitePath(cfg *config.Config, dataDir string) string {
} }
return p return p
} }
// reviewFor builds the channel-bridge review model (design/12 §6 策略四), or nil
// when no model adapter is available so the gate falls back to FIFO eviction — the
// reviewer is never invoked with a nil adapter (which would panic on .Chat).
func reviewFor(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
if llm == nil {
return nil
}
return channelbridge.NewLLMReviewer(llm, log)
}
+309
View File
@@ -0,0 +1,309 @@
package adapter
import (
"context"
"sync"
"time"
)
// Reviewer decides which buffered messages are worth keeping. It receives the
// current pending set (in arrival order) and returns the indices it wants to keep.
// Messages at a returned index survive; all others are pruned and audited.
//
// It is a pure noise/importance filter (design/12 §6 策略四). Implementations must
// never decide identity: who the sender is was already resolved structurally by the
// channel layer. A nil Reviewer makes the gate fall back to a plain FIFO cap.
type Reviewer func(ctx context.Context, msgs []UnifiedMessage) (keep []int, err error)
// GateVerdict reports how the gate classified one inbound message.
type GateVerdict struct {
// Proceed reports whether the message should be delivered downstream.
Proceed bool
// Reason is a short token describing a drop: "rate_limited", "review_pruned"
// or "capped". It is empty when Proceed is true.
Reason string
// DroppedMsg is the message that failed admission (nil when Proceed is true).
DroppedMsg *UnifiedMessage
// Pruned lists every message the gate dropped during this admission. On the
// review path it is the noise the reviewer rejected; on the cap path it is the
// oldest messages evicted to honor the total cap. Each is recorded to the
// OnPruned audit hook before being dropped, so nothing is lost silently.
Pruned []UnifiedMessage
}
// FlowGateOptions configures a per-adapter ingress gate. Every limit is
// independent and a zero value disables that limit, so an account that does not
// declare the new fields keeps its previous unlocked behavior (backward
// compatible, design/12 §6 实现要点).
type FlowGateOptions struct {
// GroupMsgPerMin caps group-chat inbound messages per minute (design/12 §6
// 策略三). Zero disables the group limit.
GroupMsgPerMin int
// MsgPerMin caps total inbound non-owner messages per minute. Zero disables it.
MsgPerMin int
// MaxMessages is the total pending-message cap. When the pending set nears it
// the Reviewer is invoked to prune noise; with no Reviewer the oldest messages
// are evicted FIFO. Zero disables the cap (design/12 §6 策略四).
MaxMessages int
// Window is the sliding window over which the per-minute limits are counted.
// Zero uses defaultRateWindow.
Window time.Duration
// Review is the optional review model. When nil the gate enforces the cap with
// FIFO eviction instead of model judgment.
Review Reviewer
// OnPruned is called once per pruned/dropped message with a short reason token
// ("review_pruned" or "capped") so the caller can record an audit trail.
// It may be nil (then the caller only sees Pruned on the verdict).
OnPruned func(UnifiedMessage, string)
// Now overrides the clock for tests. Nil uses time.Now.
Now func() time.Time
}
const (
// defaultRateWindow is the sliding window over which per-minute limits count.
defaultRateWindow = time.Minute
// defaultReviewPct is how full the pending set must be before the review model
// runs. Design/12 §6 策略四 says "maxMessages 的 80%": this is the cost guard
// that keeps review from firing on every message.
defaultReviewPct = 80
// FlowLimitDefault is the protective default of 6 group messages per minute
// (design/12 §6 策略三). It is applied when an account pins no group rate limit.
FlowLimitDefault = 6
// FlowCapDefault is the protective default total-cap of 50 pending messages
// (design/12 §6 策略四).
FlowCapDefault = 50
)
// FlowGate is a concurrency-safe, per-adapter ingress gate implementing the two
// measures from design/12 §6 策略三/四:
//
// - 策略三: a sliding-window rate limit on group chat (and, optionally, all
// non-owner messages). Over-limit messages are dropped (downgraded) rather
// than blocking a flood of messages from saturating the loop.
// - 策略四: a bounded pending-message set. When it nears its cap the Reviewer is
// invoked to prune low-value messages; with no Reviewer the oldest messages are
// evicted FIFO. Either way every dropped message is audited.
//
// Owner-private messages are not routed through the gate (the caller bypasses it),
// so the owner is never throttled or pruned. Identity is never examined here.
type FlowGate struct {
groupPerMin int
msgPerMin int
maxMessages int
reviewAt int
window time.Duration
review Reviewer
onPruned func(UnifiedMessage, string)
now func() time.Time
mu sync.Mutex
groupHits []time.Time
totalHits []time.Time
buffer []queuedMsg
lastReview time.Time
seq uint64
}
// queuedMsg wraps a buffer entry with a stable sequence so pruning decisions can
// be audited and the fate of the just-admitted message be tracked across index
// shifts (value equality on UnifiedMessage is unsafe because it holds slices).
type queuedMsg struct {
seq uint64
msg UnifiedMessage
}
// NewFlowGate builds a gate from opts. Options that are zero disable their limit,
// except Window which defaults to one minute when left zero.
func NewFlowGate(opts FlowGateOptions) *FlowGate {
g := &FlowGate{
groupPerMin: opts.GroupMsgPerMin,
msgPerMin: opts.MsgPerMin,
maxMessages: opts.MaxMessages,
window: opts.Window,
review: opts.Review,
onPruned: opts.OnPruned,
now: opts.Now,
}
if g.window <= 0 {
g.window = defaultRateWindow
}
if g.maxMessages > 0 {
g.reviewAt = g.maxMessages * defaultReviewPct / 100
if g.reviewAt < 1 {
g.reviewAt = 1
}
}
if g.now == nil {
g.now = time.Now
}
return g
}
// Admit applies the rate limit then the total-cap/review gate to one inbound
// message. It returns a verdict telling the caller whether to proceed; messages
// that the gate drops are returned (and audited) so nothing is lost silently.
func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
now := g.now()
g.mu.Lock()
defer g.mu.Unlock()
// 策略三: sliding-window rate limits. Group chat has its own cap; MsgPerMin is
// an optional total cap across all non-owner messages. Over-limit messages are
// dropped at the door (downgraded) instead of blocking.
if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup {
g.groupHits = g.trimHits(g.groupHits, now)
if len(g.groupHits) >= g.groupPerMin {
return g.drop("rate_limited", msg)
}
g.groupHits = append(g.groupHits, now)
}
if g.msgPerMin > 0 {
g.totalHits = g.trimHits(g.totalHits, now)
if len(g.totalHits) >= g.msgPerMin {
return g.drop("rate_limited", msg)
}
g.totalHits = append(g.totalHits, now)
}
if g.maxMessages <= 0 {
return GateVerdict{Proceed: true}
}
// 策略四: add to the pending set, then prune when it nears the cap.
g.seq++
seq := g.seq
g.buffer = append(g.buffer, queuedMsg{seq: seq, msg: msg})
var dropped []queuedMsg
if g.review != nil && len(g.buffer) >= g.reviewAt && !g.reviewRecently(now) {
keep, err := g.review(ctx, queueMessages(g.buffer))
if err == nil {
g.buffer, dropped = g.applyReview(g.buffer, keep)
g.lastReview = now
g.auditDropped(dropped, "review_pruned")
}
}
if len(g.buffer) > g.maxMessages {
n := len(g.buffer) - g.maxMessages
var evicted []queuedMsg
g.buffer, evicted = g.dropFront(g.buffer, n)
dropped = append(dropped, evicted...)
g.auditDropped(evicted, "capped")
}
if !g.containsSeq(seq) {
reason := "capped"
if containsSeq(dropped, seq) {
reason = "review_pruned"
}
v := GateVerdict{Proceed: false, Reason: reason, Pruned: queueMessages(dropped)}
c := msg
v.DroppedMsg = &c
return v
}
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}
}
// drop builds a refused verdict from a single message that never entered the
// buffer (rate-limited at the door), so the audit hook still sees it.
func (g *FlowGate) drop(reason string, msg UnifiedMessage) GateVerdict {
c := msg
v := GateVerdict{Proceed: false, Reason: reason, DroppedMsg: &c, Pruned: []UnifiedMessage{msg}}
g.auditMsg(msg, reason)
return v
}
// reviewRecently throttles review so it does not run on every message: it fires at
// most once per window, which is the cost guard for the review model.
func (g *FlowGate) reviewRecently(now time.Time) bool {
if g.lastReview.IsZero() {
return false
}
return now.Sub(g.lastReview) < g.window
}
// trimHits drops event timestamps older than the sliding window.
func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
cutoff := now.Add(-g.window)
keep := 0
for _, t := range hits {
if t.After(cutoff) {
hits[keep] = t
keep++
}
}
return hits[:keep]
}
// applyReview keeps only the entries at the returned indices (validated, de-duped,
// ascending) and returns the kept slice plus the dropped entries in original order.
func (g *FlowGate) applyReview(buffer []queuedMsg, keep []int) (kept, dropped []queuedMsg) {
seen := make(map[int]bool, len(keep))
for _, i := range keep {
if i >= 0 && i < len(buffer) {
seen[i] = true
}
}
out := make([]queuedMsg, 0, len(buffer))
drop := make([]queuedMsg, 0)
for i, entry := range buffer {
if seen[i] {
out = append(out, entry)
} else {
drop = append(drop, entry)
}
}
return out, drop
}
// dropFront removes n entries from the front (oldest) and returns the rest plus the
// dropped ones.
func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedMsg) {
if n >= len(buffer) {
return nil, append([]queuedMsg(nil), buffer...)
}
return buffer[n:], append([]queuedMsg(nil), buffer[:n]...)
}
// containsSeq reports whether an entry with the given sequence is still buffered.
func (g *FlowGate) containsSeq(seq uint64) bool {
for _, e := range g.buffer {
if e.seq == seq {
return true
}
}
return false
}
// containsSeq reports whether seq appears among the dropped entries.
func containsSeq(dropped []queuedMsg, seq uint64) bool {
for _, e := range dropped {
if e.seq == seq {
return true
}
}
return false
}
// auditDropped records every dropped entry to the OnPruned audit hook.
func (g *FlowGate) auditDropped(dropped []queuedMsg, reason string) {
for _, e := range dropped {
g.auditMsg(e.msg, reason)
}
}
// auditMsg records one dropped message to the OnPruned audit hook.
func (g *FlowGate) auditMsg(msg UnifiedMessage, reason string) {
if g.onPruned != nil {
g.onPruned(msg, reason)
}
}
// queueMessages extracts the payload messages of queued entries in order.
func queueMessages(queue []queuedMsg) []UnifiedMessage {
out := make([]UnifiedMessage, 0, len(queue))
for _, e := range queue {
out = append(out, e.msg)
}
return out
}
+253
View File
@@ -0,0 +1,253 @@
package adapter
import (
"context"
"testing"
"time"
)
// fixedClock returns a Now func pinned to a base time so tests can advance time by
// controlling the returned value.
func fixedClock(base time.Time) (func() time.Time, *time.Time) {
cur := base
return func() time.Time { return cur }, &cur
}
func groupMsg(i int) UnifiedMessage {
return UnifiedMessage{
Platform: "kook",
ChannelID: "-100",
ChannelType: ChannelGroup,
GroupID: "-100",
SenderID: "u" + itoa(i),
Content: "msg " + itoa(i),
ContentType: ContentText,
}
}
func directMsg(i int) UnifiedMessage {
return UnifiedMessage{
Platform: "telegram",
ChannelID: "100",
ChannelType: ChannelDirect,
SenderID: "u" + itoa(i),
Content: "dm " + itoa(i),
ContentType: ContentText,
}
}
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 TestFlowGate_AllZeroAllowsEverything(t *testing.T) {
t.Parallel()
g := NewFlowGate(FlowGateOptions{})
for i := 0; i < 20; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d dropped by zero-option gate: %+v", i, v)
}
}
}
func TestFlowGate_GroupRateLimitDowngradesOverLimit(t *testing.T) {
t.Parallel()
now, clock := fixedClock(time.Unix(1000, 0))
g := NewFlowGate(FlowGateOptions{
GroupMsgPerMin: 6,
Window: time.Minute,
Now: now,
})
// Six messages within the window are admitted; the seventh is downgraded.
for i := 0; i < 6; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d should be admitted within limit", i)
}
}
v := g.Admit(context.Background(), groupMsg(6))
if v.Proceed {
t.Fatal("7th group message in the window must be rate limited")
}
if v.Reason != "rate_limited" {
t.Fatalf("reason = %q, want rate_limited", v.Reason)
}
if v.DroppedMsg == nil || v.DroppedMsg.Content != "msg 6" {
t.Fatalf("dropped msg = %+v, want msg 6", v.DroppedMsg)
}
// Advancing past the window frees capacity again.
*clock = clock.Add(61 * time.Second)
if v := g.Admit(context.Background(), groupMsg(7)); !v.Proceed {
t.Fatalf("message after window roll should be admitted, got %+v", v)
}
}
func TestFlowGate_GroupRateLimitDoesNotTouchDirect(t *testing.T) {
t.Parallel()
g := NewFlowGate(FlowGateOptions{GroupMsgPerMin: 1, Window: time.Minute})
// Direct/private messages bypass the group rate limit.
for i := 0; i < 5; i++ {
if v := g.Admit(context.Background(), directMsg(i)); !v.Proceed {
t.Fatalf("direct message %d wrongly rate limited: %+v", i, v)
}
}
}
func TestFlowGate_MsgPerMinCapsAllNonOwner(t *testing.T) {
t.Parallel()
g := NewFlowGate(FlowGateOptions{MsgPerMin: 2, Window: time.Minute})
if v := g.Admit(context.Background(), directMsg(0)); !v.Proceed {
t.Fatal("first dm should pass")
}
if v := g.Admit(context.Background(), directMsg(1)); !v.Proceed {
t.Fatal("second dm should pass")
}
if v := g.Admit(context.Background(), directMsg(2)); v.Proceed {
t.Fatal("third dm should exceed the total per-minute cap")
}
}
func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
t.Parallel()
now, _ := fixedClock(time.Unix(1000, 0))
var audited []string
g := NewFlowGate(FlowGateOptions{
MaxMessages: 10,
Window: time.Minute,
Now: now,
Review: func(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
// Keep only the first two messages; drop the rest as noise.
return []int{0, 1}, nil
},
OnPruned: func(_ UnifiedMessage, reason string) {
audited = append(audited, reason)
},
})
// Seven messages stay below the 80% threshold (reviewAt = 8), no review fires.
for i := 0; i < 7; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d should be admitted before threshold: %+v", i, v)
}
}
if len(audited) != 0 {
t.Fatalf("review fired below threshold: %v", audited)
}
// The 8th message crosses the 80% threshold → review runs and drops msg2..7,
// including the just-admitted message 7.
v := g.Admit(context.Background(), groupMsg(7))
if v.Proceed {
t.Fatal("just-admitted message 7 must be pruned by the reviewer")
}
if v.Reason != "review_pruned" {
t.Fatalf("reason = %q, want review_pruned", v.Reason)
}
if len(v.Pruned) != 6 {
t.Fatalf("pruned = %d, want 6 (msg2..msg7)", len(v.Pruned))
}
if len(audited) != 6 {
t.Fatalf("audit count = %d, want 6", len(audited))
}
for _, r := range audited {
if r != "review_pruned" {
t.Fatalf("audit reason = %q, want review_pruned", r)
}
}
}
func TestFlowGate_ReviewThrottledWithinWindow(t *testing.T) {
t.Parallel()
now, clock := fixedClock(time.Unix(1000, 0))
reviews := 0
g := NewFlowGate(FlowGateOptions{
MaxMessages: 3,
Window: time.Minute,
Now: now,
Review: func(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
reviews++
return []int{0}, nil
},
})
// First threshold crossing triggers a review.
for i := 0; i < 3; i++ {
g.Admit(context.Background(), groupMsg(i))
}
if reviews != 1 {
t.Fatalf("reviews after crossing threshold = %d, want 1", reviews)
}
// Within the same window the gate must not re-review even when it refills.
for i := 3; i < 6; i++ {
g.Admit(context.Background(), groupMsg(i))
}
if reviews != 1 {
t.Fatalf("reviews within window = %d, want 1 (throttled)", reviews)
}
// A fresh window allows a review again.
*clock = clock.Add(2 * time.Minute)
for i := 6; i < 9; i++ {
g.Admit(context.Background(), groupMsg(i))
}
if reviews != 2 {
t.Fatalf("reviews after window roll = %d, want 2", reviews)
}
}
func TestFlowGate_FifoCapWhenNoReview(t *testing.T) {
t.Parallel()
var audited []string
g := NewFlowGate(FlowGateOptions{
MaxMessages: 3,
OnPruned: func(_ UnifiedMessage, reason string) {
audited = append(audited, reason)
},
})
for i := 0; i < 3; i++ {
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
t.Fatalf("message %d should be admitted at cap: %+v", i, v)
}
}
// The 4th evicts the oldest (msg0) to honor the cap; the new message is admitted.
v := g.Admit(context.Background(), groupMsg(3))
if !v.Proceed {
t.Fatalf("new message should be admitted after FIFO eviction, got %+v", v)
}
if len(v.Pruned) != 1 || v.Pruned[0].Content != "msg 0" {
t.Fatalf("pruned = %+v, want [msg 0]", v.Pruned)
}
if len(audited) != 1 || audited[0] != "capped" {
t.Fatalf("audit = %v, want [capped]", audited)
}
}
func TestFlowGate_Defaults(t *testing.T) {
t.Parallel()
if FlowLimitDefault != 6 {
t.Fatalf("FlowLimitDefault = %d, want 6", FlowLimitDefault)
}
if FlowCapDefault != 50 {
t.Fatalf("FlowCapDefault = %d, want 50", FlowCapDefault)
}
g := NewFlowGate(FlowGateOptions{})
if g.window != time.Minute {
t.Fatalf("default window = %v, want 1m", g.window)
}
if g.reviewAt != 0 || g.maxMessages != 0 {
t.Fatalf("zero-option gate should disable both cap and review, got reviewAt=%d max=%d", g.reviewAt, g.maxMessages)
}
}
+33
View File
@@ -53,6 +53,39 @@ func TestParseChannelAccounts_MultiAccountWithAllowDeny(t *testing.T) {
} }
} }
func TestParseChannelAccounts_FlowLimitFields(t *testing.T) {
t.Parallel()
raw := `{
"telegram": [
{"id":"conf0","config":{"bot_token":"t0"},"groupMsgPerMin":10,"maxMessages":80,"msgPerMin":30}
]
}`
got, err := ParseChannelAccounts(raw)
if err != nil {
t.Fatalf("ParseChannelAccounts: %v", err)
}
a := got["telegram"][0]
if a.GroupMsgPerMin != 10 || a.MsgPerMin != 30 || a.MaxMessages != 80 {
t.Fatalf("flow fields = group:%d msg:%d max:%d, want 10/30/80",
a.GroupMsgPerMin, a.MsgPerMin, a.MaxMessages)
}
}
func TestParseChannelAccounts_NoFlowFieldsDefaultsToZero(t *testing.T) {
t.Parallel()
raw := `{"telegram":[{"id":"conf0","config":{"bot_token":"t0"}}]}`
got, err := ParseChannelAccounts(raw)
if err != nil {
t.Fatalf("ParseChannelAccounts: %v", err)
}
a := got["telegram"][0]
// Absent fields stay zero; the bridge maps zero to the protective defaults
// (6 group/min, 50 max), so old accounts are backward compatible.
if a.GroupMsgPerMin != 0 || a.MsgPerMin != 0 || a.MaxMessages != 0 {
t.Fatalf("absent flow fields should be zero, got %+v", a)
}
}
func TestParseChannelAccounts_Malformed(t *testing.T) { func TestParseChannelAccounts_Malformed(t *testing.T) {
t.Parallel() t.Parallel()
if _, err := ParseChannelAccounts(`{`); err == nil { if _, err := ParseChannelAccounts(`{`); err == nil {
+19 -1
View File
@@ -104,11 +104,17 @@ type VoiceConfig struct {
DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"` DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"`
} }
// BackgroundConfig holds the autonomous thinking settings. // BackgroundConfig holds the autonomous thinking settings. QuietStartHour and
// QuietEndHour bound the do-not-disturb window in local clock hours; they are the
// single source shared by the backthink service and the channel bridge's
// minimal-disturbance quiet-hours gate (design/12 §6 策略五). Equal non-zero hours
// disable the window.
type BackgroundConfig struct { type BackgroundConfig struct {
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"` Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"300"` IntervalSec int `env:"THINK_INTERVAL_SEC" default:"300"`
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"` SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
QuietStartHour int `env:"QUIET_START_HOUR" default:"23"`
QuietEndHour int `env:"QUIET_END_HOUR" default:"7"`
} }
// WSHubConfig holds the WebSocket hub limits. // WSHubConfig holds the WebSocket hub limits.
@@ -193,6 +199,18 @@ type ChannelAccount struct {
// DenyUsers is a black list of user uids: a direct message from a user in this // DenyUsers is a black list of user uids: a direct message from a user in this
// list is dropped at ingress. // list is dropped at ingress.
DenyUsers []string `json:"denyUsers,omitempty"` DenyUsers []string `json:"denyUsers,omitempty"`
// GroupMsgPerMin caps group-chat inbound messages per minute for this account
// (design/12 §6 策略三). Zero follows the protective default (6); a negative
// value disables the group rate limit; a positive value pins it.
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
// MsgPerMin caps total inbound non-owner messages per minute for this account.
// Zero or negative disables it.
MsgPerMin int `json:"msgPerMin,omitempty"`
// MaxMessages is the total pending/active message cap for this account
// (design/12 §6 策略四). Zero follows the protective default (50); a negative
// value disables the cap; a positive value pins it. When the pending set nears
// this cap the review model prunes low-value messages.
MaxMessages int `json:"maxMessages,omitempty"`
} }
// Load reads configuration, merging an optional .env file (development only) // Load reads configuration, merging an optional .env file (development only)
+37 -5
View File
@@ -104,10 +104,14 @@ type Config struct {
} }
// ConfigFromBackground maps a config.BackgroundConfig (the source of the // ConfigFromBackground maps a config.BackgroundConfig (the source of the
// heartbeat interval) into a backthink.Config, leaving the push/decide knobs at // heartbeat interval and the shared quiet-hours window) into a backthink.Config,
// their defaults for normalize to fill in. // leaving the push/decide knobs at their defaults for normalize to fill in.
func ConfigFromBackground(bg config.BackgroundConfig) Config { 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 // 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 // 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 { 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 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 // tickLoop is the heartbeat goroutine. It fires one thinking pass per tick and
// exits when ctx is cancelled. // exits when ctx is cancelled.
func (b *Backthink) tickLoop(ctx context.Context) { 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) { func TestBackthink_List_NewestFirst(t *testing.T) {
t.Parallel() t.Parallel()
thinker := &mockThinker{text: "nothing to report"} thinker := &mockThinker{text: "nothing to report"}
+19 -1
View File
@@ -17,6 +17,17 @@ const (
SubjectSystemEvent = "system.event" 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 // Trigger describes what woke a thinking pass: the source subsystem, a
// human-readable summary and the event's estimated importance. // human-readable summary and the event's estimated importance.
type Trigger struct { type Trigger struct {
@@ -48,7 +59,14 @@ func triggerForEvent(e bus.Event) Trigger {
case SubjectReminderTrigger: case SubjectReminderTrigger:
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceHigh} return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceHigh}
case SubjectSystemEvent: 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: default:
return Trigger{Source: e.Subject, Summary: e.Subject, Import: ImportanceNormal} 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 // 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 // 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. // 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) 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 // Bridge owns the channel adapter lifecycle and the inbound message pipeline. It
@@ -103,12 +126,24 @@ type Bridge struct {
history history.HistoryStore history history.HistoryStore
onPersisted func(history.Message) onPersisted func(history.Message)
onRejected func(adapter.UnifiedMessage, string) onRejected func(adapter.UnifiedMessage, string)
review adapter.Reviewer
light LightJudge
quietStartHour int
quietEndHour int
now func() time.Time
mu sync.RWMutex mu sync.RWMutex
adapters map[string]adapter.PlatformAdapter adapters map[string]adapter.PlatformAdapter
connected map[string]bool connected map[string]bool
closed 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 ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
wg sync.WaitGroup wg sync.WaitGroup
@@ -129,18 +164,34 @@ func New(opts Options) (*Bridge, error) {
if log == nil { if log == nil {
log = logger.New(logger.Options{Out: 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{ return &Bridge{
log: logger.WithSource(log, "services/channelbridge"), log: logger.WithSource(log, "services/channelbridge"),
run: opts.Loop, run: opts.Loop,
sessions: opts.Sessions, sessions: opts.Sessions,
owner: opts.Owner, owner: opts.Owner,
history: opts.History, history: opts.History,
onPersisted: opts.OnPersisted, onPersisted: opts.OnPersisted,
onRejected: opts.OnRejected, onRejected: opts.OnRejected,
adapters: make(map[string]adapter.PlatformAdapter), review: opts.Review,
connected: make(map[string]bool), light: opts.Light,
cancel: func() {}, quietStartHour: qStart,
sem: make(chan struct{}, maxConcurrent), 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 }, nil
} }
@@ -341,6 +392,23 @@ func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a ad
return 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 { select {
case b.sem <- struct{}{}: case b.sem <- struct{}{}:
case <-ctx.Done(): 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, "session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable) "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 // 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 // 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 // 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 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 { func directMessage() adapter.UnifiedMessage {
return adapter.UnifiedMessage{ return adapter.UnifiedMessage{
Platform: "telegram", Platform: "telegram",
ChannelID: "100", ChannelID: "100",
ChannelType: adapter.ChannelDirect, ChannelType: adapter.ChannelDirect,
SenderID: "100", SenderID: "100",
Sender: adapter.Actor{Role: adapter.RoleOwner},
Content: "hello", Content: "hello",
ContentType: adapter.ContentText, ContentType: adapter.ContentText,
} }
@@ -180,6 +185,8 @@ func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
t.Parallel() t.Parallel()
b, chat, _, ad := newTestBridge(t) 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{ msg := adapter.UnifiedMessage{
Platform: "telegram", Platform: "telegram",
ChannelID: "-20", ChannelID: "-20",
@@ -187,6 +194,8 @@ func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
GroupID: "-20", GroupID: "-20",
GroupName: "Gang", GroupName: "Gang",
SenderID: "9", SenderID: "9",
Sender: adapter.Actor{Role: adapter.RoleMember},
Mentions: []adapter.Mention{{UserID: "9"}},
Content: "hello all", Content: "hello all",
ContentType: adapter.ContentText, ContentType: adapter.ContentText,
} }
@@ -351,10 +360,13 @@ func TestGroupMessageNotPersistedToMainSession(t *testing.T) {
t.Fatalf("New: %v", err) t.Fatalf("New: %v", err)
} }
// Addressed (mentioned) group message → responds (design/12 §6 策略二), still
// isolated to the group session.
msg := adapter.UnifiedMessage{ msg := adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: "9", Content: "hello all", GroupID: "-20", GroupName: "Gang", SenderID: "9",
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) b.process(context.Background(), msg)
@@ -463,11 +475,13 @@ func TestGroupMessageThirdPartySenderNameAndBoundary(t *testing.T) {
t.Fatalf("New: %v", err) 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{ msg := adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三", GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
Sender: adapter.Actor{Role: adapter.RoleMember}, Content: "hello all", Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: []adapter.Mention{{UserID: "9"}},
ContentType: adapter.ContentText, Content: "hello all", ContentType: adapter.ContentText,
} }
b.process(context.Background(), msg) 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()))
}
}