package queue import ( "strings" "sync" "time" "unicode" ) // Discard reasons in the HTTP contract of POST /speak // (202 {"id":"","discarded":"…"}). const ( 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) ) // 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. // // Lock order: RateLimiter.mu is always the inner lock; callers hold the // queue lock during Allow/Reserve/Next, never the other way round. type RateLimiter struct { // Interval is the minimum gap between two announcement starts. 0 or // negative = unlimited. Interval time.Duration mu sync.Mutex last time.Time // start of the announcement last booked via Reserve } // Allow reports whether an announcement could start right now. 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 } // 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. func (l *RateLimiter) Reserve() { if l == nil || l.Interval <= 0 { return } l.mu.Lock() defer l.mu.Unlock() l.last = time.Now() } // 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. func (l *RateLimiter) Next() time.Time { if l == nil || l.Interval <= 0 { return time.Time{} } l.mu.Lock() defer l.mu.Unlock() if l.last.IsZero() { return time.Time{} } return l.last.Add(l.Interval) } // 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). type Laws struct { // RateLimit is the minimum gap between two announcement starts // (0 = unlimited). RateLimit time.Duration // AllowHighBurst lets priority=high deliberately break the rate limit // (default true). Without burst rights, high waits like normal. Quiet // hours apply to high regardless. AllowHighBurst bool // Quiet is the quiet window (local time; zero value = disabled). Quiet QuietHours // 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). QuietMode string // 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. MaxText int // 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. MaxAge time.Duration } // verdict is the laws' decision about a due item. type verdict int const ( 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 ) // decide applies the laws to a due item (pure, no state): slotFree says // whether the rate-limit slot is open right now. Precedence: // // 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. func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict { if item.Now { return verdictPlay } if l.MaxAge > 0 && !item.enqueued.IsZero() && now.Sub(item.enqueued) > l.MaxAge { return verdictDiscardExpired } 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 } // Admission is the result of Enqueue: admitted items carry their ID, // discarded ones name the reason (the 202 response of POST /speak carries // it). type Admission struct { // ID of the admitted announcement (empty when discarded). ID string // Discarded is the discard reason (DiscardRateLimit or // DiscardQuietHours); empty when the announcement was admitted. Discarded string // Toast reports that the announcement was replaced by a notification // during quiet hours (quiet_mode = "toast"). Toast bool // Truncated reports that the text was cut to MaxText (the shortened // version was admitted). Truncated bool } // 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. type LawState struct { RateLimit time.Duration AllowHighBurst bool NextSlot time.Time // zero = slot open (or unlimited) Quiet QuietHours QuietActive bool QuietMode string QuietUntil time.Time // zero = quiet hours inactive MaxText int // 0 = unlimited MaxAge time.Duration // 0 = unlimited } // 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. 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 }