feat(channels): 陌生人私聊正常聊天(跳概率门)+ 强制流控 6条/分与回合槽位 + 安静时段不再硬静默 + 受限车道边界行前置
- 陌生人私聊:不再走「值不值得回」的概率门,唯一闸门 = 每发送者 6 条/分滑窗 + 同会话 1 个在跑回合 - 被限流只登记不回,且留带 source 的可见告警(不静默) - 安静时段在回复侧只作信号(规则层不再静默),群聊取消夜间静默 - 锁定流控常量:FlowDirectDefault 加字面量钉桩测试(放大它 = 静默关流控,且会让额度循环跑百万轮)
This commit is contained in:
1 parent
c6f5c3fea0
commit
7ae4298871
10 files changed
+654
-118
No files matched your search
@@ -44,6 +44,13 @@ type FlowGateOptions struct {
|
||||
// GroupMsgPerMin caps group-chat inbound messages per minute **per
|
||||
// conversation** (design/12 §6 策略三 / 裁决 06.4.3). Zero disables the limit.
|
||||
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
|
||||
// account — the anti-flood total guard. Zero disables it.
|
||||
MsgPerMin int
|
||||
@@ -76,6 +83,12 @@ const (
|
||||
// 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
|
||||
// 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
|
||||
// (design/12 §6 策略四).
|
||||
FlowCapDefault = 50
|
||||
@@ -113,16 +126,22 @@ func ConversationKey(msg UnifiedMessage) string {
|
||||
// 每个会话保存一份,因此两个群并发互不挤占(裁决 06.4.3)。
|
||||
type flowSession struct {
|
||||
groupHits []time.Time
|
||||
directHits []time.Time
|
||||
buffer []queuedMsg
|
||||
lastReview time.Time
|
||||
// inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道
|
||||
// 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。
|
||||
inFlight int
|
||||
}
|
||||
|
||||
// FlowGate is a concurrency-safe, per-account 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.
|
||||
// non-owner messages), plus the mandatory **per-sender** rate limit on direct
|
||||
// (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
|
||||
// invoked to prune low-value messages; with no Reviewer the oldest messages are
|
||||
// evicted FIFO. Either way every dropped message is audited.
|
||||
@@ -131,15 +150,18 @@ type flowSession struct {
|
||||
//
|
||||
// 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.
|
||||
// The gate also carries the per-conversation running-turn slot (AcquireTurn /
|
||||
// ReleaseTurn) used by the stranger private-chat flow control.
|
||||
type FlowGate struct {
|
||||
groupPerMin int
|
||||
msgPerMin int
|
||||
maxMessages int
|
||||
reviewAt int
|
||||
window time.Duration
|
||||
review Reviewer
|
||||
onPruned func(UnifiedMessage, string)
|
||||
now func() time.Time
|
||||
groupPerMin int
|
||||
directPerMin int
|
||||
msgPerMin int
|
||||
maxMessages int
|
||||
reviewAt int
|
||||
window time.Duration
|
||||
review Reviewer
|
||||
onPruned func(UnifiedMessage, string)
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
totalHits []time.Time
|
||||
@@ -160,14 +182,15 @@ type queuedMsg struct {
|
||||
// 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,
|
||||
sessions: make(map[string]*flowSession),
|
||||
groupPerMin: opts.GroupMsgPerMin,
|
||||
directPerMin: opts.DirectMsgPerMin,
|
||||
msgPerMin: opts.MsgPerMin,
|
||||
maxMessages: opts.MaxMessages,
|
||||
window: opts.Window,
|
||||
review: opts.Review,
|
||||
onPruned: opts.OnPruned,
|
||||
now: opts.Now,
|
||||
sessions: make(map[string]*flowSession),
|
||||
}
|
||||
if g.window <= 0 {
|
||||
g.window = defaultRateWindow
|
||||
@@ -258,6 +281,61 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
|
||||
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
|
||||
// buffer (rate-limited at the door), so the audit hook still sees it.
|
||||
func (g *FlowGate) drop(reason string, msg UnifiedMessage) GateVerdict {
|
||||
@@ -281,7 +359,8 @@ func (g *FlowGate) sessionLocked(key string) *flowSession {
|
||||
}
|
||||
|
||||
// maybeGCLocked 每 sessionGCInterval 次放行清理一次空闲会话,避免会话状态表随历史会话
|
||||
// 无界增长:滑窗时间戳全部过期且待处理队列为空的会话直接删除。调用方须持有 g.mu。
|
||||
// 无界增长:滑窗时间戳全部过期、待处理队列为空、且没有回合在跑的会话直接删除。
|
||||
// 调用方须持有 g.mu。
|
||||
func (g *FlowGate) maybeGCLocked(now time.Time) {
|
||||
g.admitted++
|
||||
if g.admitted%sessionGCInterval != 0 {
|
||||
@@ -289,7 +368,9 @@ func (g *FlowGate) maybeGCLocked(now time.Time) {
|
||||
}
|
||||
for key, st := range g.sessions {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -19,6 +19,11 @@ import (
|
||||
// (adapter.ConversationKey:platform+channelID+groupID/peerID),因此两个群绝不会
|
||||
// 相互挤占(design/12 §6 策略三/四,裁决 06.4.3)。
|
||||
func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate {
|
||||
if a == nil {
|
||||
// 便捷路径(process 用平台名找适配器)在平台上未注册任何账号时给出 nil:
|
||||
// 没有账号就没有闸门,调用方按 nil 跳过(生产路径的 a 恒非 nil)。
|
||||
return nil
|
||||
}
|
||||
name := a.Name()
|
||||
b.gateMu.Lock()
|
||||
defer b.gateMu.Unlock()
|
||||
@@ -31,15 +36,21 @@ func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate {
|
||||
}
|
||||
|
||||
// gateOptions 把适配器 Config 快照中的按账号流控限制解析进
|
||||
// FlowGate。键缺失(单账号环境变量适配器)时限制关闭,
|
||||
// FlowGate。群聊/总量/上限键缺失(单账号环境变量适配器)时限制关闭,
|
||||
// 既有渠道行为不变;键存在且值为 0 时采用保护性默认值
|
||||
// (每群每分钟 6 条、上限 50),负值则关闭限制——
|
||||
// 这是对 CHANNELS_JSON 账号的向后兼容约定(design/12 §6 实现要点)。
|
||||
//
|
||||
// **私聊(陌生人)限流例外:它不依赖任何配置键**(主人 2026-10-05 裁决③),
|
||||
// 恒定取保护性默认 FlowDirectDefault —— 原本没有任何配置的账号(如 qq-obv11)
|
||||
// 对陌生人私聊是完全敞口,而裁决要求「陌生人必须有流控」,所以这里不能
|
||||
// 因为「没配」而静默关掉。主人私聊在 onMessage 就被绕过本闸门,不受影响。
|
||||
func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions {
|
||||
opts := adapter.FlowGateOptions{
|
||||
Review: b.review,
|
||||
OnPruned: b.onRejected,
|
||||
Now: b.now,
|
||||
Review: b.review,
|
||||
OnPruned: b.onRejected,
|
||||
Now: b.now,
|
||||
DirectMsgPerMin: adapter.FlowDirectDefault,
|
||||
}
|
||||
cfg := a.Config()
|
||||
if grp, ok := cfgInt(cfg, "groupMsgPerMin"); ok {
|
||||
|
||||
@@ -104,7 +104,8 @@ func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) {
|
||||
|
||||
// TestFlowGate_GateOptionsDefaults 钉住「配置→限制」的解析规则,
|
||||
// 它保证旧的 CHANNELS_JSON 账号向后兼容:键缺失 → 关闭,0 → 保护性
|
||||
// 默认值,负值 → 关闭,正值 → 固定为该值。
|
||||
// 默认值,负值 → 关闭,正值 → 固定为该值。**私聊(陌生人)限流是例外**:
|
||||
// 它不读配置键,恒取保护性默认(裁决③:陌生人必须有流控)。
|
||||
func TestFlowGate_GateOptionsDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -135,6 +136,12 @@ func TestFlowGate_GateOptionsDefaults(t *testing.T) {
|
||||
if 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,17 +181,18 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
|
||||
"session", sess.SessionID, "actor_role", sess.ActorRole,
|
||||
"env", sess.Env(), "mem_writable", sess.MemWritable)
|
||||
|
||||
// design/12 §6 策略一/二/五:面向非主人消息的最小打扰闸门。
|
||||
// 被廉价轻量判定认为不值得回复的非主人群聊/陌生人
|
||||
// 消息,只登记到其隔离会话日志并静默——绝不
|
||||
// 驱动完整 agent 循环。主人消息绝不在此被拦,因此
|
||||
// 主人私聊与主人在群里的 @/高价值回复不受影响。
|
||||
// design/12 §6 策略一/二:面向非主人消息的最小打扰闸门。
|
||||
// 被廉价轻量判定认为不值得回复的非主人**群聊**消息,只登记到
|
||||
// 其隔离会话日志并静默——绝不驱动完整 agent 循环;**陌生人私聊**则
|
||||
// 一律走完整回合(裁决①,见 shouldRespond),只受流控约束。
|
||||
// 主人消息绝不在此被拦,因此主人私聊与主人在群里的
|
||||
// @/高价值回复不受影响。
|
||||
//
|
||||
// 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一
|
||||
// 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向
|
||||
// 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。
|
||||
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.log.Debug("non-owner message silenced (minimal disturbance)",
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -397,7 +413,8 @@ func deviceIDFor(msg adapter.UnifiedMessage) string {
|
||||
// RoleUser 消息**上 —— 用户内容只进用户角色消息是项目红线。
|
||||
//
|
||||
// 历史转录**不在本函数里**:它由 seedTurnTranscript(transcript.go)在本函数返回之后
|
||||
// 前置到列表最前面,与网关编排器的播种同序(转录在前、本次消息在后)。因此本函数只负责
|
||||
// 插入到列表中(主人私聊前置在最前;受限车道插在**最前的系统框架行之后**,
|
||||
// 于是来源行 + 第三方边界仍在最前、转录居中、当次消息在最后)。因此本函数只负责
|
||||
// **本回合自己的**消息(来源行 + 当次用户消息),受限车道的形状在有无转录时保持一致。
|
||||
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
|
||||
// design/12 §6 策略一补充:经传入前审查放行的系统通知走「限定注入」——只把剥离后的
|
||||
|
||||
@@ -11,15 +11,17 @@ import (
|
||||
)
|
||||
|
||||
// 本文件承载渠道桥接器面向公开平台的入站「最小打扰」分级
|
||||
// (DevDocs/design/12 §6 策略一/二/五)。非主人的群聊/陌生人
|
||||
// 消息在进入完整 agent 循环前先经廉价判断,使繁忙的
|
||||
// 公开群不会把每条消息都扔进昂贵的 LLM 轮次。这些逻辑
|
||||
// 放在这里是为了让 channelbridge.go 保持在模块体积上限内;它绝不
|
||||
// 读取消息内容来判定身份——身份早已由
|
||||
// (DevDocs/design/12 §6 策略一/二)。非主人的**群聊**消息在进入完整
|
||||
// agent 循环前先经廉价判断,使繁忙的公开群不会把每条消息都扔进
|
||||
// 昂贵的 LLM 轮次;**陌生人私聊**则一律正常聊天(主人 2026-10-05 裁决①),
|
||||
// 只受强制流控约束(裁决③)。这些逻辑放在这里是为了让 channelbridge.go
|
||||
// 保持在模块体积上限内;它绝不读取消息内容来判定身份——身份早已由
|
||||
// 会话/actor 层结构化解析完毕(项目红线)。
|
||||
|
||||
// 安静时段默认值,与 backthink 服务保持一致,使桥接器与
|
||||
// 后台思考器共用同一个免打扰窗口(design/12 §6 策略五)。
|
||||
// 后台思考器共用同一个免打扰窗口(design/12 §6 策略五)。它在本车道里
|
||||
// **只作为信号**随 inQuiet 传给参与度引擎/轻量判定器,不再让规则层静默
|
||||
// (主人 2026-10-05 裁决②)。
|
||||
const (
|
||||
defaultQuietStartHour = 23
|
||||
defaultQuietEndHour = 7
|
||||
@@ -32,11 +34,10 @@ type lightVerdict int
|
||||
const (
|
||||
// verdictRespond 表示规则信号显示该消息值得回复。
|
||||
verdictRespond lightVerdict = iota
|
||||
// verdictSilent 表示规则信号显示该消息应登记但不应
|
||||
// 回复(例如安静时段内没有强信号)。
|
||||
verdictSilent
|
||||
// verdictInconclusive 表示规则无法判断;交给 LightJudge(未装配
|
||||
// 时则失败关闭为静默)。
|
||||
// verdictInconclusive 表示规则无法判断;交给参与度引擎(design/18)
|
||||
// 或注入式 LightJudge(未装配时则失败关闭为静默)。安静时段**不再**
|
||||
// 单独构成静默(主人 2026-10-05 裁决②)——它只作为信号随 inQuiet
|
||||
// 传给下游判定器,让轻思考/参与度自己权衡要不要回。
|
||||
verdictInconclusive
|
||||
)
|
||||
|
||||
@@ -130,12 +131,14 @@ func hasHighValueKeyword(text string) bool {
|
||||
}
|
||||
|
||||
// ruleLightJudge 只依据廉价规则信号判断是否值得回复
|
||||
// (设计/12 §6 策略二/五)。优先级:直接点名、提及主人的显示
|
||||
// 名、高价值关键词,其次是安静时段偏向静默;否则
|
||||
// 结论为无结论(交给注入的 LightJudge,没有则静默)。它
|
||||
// 只读取已解析的 actor 元数据与消息内容——绝不推断
|
||||
// 身份。
|
||||
func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor, inQuiet bool) lightVerdict {
|
||||
// (设计/12 §6 策略二)。优先级:直接点名、提及主人的显示
|
||||
// 名、高价值关键词;除此之外一律无结论(交给参与度引擎 /
|
||||
// 注入的 LightJudge,没有则失败关闭为静默)。**安静时段不是
|
||||
// 这里的静默理由**(主人 2026-10-05 裁决②:安静时段由轻思考判断
|
||||
// 是否回复,不是硬性全静默)——该信号由调用方随 inQuiet 传给
|
||||
// 下游判定器。它只读取已解析的消息信号(提及/正文)——绝不
|
||||
// 推断身份(身份由 actor 元数据在会话层解析,红线)。
|
||||
func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage) lightVerdict {
|
||||
if len(msg.Mentions) > 0 {
|
||||
return verdictRespond
|
||||
}
|
||||
@@ -146,20 +149,26 @@ func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor,
|
||||
if hasHighValueKeyword(msg.Content) {
|
||||
return verdictRespond
|
||||
}
|
||||
if inQuiet {
|
||||
return verdictSilent
|
||||
}
|
||||
return verdictInconclusive
|
||||
}
|
||||
|
||||
// isStrangerPrivate 报告该会话是否为**陌生人私聊**(非群 + 非主人)。
|
||||
// 它只读会话层结构化解析出的标志(项目红线:身份不靠正文/提示词)。
|
||||
// 主人私聊在 onMessage/shouldRespond 之前就已被 actor 门拦下,因此
|
||||
// 这里成真的只会是陌生人私聊。
|
||||
func isStrangerPrivate(sess session.Session) bool {
|
||||
return !sess.IsGroup && !sess.Owner
|
||||
}
|
||||
|
||||
// shouldRespond 实现非主人入站消息的最小打扰分级
|
||||
// (设计/12 §6 策略一/二/五 + design/18 §2/§4/§5)。它返回该消息
|
||||
// (设计/12 §6 策略一/二 + design/18 §2/§4/§5)。它返回该消息
|
||||
// 应驱动完整 agent 循环并回复,还是只登记并静默。
|
||||
// 主人消息绝不会到这里(processFrom 以 !actor.IsOwner 为门),
|
||||
// 因此主人私聊与主人在群里的 @/高价值回复不受影响。sess 是
|
||||
// 该消息解析出的隔离群聊/陌生人会话绑定,使参与度
|
||||
// 引擎探测时能读到群的近期上下文。
|
||||
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session) (bool, string) {
|
||||
// 引擎探测时能读到群的近期上下文;a 是收到该消息的账号适配器,
|
||||
// 用于取该账号的流控闸门(陌生人私聊的强制限流)。
|
||||
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) (bool, string) {
|
||||
// 策略一:平台/系统噪声 → 只登记,绝不进完整循环。
|
||||
if isLowValueEvent(actor) {
|
||||
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"
|
||||
}
|
||||
|
||||
// 策略二/五:基于规则的轻量判定,感知安静时段。直接 @、
|
||||
// 提及主人显示名或高价值关键词一律回复;安静时段
|
||||
// 偏向静默。只有无结论的剩余部分才咨询
|
||||
// 参与度引擎。
|
||||
// 裁决①(主人 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())
|
||||
switch b.ruleLightJudge(msg, actor, inQuiet) {
|
||||
case verdictRespond:
|
||||
if b.ruleLightJudge(msg) == 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"
|
||||
}
|
||||
|
||||
// 廉价规则得不出结论。参与度引擎(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)
|
||||
}
|
||||
|
||||
// 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 是否落在配置的免打扰窗口内
|
||||
// (与 backthink 语义一致:两个非零且相等的时刻关闭该窗口,
|
||||
// 跨午夜的窗口也能正确处理)。它是 Bridge 上的纯方法,测试
|
||||
|
||||
@@ -14,15 +14,18 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// stubLightJudge 是可配置的 LightJudge,记录自己被咨询了多少次。
|
||||
// stubLightJudge 是可配置的 LightJudge,记录自己被咨询了多少次,
|
||||
// 并保留最后一次收到的输入(用于断言 inQuiet 真值确实传到了下游判定器)。
|
||||
type stubLightJudge struct {
|
||||
resp LightDecision
|
||||
err error
|
||||
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.last = in
|
||||
return s.resp, s.err
|
||||
}
|
||||
|
||||
@@ -127,40 +130,19 @@ func TestNonOwnerHighValueKeywordResponds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 策略五:安静时段内无信号的非主人消息被静默——
|
||||
// 只登记、不回复。
|
||||
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) {
|
||||
// TestNonOwnerQuietHoursRulesDoNotSilenceGroup 钉住裁决②(主人 2026-10-05):
|
||||
// **安静时段不再是规则层的硬静默**——群里没被 @、无关键词的普通消息在 02:00
|
||||
// 仍会走到轻思考(LightJudge),由它决定回不回,而不是连判定器都不咨询就被
|
||||
// 静默。变异验证:把 `if inQuiet { return verdictSilent }` 加回 ruleLightJudge,
|
||||
// 本用例立刻变红(judge.calls 会是 0、循环不会跑)。
|
||||
func TestNonOwnerQuietHoursRulesDoNotSilenceGroup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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{},
|
||||
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) },
|
||||
})
|
||||
@@ -168,12 +150,72 @@ func TestNonOwnerQuietHoursMentionResponds(t *testing.T) {
|
||||
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"}))
|
||||
if chat.sessionID != "sess-g" {
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,9 @@ import (
|
||||
// 看到的历史逐条一致 —— 她不会像第一次见到主人(主人 2026-10-05 要求两条车道环境一致)。
|
||||
// - **群聊 / 陌生人(受限车道)**:播种**该隔离会话自己**(`sess-*`)的最近转录,因此群里
|
||||
// 前几句、以及被最小打扰静默掉而只登记不回的那几条(light.go 的 registerSilenced 已把它们
|
||||
// 落进同一会话)都在她的上下文里,她据此才能理解一段连贯对话的意图。
|
||||
// 落进同一会话)都在她的上下文里,她据此才能理解一段连贯对话的意图。**顺序为
|
||||
// frame 先行**:来源行 + 第三方身份边界在最前、转录居中、当次消息在最后
|
||||
// (主人 2026-10-05 裁定,见 seedTurnTranscript)。
|
||||
//
|
||||
// 两条车道读的会话不同,重建逻辑与条数上限却是同一份:internal/runtime/transcript
|
||||
// (ContextCount / Read / Message)—— 与网关同一份实现、同一个常量,绝不另写第二套
|
||||
@@ -51,6 +53,13 @@ func turnEnv(sess session.Session, target inboundTarget, personaID string) loop.
|
||||
// - 主人私聊 → 主会话(`web`)的转录,与 Web 主会话逐条一致;
|
||||
// - 群聊 / 陌生人 → 该隔离会话(`sess-*`)的转录,含被静默登记的群成员发言。
|
||||
//
|
||||
// **受限车道的顺序是「frame 先行」**(主人 2026-10-05 裁定):来源系统行 +
|
||||
// 第三方身份边界必须排在**最前**,转录居中,当次用户消息在最后——即
|
||||
// `[边界/来源系统行, …转录…, 当次用户消息]`。理由是提示词注入面:若把转录
|
||||
// 前置在边界之前,群友的历史发言会先于「这是群、这些人是第三方」进入她的
|
||||
// 上下文。主人私聊车道**顺序逐字不变**(转录最前、来源行、当次消息),因为
|
||||
// 那条车道的上下文隔离本就不同。
|
||||
//
|
||||
// 时刻与网关一致:调用点在本回合用户消息落库**之前**(runInboundTurn 里装配先于
|
||||
// persistToMain / persistIsolated),因此当次消息绝不会在上下文里出现两次。
|
||||
//
|
||||
@@ -69,7 +78,27 @@ func (b *Bridge) seedTurnTranscript(ctx context.Context, target inboundTarget, m
|
||||
if len(seed) == 0 {
|
||||
return msgs
|
||||
}
|
||||
out := make([]model.Message, 0, len(seed)+len(msgs))
|
||||
// 主人私聊:转录前置在最前(既有顺序,逐字不变)。
|
||||
if target.private {
|
||||
out := make([]model.Message, 0, len(seed)+len(msgs))
|
||||
out = append(out, seed...)
|
||||
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...)
|
||||
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}))
|
||||
|
||||
// 形状:该群最近 3 条转录(最旧优先)+ 第三方边界/来源系统行 + 当次用户消息。
|
||||
// 形状(frame 先行):第三方边界/来源系统行**在最前**,该群最近 3 条转录居中
|
||||
// (最旧优先),当次用户消息在最后。
|
||||
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 != "张三" {
|
||||
t.Fatalf("messages[0] = %+v, want 张三's earlier line attributed to 张三", got)
|
||||
if got := chat.msgs[1]; got.Role != model.RoleUser || turnMessageText(got) != "晚上要不要开黑" || got.SenderName != "张三" {
|
||||
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 != "李四" {
|
||||
t.Fatalf("messages[1] = %+v, want 李四's silenced line attributed to 李四", got)
|
||||
if got := chat.msgs[2]; got.Role != model.RoleUser || turnMessageText(got) != "我还在加班" || got.SenderName != "李四" {
|
||||
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" {
|
||||
t.Fatalf("messages[2] = %+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[3]; got.Role != model.RoleAssistant || turnMessageText(got) != "那你先忙" || got.SenderName != "cyrene" {
|
||||
t.Fatalf("messages[3] = %+v, want her earlier reply attributed to cyrene", got)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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 != "张三" {
|
||||
t.Fatalf("messages[0] = %+v, want the group's own earlier line", got)
|
||||
// frame 先行:主人本人在群里同样是受限(群聊)车道 —— 边界/来源行仍在最前,
|
||||
// 转录居中,当次消息在最后。
|
||||
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 {
|
||||
if strings.Contains(turnMessageText(m), "主人的私事") {
|
||||
|
||||
Reference in new issue
Block a user