feat(channels): 流控可配 —— 陌生人私聊额度走账号配置 directMsgPerMin(裁决 13)
- 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN)
- 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6
- 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要
- 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行
- 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原
- ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0
This commit is contained in:
1 parent
ba17820040
commit
a057a0f61f
18 files changed
+339
-70
No files matched your search
@@ -183,6 +183,10 @@ type Bridge struct {
|
||||
// noticeLog 是系统通知传入前审查(design/12 §6 策略一补充)的子功能日志器,
|
||||
// 与 voiceLog 同款:`source=services/channelbridge/notice` 便于跨模块定位。
|
||||
noticeLog *slog.Logger
|
||||
// flowLog 是流控解析(design/12 §6 策略三,含账号级 directMsgPerMin)的子功能
|
||||
// 日志器:`source=services/channelbridge/flowgate`,使「主人显式关掉某项流控」
|
||||
// 在日志里可追溯(见 flowgate.go 的 directLimit)。
|
||||
flowLog *slog.Logger
|
||||
|
||||
// 参与度引擎状态(design/18):按会话的内存状态机,
|
||||
// 加上约束它的角色提醒词 / 参与度参数,以及概率触发
|
||||
@@ -281,6 +285,7 @@ func New(opts Options) (*Bridge, error) {
|
||||
quietEndHour: qEnd,
|
||||
now: now,
|
||||
noticeLog: logger.WithSource(log, "services/channelbridge/notice"),
|
||||
flowLog: logger.WithSource(log, flowgateLogSource),
|
||||
triggerWords: opts.TriggerWords,
|
||||
personaPrompt: opts.PersonaPrompt,
|
||||
engage: opts.Engage,
|
||||
|
||||
@@ -41,18 +41,19 @@ func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate {
|
||||
// (每群每分钟 6 条、上限 50),负值则关闭限制——
|
||||
// 这是对 CHANNELS_JSON 账号的向后兼容约定(design/12 §6 实现要点)。
|
||||
//
|
||||
// **私聊(陌生人)限流例外:它不依赖任何配置键**(主人 2026-10-05 裁决③),
|
||||
// 恒定取保护性默认 FlowDirectDefault —— 原本没有任何配置的账号(如 qq-obv11)
|
||||
// 对陌生人私聊是完全敞口,而裁决要求「陌生人必须有流控」,所以这里不能
|
||||
// 因为「没配」而静默关掉。主人私聊在 onMessage 就被绕过本闸门,不受影响。
|
||||
// **私聊(陌生人)限流走 directMsgPerMin**(主人 2026-10-05 裁决③与裁决 13
|
||||
// 「流控时间要可配置」):它与群聊同一条读法,但缺省/0 一律取保护性默认
|
||||
// FlowDirectDefault —— 原本没有任何配置的账号(如 qq-obv11)对陌生人私聊是完全
|
||||
// 敞口,而裁决要求「陌生人必须有流控」,所以这里绝不能因为「没配」而静默关掉。
|
||||
// 主人私聊在 onMessage 就被绕过本闸门,不受影响。
|
||||
func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions {
|
||||
cfg := a.Config()
|
||||
opts := adapter.FlowGateOptions{
|
||||
Review: b.review,
|
||||
OnPruned: b.onRejected,
|
||||
Now: b.now,
|
||||
DirectMsgPerMin: adapter.FlowDirectDefault,
|
||||
DirectMsgPerMin: directLimit(cfg, b.flowLog),
|
||||
}
|
||||
cfg := a.Config()
|
||||
if grp, ok := cfgInt(cfg, "groupMsgPerMin"); ok {
|
||||
opts.GroupMsgPerMin = flowOrDefault(grp, adapter.FlowLimitDefault)
|
||||
}
|
||||
@@ -65,6 +66,29 @@ func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions
|
||||
return opts
|
||||
}
|
||||
|
||||
// directLimit 解析账号级 directMsgPerMin(非主人私聊 = 陌生人的每分钟每发送者上限),
|
||||
// 语义与 flowOrDefault 逐字同口径:**键缺失、非整数或 0 ⇒ 保护性默认
|
||||
// adapter.FlowDirectDefault;正值 ⇒ 该值;负值 ⇒ 0(关闭该限制)**。
|
||||
//
|
||||
// 与群聊的唯一区别是缺省侧:缺省绝不解释成「关闭」,否则一个漏配的键就会
|
||||
// 把陌生人私聊重新变成全敞口(主人 2026-10-05 裁决③的既定裁决)。负值关闭
|
||||
// 只有手写负数才能到达,属主人的**显式**选择,因此这里留一条带 source 的 WARN
|
||||
// 日志 —— 流控被关必须在日志里可追溯,不许静默发生。
|
||||
func directLimit(cfg map[string]any, log *slog.Logger) int {
|
||||
v, ok := cfgInt(cfg, directMsgPerMinKey)
|
||||
if !ok {
|
||||
return adapter.FlowDirectDefault
|
||||
}
|
||||
if v < 0 {
|
||||
if log != nil {
|
||||
log.Warn("non-owner private rate limit turned off by account config",
|
||||
"key", directMsgPerMinKey, "value", v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return flowOrDefault(v, adapter.FlowDirectDefault)
|
||||
}
|
||||
|
||||
// flowOrDefault 把配置的限制值映射为实际生效的限制:负值
|
||||
// 关闭限制(0),零采用保护性默认值,正值则
|
||||
// 固定为该值。这就是「没有该字段的旧账号保持原有行为或
|
||||
@@ -99,6 +123,13 @@ func cfgInt(m map[string]any, key string) (int, bool) {
|
||||
}
|
||||
|
||||
const (
|
||||
// directMsgPerMinKey 是账号级「非主人私聊(陌生人)每分钟每发送者上限」的配置键
|
||||
// (design/12 §6 策略三;主人 2026-10-05 裁决 13)。它与 groupMsgPerMin 同一条读法:
|
||||
// 由 cmd/cyrene 的 namedAdapter 从账号字段透出到 Config(),桥在这里解析。
|
||||
directMsgPerMinKey = "directMsgPerMin"
|
||||
// flowgateLogSource 是本文件日志的来源标记,使「流控解析结果」跨模块可定位
|
||||
// (项目规范 §五:禁止无来源裸日志)。
|
||||
flowgateLogSource = "services/channelbridge/flowgate"
|
||||
// reviewMaxTokens 限定审查模型的输出,使冗长的队列清单绝不会
|
||||
// 产出超长补全(design/12 §6 策略四 成本护栏)。
|
||||
reviewMaxTokens = 256
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
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"
|
||||
@@ -104,8 +108,9 @@ func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) {
|
||||
|
||||
// TestFlowGate_GateOptionsDefaults 钉住「配置→限制」的解析规则,
|
||||
// 它保证旧的 CHANNELS_JSON 账号向后兼容:键缺失 → 关闭,0 → 保护性
|
||||
// 默认值,负值 → 关闭,正值 → 固定为该值。**私聊(陌生人)限流是例外**:
|
||||
// 它不读配置键,恒取保护性默认(裁决③:陌生人必须有流控)。
|
||||
// 默认值,负值 → 关闭,正值 → 固定为该值。**私聊(陌生人)限流同一口径,
|
||||
// 但缺省侧相反**:键缺失/非整数/0 一律取保护性默认 6(裁决③「陌生人必须有流控」
|
||||
// +裁决 13「流控时间要可配置」)——绝不因为「没配」而静默关掉。
|
||||
func TestFlowGate_GateOptionsDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -114,18 +119,25 @@ func TestFlowGate_GateOptionsDefaults(t *testing.T) {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// direct 列用**字面量 6**(该常量由 adapter/flowgate_direct_test.go 的
|
||||
// TestFlowDirectDefaultIsPinned 钉住):缺键时若解析成 0(= 关掉流控)本用例必须红。
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]any
|
||||
group, max int
|
||||
direct 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},
|
||||
{"absent keys off", map[string]any{"bot_token": "x"}, 0, 0, 6},
|
||||
{"group 0 defaults 6", map[string]any{"bot_token": "x", "groupMsgPerMin": 0}, 6, 0, 6},
|
||||
{"group -1 off", map[string]any{"bot_token": "x", "groupMsgPerMin": -1}, 0, 0, 6},
|
||||
{"group 10 pinned", map[string]any{"bot_token": "x", "groupMsgPerMin": 10}, 10, 0, 6},
|
||||
{"max 0 defaults 50", map[string]any{"bot_token": "x", "maxMessages": 0}, 0, 50, 6},
|
||||
{"max -1 off", map[string]any{"bot_token": "x", "maxMessages": -1}, 0, 0, 6},
|
||||
{"max 80 pinned", map[string]any{"bot_token": "x", "maxMessages": 80}, 0, 80, 6},
|
||||
{"direct 0 defaults 6", map[string]any{"bot_token": "x", "directMsgPerMin": 0}, 0, 0, 6},
|
||||
{"direct -1 off", map[string]any{"bot_token": "x", "directMsgPerMin": -1}, 0, 0, 0},
|
||||
{"direct 12 pinned", map[string]any{"bot_token": "x", "directMsgPerMin": 12}, 0, 0, 12},
|
||||
{"direct non-numeric defaults 6", map[string]any{"bot_token": "x", "directMsgPerMin": "abc"}, 0, 0, 6},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -136,16 +148,116 @@ 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)
|
||||
if opts.DirectMsgPerMin != c.direct {
|
||||
t.Fatalf("DirectMsgPerMin = %d, want %d(缺失/0 走保护性默认 6,负值=关闭,正值=该值)",
|
||||
opts.DirectMsgPerMin, c.direct)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_DirectLimitFromAccountConfig 验证 directMsgPerMin 从账号配置到闸门的
|
||||
// **整条链**:键缺失 ⇒ 保护性默认 6 条/分真的拦下第 7 条;正值 ⇒ 该值;负值 ⇒ 关闭
|
||||
// (全部放行)。计数按发送者独立(AllowDirect 的会话键含 sender)。
|
||||
func TestFlowGate_DirectLimitFromAccountConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]any
|
||||
// allowed 是限制内会被放行的条数;off = 限制被显式关闭(第 allowed+1 条不会被拒)。
|
||||
allowed int
|
||||
off bool
|
||||
}{
|
||||
{"absent key keeps the protective default 6", map[string]any{"bot_token": "x"}, 6, false},
|
||||
{"pinned 2", map[string]any{"bot_token": "x", "directMsgPerMin": 2}, 2, false},
|
||||
{"negative disables the limit", map[string]any{"bot_token": "x", "directMsgPerMin": -1}, 12, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b, ad := newGateBridge(t, c.cfg)
|
||||
gate := b.gateFor(ad)
|
||||
if gate == nil {
|
||||
t.Fatal("gateFor = nil, want a gate for the configured adapter")
|
||||
}
|
||||
for i := 0; i < c.allowed; i++ {
|
||||
if !gate.AllowDirect(strangerDirectMsg("777", "第 "+itoa(i)+" 句")) {
|
||||
t.Fatalf("额度内第 %d 条被拒,want allowed", i+1)
|
||||
}
|
||||
}
|
||||
// 限制生效时第 allowed+1 条必须被拒(off=false);关闭时(off=true)必须放行。
|
||||
if over := gate.AllowDirect(strangerDirectMsg("777", "第 N+1 句")); over != c.off {
|
||||
t.Fatalf("第 %d 条的放行结果 = %v(off=%v),want %v",
|
||||
c.allowed+1, over, c.off, c.off)
|
||||
}
|
||||
// 另一个发送者有独立额度:不被前一个发送者挤占。
|
||||
if !gate.AllowDirect(strangerDirectMsg("888", "我是另一个人")) {
|
||||
t.Fatal("另一个发送者的额度被挤占")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newGateBridge 构建一条只用于流控解析的桥接器(telegram 适配器 + 给定账号配置),
|
||||
// 使 directMsgPerMin 走与生产完全相同的「Config() → gateOptions」链。
|
||||
func newGateBridge(t *testing.T, cfg map[string]any) (*Bridge, *fakeAdapter) {
|
||||
t.Helper()
|
||||
b, err := New(Options{Loop: &stubChatter{}, Sessions: &stubSessions{}})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
ad := &fakeAdapter{name: "telegram", cfg: cfg}
|
||||
b.Add(ad)
|
||||
return b, ad
|
||||
}
|
||||
|
||||
// TestFlowGate_DirectLimitOffIsLogged 钉住「负值关闭必须留痕」:主人显式关掉熟人/
|
||||
// 陌生人私聊流控时,日志里必须出现一条带 source 的 WARN(不许静默关闭);而正值或
|
||||
// 缺省(保护性默认)绝不产生这条告警(否则主人会以为流控被关了)。
|
||||
func TestFlowGate_DirectLimitOffIsLogged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
b, _, ad := newStrangerBridge(t, &stubChatter{}, &captureHistory{}, at, log)
|
||||
|
||||
ad.cfg = map[string]any{"bot_token": "x", "directMsgPerMin": -1}
|
||||
if opts := b.gateOptions(ad); opts.DirectMsgPerMin != 0 {
|
||||
t.Fatalf("DirectMsgPerMin = %d, want 0(负值 = 关闭)", opts.DirectMsgPerMin)
|
||||
}
|
||||
logged := buf.String()
|
||||
if !strings.Contains(logged, "non-owner private rate limit turned off") ||
|
||||
!strings.Contains(logged, "source=services/channelbridge/flowgate") ||
|
||||
!strings.Contains(logged, "directMsgPerMin") {
|
||||
t.Fatalf("负值关闭流控没有留下带 source 的可见告警:%q", logged)
|
||||
}
|
||||
|
||||
// 正值:不告警,且额度就是该值。
|
||||
buf.Reset()
|
||||
ad.cfg = map[string]any{"bot_token": "x", "directMsgPerMin": 12}
|
||||
if opts := b.gateOptions(ad); opts.DirectMsgPerMin != 12 {
|
||||
t.Fatalf("DirectMsgPerMin = %d, want 12", opts.DirectMsgPerMin)
|
||||
}
|
||||
if got := buf.String(); got != "" {
|
||||
t.Fatalf("正值不该产生「流控被关闭」告警:%q", got)
|
||||
}
|
||||
|
||||
// 缺省(保护性默认):同样不告警 —— 默认不是「主人关掉了它」。
|
||||
buf.Reset()
|
||||
ad.cfg = map[string]any{"bot_token": "x"}
|
||||
if opts := b.gateOptions(ad); opts.DirectMsgPerMin != adapter.FlowDirectDefault {
|
||||
t.Fatalf("DirectMsgPerMin = %d, want the protective default %d",
|
||||
opts.DirectMsgPerMin, adapter.FlowDirectDefault)
|
||||
}
|
||||
if got := buf.String(); got != "" {
|
||||
t.Fatalf("缺省不该产生「流控被关闭」告警:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// mockLLM 是返回固定补全的 model.Adapter 桩。
|
||||
type mockLLM struct {
|
||||
content string
|
||||
|
||||
@@ -177,16 +177,17 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
}
|
||||
|
||||
// 裁决①(主人 2026-10-05):**陌生人私聊 = 正常聊天** —— 不设安静期、
|
||||
// 不走「值不值得回」的概率门,直接进入完整回合。唯一的闸门是强制
|
||||
// 流控:同一发送者每分钟 ≤ FlowDirectDefault 条(AllowDirect 记账)
|
||||
// 与同时最多 1 个在跑的回合(processFrom 用 AcquireTurn 施加)。
|
||||
// 不走「值不值得回」的概率门,直接进入完整回合。唯一的闸门是流控:
|
||||
// 同一发送者每分钟 ≤ 该账号的 directMsgPerMin(未配 = 保护性默认 6,裁决 13
|
||||
// 让它可配;AllowDirect 记账)与同时最多 1 个在跑的回合
|
||||
// (processFrom 用 AcquireTurn 施加)。
|
||||
// 被限流时**不回**:调用方会把它登记进隔离会话,这里留一条带
|
||||
// source 的可见告警(绝不静默)。
|
||||
// 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)
|
||||
"limit_per_min", g.DirectPerMin())
|
||||
return false, "stranger private: over the per-sender flow limit"
|
||||
}
|
||||
return true, "stranger private chat: normal reply"
|
||||
|
||||
Reference in new issue
Block a user