feat(channel/session): 渠道↔会话映射 + 主人识别(actor元数据+白名单,不读消息内容) + 群聊隔离 + 主动推送策略(iLink24h/OneBot限频)
This commit is contained in:
1 parent
3ce9489f8e
commit
7adaf490c7
7 files changed
+1133
No files matched your search
@@ -0,0 +1,169 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user