2026-10-18 17:10:26 +02:00
|
|
|
package queue
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-24 09:54:40 +02:00
|
|
|
"strings"
|
2026-10-18 17:10:26 +02:00
|
|
|
"sync"
|
|
|
|
|
"time"
|
2026-10-24 09:54:40 +02:00
|
|
|
"unicode"
|
2026-10-18 17:10:26 +02:00
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Discard reasons in the HTTP contract of POST /speak
|
2026-10-18 17:40:41 +02:00
|
|
|
// (202 {"id":"","discarded":"…"}).
|
|
|
|
|
const (
|
2026-10-24 11:15:35 +02:00
|
|
|
DiscardRateLimit = "rate_limit" // priority=low over the window budget
|
|
|
|
|
DiscardQuietHours = "quiet_hours" // quiet hours active (discard or toast)
|
|
|
|
|
DiscardExpired = "expired" // waited longer than queue.max_age (stale)
|
2026-10-18 17:40:41 +02:00
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// RateLimiter is the time window of the daemon's anti-spam law: default
|
|
|
|
|
// about one announcement per 30 s (queue.rate_limit), 0 = unlimited. Every
|
|
|
|
|
// started announcement books the window anew (Reserve), including ones that
|
|
|
|
|
// broke out via now or high burst: the window counts playback starts, not
|
|
|
|
|
// their legitimacy.
|
2026-10-18 17:10:26 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// Lock order: RateLimiter.mu is always the inner lock; callers hold the
|
|
|
|
|
// queue lock during Allow/Reserve/Next, never the other way round.
|
2026-10-18 17:10:26 +02:00
|
|
|
type RateLimiter struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
// Interval is the minimum gap between two announcement starts. 0 or
|
|
|
|
|
// negative = unlimited.
|
2026-10-18 17:10:26 +02:00
|
|
|
Interval time.Duration
|
|
|
|
|
|
|
|
|
|
mu sync.Mutex
|
2026-10-24 11:15:35 +02:00
|
|
|
last time.Time // start of the announcement last booked via Reserve
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Allow reports whether an announcement could start right now.
|
2026-10-18 17:10:26 +02:00
|
|
|
func (l *RateLimiter) Allow() bool {
|
|
|
|
|
if l == nil || l.Interval <= 0 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
return time.Since(l.last) >= l.Interval
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Reserve books a slot. Call it right after the play decision, before actual
|
|
|
|
|
// playback: failed announcements consume the window too, so a broken
|
|
|
|
|
// provider cannot turn into a machine gun.
|
2026-10-18 17:10:26 +02:00
|
|
|
func (l *RateLimiter) Reserve() {
|
|
|
|
|
if l == nil || l.Interval <= 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
l.last = time.Now()
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Next returns when the next slot opens; the zero time when Allow would
|
|
|
|
|
// return true right away (unlimited or window elapsed). Basis for parking
|
|
|
|
|
// waiting items and for the status endpoint.
|
2026-10-18 17:10:26 +02:00
|
|
|
func (l *RateLimiter) Next() time.Time {
|
|
|
|
|
if l == nil || l.Interval <= 0 {
|
|
|
|
|
return time.Time{}
|
|
|
|
|
}
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
defer l.mu.Unlock()
|
2026-10-18 17:40:41 +02:00
|
|
|
if l.last.IsZero() {
|
|
|
|
|
return time.Time{}
|
|
|
|
|
}
|
2026-10-18 17:10:26 +02:00
|
|
|
return l.last.Add(l.Interval)
|
|
|
|
|
}
|
2026-10-18 17:40:41 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Laws are the daemon's anti-spam laws: rate limit with configurable burst
|
|
|
|
|
// rights for high, quiet hours, text length cap and maximum queue age. The
|
|
|
|
|
// zero value allows everything; the daemon supplies the laws from the
|
|
|
|
|
// config (defaults in internal/config.Defaults).
|
2026-10-18 17:40:41 +02:00
|
|
|
type Laws struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
// RateLimit is the minimum gap between two announcement starts
|
|
|
|
|
// (0 = unlimited).
|
2026-10-18 17:40:41 +02:00
|
|
|
RateLimit time.Duration
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// AllowHighBurst lets priority=high deliberately break the rate limit
|
|
|
|
|
// (default true). Without burst rights, high waits like normal. Quiet
|
|
|
|
|
// hours apply to high regardless.
|
2026-10-18 17:40:41 +02:00
|
|
|
AllowHighBurst bool
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Quiet is the quiet window (local time; zero value = disabled).
|
2026-10-18 17:40:41 +02:00
|
|
|
Quiet QuietHours
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// QuietMode is the behaviour during quiet hours: "discard" drops
|
|
|
|
|
// silently, "toast" replaces audio with a desktop notification (the
|
|
|
|
|
// queue fires the OnToast hook; the daemon wires the notification).
|
2026-10-18 17:40:41 +02:00
|
|
|
QuietMode string
|
2026-10-24 09:54:40 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// MaxText caps announcement length in characters (runes). Longer texts
|
|
|
|
|
// are cut at a word boundary on admission and reported as truncated: an
|
|
|
|
|
// agent reading out its long answer costs neither minutes nor API
|
|
|
|
|
// budget, and the user still hears the beginning. 0 = unlimited.
|
2026-10-24 09:54:40 +02:00
|
|
|
MaxText int
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// MaxAge is the longest an announcement may wait in the queue. Whatever
|
|
|
|
|
// parks longer (rate limit, backlog) is stale and gets discarded when it
|
|
|
|
|
// comes due (DiscardExpired) instead of spoken. 0 = unlimited. now:true
|
|
|
|
|
// never waits long and is exempt.
|
2026-10-24 09:54:40 +02:00
|
|
|
MaxAge time.Duration
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// verdict is the laws' decision about a due item.
|
2026-10-18 17:40:41 +02:00
|
|
|
type verdict int
|
|
|
|
|
|
|
|
|
|
const (
|
2026-10-24 11:15:35 +02:00
|
|
|
verdictPlay verdict = iota // play
|
|
|
|
|
verdictWait // park until the slot opens
|
|
|
|
|
verdictDiscardRate // discard: low over budget
|
|
|
|
|
verdictDiscardQuiet // discard: quiet hours (discard)
|
|
|
|
|
verdictToast // toast instead of audio (quiet hours)
|
|
|
|
|
verdictDiscardExpired // discard: waited longer than MaxAge
|
2026-10-18 17:40:41 +02:00
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// decide applies the laws to a due item (pure, no state): slotFree says
|
|
|
|
|
// whether the rate-limit slot is open right now. Precedence:
|
2026-10-18 17:40:41 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// 1. now:true deliberately breaks out: it skips rate limit AND quiet hours
|
|
|
|
|
// (the documented escape for urgent matters; it still books a new rate
|
|
|
|
|
// window).
|
|
|
|
|
// 2. Stale: an item that waited longer than MaxAge is discarded; an
|
|
|
|
|
// announcement from five minutes ago is no longer news.
|
|
|
|
|
// 3. Quiet hours: discard drops, toast replaces; this applies to high too,
|
|
|
|
|
// because burst is a rate right, not a quiet right.
|
|
|
|
|
// 4. Rate limit: an open slot plays; high plays with burst rights; low is
|
|
|
|
|
// discarded; normal (and high without rights) park until the slot.
|
2026-10-18 17:40:41 +02:00
|
|
|
func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
|
|
|
|
|
if item.Now {
|
|
|
|
|
return verdictPlay
|
|
|
|
|
}
|
2026-10-24 09:54:40 +02:00
|
|
|
if l.MaxAge > 0 && !item.enqueued.IsZero() && now.Sub(item.enqueued) > l.MaxAge {
|
|
|
|
|
return verdictDiscardExpired
|
|
|
|
|
}
|
2026-10-18 17:40:41 +02:00
|
|
|
if l.Quiet.Active(now) {
|
|
|
|
|
if l.QuietMode == QuietModeToast {
|
|
|
|
|
return verdictToast
|
|
|
|
|
}
|
|
|
|
|
return verdictDiscardQuiet
|
|
|
|
|
}
|
|
|
|
|
if slotFree {
|
|
|
|
|
return verdictPlay
|
|
|
|
|
}
|
|
|
|
|
if item.Priority == PriorityHigh && l.AllowHighBurst {
|
|
|
|
|
return verdictPlay
|
|
|
|
|
}
|
|
|
|
|
if item.Priority == PriorityLow {
|
|
|
|
|
return verdictDiscardRate
|
|
|
|
|
}
|
|
|
|
|
return verdictWait
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Admission is the result of Enqueue: admitted items carry their ID,
|
|
|
|
|
// discarded ones name the reason (the 202 response of POST /speak carries
|
|
|
|
|
// it).
|
2026-10-18 17:40:41 +02:00
|
|
|
type Admission struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
// ID of the admitted announcement (empty when discarded).
|
2026-10-18 17:40:41 +02:00
|
|
|
ID string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Discarded is the discard reason (DiscardRateLimit or
|
|
|
|
|
// DiscardQuietHours); empty when the announcement was admitted.
|
2026-10-18 17:40:41 +02:00
|
|
|
Discarded string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Toast reports that the announcement was replaced by a notification
|
|
|
|
|
// during quiet hours (quiet_mode = "toast").
|
2026-10-18 17:40:41 +02:00
|
|
|
Toast bool
|
2026-10-24 09:54:40 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Truncated reports that the text was cut to MaxText (the shortened
|
|
|
|
|
// version was admitted).
|
2026-10-24 09:54:40 +02:00
|
|
|
Truncated bool
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// LawState is the /status view of the laws: the configured rate limit with
|
|
|
|
|
// the next open slot, the quiet window, and the length and age limits.
|
2026-10-18 17:40:41 +02:00
|
|
|
type LawState struct {
|
|
|
|
|
RateLimit time.Duration
|
|
|
|
|
AllowHighBurst bool
|
2026-10-24 11:15:35 +02:00
|
|
|
NextSlot time.Time // zero = slot open (or unlimited)
|
2026-10-18 17:40:41 +02:00
|
|
|
Quiet QuietHours
|
|
|
|
|
QuietActive bool
|
|
|
|
|
QuietMode string
|
2026-10-24 11:15:35 +02:00
|
|
|
QuietUntil time.Time // zero = quiet hours inactive
|
|
|
|
|
MaxText int // 0 = unlimited
|
|
|
|
|
MaxAge time.Duration // 0 = unlimited
|
2026-10-24 09:54:40 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// truncateText cuts s to at most max runes, preferably at a word boundary
|
|
|
|
|
// (unless that lies in the first half), and reports whether it cut.
|
|
|
|
|
// max <= 0 = unlimited.
|
2026-10-24 09:54:40 +02:00
|
|
|
func truncateText(s string, max int) (string, bool) {
|
|
|
|
|
if max <= 0 {
|
|
|
|
|
return s, false
|
|
|
|
|
}
|
|
|
|
|
runes := []rune(s)
|
|
|
|
|
if len(runes) <= max {
|
|
|
|
|
return s, false
|
|
|
|
|
}
|
|
|
|
|
cut := max
|
|
|
|
|
for i := max; i > max/2; i-- {
|
|
|
|
|
if unicode.IsSpace(runes[i]) {
|
|
|
|
|
cut = i
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimRight(string(runes[:cut]), " \t\r\n,;:-–—"), true
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|