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 }