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" ) // Proactive push per-platform defaults (design/08 §3): weixin-ilink allows push // only within 24h of the last inbound message; OneBot allows push but must be // rate-limited to avoid wind-control flags; telegram/webhook push freely. 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 ) // 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 returns the default proactive policy for a known // platform identifier, following design/08 §3. Unknown platforms get an // unrestricted policy, which is the conservative-outcome of "strong" channels. 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}) default: return NewProactivePolicy(ProactiveOptions{}) } } // 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 }