Files
Cyrene_v2/internal/channel/session/proactive.go
T

170 lines
5.4 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"
)
// 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
}