package queue import ( "strings" "sync" "time" "unicode" ) // 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) DiscardExpired = "expired" // länger gewartet als queue.max_age (Ansage wäre veraltet) ) // 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. // // Lock-Ordnung: RateLimiter.mu ist immer die innere Sperre — Aufrufer // halten beim Allow/Reserve/Next die Queue-sperre (niemals umgekehrt). type RateLimiter struct { // Interval ist der Mindestabstand zwischen zwei Ansage-Starten. // 0 oder negativ = unbegrenzt. 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 } // 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). func (l *RateLimiter) Reserve() { if l == nil || l.Interval <= 0 { return } l.mu.Lock() defer l.mu.Unlock() l.last = time.Now() } // 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. 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 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 // 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 } // verdict ist die Gesetzes-Entscheidung über ein fälliges Item. type verdict int const ( 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 ) // 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). // 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, // denn Burst ist ein Rate-Recht, kein Ruhe-Recht. // 4. Rate-Limit: freier Slot spielt; high spielt mit Burst-Recht; low // 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 } 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 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 // Truncated meldet, dass der Text auf MaxText gekürzt wurde (die // gekürzte Fassung ist aufgenommen). Truncated bool } // 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 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 }