- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner - session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝 - 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效) - 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测) - 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
178 lines
6.2 KiB
Go
178 lines
6.2 KiB
Go
package session
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
)
|
|
|
|
// 主动推送的每平台默认值(design/08 §3):weixin-ilink 只允许在最后一条入站消息的
|
|
// 24 小时内推送(超 24h 推不过去);OneBot 可推送但要限频以避开风控;telegram/webhook
|
|
// 推送强、不设限;**能力未声明的未知平台保守默认**(裁决 06.4.4)——严格限频,绝不给
|
|
// 「无限制」。真正的限幅落在适配器实现,策略层只声明口径(design/08 §3)。
|
|
const (
|
|
// iLinkWindow is the weixin-ilink proactive push window.
|
|
iLinkWindow = 24 * time.Hour
|
|
// oneBotBurstLimit is the maximum OneBot pushes within the window.
|
|
oneBotBurstLimit = 3
|
|
// oneBotBurstWindow is the OneBot rate-limit window.
|
|
oneBotBurstWindow = 10 * time.Second
|
|
// unknownBurstLimit / unknownBurstWindow 是未知平台的保守默认:每分钟最多 1 条主动
|
|
// 推送(比 OneBot 的 3 条/10s 更紧),保证「未知 ≠ 无限制」。
|
|
unknownBurstLimit = 1
|
|
unknownBurstWindow = time.Minute
|
|
)
|
|
|
|
// ProactiveOptions configures a ProactivePolicy.
|
|
type ProactiveOptions struct {
|
|
// Window is the inactivity window after which a push is refused. Zero
|
|
// disables the window check (unrestricted channels).
|
|
Window time.Duration
|
|
// RateLimit is the maximum push count within RateWindow. Zero disables the
|
|
// window check and rate limiting together.
|
|
RateLimit int
|
|
// RateWindow is the sliding window over which RateLimit is enforced. Zero
|
|
// disables rate limiting.
|
|
RateWindow time.Duration
|
|
}
|
|
|
|
// ProactivePolicy decides whether a proactive push to a channel is allowed. It
|
|
// encodes the per-platform rules from design/08 §3: a capability gate (the
|
|
// adapter must declare Capabilities.Proactive), an optional inactivity window
|
|
// (weixin-ilink), and an optional burst rate limit (OneBot). The policy is
|
|
// immutable after construction and safe for concurrent use.
|
|
type ProactivePolicy struct {
|
|
log *slog.Logger
|
|
window time.Duration
|
|
limiter *RateLimiter
|
|
}
|
|
|
|
// NewProactivePolicy builds a policy from opts. A zero Window and a zero
|
|
// RateLimit yield an unrestricted policy (telegram/webhook). WithLogger can be
|
|
// chained to tag denial records with the channel/session source.
|
|
func NewProactivePolicy(opts ProactiveOptions) *ProactivePolicy {
|
|
p := &ProactivePolicy{window: opts.Window}
|
|
if opts.RateLimit > 0 && opts.RateWindow > 0 {
|
|
p.limiter = NewRateLimiter(opts.RateLimit, opts.RateWindow)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ProactivePolicyForPlatform 返回某平台标识对应的默认主动推送策略(design/08 §3)。
|
|
// 已声明的平台按其规格:weixin-ilink 走 24h 窗口、OneBot 限频、telegram/webhook 推送强
|
|
// 不设限。**未知(能力未声明)平台保守默认**(裁决 06.4.4):给最严格的限频
|
|
// (1 条/分钟),不再返回「无限制」——限幅落在适配器实现,策略层只声明口径。
|
|
func ProactivePolicyForPlatform(platform string) *ProactivePolicy {
|
|
switch platform {
|
|
case "weixin-ilink":
|
|
return NewProactivePolicy(ProactiveOptions{Window: iLinkWindow})
|
|
case "qq-obv11", "qq-onebot", "onebot":
|
|
return NewProactivePolicy(ProactiveOptions{RateLimit: oneBotBurstLimit, RateWindow: oneBotBurstWindow})
|
|
case "telegram", "webhook":
|
|
return NewProactivePolicy(ProactiveOptions{})
|
|
default:
|
|
return NewProactivePolicy(ProactiveOptions{RateLimit: unknownBurstLimit, RateWindow: unknownBurstWindow})
|
|
}
|
|
}
|
|
|
|
// WithLogger returns a copy of p that logs denials through l with the
|
|
// channel/session source tag. It does not mutate p.
|
|
func (p *ProactivePolicy) WithLogger(l *slog.Logger) *ProactivePolicy {
|
|
if p == nil || l == nil {
|
|
return p
|
|
}
|
|
cp := *p
|
|
cp.log = logger.WithSource(l, "channel/session")
|
|
return &cp
|
|
}
|
|
|
|
// Allow reports whether a proactive push may be sent now. It fails closed: a nil
|
|
// policy, a canceled ctx, or an adapter that does not declare proactive support
|
|
// all deny. Then it applies the configured inactivity window and burst limit.
|
|
func (p *ProactivePolicy) Allow(ctx context.Context, caps adapter.Capabilities, lastMessageAt time.Time) bool {
|
|
if p == nil {
|
|
return false
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
p.logDeny("context canceled", err.Error())
|
|
return false
|
|
}
|
|
if !caps.Proactive {
|
|
p.logDeny("capability off", "capture.proactive=false")
|
|
return false
|
|
}
|
|
if p.window > 0 {
|
|
if lastMessageAt.IsZero() || time.Since(lastMessageAt) > p.window {
|
|
p.logDeny("outside window", "last_message_at="+lastMessageAt.UTC().Format(time.RFC3339))
|
|
return false
|
|
}
|
|
}
|
|
if p.limiter != nil && !p.limiter.Allow() {
|
|
p.logDeny("rate limited", "")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (p *ProactivePolicy) logDeny(reason, detail string) {
|
|
if p == nil || p.log == nil {
|
|
return
|
|
}
|
|
args := []any{"reason", reason}
|
|
if detail != "" {
|
|
args = append(args, "detail", detail)
|
|
}
|
|
p.log.Warn("proactive push denied by policy", args...)
|
|
}
|
|
|
|
// RateLimiter is a concurrency-safe sliding-window rate limiter. It keeps event
|
|
// timestamps in memory and reports whether a new event may proceed. It is
|
|
// independent of any bus: the sliding window is a purely in-process counter that
|
|
// is safe to share across goroutines.
|
|
type RateLimiter struct {
|
|
mu sync.Mutex
|
|
limit int
|
|
window time.Duration
|
|
events []time.Time
|
|
}
|
|
|
|
// NewRateLimiter builds a limiter that allows at most limit events per window.
|
|
// A limit <= 0 or window <= 0 yields an unlimited (always-allow) limiter, which
|
|
// is the safe "no rule configured" default.
|
|
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
|
return &RateLimiter{limit: limit, window: window}
|
|
}
|
|
|
|
// Allow records a new event if the sliding window has capacity, returning true
|
|
// when the event may proceed, and false when it would exceed the limit. Events
|
|
// older than window are dropped before counting.
|
|
func (l *RateLimiter) Allow() bool {
|
|
if l == nil || l.limit <= 0 || l.window <= 0 {
|
|
return true
|
|
}
|
|
now := time.Now()
|
|
cutoff := now.Add(-l.window)
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
keep := 0
|
|
for _, t := range l.events {
|
|
if t.After(cutoff) {
|
|
l.events[keep] = t
|
|
keep++
|
|
}
|
|
}
|
|
l.events = l.events[:keep]
|
|
|
|
if len(l.events) >= l.limit {
|
|
return false
|
|
}
|
|
l.events = append(l.events, now)
|
|
return true
|
|
}
|