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-18 17:40:41 +02:00
|
|
|
// Verwurfs-Gründe im HTTP-Vertrag von POST /speak
|
|
|
|
|
// (202 {"id":"","discarded":"…"}).
|
|
|
|
|
const (
|
|
|
|
|
DiscardRateLimit = "rate_limit" // priority=low über dem Fenster-Budget
|
|
|
|
|
DiscardQuietHours = "quiet_hours" // Ruhezeit aktiv (discard oder toast)
|
2026-10-24 09:54:40 +02:00
|
|
|
DiscardExpired = "expired" // länger gewartet als queue.max_age (Ansage wäre veraltet)
|
2026-10-18 17:40:41 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// RateLimiter ist das Zeitfenster des Daemon-Gesetzes gegen Ansage-Spam:
|
|
|
|
|
// Spec-Default ca. 1 Ansage / 30 s (queue.rate_limit), 0 = unbegrenzt.
|
|
|
|
|
// Jede gestartete Ansage bucht das Fenster neu (Reserve) — auch eine per
|
|
|
|
|
// now oder high-burst ausgebrochene: Das Fenster zählt Abspiel-Starts,
|
|
|
|
|
// nicht deren Legitimität.
|
2026-10-18 17:10:26 +02:00
|
|
|
//
|
2026-10-18 17:40:41 +02:00
|
|
|
// Lock-Ordnung: RateLimiter.mu ist immer die innere Sperre — Aufrufer
|
|
|
|
|
// halten beim Allow/Reserve/Next die Queue-sperre (niemals umgekehrt).
|
2026-10-18 17:10:26 +02:00
|
|
|
type RateLimiter struct {
|
2026-10-18 17:40:41 +02:00
|
|
|
// Interval ist der Mindestabstand zwischen zwei Ansage-Starten.
|
|
|
|
|
// 0 oder negativ = unbegrenzt.
|
2026-10-18 17:10:26 +02:00
|
|
|
Interval time.Duration
|
|
|
|
|
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
last time.Time // Start der zuletzt per Reserve gebuchten Ansage
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Allow meldet, ob sofort eine Ansage starten dürfte.
|
|
|
|
|
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-18 17:40:41 +02:00
|
|
|
// Reserve bucht einen Ansage-Slot (direkt nach der Play-Entscheidung
|
|
|
|
|
// aufrufen, vor dem eigentlichen Abspielen — auch fehlgeschlagene Ansagen
|
|
|
|
|
// verbrauchen das Fenster: Ein kaputter Provider darf nicht zum Maschinengewehr
|
|
|
|
|
// werden).
|
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-18 17:40:41 +02:00
|
|
|
// Next liefert, wann der nächste Ansage-Slot frei wird — Nullzeit, wenn
|
|
|
|
|
// Allow sofort true liefern würde (unbegrenzt oder Fenster abgelaufen).
|
|
|
|
|
// Basis für das Parken wartender Items und den 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
|
|
|
|
|
|
|
|
// Laws sind die Anti-Spam-Gesetze des Daemons (Task 05): Rate-Limit mit
|
|
|
|
|
// verkonfigurierbarem Verstoß-Recht für high, plus Ruhezeiten. Der Nullwert
|
|
|
|
|
// erlaubt alles (unbegrenzt, kein Ruhefenster) — der Daemon liefert die
|
|
|
|
|
// Gesetze aus der Config; Default siehe internal/config.Defaults.
|
|
|
|
|
type Laws struct {
|
|
|
|
|
// RateLimit ist der Mindestabstand zwischen zwei Ansage-Starten
|
|
|
|
|
// (0 = unbegrenzt).
|
|
|
|
|
RateLimit time.Duration
|
|
|
|
|
|
|
|
|
|
// AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu
|
|
|
|
|
// verstoßen (Default true). Ohne Burst-Recht wartet high wie normal.
|
|
|
|
|
// Die Ruhezeit gilt davon unberührt auch für high.
|
|
|
|
|
AllowHighBurst bool
|
|
|
|
|
|
|
|
|
|
// Quiet ist das Ruhezeit-Fenster (lokale Zeit; Nullwert = deaktiviert).
|
|
|
|
|
Quiet QuietHours
|
|
|
|
|
|
|
|
|
|
// QuietMode bestimmt das Verhalten in der Ruhezeit: "discard" verwirft
|
|
|
|
|
// still, "toast" ersetzt Audio durch eine Benachrichtigung — aktuell
|
|
|
|
|
// eine Protokoll-Zeile über den Notification-Pfad des Loggers; der
|
|
|
|
|
// echte OS-Toast folgt mit der Notification-Anbindung (dokumentierte
|
|
|
|
|
// Einschränkung, siehe Task-05-Report).
|
|
|
|
|
QuietMode string
|
2026-10-24 09:54:40 +02:00
|
|
|
|
|
|
|
|
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen (Runen).
|
|
|
|
|
// Längere Texte werden beim Eintritt an einer Wortgrenze gekürzt und
|
|
|
|
|
// als gekürzt gemeldet — ein Agent, der die Langantwort vorliest,
|
|
|
|
|
// kostet so weder Minuten noch API-Budget; der Nutzer hört den Anfang.
|
|
|
|
|
// 0 = unbegrenzt.
|
|
|
|
|
MaxText int
|
|
|
|
|
|
|
|
|
|
// MaxAge ist die längste Wartezeit einer Ansage in der Queue. Was
|
|
|
|
|
// länger parkt (Rate-Limit, Stau), ist veraltet und wird beim
|
|
|
|
|
// Fälligwerden verworfen (DiscardExpired) statt gesprochen. 0 =
|
|
|
|
|
// unbegrenzt. now:true wartet nie lange und ist davon ausgenommen.
|
|
|
|
|
MaxAge time.Duration
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// verdict ist die Gesetzes-Entscheidung über ein fälliges Item.
|
|
|
|
|
type verdict int
|
|
|
|
|
|
|
|
|
|
const (
|
2026-10-24 09:54:40 +02:00
|
|
|
verdictPlay verdict = iota // abspielen
|
|
|
|
|
verdictWait // parken bis zum freien Slot
|
|
|
|
|
verdictDiscardRate // verwerfen: low über dem Budget
|
|
|
|
|
verdictDiscardQuiet // verwerfen: Ruhezeit (discard)
|
|
|
|
|
verdictToast // Toast statt Audio (Ruhezeit)
|
|
|
|
|
verdictDiscardExpired // verwerfen: länger gewartet als MaxAge
|
2026-10-18 17:40:41 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// decide wendet die Gesetze auf ein fälliges Item an (rein, ohne Zustand):
|
|
|
|
|
// slotFree sagt, ob der Rate-Limit-Slot gerade frei ist. Rangordnung:
|
|
|
|
|
//
|
|
|
|
|
// 1. now:true bricht bewusst aus — überspringt Rate-Limit UND Ruhezeit
|
|
|
|
|
// (dokumentierter Ausbruch für Dringendes; startet trotzdem ein neues
|
|
|
|
|
// Rate-Fenster).
|
2026-10-24 09:54:40 +02:00
|
|
|
// 2. Veraltet: Hat das Item länger als MaxAge gewartet, wird es verworfen
|
|
|
|
|
// — eine Ansage von vor fünf Minuten ist keine Nachricht mehr.
|
|
|
|
|
// 3. Ruhezeit: discard verwirft, toast ersetzt — gilt auch für high,
|
2026-10-18 17:40:41 +02:00
|
|
|
// denn Burst ist ein Rate-Recht, kein Ruhe-Recht.
|
2026-10-24 09:54:40 +02:00
|
|
|
// 4. Rate-Limit: freier Slot spielt; high spielt mit Burst-Recht; low
|
2026-10-18 17:40:41 +02:00
|
|
|
// wird verworfen; normal (und high ohne Recht) parken bis zum Slot.
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Admission ist das Ergebnis von Enqueue: Aufgenommene tragen ihre ID,
|
|
|
|
|
// verworfene nennen den Grund (die 202-Antwort von POST /speak führt ihn).
|
|
|
|
|
type Admission struct {
|
|
|
|
|
// ID der aufgenommenen Ansage (leer, wenn verworfen).
|
|
|
|
|
ID string
|
|
|
|
|
|
|
|
|
|
// Discarded ist der Verwurfs-Grund (DiscardRateLimit bzw.
|
|
|
|
|
// DiscardQuietHours); leer, wenn die Ansage aufgenommen wurde.
|
|
|
|
|
Discarded string
|
|
|
|
|
|
|
|
|
|
// Toast meldet, dass die Ansage in der Ruhezeit durch eine
|
|
|
|
|
// Benachrichtigung ersetzt wurde (quiet_mode = "toast").
|
|
|
|
|
Toast bool
|
2026-10-24 09:54:40 +02:00
|
|
|
|
|
|
|
|
// Truncated meldet, dass der Text auf MaxText gekürzt wurde (die
|
|
|
|
|
// gekürzte Fassung ist aufgenommen).
|
|
|
|
|
Truncated bool
|
2026-10-18 17:40:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LawState ist die /status-Sicht auf die Anti-Spam-Gesetze: konfiguriertes
|
|
|
|
|
// Rate-Limit mit dem nächsten freien Slot sowie das Ruhezeit-Fenster.
|
|
|
|
|
type LawState struct {
|
|
|
|
|
RateLimit time.Duration
|
|
|
|
|
AllowHighBurst bool
|
|
|
|
|
NextSlot time.Time // Nullzeit = Slot frei (oder unbegrenzt)
|
|
|
|
|
Quiet QuietHours
|
|
|
|
|
QuietActive bool
|
|
|
|
|
QuietMode string
|
2026-10-24 09:54:40 +02:00
|
|
|
QuietUntil time.Time // Nullzeit = Ruhezeit inaktiv
|
|
|
|
|
MaxText int // 0 = unbegrenzt
|
|
|
|
|
MaxAge time.Duration // 0 = unbegrenzt
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// truncateText kürzt s auf höchstens max Runen, bevorzugt an einer
|
|
|
|
|
// Wortgrenze (sofern die nicht in der ersten Hälfte liegt), und meldet, ob
|
|
|
|
|
// gekürzt wurde. max <= 0 = unbegrenzt.
|
|
|
|
|
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
|
|
|
}
|