feat(channels): 陌生人私聊正常聊天(跳概率门)+ 强制流控 6条/分与回合槽位 + 安静时段不再硬静默 + 受限车道边界行前置
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 陌生人私聊:不再走「值不值得回」的概率门,唯一闸门 = 每发送者 6 条/分滑窗 + 同会话 1 个在跑回合
- 被限流只登记不回,且留带 source 的可见告警(不静默)
- 安静时段在回复侧只作信号(规则层不再静默),群聊取消夜间静默
- 锁定流控常量:FlowDirectDefault 加字面量钉桩测试(放大它 = 静默关流控,且会让额度循环跑百万轮)
This commit is contained in:
AskaEth committed 2026-10-05 13:26:15 +08:00
1 parent c6f5c3fea0
commit 7ae4298871
10 files changed
+633 -97

No files matched your search

+85 -4
View File
@@ -44,6 +44,13 @@ type FlowGateOptions struct {
// GroupMsgPerMin caps group-chat inbound messages per minute **per // GroupMsgPerMin caps group-chat inbound messages per minute **per
// conversation** (design/12 §6 策略三 / 裁决 06.4.3). Zero disables the limit. // conversation** (design/12 §6 策略三 / 裁决 06.4.3). Zero disables the limit.
GroupMsgPerMin int GroupMsgPerMin int
// DirectMsgPerMin caps **direct (private)** inbound messages per minute
// **per conversation** — and for a private message the conversation key is
// (platform, channel, sender), so the limit throttles one sender rather than
// the whole account. It is the mandatory flow control for stranger private
// chats (主人 2026-10-05 裁决③): strangers chat normally, so a sender must be
// unable to flood her. Zero disables the limit.
DirectMsgPerMin int
// MsgPerMin caps total inbound non-owner messages per minute across the whole // MsgPerMin caps total inbound non-owner messages per minute across the whole
// account — the anti-flood total guard. Zero disables it. // account — the anti-flood total guard. Zero disables it.
MsgPerMin int MsgPerMin int
@@ -76,6 +83,12 @@ const (
// FlowLimitDefault is the protective default of 6 group messages per minute // 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. // (design/12 §6 策略三). It is applied when an account pins no group rate limit.
FlowLimitDefault = 6 FlowLimitDefault = 6
// FlowDirectDefault is the protective default of 6 direct (private) messages
// per minute **per sender** — the mandatory flow control for stranger private
// chats (主人 2026-10-05 裁决③). 6/分钟与群聊保护性默认同一口径:一个人正常
// 一来一回打字到不了 6 条/分钟,而刷屏/机器人会在第 7 条被挡下。它不随
// groupMsgPerMin 变化(陌生人流控不允许被「没配那项」静默关掉)。
FlowDirectDefault = 6
// FlowCapDefault is the protective default total-cap of 50 pending messages // FlowCapDefault is the protective default total-cap of 50 pending messages
// (design/12 §6 策略四). // (design/12 §6 策略四).
FlowCapDefault = 50 FlowCapDefault = 50
@@ -113,16 +126,22 @@ func ConversationKey(msg UnifiedMessage) string {
// 每个会话保存一份,因此两个群并发互不挤占(裁决 06.4.3)。 // 每个会话保存一份,因此两个群并发互不挤占(裁决 06.4.3)。
type flowSession struct { type flowSession struct {
groupHits []time.Time groupHits []time.Time
directHits []time.Time
buffer []queuedMsg buffer []queuedMsg
lastReview time.Time lastReview time.Time
// inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道
// 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。
inFlight int
} }
// FlowGate is a concurrency-safe, per-account ingress gate implementing the two // FlowGate is a concurrency-safe, per-account ingress gate implementing the two
// measures from design/12 §6 策略三/四: // measures from design/12 §6 策略三/四:
// //
// - 策略三: a sliding-window rate limit on group chat (and, optionally, all // - 策略三: a sliding-window rate limit on group chat (and, optionally, all
// non-owner messages). Over-limit messages are dropped (downgraded) rather // non-owner messages), plus the mandatory **per-sender** rate limit on direct
// than blocking a flood of messages from saturating the loop. // (private) messages that backs the stranger private-chat flow control
// (AllowDirect). 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 // - 策略四: 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 // invoked to prune low-value messages; with no Reviewer the oldest messages are
// evicted FIFO. Either way every dropped message is audited. // evicted FIFO. Either way every dropped message is audited.
@@ -131,8 +150,11 @@ type flowSession struct {
// //
// Owner-private messages are not routed through the gate (the caller bypasses it), // 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. // so the owner is never throttled or pruned. Identity is never examined here.
// The gate also carries the per-conversation running-turn slot (AcquireTurn /
// ReleaseTurn) used by the stranger private-chat flow control.
type FlowGate struct { type FlowGate struct {
groupPerMin int groupPerMin int
directPerMin int
msgPerMin int msgPerMin int
maxMessages int maxMessages int
reviewAt int reviewAt int
@@ -161,6 +183,7 @@ type queuedMsg struct {
func NewFlowGate(opts FlowGateOptions) *FlowGate { func NewFlowGate(opts FlowGateOptions) *FlowGate {
g := &FlowGate{ g := &FlowGate{
groupPerMin: opts.GroupMsgPerMin, groupPerMin: opts.GroupMsgPerMin,
directPerMin: opts.DirectMsgPerMin,
msgPerMin: opts.MsgPerMin, msgPerMin: opts.MsgPerMin,
maxMessages: opts.MaxMessages, maxMessages: opts.MaxMessages,
window: opts.Window, window: opts.Window,
@@ -258,6 +281,61 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)} return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}
} }
// AllowDirect enforces the per-sender sliding-window rate limit for a **direct
// (private)** message (主人 2026-10-05 裁决③:陌生人私聊=正常聊天,因此必须有流控).
// For a private message the conversation key is (platform, channel, sender), so
// the window throttles one sender instead of the whole account and two strangers
// never crowd each other out. It returns false when that sender is over
// DirectMsgPerMin; the refused message is recorded to the OnPruned audit hook so
// nothing is dropped silently. A zero limit or a group message always allows.
func (g *FlowGate) AllowDirect(msg UnifiedMessage) bool {
if g.directPerMin <= 0 || msg.ChannelType != ChannelDirect {
return true
}
now := g.now()
key := ConversationKey(msg)
g.mu.Lock()
defer g.mu.Unlock()
st := g.sessionLocked(key)
st.directHits = g.trimHits(st.directHits, now)
if len(st.directHits) >= g.directPerMin {
g.auditMsg(msg, "rate_limited")
return false
}
st.directHits = append(st.directHits, now)
return true
}
// AcquireTurn reserves the single running-turn slot of a conversation, returning
// false when a turn is already in flight for it. Callers must pair every true
// result with ReleaseTurn. It is the second half of the mandatory stranger flow
// control (主人 2026-10-05 裁决③:「同一发送者同时最多 1 个在跑的回合」): a stranger
// can send several messages inside the per-minute allowance, yet only one of them
// may occupy an expensive agent turn at a time. It never decides identity — the
// key is the same structural ConversationKey the rest of the gate uses.
func (g *FlowGate) AcquireTurn(msg UnifiedMessage) bool {
key := ConversationKey(msg)
g.mu.Lock()
defer g.mu.Unlock()
st := g.sessionLocked(key)
if st.inFlight >= 1 {
return false
}
st.inFlight++
return true
}
// ReleaseTurn releases a slot reserved by AcquireTurn.
func (g *FlowGate) ReleaseTurn(msg UnifiedMessage) {
key := ConversationKey(msg)
g.mu.Lock()
defer g.mu.Unlock()
st := g.sessionLocked(key)
if st.inFlight > 0 {
st.inFlight--
}
}
// drop builds a refused verdict from a single message that never entered the // 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. // buffer (rate-limited at the door), so the audit hook still sees it.
func (g *FlowGate) drop(reason string, msg UnifiedMessage) GateVerdict { func (g *FlowGate) drop(reason string, msg UnifiedMessage) GateVerdict {
@@ -281,7 +359,8 @@ func (g *FlowGate) sessionLocked(key string) *flowSession {
} }
// maybeGCLocked 每 sessionGCInterval 次放行清理一次空闲会话,避免会话状态表随历史会话 // maybeGCLocked 每 sessionGCInterval 次放行清理一次空闲会话,避免会话状态表随历史会话
// 无界增长:滑窗时间戳全部过期且待处理队列为空的会话直接删除。调用方须持有 g.mu。 // 无界增长:滑窗时间戳全部过期、待处理队列为空、且没有回合在跑的会话直接删除。
// 调用方须持有 g.mu。
func (g *FlowGate) maybeGCLocked(now time.Time) { func (g *FlowGate) maybeGCLocked(now time.Time) {
g.admitted++ g.admitted++
if g.admitted%sessionGCInterval != 0 { if g.admitted%sessionGCInterval != 0 {
@@ -289,7 +368,9 @@ func (g *FlowGate) maybeGCLocked(now time.Time) {
} }
for key, st := range g.sessions { for key, st := range g.sessions {
st.groupHits = g.trimHits(st.groupHits, now) st.groupHits = g.trimHits(st.groupHits, now)
if len(st.groupHits) == 0 && len(st.buffer) == 0 { st.directHits = g.trimHits(st.directHits, now)
if len(st.groupHits) == 0 && len(st.directHits) == 0 &&
len(st.buffer) == 0 && st.inFlight == 0 {
delete(g.sessions, key) delete(g.sessions, key)
} }
} }
@@ -0,0 +1,104 @@
package adapter
// 本文件覆盖主人 2026-10-05 裁决③的**陌生人私聊流控**两件套:
// - AllowDirect:私聊按发送者(会话键 platform+channel+sender)的每分钟滑窗;
// - AcquireTurn/ReleaseTurn:同一发送者同时最多 1 个在跑的回合。
// 两者都复用同一张 flowSession 表与同一套 ConversationKey 口径,不另造第二套限流。
import (
"testing"
"time"
)
// TestFlowGate_AllowDirectIsPerSender 钉住私聊滑窗按发送者独立计数:
// A 用尽额度不挤占 B;窗口滚过后 A 恢复;群聊消息不经这条通道。
func TestFlowGate_AllowDirectIsPerSender(t *testing.T) {
t.Parallel()
now, clock := fixedClock(time.Unix(2000, 0))
var audited []string
g := NewFlowGate(FlowGateOptions{
DirectMsgPerMin: 2,
Window: time.Minute,
Now: now,
OnPruned: func(_ UnifiedMessage, reason string) { audited = append(audited, reason) },
})
for i := 0; i < 2; i++ {
if !g.AllowDirect(directMsgTo("peer-1", i)) {
t.Fatalf("peer-1 第 %d 条被拒,want allowed", i+1)
}
}
if g.AllowDirect(directMsgTo("peer-1", 2)) {
t.Fatal("peer-1 超出自身额度仍被放行")
}
if len(audited) != 1 || audited[0] != "rate_limited" {
t.Fatalf("拒绝未入审计:%v", audited)
}
if !g.AllowDirect(directMsgTo("peer-2", 0)) {
t.Fatal("peer-2 被 peer-1 的额度挤占(私聊限流未按发送者独立)")
}
// 群聊走 Admit 的群聊滑窗,AllowDirect 对它恒放行。
if !g.AllowDirect(groupMsgIn("-100", 0)) {
t.Fatal("群聊消息不该被私聊限流拦下")
}
*clock = clock.Add(61 * time.Second)
if !g.AllowDirect(directMsgTo("peer-1", 3)) {
t.Fatal("窗口滚过后 peer-1 应恢复放行")
}
}
// TestFlowGate_AllowDirectDisabledByZero 钉住零值 = 关闭:没有配置私聊限流时
// (DirectMsgPerMin 为 0)AllowDirect 恒放行,既有调用行为不变。
func TestFlowGate_AllowDirectDisabledByZero(t *testing.T) {
t.Parallel()
g := NewFlowGate(FlowGateOptions{})
for i := 0; i < 50; i++ {
if !g.AllowDirect(directMsgTo("peer-1", i)) {
t.Fatalf("零值额度下第 %d 条被拒,want allowed", i+1)
}
}
}
// TestFlowDirectDefaultIsPinned 用**字面量 6** 钉住保护性默认值(与 FlowLimitDefault
// 的既有钉桩同口径)。不这么钉的后果:`stranger_test.go` 的额度循环以该常量为上界,
// 一旦有人把它改成近似无限,测试不会断言失败,而是**跑一百万轮把整包测试拖到超时**
// ——看起来像「偶发卡死」,实际是流控被静默关掉。主人 2026-10-05 裁决③要求陌生人
// 私聊**必须有**流控,因此这条默认值属于契约,不允许随手放大。
func TestFlowDirectDefaultIsPinned(t *testing.T) {
t.Parallel()
if FlowDirectDefault != 6 {
t.Fatalf("FlowDirectDefault = %d, want 6(每发送者每分钟 6 条的保护性默认;"+
"放大它等于静默关闭陌生人私聊流控)", FlowDirectDefault)
}
}
// TestFlowGate_AcquireTurnSinglePerConversation 钉住「同一会话同时最多 1 个在跑的回合」:
// 第一个占位成功、第二个被拒;换一个发送者互不影响;释放后又能占。
func TestFlowGate_AcquireTurnSinglePerConversation(t *testing.T) {
t.Parallel()
g := NewFlowGate(FlowGateOptions{})
a := directMsgTo("peer-1", 0)
b := directMsgTo("peer-2", 0)
if !g.AcquireTurn(a) {
t.Fatal("首个回合应能占到槽位")
}
if g.AcquireTurn(a) {
t.Fatal("同一发送者的第二个回合不该同时占到槽位")
}
if !g.AcquireTurn(b) {
t.Fatal("另一个发送者的槽位被挤占")
}
g.ReleaseTurn(a)
if !g.AcquireTurn(a) {
t.Fatal("释放后应能重新占到槽位")
}
g.ReleaseTurn(a)
g.ReleaseTurn(b)
}
+12 -1
View File
@@ -19,6 +19,11 @@ import (
// (adapter.ConversationKey:platform+channelID+groupID/peerID),因此两个群绝不会 // (adapter.ConversationKey:platform+channelID+groupID/peerID),因此两个群绝不会
// 相互挤占(design/12 §6 策略三/四,裁决 06.4.3)。 // 相互挤占(design/12 §6 策略三/四,裁决 06.4.3)。
func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate { func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate {
if a == nil {
// 便捷路径(process 用平台名找适配器)在平台上未注册任何账号时给出 nil:
// 没有账号就没有闸门,调用方按 nil 跳过(生产路径的 a 恒非 nil)。
return nil
}
name := a.Name() name := a.Name()
b.gateMu.Lock() b.gateMu.Lock()
defer b.gateMu.Unlock() defer b.gateMu.Unlock()
@@ -31,15 +36,21 @@ func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate {
} }
// gateOptions 把适配器 Config 快照中的按账号流控限制解析进 // gateOptions 把适配器 Config 快照中的按账号流控限制解析进
// FlowGate。键缺失(单账号环境变量适配器)时限制关闭, // FlowGate。群聊/总量/上限键缺失(单账号环境变量适配器)时限制关闭,
// 既有渠道行为不变;键存在且值为 0 时采用保护性默认值 // 既有渠道行为不变;键存在且值为 0 时采用保护性默认值
// (每群每分钟 6 条、上限 50),负值则关闭限制—— // (每群每分钟 6 条、上限 50),负值则关闭限制——
// 这是对 CHANNELS_JSON 账号的向后兼容约定(design/12 §6 实现要点)。 // 这是对 CHANNELS_JSON 账号的向后兼容约定(design/12 §6 实现要点)。
//
// **私聊(陌生人)限流例外:它不依赖任何配置键**(主人 2026-10-05 裁决③),
// 恒定取保护性默认 FlowDirectDefault —— 原本没有任何配置的账号(如 qq-obv11)
// 对陌生人私聊是完全敞口,而裁决要求「陌生人必须有流控」,所以这里不能
// 因为「没配」而静默关掉。主人私聊在 onMessage 就被绕过本闸门,不受影响。
func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions { func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions {
opts := adapter.FlowGateOptions{ opts := adapter.FlowGateOptions{
Review: b.review, Review: b.review,
OnPruned: b.onRejected, OnPruned: b.onRejected,
Now: b.now, Now: b.now,
DirectMsgPerMin: adapter.FlowDirectDefault,
} }
cfg := a.Config() cfg := a.Config()
if grp, ok := cfgInt(cfg, "groupMsgPerMin"); ok { if grp, ok := cfgInt(cfg, "groupMsgPerMin"); ok {
@@ -104,7 +104,8 @@ func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) {
// TestFlowGate_GateOptionsDefaults 钉住「配置→限制」的解析规则, // TestFlowGate_GateOptionsDefaults 钉住「配置→限制」的解析规则,
// 它保证旧的 CHANNELS_JSON 账号向后兼容:键缺失 → 关闭,0 → 保护性 // 它保证旧的 CHANNELS_JSON 账号向后兼容:键缺失 → 关闭,0 → 保护性
// 默认值,负值 → 关闭,正值 → 固定为该值。 // 默认值,负值 → 关闭,正值 → 固定为该值。**私聊(陌生人)限流是例外**:
// 它不读配置键,恒取保护性默认(裁决③:陌生人必须有流控)。
func TestFlowGate_GateOptionsDefaults(t *testing.T) { func TestFlowGate_GateOptionsDefaults(t *testing.T) {
t.Parallel() t.Parallel()
@@ -135,6 +136,12 @@ func TestFlowGate_GateOptionsDefaults(t *testing.T) {
if opts.MaxMessages != c.max { if opts.MaxMessages != c.max {
t.Fatalf("MaxMessages = %d, want %d", opts.MaxMessages, c.max) t.Fatalf("MaxMessages = %d, want %d", opts.MaxMessages, c.max)
} }
// 陌生人私聊的强制流控不随账号配置开关:无论群聊/上限怎么配,
// 私聊限流恒为保护性默认(主人 2026-10-05 裁决③)。
if opts.DirectMsgPerMin != adapter.FlowDirectDefault {
t.Fatalf("DirectMsgPerMin = %d, want the protective default %d",
opts.DirectMsgPerMin, adapter.FlowDirectDefault)
}
}) })
} }
} }
+24 -7
View File
@@ -181,17 +181,18 @@ 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 策略一/二/五:面向非主人消息的最小打扰闸门。 // design/12 §6 策略一/二:面向非主人消息的最小打扰闸门。
// 被廉价轻量判定认为不值得回复的非主人群聊/陌生人 // 被廉价轻量判定认为不值得回复的非主人**群聊**消息,只登记到
// 消息,只登记到其隔离会话日志并静默——绝不 // 其隔离会话日志并静默——绝不驱动完整 agent 循环;**陌生人私聊**则
// 驱动完整 agent 循环。主人消息绝不在此被拦,因此 // 一律走完整回合(裁决①,见 shouldRespond),只受流控约束。
// 主人私聊与主人在群里的 @/高价值回复不受影响。 // 主人消息绝不在此被拦,因此主人私聊与主人在群里的
// @/高价值回复不受影响。
// //
// 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一 // 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一
// 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向 // 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向
// 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。 // 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。
if !actor.IsOwner() && !notice.Applicable { if !actor.IsOwner() && !notice.Applicable {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !ok { if ok, reason := b.shouldRespond(ctx, msg, actor, sess, a); !ok {
b.registerSilenced(ctx, msg, actor, sess, a) b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Debug("non-owner message silenced (minimal disturbance)", b.log.Debug("non-owner message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID, "platform", msg.Platform, "channel", msg.ChannelID,
@@ -200,6 +201,21 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
} }
} }
// 裁决①的第二个流控(主人 2026-10-05):陌生人私聊虽然一律回答,但
// **同一发送者同时最多 1 个在跑的回合**。槽位复用同一个 flowgate 的
// 会话计数(AcquireTurn/ReleaseTurn,键口径与限流一致);已有回合在跑时
// 这一条只登记、不回,并留可见告警 —— 一个陌生人无法用并发消息占满循环。
if isStrangerPrivate(sess) {
if gate := b.gateFor(a); gate != nil {
if !gate.AcquireTurn(msg) {
b.registerThrottled(ctx, msg, actor, sess, a,
"stranger private: a turn is already running for this sender")
return
}
defer gate.ReleaseTurn(msg)
}
}
b.runInboundTurn(ctx, msg, a, text, actor, sess) b.runInboundTurn(ctx, msg, a, text, actor, sess)
} }
@@ -397,7 +413,8 @@ func deviceIDFor(msg adapter.UnifiedMessage) string {
// RoleUser 消息**上 —— 用户内容只进用户角色消息是项目红线。 // RoleUser 消息**上 —— 用户内容只进用户角色消息是项目红线。
// //
// 历史转录**不在本函数里**:它由 seedTurnTranscript(transcript.go)在本函数返回之后 // 历史转录**不在本函数里**:它由 seedTurnTranscript(transcript.go)在本函数返回之后
// 前置到列表最前面,与网关编排器的播种同序(转录在前、本次消息在后)。因此本函数只负责 // 插入到列表中(主人私聊前置在最前;受限车道插在**最前的系统框架行之后**,
// 于是来源行 + 第三方边界仍在最前、转录居中、当次消息在最后)。因此本函数只负责
// **本回合自己的**消息(来源行 + 当次用户消息),受限车道的形状在有无转录时保持一致。 // **本回合自己的**消息(来源行 + 当次用户消息),受限车道的形状在有无转录时保持一致。
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message { func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
// design/12 §6 策略一补充:经传入前审查放行的系统通知走「限定注入」——只把剥离后的 // design/12 §6 策略一补充:经传入前审查放行的系统通知走「限定注入」——只把剥离后的
+65 -33
View File
@@ -11,15 +11,17 @@ import (
) )
// 本文件承载渠道桥接器面向公开平台的入站「最小打扰」分级 // 本文件承载渠道桥接器面向公开平台的入站「最小打扰」分级
// (DevDocs/design/12 §6 策略一/二/五)。非主人的群聊/陌生人 // (DevDocs/design/12 §6 策略一/二)。非主人的**群聊**消息在进入完整
// 消息在进入完整 agent 循环前先经廉价判断,使繁忙的 // agent 循环前先经廉价判断,使繁忙的公开群不会把每条消息都扔进
// 公开群不会把每条消息都扔进昂贵的 LLM 轮次。这些逻辑 // 昂贵的 LLM 轮次;**陌生人私聊**则一律正常聊天(主人 2026-10-05 裁决①),
// 放在这里是为了让 channelbridge.go 保持在模块体积上限内;它绝不 // 只受强制流控约束(裁决③)。这些逻辑放在这里是为了让 channelbridge.go
// 读取消息内容来判定身份——身份早已由 // 保持在模块体积上限内;它绝不读取消息内容来判定身份——身份早已由
// 会话/actor 层结构化解析完毕(项目红线)。 // 会话/actor 层结构化解析完毕(项目红线)。
// 安静时段默认值,与 backthink 服务保持一致,使桥接器与 // 安静时段默认值,与 backthink 服务保持一致,使桥接器与
// 后台思考器共用同一个免打扰窗口(design/12 §6 策略五)。 // 后台思考器共用同一个免打扰窗口(design/12 §6 策略五)。它在本车道里
// **只作为信号**随 inQuiet 传给参与度引擎/轻量判定器,不再让规则层静默
// (主人 2026-10-05 裁决②)。
const ( const (
defaultQuietStartHour = 23 defaultQuietStartHour = 23
defaultQuietEndHour = 7 defaultQuietEndHour = 7
@@ -32,11 +34,10 @@ type lightVerdict int
const ( const (
// verdictRespond 表示规则信号显示该消息值得回复。 // verdictRespond 表示规则信号显示该消息值得回复。
verdictRespond lightVerdict = iota verdictRespond lightVerdict = iota
// verdictSilent 表示规则信号显示该消息应登记但不应 // verdictInconclusive 表示规则无法判断;交给参与度引擎(design/18)
// 回复(例如安静时段内没有强信号)。 // 或注入式 LightJudge(未装配时则失败关闭为静默)。安静时段**不再**
verdictSilent // 单独构成静默(主人 2026-10-05 裁决②)——它只作为信号随 inQuiet
// verdictInconclusive 表示规则无法判断;交给 LightJudge(未装配 // 传给下游判定器,让轻思考/参与度自己权衡要不要回。
// 时则失败关闭为静默)。
verdictInconclusive verdictInconclusive
) )
@@ -130,12 +131,14 @@ func hasHighValueKeyword(text string) bool {
} }
// ruleLightJudge 只依据廉价规则信号判断是否值得回复 // ruleLightJudge 只依据廉价规则信号判断是否值得回复
// (设计/12 §6 策略二/五)。优先级:直接点名、提及主人的显示 // (设计/12 §6 策略二)。优先级:直接点名、提及主人的显示
// 名、高价值关键词,其次是安静时段偏向静默;否则 // 名、高价值关键词;除此之外一律无结论(交给参与度引擎 /
// 结论为无结论(交给注入的 LightJudge,没有则静默)。它 // 注入的 LightJudge,没有则失败关闭为静默)。**安静时段不是
// 只读取已解析的 actor 元数据与消息内容——绝不推断 // 这里的静默理由**(主人 2026-10-05 裁决②:安静时段由轻思考判断
// 身份。 // 是否回复,不是硬性全静默)——该信号由调用方随 inQuiet 传给
func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor, inQuiet bool) lightVerdict { // 下游判定器。它只读取已解析的消息信号(提及/正文)——绝不
// 推断身份(身份由 actor 元数据在会话层解析,红线)。
func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage) lightVerdict {
if len(msg.Mentions) > 0 { if len(msg.Mentions) > 0 {
return verdictRespond return verdictRespond
} }
@@ -146,20 +149,26 @@ func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor,
if hasHighValueKeyword(msg.Content) { if hasHighValueKeyword(msg.Content) {
return verdictRespond return verdictRespond
} }
if inQuiet {
return verdictSilent
}
return verdictInconclusive return verdictInconclusive
} }
// isStrangerPrivate 报告该会话是否为**陌生人私聊**(非群 + 非主人)。
// 它只读会话层结构化解析出的标志(项目红线:身份不靠正文/提示词)。
// 主人私聊在 onMessage/shouldRespond 之前就已被 actor 门拦下,因此
// 这里成真的只会是陌生人私聊。
func isStrangerPrivate(sess session.Session) bool {
return !sess.IsGroup && !sess.Owner
}
// shouldRespond 实现非主人入站消息的最小打扰分级 // shouldRespond 实现非主人入站消息的最小打扰分级
// (设计/12 §6 策略一/二/五 + design/18 §2/§4/§5)。它返回该消息 // (设计/12 §6 策略一/二 + design/18 §2/§4/§5)。它返回该消息
// 应驱动完整 agent 循环并回复,还是只登记并静默。 // 应驱动完整 agent 循环并回复,还是只登记并静默。
// 主人消息绝不会到这里(processFrom 以 !actor.IsOwner 为门), // 主人消息绝不会到这里(processFrom 以 !actor.IsOwner 为门),
// 因此主人私聊与主人在群里的 @/高价值回复不受影响。sess 是 // 因此主人私聊与主人在群里的 @/高价值回复不受影响。sess 是
// 该消息解析出的隔离群聊/陌生人会话绑定,使参与度 // 该消息解析出的隔离群聊/陌生人会话绑定,使参与度
// 引擎探测时能读到群的近期上下文。 // 引擎探测时能读到群的近期上下文;a 是收到该消息的账号适配器,
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session) (bool, string) { // 用于取该账号的流控闸门(陌生人私聊的强制限流)。
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) (bool, string) {
// 策略一:平台/系统噪声 → 只登记,绝不进完整循环。 // 策略一:平台/系统噪声 → 只登记,绝不进完整循环。
if isLowValueEvent(actor) { if isLowValueEvent(actor) {
b.log.Debug("non-owner low-value system event, registered only", b.log.Debug("non-owner low-value system event, registered only",
@@ -167,18 +176,29 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage,
return false, "low-value system event; registered only" return false, "low-value system event; registered only"
} }
// 策略二/五:基于规则的轻量判定,感知安静时段。直接 @、 // 裁决①(主人 2026-10-05):**陌生人私聊 = 正常聊天** —— 不设安静期、
// 提及主人显示名或高价值关键词一律回复;安静时段 // 不走「值不值得回」的概率门,直接进入完整回合。唯一的闸门是强制
// 偏向静默。只有无结论的剩余部分才咨询 // 流控:同一发送者每分钟 ≤ FlowDirectDefault 条(AllowDirect 记账)
// 参与度引擎。 // 与同时最多 1 个在跑的回合(processFrom 用 AcquireTurn 施加)。
// 被限流时**不回**:调用方会把它登记进隔离会话,这里留一条带
// source 的可见告警(绝不静默)。
if isStrangerPrivate(sess) {
if g := b.gateFor(a); g != nil && !g.AllowDirect(msg) {
b.log.Warn("stranger private chat rate limited, registered only",
"platform", msg.Platform, "channel", msg.ChannelID, "sender", msg.SenderID,
"limit_per_min", adapter.FlowDirectDefault)
return false, "stranger private: over the per-sender flow limit"
}
return true, "stranger private chat: normal reply"
}
// 策略二/五:基于规则的轻量判定。直接 @、提及主人显示名或
// 高价值关键词一律直接回;规则得不出结论的部分交给参与度
// 引擎 / 轻量判定器。**安静时段只作为信号**(inQuiet)继续往下传,
// 规则层不再因为它静默(裁决②)。
inQuiet := b.inQuietHours(b.now()) inQuiet := b.inQuietHours(b.now())
switch b.ruleLightJudge(msg, actor, inQuiet) { if b.ruleLightJudge(msg) == verdictRespond {
case verdictRespond:
return true, "rule signal: worth responding" 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"
} }
// 廉价规则得不出结论。参与度引擎(design/18)已配置时 // 廉价规则得不出结论。参与度引擎(design/18)已配置时
@@ -245,6 +265,18 @@ func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessag
"platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID) "platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID)
} }
// registerThrottled 是一次被强制流控(同一发送者已有回合在跑)拦下的
// 非主人消息的落点:先走最小打扰的登记路径(registerSilenced)把它写进
// 其隔离会话,再留一条**带 source 的可见告警** —— 被限流绝不静默
// (主人 2026-10-05 裁决③)。限流路径与普通静默因此区分得开:前者
// 一定有可见日志,后者只有 debug。
func (b *Bridge) registerThrottled(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter, reason string) {
b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Warn("inbound message throttled by flow gate, registered only",
"platform", msg.Platform, "channel", msg.ChannelID,
"sender", msg.SenderID, "reason", reason)
}
// inQuietHours 报告 at 是否落在配置的免打扰窗口内 // inQuietHours 报告 at 是否落在配置的免打扰窗口内
// (与 backthink 语义一致:两个非零且相等的时刻关闭该窗口, // (与 backthink 语义一致:两个非零且相等的时刻关闭该窗口,
// 跨午夜的窗口也能正确处理)。它是 Bridge 上的纯方法,测试 // 跨午夜的窗口也能正确处理)。它是 Bridge 上的纯方法,测试
+75 -33
View File
@@ -14,15 +14,18 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/user" "git.yeij.top/AskaEth/Cyrene_v2/internal/user"
) )
// stubLightJudge 是可配置的 LightJudge,记录自己被咨询了多少次。 // stubLightJudge 是可配置的 LightJudge,记录自己被咨询了多少次,
// 并保留最后一次收到的输入(用于断言 inQuiet 真值确实传到了下游判定器)。
type stubLightJudge struct { type stubLightJudge struct {
resp LightDecision resp LightDecision
err error err error
calls int calls int
last LightInput
} }
func (s *stubLightJudge) Judge(context.Context, LightInput) (LightDecision, error) { func (s *stubLightJudge) Judge(_ context.Context, in LightInput) (LightDecision, error) {
s.calls++ s.calls++
s.last = in
return s.resp, s.err return s.resp, s.err
} }
@@ -127,40 +130,19 @@ func TestNonOwnerHighValueKeywordResponds(t *testing.T) {
} }
} }
func TestNonOwnerQuietHoursSilenced(t *testing.T) { // TestNonOwnerQuietHoursRulesDoNotSilenceGroup 钉住裁决②(主人 2026-10-05):
t.Parallel() // **安静时段不再是规则层的硬静默**——群里没被 @、无关键词的普通消息在 02:00
// 仍会走到轻思考(LightJudge),由它决定回不回,而不是连判定器都不咨询就被
chat := &stubChatter{res: &loop.Result{Text: "不该回"}} // 静默。变异验证:把 `if inQuiet { return verdictSilent }` 加回 ruleLightJudge,
sess := &stubSessions{sess: nonOwnerSession()} // 本用例立刻变红(judge.calls 会是 0、循环不会跑)。
hist := &captureHistory{} func TestNonOwnerQuietHoursRulesDoNotSilenceGroup(t *testing.T) {
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)
}
// 策略五:安静时段内无信号的非主人消息被静默——
// 只登记、不回复。
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() t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: true, Reason: "夜里这场值得聊"}}
chat := &stubChatter{res: &loop.Result{Text: "在的"}} chat := &stubChatter{res: &loop.Result{Text: "在的"}}
sess := &stubSessions{sess: nonOwnerSession()} sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{ b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Light: judge,
QuietStartHour: 23, QuietEndHour: 7, QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) }, Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
}) })
@@ -168,12 +150,72 @@ func TestNonOwnerQuietHoursMentionResponds(t *testing.T) {
t.Fatalf("New: %v", err) t.Fatalf("New: %v", err)
} }
// 策略五不是硬性静默:被直接点名的消息在安静时段 // 无 @、无关键词、无主人显示名的群消息,落在安静时段内。
// 仍会得到回复。 b.process(context.Background(), nonOwnerGroupMsg("hello all"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1(安静时段不得在规则层硬静默,应交给轻思考)", judge.calls)
}
// 安静时段作为**信号**必须是真的(而非硬编码 false)传到下游判定器。
if !judge.last.InQuietHours {
t.Fatalf("light judge InQuietHours = false, want true at 02:00(安静时段信号失真)")
}
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g(轻思考说回就该回)", chat.sessionID)
}
}
// TestNonOwnerQuietHoursMentionResponds 保留「安静时段不是硬性全静默」的正面口径:
// 被直接点名的群消息在 02:00 仍直接回,且**根本不咨询轻思考**(规则已能给结论)。
func TestNonOwnerQuietHoursMentionResponds(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: false, Reason: "不该被问到"}}
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Light: judge,
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.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"})) b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "sess-g" { if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (mention should break quiet-hours silence)", chat.sessionID) t.Fatalf("loop session = %q, want sess-g (mention should break quiet-hours silence)", chat.sessionID)
} }
if judge.calls != 0 {
t.Fatalf("light judge calls = %d, want 0(@ 是规则可直接判定的强信号)", judge.calls)
}
}
// TestNonOwnerQuietHoursKeywordResponds 补齐另一条强信号:命中高价值关键词的群消息
// 在 02:00 也直接回,同样不咨询轻思考。
func TestNonOwnerQuietHoursKeywordResponds(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: false, Reason: "不该被问到"}}
chat := &stubChatter{res: &loop.Result{Text: "我看看"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Light: judge,
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.process(context.Background(), nonOwnerGroupMsg("有人在吗,紧急求助"))
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (keyword should break quiet-hours silence)", chat.sessionID)
}
if judge.calls != 0 {
t.Fatalf("light judge calls = %d, want 0(高价值关键词是规则可直接判定的强信号)", judge.calls)
}
} }
func TestOwnerUnaffectedInQuietHours(t *testing.T) { func TestOwnerUnaffectedInQuietHours(t *testing.T) {
@@ -0,0 +1,203 @@
package channelbridge
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件覆盖主人 2026-10-05 裁决①②③的三条口径:
// - 陌生人私聊 = 正常聊天:非群 + 非主人一律直接进入完整回合,任何时刻(含安静时段
// 23:30)都不受安静期或概率门影响;
// - 安静时段的硬静默已从规则层摘除(断言在 light_test.go);
// - 陌生人**必须有流控**:同一发送者每分钟 ≤ FlowDirectDefault 条、同时最多 1 个在跑的
// 回合;触发时只登记 + 一条带 source 的可见告警,绝不静默。
// strangerPrivateSession 是陌生人私聊的隔离会话绑定:非群、非主人、记忆只读。
func strangerPrivateSession() session.Session {
return session.Session{SessionID: "sess-s", IsGroup: false, Owner: false, MemWritable: false}
}
// strangerDirectMsg 构造一条来自陌生人(非主人,由 owner 白名单结构化判定)的私聊消息。
// 私聊的会话键是 (platform, channel, sender),因此换一个 sender 就换一个限流桶。
func strangerDirectMsg(sender, text string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram",
ChannelID: "100",
ChannelType: adapter.ChannelDirect,
SenderID: sender,
SenderName: "陌生人" + sender,
Sender: adapter.Actor{Role: adapter.RoleMember},
Content: text,
ContentType: adapter.ContentText,
}
}
// newStrangerBridge 构建一条接好 telegram 适配器的陌生人私聊测试桥接器。
func newStrangerBridge(t *testing.T, chat *stubChatter, hist *captureHistory, at time.Time, log *slog.Logger) (*Bridge, *stubSessions, *fakeAdapter) {
t.Helper()
sess := &stubSessions{sess: strangerPrivateSession()}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist, Log: log,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return at },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
return b, sess, ad
}
// TestStrangerPrivateChatRespondsIncludingQuietHours 钉住裁决①:陌生人私聊(非群 + 非主人)
// 在任何时刻都是正常聊天 —— 白天如此,安静时段 23:30 也如此(安静时段不设在她与陌生人之间)。
// 变异验证:把「陌生人跳过概率门」那段摘掉,本用例立刻变红(shouldRespond 会落到规则/参与度
// 链上,无信号 ⇒ 静默、循环不跑)。
func TestStrangerPrivateChatRespondsIncludingQuietHours(t *testing.T) {
t.Parallel()
cases := []struct {
name string
at time.Time
}{
{"daytime", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)},
{"quiet hours 23:30", time.Date(2026, 1, 1, 23, 30, 0, 0, time.UTC)},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "你好呀"}}
b, sess, ad := newStrangerBridge(t, chat, &captureHistory{}, tc.at, nil)
msg := strangerDirectMsg("777", "在吗,普通的一句话")
actor := session.ResolveActor(msg, b.owner)
if ok, reason := b.shouldRespond(context.Background(), msg, actor, sess.sess, ad); !ok {
t.Fatalf("shouldRespond = false (%s), want true(陌生人私聊一律正常聊天)", reason)
}
b.process(context.Background(), msg)
if chat.sessionID != "sess-s" {
t.Fatalf("loop session = %q, want the isolated sess-s", chat.sessionID)
}
if len(ad.sends) != 1 || ad.sends[0].Content != "你好呀" {
t.Fatalf("stranger replies = %+v, want one text reply", ad.sends)
}
})
}
}
// TestStrangerPrivateFlowControlPerSender 钉住裁决③:陌生人私聊有强制流控。
// 同一发送者在额度内连发都会被回答;超出 FlowDirectDefault 的第 N+1 条**不回**、
// 只登记,并留一条**带 source 的可见告警**;另一条发送者的额度完全独立、不受挤占。
func TestStrangerPrivateFlowControlPerSender(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
hist := &captureHistory{}
var logBuf bytes.Buffer
log := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
at := time.Date(2026, 1, 1, 23, 30, 0, 0, time.UTC)
b, sess, ad := newStrangerBridge(t, chat, hist, at, log)
for i := 0; i < adapter.FlowDirectDefault; i++ {
chat.sessionID = ""
b.process(context.Background(), strangerDirectMsg("777", "第 "+itoa(i)+" 句"))
if chat.sessionID != "sess-s" {
t.Fatalf("额度内第 %d 条被拒(session=%q)", i+1, chat.sessionID)
}
}
// 第 N+1 条超限:不回,只登记,且有可见日志。
before := len(hist.records())
chat.sessionID = ""
b.process(context.Background(), strangerDirectMsg("777", "刷屏第 N+1 句"))
if chat.sessionID != "" {
t.Fatalf("超限消息驱动了循环:session=%q", chat.sessionID)
}
if got := len(hist.records()) - before; got != 1 {
t.Fatalf("超限消息新增登记 %d 条,want 1(只登记不回)", got)
}
logged := logBuf.String()
if !strings.Contains(logged, "stranger private chat rate limited") ||
!strings.Contains(logged, "source=services/channelbridge") {
t.Fatalf("被限流没有留下带 source 的可见日志:%q", logged)
}
// 另一条发送者:额度独立,第一条照常回答。
other := strangerDirectMsg("888", "我是另一个人")
if ok, reason := b.shouldRespond(context.Background(), other, session.ResolveActor(other, b.owner), sess.sess, ad); !ok {
t.Fatalf("另一个发送者被同一额度挤占:reason=%s", reason)
}
}
// TestStrangerPrivateSecondTurnRegisteredWhileOneRuns 钉住裁决③的后半句:
// 「同一发送者同时最多 1 个在跑的回合」。先用 AcquireTurn 占住该发送者的槽位(模拟
// 一个回合正在跑),再让下一条消息过门 —— 它必须只登记、不回,并留可见告警。
func TestStrangerPrivateSecondTurnRegisteredWhileOneRuns(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
hist := &captureHistory{}
var logBuf bytes.Buffer
log := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
b, _, ad := newStrangerBridge(t, chat, hist, time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), log)
msg := strangerDirectMsg("777", "在吗")
gate := b.gateFor(ad)
if gate == nil || !gate.AcquireTurn(msg) {
t.Fatal("占位失败:测试前置条件不成立")
}
t.Cleanup(func() { gate.ReleaseTurn(msg) })
b.process(context.Background(), msg)
if chat.sessionID != "" {
t.Fatalf("已有回合在跑时仍开了第二个回合:session=%q", chat.sessionID)
}
if got := len(hist.records()); got != 1 {
t.Fatalf("被并发拦下的消息登记 %d 条,want 1", got)
}
logged := logBuf.String()
if !strings.Contains(logged, "throttled by flow gate") ||
!strings.Contains(logged, "source=services/channelbridge") {
t.Fatalf("并发拦下没有留下带 source 的可见日志:%q", logged)
}
}
// TestNonOwnerLowValueSystemEventRegisteredOnly 钉住既有行为不变:系统/合成发送者的
// 低价值事件只登记、绝不驱动完整循环(design/12 §6 策略一)。
func TestNonOwnerLowValueSystemEventRegisteredOnly(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)
}
msg := nonOwnerGroupMsg("群成员变更通知")
msg.Sender.Role = adapter.RoleSystem
b.process(context.Background(), msg)
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("低价值系统事件驱动了循环:session=%q messages=%+v", chat.sessionID, chat.messages)
}
if got := len(hist.records()); got != 1 {
t.Fatalf("records = %d, want 1(只登记,不回复)", got)
}
}
+30 -1
View File
@@ -22,7 +22,9 @@ import (
// 看到的历史逐条一致 —— 她不会像第一次见到主人(主人 2026-10-05 要求两条车道环境一致)。 // 看到的历史逐条一致 —— 她不会像第一次见到主人(主人 2026-10-05 要求两条车道环境一致)。
// - **群聊 / 陌生人(受限车道)**:播种**该隔离会话自己**(`sess-*`)的最近转录,因此群里 // - **群聊 / 陌生人(受限车道)**:播种**该隔离会话自己**(`sess-*`)的最近转录,因此群里
// 前几句、以及被最小打扰静默掉而只登记不回的那几条(light.go 的 registerSilenced 已把它们 // 前几句、以及被最小打扰静默掉而只登记不回的那几条(light.go 的 registerSilenced 已把它们
// 落进同一会话)都在她的上下文里,她据此才能理解一段连贯对话的意图。 // 落进同一会话)都在她的上下文里,她据此才能理解一段连贯对话的意图。**顺序为
// frame 先行**:来源行 + 第三方身份边界在最前、转录居中、当次消息在最后
// (主人 2026-10-05 裁定,见 seedTurnTranscript)。
// //
// 两条车道读的会话不同,重建逻辑与条数上限却是同一份:internal/runtime/transcript // 两条车道读的会话不同,重建逻辑与条数上限却是同一份:internal/runtime/transcript
// (ContextCount / Read / Message)—— 与网关同一份实现、同一个常量,绝不另写第二套 // (ContextCount / Read / Message)—— 与网关同一份实现、同一个常量,绝不另写第二套
@@ -51,6 +53,13 @@ func turnEnv(sess session.Session, target inboundTarget, personaID string) loop.
// - 主人私聊 → 主会话(`web`)的转录,与 Web 主会话逐条一致; // - 主人私聊 → 主会话(`web`)的转录,与 Web 主会话逐条一致;
// - 群聊 / 陌生人 → 该隔离会话(`sess-*`)的转录,含被静默登记的群成员发言。 // - 群聊 / 陌生人 → 该隔离会话(`sess-*`)的转录,含被静默登记的群成员发言。
// //
// **受限车道的顺序是「frame 先行」**(主人 2026-10-05 裁定):来源系统行 +
// 第三方身份边界必须排在**最前**,转录居中,当次用户消息在最后——即
// `[边界/来源系统行, …转录…, 当次用户消息]`。理由是提示词注入面:若把转录
// 前置在边界之前,群友的历史发言会先于「这是群、这些人是第三方」进入她的
// 上下文。主人私聊车道**顺序逐字不变**(转录最前、来源行、当次消息),因为
// 那条车道的上下文隔离本就不同。
//
// 时刻与网关一致:调用点在本回合用户消息落库**之前**(runInboundTurn 里装配先于 // 时刻与网关一致:调用点在本回合用户消息落库**之前**(runInboundTurn 里装配先于
// persistToMain / persistIsolated),因此当次消息绝不会在上下文里出现两次。 // persistToMain / persistIsolated),因此当次消息绝不会在上下文里出现两次。
// //
@@ -69,7 +78,27 @@ func (b *Bridge) seedTurnTranscript(ctx context.Context, target inboundTarget, m
if len(seed) == 0 { if len(seed) == 0 {
return msgs return msgs
} }
// 主人私聊:转录前置在最前(既有顺序,逐字不变)。
if target.private {
out := make([]model.Message, 0, len(seed)+len(msgs)) out := make([]model.Message, 0, len(seed)+len(msgs))
out = append(out, seed...) out = append(out, seed...)
return append(out, msgs...) return append(out, msgs...)
}
return insertSeedAfterFrame(msgs, seed)
}
// insertSeedAfterFrame 把受限车道的转录插在**最前面的系统框架行之后**
// (来源行 + 第三方身份边界仍在最前,frame 先行),当次用户消息保持在最后:
// `[系统框架行, …转录…, 当次用户消息]`。受限车道的本回合消息恰好是
// 「框架系统行 + 当次用户消息」,因此这一插入位置等价于「边界最前、历史居中、
// 当次最后」,且**当次消息绝不重复**。
func insertSeedAfterFrame(msgs, seed []model.Message) []model.Message {
frame := 0
for frame < len(msgs) && msgs[frame].Role == model.RoleSystem {
frame++
}
out := make([]model.Message, 0, len(msgs)+len(seed))
out = append(out, msgs[:frame]...)
out = append(out, seed...)
return append(out, msgs[frame:]...)
} }
@@ -249,27 +249,29 @@ func TestGroupTurnSeedsItsOwnSessionTranscript(t *testing.T) {
b.process(context.Background(), groupMessage("9", adapter.Actor{Role: adapter.RoleMember})) b.process(context.Background(), groupMessage("9", adapter.Actor{Role: adapter.RoleMember}))
// 形状:该群最近 3 条转录(最旧优先)+ 第三方边界/来源系统行 + 当次用户消息。 // 形状(frame 先行):第三方边界/来源系统行**在最前**,该群最近 3 条转录居中
// (最旧优先),当次用户消息在最后。
if len(chat.msgs) != 5 { if len(chat.msgs) != 5 {
t.Fatalf("loop messages = %d, want 5 (transcript 3 + boundary + user): %+v", len(chat.msgs), chat.msgs) t.Fatalf("loop messages = %d, want 5 (boundary + transcript 3 + user): %+v", len(chat.msgs), chat.msgs)
}
// 边界行必须最先:历史里的陌生人发言绝不能在她还不知道「这是群、这些人是外人」
// 之前进入上下文(提示词注入面,主人 2026-10-05 裁定)。
if got := chat.msgs[0]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), thirdPartyBoundaryPrompt) ||
!strings.Contains(turnMessageText(got), "群「Gang」") {
t.Fatalf("messages[0] = %+v, want the origin + third-party boundary system row first", got)
} }
// 说话人归属逐条正确:两个不同说话人各归各,且走结构化字段(不是织进正文的标签)。 // 说话人归属逐条正确:两个不同说话人各归各,且走结构化字段(不是织进正文的标签)。
if got := chat.msgs[0]; got.Role != model.RoleUser || turnMessageText(got) != "晚上要不要开黑" || got.SenderName != "张三" { if got := chat.msgs[1]; got.Role != model.RoleUser || turnMessageText(got) != "晚上要不要开黑" || got.SenderName != "张三" {
t.Fatalf("messages[0] = %+v, want 张三's earlier line attributed to 张三", got) t.Fatalf("messages[1] = %+v, want 张三's earlier line attributed to 张三", got)
} }
if got := chat.msgs[1]; got.Role != model.RoleUser || turnMessageText(got) != "我还在加班" || got.SenderName != "李四" { if got := chat.msgs[2]; got.Role != model.RoleUser || turnMessageText(got) != "我还在加班" || got.SenderName != "李四" {
t.Fatalf("messages[1] = %+v, want 李四's silenced line attributed to 李四", got) t.Fatalf("messages[2] = %+v, want 李四's silenced line attributed to 李四", got)
} }
if got := chat.msgs[2]; got.Role != model.RoleAssistant || turnMessageText(got) != "那你先忙" || got.SenderName != "cyrene" { if got := chat.msgs[3]; got.Role != model.RoleAssistant || turnMessageText(got) != "那你先忙" || got.SenderName != "cyrene" {
t.Fatalf("messages[2] = %+v, want her earlier reply attributed to cyrene", got) t.Fatalf("messages[3] = %+v, want her earlier reply attributed to cyrene", got)
}
// 第三方身份边界与来源行照旧(内容一个字没变,仍在每轮注入)。
if got := chat.msgs[3]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), thirdPartyBoundaryPrompt) ||
!strings.Contains(turnMessageText(got), "群「Gang」") {
t.Fatalf("messages[3] = %+v, want the origin + third-party boundary system row", got)
} }
if got := chat.msgs[4]; got.Role != model.RoleUser || turnMessageText(got) != "hello all" || got.SenderName != "张三" { if got := chat.msgs[4]; got.Role != model.RoleUser || turnMessageText(got) != "hello all" || got.SenderName != "张三" {
t.Fatalf("messages[4] = %+v, want this turn's user message", got) t.Fatalf("messages[4] = %+v, want this turn's user message last", got)
} }
// 当次消息只出现一次:转录是在本回合消息落库**之前**读的。活夹具把已经落库的行也返回, // 当次消息只出现一次:转录是在本回合消息落库**之前**读的。活夹具把已经落库的行也返回,
// 因此播种时刻一旦挪到落库之后,这里立刻数出两次。 // 因此播种时刻一旦挪到落库之后,这里立刻数出两次。
@@ -430,10 +432,18 @@ func TestOwnerGroupTurnSeedsOnlyItsOwnIsolatedTranscript(t *testing.T) {
t.Fatalf("loop session id = %q, want the isolated sess-g", chat.sessionID) t.Fatalf("loop session id = %q, want the isolated sess-g", chat.sessionID)
} }
if len(chat.msgs) != 3 { if len(chat.msgs) != 3 {
t.Fatalf("loop messages = %d, want 3 (isolated transcript + origin/boundary + user): %+v", len(chat.msgs), chat.msgs) t.Fatalf("loop messages = %d, want 3 (boundary + isolated transcript + user): %+v", len(chat.msgs), chat.msgs)
} }
if got := chat.msgs[0]; turnMessageText(got) != "群里的上一句" || got.SenderName != "张三" { // frame 先行:主人本人在群里同样是受限(群聊)车道 —— 边界/来源行仍在最前,
t.Fatalf("messages[0] = %+v, want the group's own earlier line", got) // 转录居中,当次消息在最后。
if got := chat.msgs[0]; got.Role != model.RoleSystem || !strings.Contains(turnMessageText(got), thirdPartyBoundaryPrompt) {
t.Fatalf("messages[0] = %+v, want the boundary/origin system row first", got)
}
if got := chat.msgs[1]; turnMessageText(got) != "群里的上一句" || got.SenderName != "张三" {
t.Fatalf("messages[1] = %+v, want the group's own earlier line", got)
}
if got := chat.msgs[2]; got.Role != model.RoleUser {
t.Fatalf("messages[2] = %+v, want this turn's user message last", got)
} }
for i, m := range chat.msgs { for i, m := range chat.msgs {
if strings.Contains(turnMessageText(m), "主人的私事") { if strings.Contains(turnMessageText(m), "主人的私事") {