170 lines
5.4 KiB
Go
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
|
|
}
|