2713 lines
145 KiB
Diff
2713 lines
145 KiB
Diff
# Review package: dc94f45..HEAD
|
|
|
|
## Commits
|
|
24ceff3 docs(readme): Rate-Limit, Ruhezeiten und now-Ausbruch dokumentieren
|
|
5979d45 test: Prozessgrenzen-Tests für Rate-Limit und Ruhezeiten
|
|
b56c8ab feat(api): /speak meldet Gesetz-Verwürfe, /status zeigt Gesetz-State
|
|
5385a4b feat(queue): Rate-Limit und Ruhezeiten als Gesetz im Worker-Loop
|
|
b20e6dd feat(config): Gesetz-Felder rate_limit, allow_high_burst, quiet_hours, quiet_mode
|
|
|
|
## Files changed
|
|
README.md | 58 ++++++--
|
|
cmd/talk/control.go | 45 +++++-
|
|
cmd/talk/daemon.go | 29 ++++
|
|
cmd/talkd/main.go | 33 ++--
|
|
internal/api/doc.go | 10 +-
|
|
internal/api/server.go | 45 +++++-
|
|
internal/api/types.go | 59 +++++++-
|
|
internal/config/config.go | 92 +++++++++++-
|
|
internal/config/config_test.go | 179 ++++++++++++++++++++++
|
|
internal/queue/doc.go | 8 +-
|
|
internal/queue/queue.go | 240 ++++++++++++++++++++++++------
|
|
internal/queue/queue_test.go | 238 +++++++++++++++++++++++++++--
|
|
internal/queue/quiet.go | 109 ++++++++++++++
|
|
internal/queue/quiet_test.go | 131 ++++++++++++++++
|
|
internal/queue/rate.go | 136 +++++++++++++++--
|
|
test/harness_test.go | 26 ++++
|
|
test/ratelimit_test.go | 331 +++++++++++++++++++++++++++++++++++++++++
|
|
17 files changed, 1646 insertions(+), 123 deletions(-)
|
|
|
|
## Diff
|
|
README.md --- 1/3 --- Text
|
|
33 `talkd`, siehe unten), `mute`, `unm 33 `talkd`, siehe unten), `mute`, `unm
|
|
.. ute`, `status`, `cancel`. Die übrig .. ute`, `status`, `cancel`. Die übrig
|
|
.. en .. en
|
|
34 Befehle sind Stubs und melden `not 34 Befehle sind Stubs und melden `not
|
|
.. implemented yet` (Exit-Code ≠ 0). .. implemented yet` (Exit-Code ≠ 0).
|
|
35 35
|
|
36 ## Daemon talkd (Task 04) 36 ## Daemon talkd (Task 04 + 05)
|
|
37 37
|
|
38 `talkd` besitzt die Ansage-Queue un 38 `talkd` besitzt die Ansage-Queue un
|
|
.. d spricht seriell ab — genau eine A .. d spricht seriell ab — genau eine A
|
|
.. nsage .. nsage
|
|
39 gleichzeitig, Reihenfolge `now` > ` 39 gleichzeitig, Reihenfolge `now` > `
|
|
.. high` > `normal` > `low` (FIFO je .. high` > `normal` > `low` (FIFO je
|
|
|
|
README.md --- 2/3 --- Text
|
|
42 42
|
|
43 | Endpunkt | Wirkung | 43 | Endpunkt | Wirkung |
|
|
44 | --- | --- | 44 | --- | --- |
|
|
45 | `POST /speak` | `{text, priority? 45 | `POST /speak` | `{text, priority?
|
|
.. , provider?, voice?, lang?, now?}` .. , provider?, voice?, lang?, now?}`
|
|
.. → `202 {"id":"…"}`; bei Mute: `202 .. → `202 {"id":"…"}`; bei Mute: `202
|
|
.. {"id":"","muted":true}` (Ansage ver .. {"id":"","muted":true}`; bei Gesetz
|
|
.. worfen — dokumentiertes Verhalten) .. -Verwurf: `202 {"id":"","discarded"
|
|
.. | .. :"rate_limit"\|"quiet_hours"}` (+ `
|
|
.. .. "toast":true` im Toast-Modus) — ver
|
|
.. .. worfen, dokumentiertes Verhalten |
|
|
46 | `GET /status` | `{"version", "que 46 | `GET /status` | `{"version", "que
|
|
.. ue", "queue_ids", "current", "muted .. ue", "queue_ids", "current", "muted
|
|
.. ", "provider"}` | .. ", "provider", "rate_limit": {"inte
|
|
.. .. rval", "allow_high_burst", "next_sl
|
|
.. .. ot"}, "quiet_hours": {"window", "ac
|
|
.. .. tive", "mode", "until"}}` |
|
|
47 | `POST /cancel` | bricht die laufe 47 | `POST /cancel` | bricht die laufe
|
|
.. nde Ansage ab + leert die Queue → ` .. nde Ansage ab + leert die Queue → `
|
|
.. 200 {"cancelled":n}` | .. 200 {"cancelled":n}` |
|
|
48 | `POST /mute` / `POST /unmute` | p 48 | `POST /mute` / `POST /unmute` | p
|
|
.. ersistenter Mute-State → `200 {"mut .. ersistenter Mute-State → `200 {"mut
|
|
.. ed":bool}` | .. ed":bool}` |
|
|
49 49
|
|
50 - **CLI-Verhalten:** `talk say` (un 50 - **CLI-Verhalten:** `talk say` (un
|
|
.. d `talk "Text"`) erkennt den Daemon .. d `talk "Text"`) erkennt den Daemon
|
|
.. via .. via
|
|
51 `GET /status`. Läuft er: enqueue 51 `GET /status`. Läuft er: enqueue
|
|
.. + sofortige Rückkehr (non-blocking, .. + sofortige Rückkehr (non-blocking,
|
|
52 stdout erhält die Item-ID); `--wa 52 stdout erhält die Item-ID); `--wa
|
|
.. it` pollt bis zur Fertigstellung; .. it` pollt bis zur Fertigstellung;
|
|
53 `--now` springt vor die Warteschl 53 `--now` springt vor die Warteschl
|
|
.. ange; bei Mute sagt die CLI `muted` .. ange; bei Mute sagt die CLI `muted`
|
|
.. . .. ,
|
|
54 `talk mute`/`unmute`/`status`/`ca 54 bei Gesetz-Verwurf `discarded: <g
|
|
.. ncel` sind Daemon-Aufrufe mit plain .. rund>`. `talk mute`/`unmute`/`statu
|
|
.. .. s`/
|
|
55 Output. 55 `cancel` sind Daemon-Aufrufe mit
|
|
.. .. plain Output.
|
|
56 - **Mute ist persistent** (Datei `m 56 - **Mute ist persistent** (Datei `m
|
|
.. ute` im Config-Dir, Inhalt `true`/ .. ute` im Config-Dir, Inhalt `true`/
|
|
57 `false`) und überlebt Daemon-Rest 57 `false`) und überlebt Daemon-Rest
|
|
.. arts. `talk mute` ohne laufenden Da .. arts. `talk mute` ohne laufenden Da
|
|
.. emon .. emon
|
|
58 schreibt die Datei direkt — der D 58 schreibt die Datei direkt — der D
|
|
.. aemon liest sie bei jeder Ansage ne .. aemon liest sie bei jeder Ansage ne
|
|
.. u, .. u,
|
|
|
|
README.md --- 3/3 --- Text
|
|
61 sequenziell weiter — serialisiert 61 sequenziell weiter — serialisiert
|
|
.. über die Lock-Datei `talk.lock` im .. über die Lock-Datei `talk.lock` im
|
|
62 Config-Dir (Kollision = warten; v 62 Config-Dir (Kollision = warten; v
|
|
.. erwaisste Locks gelten nach 60 s al .. erwaisste Locks gelten nach 60 s al
|
|
.. s .. s
|
|
63 verfallen). Mute wird auch hier r 63 verfallen). Mute wird auch hier r
|
|
.. espektiert. Nichts bricht still. .. espektiert. Nichts bricht still.
|
|
.. 64
|
|
64 - Rate-Limit und Ruhezeiten folgen 65 ### Rate-Limit + Ruhezeiten (Task 0
|
|
.. in Task 05; die Rate-Limit-Struktur .. 5)
|
|
.. 66
|
|
.. 67 Der Daemon erzwingt das Anti-Spam-G
|
|
.. .. esetz als Backstop unter allem
|
|
.. 68 Prompt-Verhalten. **Rate-Limit:** J
|
|
.. .. ede *gestartete* Ansage bucht ein
|
|
.. 69 Zeitfenster — Default **1 Ansage /
|
|
.. .. 30 s**. Bis zum Fenster-Ablauf gilt
|
|
.. .. :
|
|
.. 70
|
|
.. 71 - `priority=low` wird **verworfen**
|
|
.. .. (schon beim Eintritt, die 202-Antw
|
|
.. .. ort
|
|
.. 72 nennt `discarded: "rate_limit"`);
|
|
.. .. war das Fenster beim Eintritt noch
|
|
.. 73 frei, entscheidet der Worker spät
|
|
.. .. er.
|
|
.. 74 - `normal` **parkt** in der Queue u
|
|
.. .. nd spielt zum freien Slot (in `/sta
|
|
.. .. tus`
|
|
.. 75 als `rate_limit.next_slot` sichtb
|
|
.. .. ar).
|
|
.. 76 - `priority=high` darf mit `queue.a
|
|
.. .. llow_high_burst = true` (Default)
|
|
.. 77 **bewusst verstoßen**; ohne Burst
|
|
.. .. -Recht wartet high wie normal.
|
|
.. 78 - `now:true` **umgeht auch das Rate
|
|
.. .. -Limit** — der dokumentierte Ausbru
|
|
.. .. ch
|
|
65 existiert bereits (`internal/queu 79 für Dringendes (startet trotzdem
|
|
.. e`), ist in diesem Stand aber bewus .. ein neues Fenster).
|
|
.. st ..
|
|
.. 80
|
|
.. 81 **Ruhezeiten:** `quiet_hours = "18:
|
|
.. .. 00-08:00"` (lokale Zeit, überspannt
|
|
.. 82 Mitternacht; leer/`off` = aus — bew
|
|
.. .. usst kein Default-Fenster, Ruhezeit
|
|
.. .. en
|
|
.. 83 sind Opt-in wie Mute). In der Ruhez
|
|
.. .. eit verfällt Audio, auch für high
|
|
.. 84 (Burst ist ein Rate-Recht, kein Ruh
|
|
.. .. e-Recht):
|
|
.. 85
|
|
66 noch nicht angebunden (Default: u 86 - `quiet_mode = "discard"` (Default
|
|
.. nbegrenzt). .. ): still verwerfen.
|
|
.. 87 - `quiet_mode = "toast"`: Benachric
|
|
.. .. htigung statt Audio — derzeit eine
|
|
.. 88 Protokoll-Zeile über den Notifica
|
|
.. .. tion-Pfad (OS-Toast folgt mit der
|
|
.. 89 Notification-Anbindung).
|
|
.. 90 - `now:true` **bricht auch die Ruhe
|
|
.. .. zeit** (bewusster Ausbruch).
|
|
.. 91
|
|
.. 92 **Rangordnung:** manuelles Mute gew
|
|
.. .. innt immer > `now` > Ruhezeit >
|
|
.. 93 Rate-Limit. Konfiguration (config.t
|
|
.. .. oml, Env schlägt Datei):
|
|
.. 94
|
|
.. 95 ```toml
|
|
.. 96 [queue]
|
|
.. 97 rate_limit = "30s" # Dauer;
|
|
.. .. "0s" = unbegrenzt (TALK_RATE_LIMIT)
|
|
.. 98 allow_high_burst = true # high da
|
|
.. .. rf verstoßen (TALK_ALLOW_HIGH_BURST
|
|
.. .. )
|
|
.. 99
|
|
.. 100 quiet_hours = "18:00-08:00" # leer
|
|
.. ... = aus; Env TALK_QUIET_HOURS ("off"
|
|
.. ... = aus)
|
|
.. 101 quiet_mode = "discard" # disc
|
|
.. ... ard | toast (TALK_QUIET_MODE)
|
|
.. 102 ```
|
|
.. 103
|
|
.. 104 `talk status` zeigt den Gesetz-Stat
|
|
.. ... e: `rate-limit: 30s (high-burst
|
|
.. 105 erlaubt), nächster slot …` und `ruh
|
|
.. ... ezeit: 18:00-08:00 (modus discard,
|
|
.. 106 aktiv bis 08:00)`.
|
|
67 107
|
|
68 ### Provider-Kette (Task 03) 108 ### Provider-Kette (Task 03)
|
|
69 109
|
|
|
|
cmd/talk/control.go --- 1/3 --- Go
|
|
5 5 "fmt"
|
|
6 6 "io"
|
|
7 7 "path/filepath"
|
|
. 8 "time"
|
|
8 9
|
|
9 10 "code.m3ta.dev/m3tam3re/talk/internal/api"
|
|
10 11 "code.m3ta.dev/m3tam3re/talk/internal/config"
|
|
|
|
cmd/talk/control.go --- 2/3 --- Go
|
|
71 72
|
|
72 // runStatus implementiert `talk st 73 // runStatus implementiert `talk st
|
|
.. atus`: plain Block aus GET /status .. atus`: plain Block aus GET /status
|
|
.. — .. —
|
|
73 // Daemon-Adresse + Version, Queue- 74 // Daemon-Adresse + Version, Queue-
|
|
.. Länge, aktuelles Item, Mute-State, .. Länge, aktuelles Item, Mute-State,
|
|
74 // aktiver Provider. Ohne Daemon: k 75 // aktiver Provider, Gesetz-State (
|
|
.. larer Fehler (status ist eine Anfra .. Rate-Limit, Ruhezeit). Ohne Daemon:
|
|
.. ge an ..
|
|
.. 76 // klarer Fehler (status ist eine A
|
|
.. .. nfrage an den Daemon; der Degraded
|
|
.. .. Mode
|
|
75 // den Daemon; der Degraded Mode ha 77 // hat keinen Status).
|
|
.. t keinen Status). ..
|
|
76 func runStatus(args []string, opts 78 func runStatus(args []string, opts
|
|
.. *sayOptions, stdin io.Reader, stdou .. *sayOptions, stdin io.Reader, stdou
|
|
.. t, stderr io.Writer) int { .. t, stderr io.Writer) int {
|
|
77 cfg, err := config.Load(config. 79 cfg, err := config.Load(config.
|
|
.. Options{Getenv: getenv}) .. Options{Getenv: getenv})
|
|
78 if err != nil { 80 if err != nil {
|
|
|
|
cmd/talk/control.go --- 3/3 --- Go
|
|
101 103 }
|
|
102 104 fmt.Fprintf(stdout, "mute: %s\n", muteState)
|
|
103 105 fmt.Fprintf(stdout, "provider: %s\n", st.Provider)
|
|
... 106 printLawState(stdout, st)
|
|
104 107 return exitOK
|
|
105 108 }
|
|
... 109
|
|
... 110 // printLawState schreibt den Gesetz-State (Task 05) aus GET /status:
|
|
... 111 // Rate-Limit mit Burst-Recht und nächstem Slot sowie das Ruhezeit-Fenster.
|
|
... 112 func printLawState(stdout io.Writer, st *api.StatusResponse) {
|
|
... 113 if st.RateLimit != nil {
|
|
... 114 interval := st.RateLimit.Interval
|
|
... 115 if interval == "0s" {
|
|
... 116 interval = "aus (unbegrenzt)"
|
|
... 117 }
|
|
... 118 burst := "verboten"
|
|
... 119 if st.RateLimit.AllowHighBurst {
|
|
... 120 burst = "erlaubt"
|
|
... 121 }
|
|
... 122 line := fmt.Sprintf("rate-limit: %s (high-burst %s)", interval, burst)
|
|
... 123 if st.RateLimit.NextSlot != nil {
|
|
... 124 if ts, err := time.Parse(time.RFC3339, *st.RateLimit.NextSlot); err == nil {
|
|
... 125 line += fmt.Sprintf(", nächster slot %s", ts.Format("15:04:05"))
|
|
... 126 }
|
|
... 127 }
|
|
... 128 fmt.Fprintln(stdout, line)
|
|
... 129 }
|
|
... 130 if st.QuietHours != nil {
|
|
... 131 window := st.QuietHours.Window
|
|
... 132 if window == "" {
|
|
... 133 window = "aus"
|
|
... 134 }
|
|
... 135 state := "inaktiv"
|
|
... 136 if st.QuietHours.Active {
|
|
... 137 state = "aktiv"
|
|
... 138 if st.QuietHours.Until != nil {
|
|
... 139 if ts, err := time.Parse(time.RFC3339, *st.QuietHours.Until); err == nil {
|
|
... 140 state += " bis " + ts.Format("15:04")
|
|
... 141 }
|
|
... 142 }
|
|
... 143 }
|
|
... 144 fmt.Fprintf(stdout, "ruhezeit: %s (modus %s, %s)\n", window, st.QuietHours.Mode, state)
|
|
... 145 }
|
|
... 146 }
|
|
106 147
|
|
107 148 // runCancel implementiert `talk cancel`: POST /cancel — bricht die laufende
|
|
108 149 // Ansage ab und leert die Queue; stdout meldet die Zahl der abgebrochenen
|
|
|
|
cmd/talk/daemon.go --- 1/2 --- Go
|
|
9 9
|
|
10 10 "code.m3ta.dev/m3tam3re/talk/internal/api"
|
|
11 11 "code.m3ta.dev/m3tam3re/talk/internal/config"
|
|
.. 12 "code.m3ta.dev/m3tam3re/talk/internal/queue"
|
|
12 13 )
|
|
13 14
|
|
14 15 // Zeit-Budgets der Daemon-Kommunikation. Die Daemon-Erkennung (GET /status)
|
|
|
|
cmd/talk/daemon.go --- 2/2 --- Go
|
|
64 65 fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
|
|
65 66 return exitOK, true
|
|
66 67 }
|
|
.. 68 if resp.Discarded != "" {
|
|
.. 69 // Gesetz-Verwurf (Rate-Limit-Budget / Ruhezeit): dokumentiertes
|
|
.. 70 // Verhalten wie bei Mute — plain Token statt Fehler; --wait hat
|
|
.. 71 // nichts zu warten, es gibt keine Item-ID.
|
|
.. 72 line := "discarded: " + resp.Discarded
|
|
.. 73 if resp.Toast {
|
|
.. 74 line += " (toast)"
|
|
.. 75 }
|
|
.. 76 fmt.Fprintln(stdout, line)
|
|
.. 77 fmt.Fprintln(stderr, "talk: "+discardHint(resp.Discarded, resp.Toast))
|
|
.. 78 return exitOK, true
|
|
.. 79 }
|
|
67 80 fmt.Fprintln(stdout, resp.ID)
|
|
68 81 if !opts.wait {
|
|
69 82 return exitOK, true
|
|
70 83 }
|
|
71 84 return waitForItem(client, resp.ID, stderr), true
|
|
72 85 }
|
|
.. 86
|
|
.. 87 // discardHint übersetzt den Verwurfs-Grund der Daemon-Antwort in einen
|
|
.. 88 // menschlichen Hinweis.
|
|
.. 89 func discardHint(reason string, toast bool) string {
|
|
.. 90 switch reason {
|
|
.. 91 case queue.DiscardQuietHours:
|
|
.. 92 if toast {
|
|
.. 93 return "Ruhezeit aktiv — Ansage durch Toast ersetzt (kein Audio)"
|
|
.. 94 }
|
|
.. 95 return "Ruhezeit aktiv — Ansage verworfen (quiet_mode = discard; now bricht bewusst aus)"
|
|
.. 96 case queue.DiscardRateLimit:
|
|
.. 97 return "Rate-Limit — Ansage verworfen (Priorität low über dem Fenster-Budget)"
|
|
.. 98 default:
|
|
.. 99 return "Ansage verworfen (" + reason + ")"
|
|
.. 100 }
|
|
.. 101 }
|
|
73 102
|
|
74 103 // waitForItem pollt /status, bis die Ansage id weder läuft noch wartet —
|
|
75 104 // sie ist dann fertig (oder abgebrochen; in beiden Fällen hat der Aufrufer
|
|
|
|
cmd/talkd/main.go --- 1/3 --- Go
|
|
1 // talkd ist der Daemon für talk 2. 1 // talkd ist der Daemon für talk 2.
|
|
. 0: HTTP-API auf 127.0.0.1 (Default- . 0: HTTP-API auf 127.0.0.1 (Default-
|
|
. Port . Port
|
|
2 // 7800, server.port/TALK_PORT), se 2 // 7800, server.port/TALK_PORT), se
|
|
. rialisierte Ansage-Queue mit Priori . rialisierte Ansage-Queue mit Priori
|
|
. täten . täten
|
|
3 // und now-Sprung, persistenter Mut 3 // und now-Sprung, persistenter Mut
|
|
. e-State (Datei mute im Config-Dir). . e-State (Datei mute im Config-Dir)
|
|
. . —
|
|
4 // 4 // und seit Task 05 das Anti-Spam-G
|
|
. . esetz: Rate-Limit (queue.rate_limit
|
|
. . ,
|
|
5 // Task 04: POST /speak, GET /statu 5 // Default 1 Ansage / 30 s; low übe
|
|
. s, POST /cancel, POST /mute, POST / . r Budget wird verworfen, normal par
|
|
. unmute. . kt,
|
|
6 // Rate-Limit/Ruhezeiten folgen in 6 // high darf mit queue.allow_high_b
|
|
. Task 05, CORS/MCP in Task 06 — die . urst verstoßen) plus Ruhezeiten
|
|
7 // Rate-Limit-Struktur existiert be 7 // (quiet_hours/quiet_mode; now:tru
|
|
. reits (internal/queue/rate.go), ist . e bricht bewusst aus beiden aus).
|
|
. hier .
|
|
8 // aber bewusst noch nicht angebund 8 // CORS/MCP folgen in Task 06.
|
|
. en (Task-04-Default: unbegrenzt). .
|
|
9 package main 9 package main
|
|
10 10
|
|
11 import ( 11 import (
|
|
|
|
cmd/talkd/main.go --- 2/3 --- Go
|
|
88 88 return chain.Speak(ctx, item.Text, lang)
|
|
89 89 }
|
|
.. 90
|
|
.. 91 // Anti-Spam-Gesetz (Task 05): Rate-Limit + High-Burst + Ruhezeiten.
|
|
.. 92 // config.Load hat quiet_hours bereits validiert — das Parsen hier kann
|
|
.. 93 // praktisch nicht scheitern, aber ein Fehler darf nie still werden.
|
|
.. 94 quiet, err := queue.ParseQuietHours(cfg.QuietHours)
|
|
.. 95 if err != nil {
|
|
.. 96 return err
|
|
.. 97 }
|
|
.. 98 laws := queue.Laws{
|
|
.. 99 RateLimit: cfg.Queue.RateLimit,
|
|
.. 100 AllowHighBurst: cfg.Queue.AllowHighBurst,
|
|
.. 101 Quiet: quiet,
|
|
.. 102 QuietMode: cfg.QuietMode,
|
|
.. 103 }
|
|
90 104
|
|
91 105 q := queue.New(laws, speak, lw)
|
|
92 106
|
|
93 107 // Persistenter Mute-State (Datei mute im Config-Dir). Ohne Config-Dir
|
|
94 108 // läuft der Daemon ohne Mute weiter (dokumentierte Warnung) statt zu
|
|
|
|
cmd/talkd/main.go --- 3/3 --- Go
|
|
125 srv := &http.Server{Handler: ha 139 srv := &http.Server{Handler: ha
|
|
... ndler, ReadHeaderTimeout: 5 * time. ... ndler, ReadHeaderTimeout: 5 * time.
|
|
... Second} ... Second}
|
|
126 errCh := make(chan error, 1) 140 errCh := make(chan error, 1)
|
|
127 go func() { errCh <- srv.Serve( 141 go func() { errCh <- srv.Serve(
|
|
... ln) }() ... ln) }()
|
|
128 log.Printf("listening on http:/ 142 log.Printf("listening on http:/
|
|
... /%s (version %s, audio sink %s, mut ... /%s (version %s, audio sink %s, mut
|
|
... e=%v)", ... e=%v, rate-limit %s, high-burst=%v,
|
|
... ... ruhezeit=%q modus %s)",
|
|
129 addr, versionString(), sink 143 addr, versionString(), sink
|
|
... .Name(), mute.Muted()) ... .Name(), mute.Muted(),
|
|
... 144 cfg.Queue.RateLimit, cfg.Qu
|
|
... ... eue.AllowHighBurst, cfg.QuietHours,
|
|
... ... cfg.QuietMode)
|
|
130 145
|
|
131 sig := make(chan os.Signal, 1) 146 sig := make(chan os.Signal, 1)
|
|
132 signal.Notify(sig, os.Interrupt 147 signal.Notify(sig, os.Interrupt
|
|
... , syscall.SIGTERM) ... , syscall.SIGTERM)
|
|
|
|
internal/api/doc.go --- Go
|
|
1 // Package api definiert die HTTP-Obe 1 // Package api definiert die HTTP-Obe
|
|
. rfläche des Daemons auf 127.0.0.1: . rfläche des Daemons auf 127.0.0.1:
|
|
2 // POST /speak (enqueue, 202 {id}; be 2 // POST /speak (enqueue, 202 {id}; be
|
|
. i Mute 202 {muted:true}), GET /status . i Mute 202 {muted:true}; bei Gesetz-
|
|
. , .
|
|
. 3 // Verwurf 202 {discarded:"rate_limit
|
|
. . "|"quiet_hours"} — Task 05),
|
|
. 4 // GET /status (inkl. Rate-Limit- und
|
|
. . Ruhezeit-State), POST /cancel,
|
|
3 // POST /cancel, POST /mute, POST /un 5 // POST /mute, POST /unmute. CORS für
|
|
. mute (Task 04). CORS für /speak und . /speak und der MCP-Endpoint
|
|
4 // der MCP-Endpoint (Streamable HTTP) 6 // (Streamable HTTP) folgen in Task 0
|
|
. folgen in Task 06. Der Client-Teil . 6. Der Client-Teil ist die CLI-Seite
|
|
5 // ist die CLI-Seite derselben Vertra 7 // derselben Vertragsschnittstelle.
|
|
. gsschnittstelle. .
|
|
6 package api 8 package api
|
|
|
|
internal/api/server.go --- 1/5 --- Go
|
|
5 5 "fmt"
|
|
6 6 "net/http"
|
|
7 7 "strings"
|
|
. 8 "time"
|
|
8 9
|
|
9 10 "code.m3ta.dev/m3tam3re/talk/internal/queue"
|
|
10 11 )
|
|
|
|
internal/api/server.go --- 2/5 --- Go
|
|
68 69
|
|
69 // speak behandelt POST /speak: val 70 // speak behandelt POST /speak: val
|
|
.. idieren, bei Mute verwerfen (202, .. idieren, bei Mute verwerfen (202,
|
|
70 // dokumentiertes Verhalten — bewus 71 // dokumentiertes Verhalten — bewus
|
|
.. st kein Fehler, damit Aufrufer kein .. st kein Fehler, damit Aufrufer kein
|
|
.. e .. e
|
|
71 // Sonderbehandlung brauchen), sons 72 // Sonderbehandlung brauchen), sons
|
|
.. t enqueue → 202 {id}. .. t enqueue → 202 {id}. Das Gesetz
|
|
.. 73 // (Rate-Limit-Budget, Ruhezeit) ka
|
|
.. .. nn beim Eintritt ebenfalls verwerfe
|
|
.. .. n —
|
|
.. 74 // die Antwort nennt dann den Grund
|
|
.. .. ({"discarded":"…"}).
|
|
72 func (h *handler) speak(w http.Resp 75 func (h *handler) speak(w http.Resp
|
|
.. onseWriter, r *http.Request) { .. onseWriter, r *http.Request) {
|
|
73 var req SpeakRequest 76 var req SpeakRequest
|
|
74 if err := json.NewDecoder(http. 77 if err := json.NewDecoder(http.
|
|
.. MaxBytesReader(w, r.Body, maxBodyBy .. MaxBytesReader(w, r.Body, maxBodyBy
|
|
.. tes)).Decode(&req); err != nil { .. tes)).Decode(&req); err != nil {
|
|
|
|
internal/api/server.go --- 3/5 --- Go
|
|
100 return 103 return
|
|
101 } 104 }
|
|
102 105
|
|
103 id, err := h.queue.Enqueue(queu 106 adm, err := h.queue.Enqueue(que
|
|
... e.Item{ ... ue.Item{
|
|
104 Text: req.Text, 107 Text: req.Text,
|
|
105 Lang: req.Lang, 108 Lang: req.Lang,
|
|
106 Provider: req.Provider, 109 Provider: req.Provider,
|
|
|
|
internal/api/server.go --- 4/5 --- Go
|
|
112 writeError(w, http.StatusSe 115 writeError(w, http.StatusSe
|
|
... rviceUnavailable, "enqueue: "+err.E ... rviceUnavailable, "enqueue: "+err.E
|
|
... rror()) ... rror())
|
|
113 return 116 return
|
|
114 } 117 }
|
|
... 118 if adm.Discarded != "" {
|
|
... 119 // Gesetz-Verwurf (Rate-Lim
|
|
... ... it-Budget / Ruhezeit): dokumentiert
|
|
... ... es
|
|
... 120 // Verhalten wie bei Mute —
|
|
... ... 202 mit Grund statt Fehler, damit
|
|
... 121 // Aufrufer keine Sonderbeh
|
|
... ... andlung brauchen.
|
|
... 122 writeJSON(w, http.StatusAcc
|
|
... ... epted, SpeakResponse{Discarded: adm
|
|
... ... .Discarded, Toast: adm.Toast})
|
|
... 123 return
|
|
... 124 }
|
|
115 writeJSON(w, http.StatusAccepte 125 writeJSON(w, http.StatusAccepte
|
|
... d, SpeakResponse{ID: id}) ... d, SpeakResponse{ID: adm.ID})
|
|
116 } 126 }
|
|
117 127
|
|
118 // status behandelt GET /status: Qu 128 // status behandelt GET /status: Qu
|
|
... eue-Länge (+ wartende IDs), aktuell ... eue-Länge (+ wartende IDs), aktuell
|
|
... es ... es
|
|
119 // Item, Mute-State, aktiver Provid 129 // Item, Mute-State, aktiver Provid
|
|
... er, Version. ... er, Version — und seit Task 05 den
|
|
... 130 // Gesetz-State (Rate-Limit mit näc
|
|
... ... hstem Slot, Ruhezeit-Fenster).
|
|
120 func (h *handler) status(w http.Res 131 func (h *handler) status(w http.Res
|
|
... ponseWriter, r *http.Request) { ... ponseWriter, r *http.Request) {
|
|
121 pending := h.queue.Pending() 132 pending := h.queue.Pending()
|
|
122 ids := make([]string, len(pendi 133 ids := make([]string, len(pendi
|
|
... ng)) ... ng))
|
|
|
|
internal/api/server.go --- 5/5 --- Go
|
|
140 151 if h.provider != nil {
|
|
141 152 st.Provider = h.provider()
|
|
142 153 }
|
|
... 154 st.RateLimit, st.QuietHours = lawState(h.queue.LawState())
|
|
143 155 writeJSON(w, http.StatusOK, st)
|
|
144 156 }
|
|
... 157
|
|
... 158 // lawState überführt die Gesetz-Sicht der Queue in die JSON-Form von
|
|
... 159 // /status: Zeitstempel nur, wenn sie in der Zukunft liegen (null = Slot
|
|
... 160 // frei / Ruhezeit inaktiv).
|
|
... 161 func lawState(l queue.LawState) (*RateLimitState, *QuietHoursState) {
|
|
... 162 now := time.Now()
|
|
... 163 rl := &RateLimitState{
|
|
... 164 Interval: l.RateLimit.String(),
|
|
... 165 AllowHighBurst: l.AllowHighBurst,
|
|
... 166 }
|
|
... 167 if l.NextSlot.After(now) {
|
|
... 168 slot := l.NextSlot.Format(time.RFC3339)
|
|
... 169 rl.NextSlot = &slot
|
|
... 170 }
|
|
... 171 qh := &QuietHoursState{
|
|
... 172 Window: l.Quiet.String(),
|
|
... 173 Active: l.QuietActive,
|
|
... 174 Mode: l.QuietMode,
|
|
... 175 }
|
|
... 176 if !l.QuietUntil.IsZero() {
|
|
... 177 until := l.QuietUntil.Format(time.RFC3339)
|
|
... 178 qh.Until = &until
|
|
... 179 }
|
|
... 180 return rl, qh
|
|
... 181 }
|
|
145 182
|
|
146 183 // cancel behandelt POST /cancel: laufende Ansage abbrechen + Queue leeren.
|
|
147 184 func (h *handler) cancel(w http.ResponseWriter, r *http.Request) {
|
|
|
|
internal/api/types.go --- 1/2 --- Go
|
|
27 27
|
|
28 // SpeakResponse ist die Antwort vo 28 // SpeakResponse ist die Antwort vo
|
|
.. n POST /speak: 202 {"id":"…"} — bei .. n POST /speak: 202 {"id":"…"} — bei
|
|
29 // aktivem Mute 202 {"id":"","muted 29 // aktivem Mute 202 {"id":"","muted
|
|
.. ":true} (Ansage verworfen; dokument .. ":true} (Ansage verworfen; dokument
|
|
.. iertes .. iertes
|
|
30 // Verhalten, kein Fehler). 30 // Verhalten, kein Fehler). Seit Ta
|
|
.. .. sk 05 meldet verworfenen Spam ebens
|
|
.. .. o
|
|
.. 31 // dokumentiert: 202 {"id":"","disc
|
|
.. .. arded":"rate_limit"|"quiet_hours"}
|
|
.. .. —
|
|
.. 32 // plus "toast":true, wenn die Ruhe
|
|
.. .. zeit-Ansage durch eine Benachrichti
|
|
.. .. gung
|
|
.. 33 // ersetzt wurde (quiet_mode = "toa
|
|
.. .. st").
|
|
31 type SpeakResponse struct { 34 type SpeakResponse struct {
|
|
32 ID string `json:"id"` 35 ID string `json:"id"`
|
|
33 Muted bool `json:"muted,omite 36 Muted bool `json:"muted,omite
|
|
.. mpty"` .. mpty"`
|
|
.. 37
|
|
.. 38 // Discarded ist der Verwurfs-G
|
|
.. .. rund ("rate_limit" | "quiet_hours")
|
|
.. .. ;
|
|
.. 39 // leer, wenn die Ansage aufgen
|
|
.. .. ommen wurde.
|
|
.. 40 Discarded string `json:"discard
|
|
.. .. ed,omitempty"`
|
|
.. 41
|
|
.. 42 // Toast meldet die Ersetzung d
|
|
.. .. urch eine Benachrichtigung.
|
|
.. 43 Toast bool `json:"toast,omitemp
|
|
.. .. ty"`
|
|
34 } 44 }
|
|
35 45
|
|
36 // CurrentItem beschreibt das laufe 46 // CurrentItem beschreibt das laufe
|
|
.. nde Item in GET /status. .. nde Item in GET /status.
|
|
|
|
internal/api/types.go --- 2/2 --- Go
|
|
44 // StatusResponse ist die Antwort v 54 // StatusResponse ist die Antwort v
|
|
.. on GET /status: Queue-Länge, aktuel .. on GET /status: Queue-Länge, aktuel
|
|
.. les .. les
|
|
45 // Item (null im Leerlauf), Mute-St 55 // Item (null im Leerlauf), Mute-St
|
|
.. ate, aktiver Provider, Daemon-Versi .. ate, aktiver Provider, Daemon-Versi
|
|
.. on. .. on.
|
|
46 // QueueIDs ist die Ergänzung der w 56 // QueueIDs ist die Ergänzung der w
|
|
.. artenden IDs in Abarbeitungs-Reihen .. artenden IDs in Abarbeitungs-Reihen
|
|
.. folge .. folge
|
|
47 // — die CLI braucht sie für ihr -- 57 // — die CLI braucht sie für ihr --
|
|
.. wait-Polling. .. wait-Polling. Task 05 ergänzt addit
|
|
.. .. iv
|
|
.. 58 // den Gesetz-State (rate_limit, qu
|
|
.. .. iet_hours).
|
|
48 type StatusResponse struct { 59 type StatusResponse struct {
|
|
49 Version string `json:"ve 60 Version string `js
|
|
.. rsion"` .. on:"version"`
|
|
50 Queue int `json:"qu 61 Queue int `js
|
|
.. eue"` .. on:"queue"`
|
|
51 QueueIDs []string `json:"qu 62 QueueIDs []string `js
|
|
.. eue_ids,omitempty"` .. on:"queue_ids,omitempty"`
|
|
52 Current *CurrentItem `json:"cu 63 Current *CurrentItem `js
|
|
.. rrent"` .. on:"current"`
|
|
53 Muted bool `json:"mu 64 Muted bool `js
|
|
.. ted"` .. on:"muted"`
|
|
54 Provider string `json:"pr 65 Provider string `js
|
|
.. ovider"` .. on:"provider"`
|
|
.. 66 RateLimit *RateLimitState `js
|
|
.. .. on:"rate_limit,omitempty"`
|
|
.. 67 QuietHours *QuietHoursState `js
|
|
.. .. on:"quiet_hours,omitempty"`
|
|
55 } 68 }
|
|
.. 69
|
|
.. 70 // RateLimitState ist der Rate-Limi
|
|
.. .. t-State in GET /status (Task 05).
|
|
.. 71 type RateLimitState struct {
|
|
.. 72 // Interval ist der konfigurier
|
|
.. .. te Mindestabstand als Dauer-String
|
|
.. 73 // ("30s"; "0s" = unbegrenzt).
|
|
.. 74 Interval string `json:"interval
|
|
.. .. "`
|
|
.. 75
|
|
.. 76 // AllowHighBurst meldet, ob pr
|
|
.. .. iority=high verstoßen darf.
|
|
.. 77 AllowHighBurst bool `json:"allo
|
|
.. .. w_high_burst"`
|
|
.. 78
|
|
.. 79 // NextSlot ist der nächste fre
|
|
.. .. ie Ansage-Slot als RFC3339-Zeitstem
|
|
.. .. pel;
|
|
.. 80 // null, wenn sofort gespielt w
|
|
.. .. erden dürfte (oder unbegrenzt).
|
|
.. 81 NextSlot *string `json:"next_sl
|
|
.. .. ot"`
|
|
.. 82 }
|
|
.. 83
|
|
.. 84 // QuietHoursState ist der Ruhezeit
|
|
.. .. -State in GET /status (Task 05).
|
|
.. 85 type QuietHoursState struct {
|
|
.. 86 // Window ist das konfigurierte
|
|
.. .. Fenster ("18:00-08:00"; "" = deakt
|
|
.. .. iviert).
|
|
.. 87 Window string `json:"window"`
|
|
.. 88
|
|
.. 89 // Active meldet, ob die lokale
|
|
.. .. Zeit gerade im Fenster liegt.
|
|
.. 90 Active bool `json:"active"`
|
|
.. 91
|
|
.. 92 // Mode ist der konfigurierte R
|
|
.. .. uhezeit-Modus ("discard" | "toast")
|
|
.. .. .
|
|
.. 93 Mode string `json:"mode"`
|
|
.. 94
|
|
.. 95 // Until ist das Fenster-Ende a
|
|
.. .. ls RFC3339-Zeitstempel, wenn aktiv;
|
|
.. 96 // sonst null.
|
|
.. 97 Until *string `json:"until"`
|
|
.. 98 }
|
|
56 99
|
|
57 // PendingOrCurrent meldet, ob die 100 // PendingOrCurrent meldet, ob die
|
|
.. Ansage mit der ID noch wartet oder ... Ansage mit der ID noch wartet oder
|
|
58 // gerade läuft — Grundlage des --w 101 // gerade läuft — Grundlage des --w
|
|
.. ait-Pollings der CLI. Ist die Ansag ... ait-Pollings der CLI. Ist die Ansag
|
|
.. e ... e
|
|
|
|
internal/config/config.go --- 1/7 --- Go
|
|
6 6 "os"
|
|
7 7 "path/filepath"
|
|
8 8 "strconv"
|
|
. 9 "time"
|
|
9 10
|
|
10 11 "github.com/BurntSushi/toml"
|
|
.. 12
|
|
.. 13 "code.m3ta.dev/m3tam3re/talk/internal/queue"
|
|
11 14 )
|
|
12 15
|
|
13 16 // Erlaubte Werte für audio.sink (Teil des öffentlichen Vertrags; Spec:
|
|
|
|
internal/config/config.go --- 2/7 --- Go
|
|
21 // 127.0.0.1 (Task-04-Brief: „Port 24 // 127.0.0.1 (Task-04-Brief: „Port
|
|
.. Default 7800"). .. Default 7800").
|
|
22 const DefaultServerPort = 7800 25 const DefaultServerPort = 7800
|
|
.. 26
|
|
.. 27 // DefaultRateLimit ist das Daemon-
|
|
.. .. Gesetz gegen Spam (Task-05-Brief):
|
|
.. 28 // Default 1 Ansage / 30 s. „0s" sc
|
|
.. .. haltet das Limit ab.
|
|
.. 29 const DefaultRateLimit = 30 * time.
|
|
.. .. Second
|
|
23 30
|
|
24 // Config ist der effektiv geladene 31 // Config ist der effektiv geladene
|
|
.. Konfigurations-Stand von talk. Tas .. Konfigurations-Stand von talk. Tas
|
|
.. k 02 .. k 02
|
|
25 // führt audio.sink; Task 03 ergänz 32 // führt audio.sink; Task 03 ergänz
|
|
.. t den Provider-Pin, Provider-Endpun .. t den Provider-Pin, Provider-Endpun
|
|
.. kte .. kte
|
|
26 // und das Voice-Mapping; Task 04 d 33 // und das Voice-Mapping; Task 04 d
|
|
.. en Daemon-Port (server.port, TALK_P .. en Daemon-Port (server.port, TALK_P
|
|
.. ORT); .. ORT);
|
|
.. 34 // Task 05 die Anti-Spam-Gesetze (q
|
|
.. .. ueue.rate_limit, queue.allow_high_b
|
|
.. .. urst,
|
|
27 // spätere Tasks ergänzen weitere F 35 // quiet_hours, quiet_mode); später
|
|
.. elder (queue …), ohne die Auflösung .. e Tasks ergänzen weitere Felder, oh
|
|
.. s- .. ne die
|
|
28 // reihenfolge zu ändern. 36 // Auflösungs-Reihenfolge zu ändern
|
|
.. .. .
|
|
29 type Config struct { 37 type Config struct {
|
|
30 Audio Audio `toml:"audio"` 38 Audio Audio `toml:"audio"`
|
|
31 39
|
|
32 // Server steuert den Daemon-HT 40 // Server steuert den Daemon-HT
|
|
.. TP-Server (talkd) auf 127.0.0.1. .. TP-Server (talkd) auf 127.0.0.1.
|
|
33 Server Server `toml:"server"` 41 Server Server `toml:"server"`
|
|
.. 42
|
|
.. 43 // Queue steuert die Anti-Spam-
|
|
.. .. Gesetze des Daemons (Task 05).
|
|
.. 44 Queue Queue `toml:"queue"`
|
|
34 45
|
|
35 // Provider pinnt die Kette auf 46 // Provider pinnt die Kette auf
|
|
.. genau einen Provider (z. B. .. genau einen Provider (z. B.
|
|
36 // `provider = "elevenlabs"`); 47 // `provider = "elevenlabs"`);
|
|
.. leer = komplette Default-Kette .. leer = komplette Default-Kette
|
|
|
|
internal/config/config.go --- 3/7 --- Go
|
|
49 60 // eingebauten neutralen Defaults; das CLI-Flag --voice schlägt alles.
|
|
50 61 Voice map[string]map[string]string `toml:"voice"`
|
|
.. 62
|
|
.. 63 // QuietHours ist das Ruhezeit-Fenster "HH:MM-HH:MM" in lokaler Zeit
|
|
.. 64 // (überspannt Mitternacht, z. B. "18:00-08:00"); leer = deaktiviert.
|
|
.. 65 // Bewusst kein Default-Fenster: Ein aktives Gesetz, das nach Uhrzeit
|
|
.. 66 // verwirft, würde jeden Cron/Test in den Abendstunden stilllegen —
|
|
.. 67 // Ruhezeiten sind Opt-in wie Mute.
|
|
.. 68 QuietHours string `toml:"quiet_hours"`
|
|
.. 69
|
|
.. 70 // QuietMode bestimmt das Verhalten in der Ruhezeit: "discard" (Default,
|
|
.. 71 // still verwerfen) oder "toast" (Benachrichtigung statt Audio).
|
|
.. 72 QuietMode string `toml:"quiet_mode"`
|
|
51 73 }
|
|
52 74
|
|
53 75 // Audio steuert die Audio-Ausgabe.
|
|
|
|
internal/config/config.go --- 4/7 --- Go
|
|
65 87 Port int `toml:"port"`
|
|
66 88 }
|
|
.. 89
|
|
.. 90 // Queue sind die Rate-Limit-Gesetze des Daemons (TOML `[queue]`).
|
|
.. 91 type Queue struct {
|
|
.. 92 // RateLimit ist der Mindestabstand zwischen zwei Ansage-Starten als
|
|
.. 93 // Dauer-String (z. B. "30s"; "0s" = unbegrenzt). Default: 30 s.
|
|
.. 94 RateLimit time.Duration `toml:"rate_limit"`
|
|
.. 95
|
|
.. 96 // AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu
|
|
.. 97 // verstoßen (Default true). Die Ruhezeit gilt davon unberührt.
|
|
.. 98 AllowHighBurst bool `toml:"allow_high_burst"`
|
|
.. 99 }
|
|
67 100
|
|
68 101 // ProviderSettings sind die per Provider konfigurierbaren Endpunkte und
|
|
69 102 // Modelle (TOML `[providers.<name>]`). Leer = eingebaute Defaults; die
|
|
|
|
internal/config/config.go --- 5/7 --- Go
|
|
82 115 return Config{
|
|
83 116 Audio: Audio{Sink: SinkOto},
|
|
84 117 Server: Server{Port: DefaultServerPort},
|
|
.. 118 Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true},
|
|
.. 119 QuietMode: queue.QuietModeDiscard,
|
|
85 120 }
|
|
86 121 }
|
|
87 122
|
|
|
|
internal/config/config.go --- 6/7 --- Go
|
|
99 } 134 }
|
|
100 135
|
|
101 // Load löst die Konfiguration schi 136 // Load löst die Konfiguration schi
|
|
... chtweise auf: Defaults → config.tom ... chtweise auf: Defaults → config.tom
|
|
... l ... l
|
|
102 // (falls vorhanden) → Env-Override 137 // (falls vorhanden) → Env-Override
|
|
... s (TALK_AUDIO_SINK schlägt die Date ... s (TALK_* schlägt die Datei). Env-
|
|
... i). ... und
|
|
103 // Env- und Datei-Validierungsfehle 138 // Datei-Validierungsfehler werden
|
|
... r werden als klarer Fehler gemeldet ... als klarer Fehler gemeldet, nie sti
|
|
... , nie ... ll
|
|
104 // still ignoriert. Fehlt das Confi 139 // ignoriert. Fehlt das Config-Verz
|
|
... g-Verzeichnis (z. B. keine HOME-Var ... eichnis (z. B. keine HOME-Variable)
|
|
... iable), ... ,
|
|
105 // läuft talk mit Defaults + Env we 140 // läuft talk mit Defaults + Env we
|
|
... iter — die Datei ist optional, kapu ... iter — die Datei ist optional, kapu
|
|
... tte ... tte
|
|
106 // Umgebungen dürfen talk nicht unb 141 // Umgebungen dürfen talk nicht unb
|
|
... rauchbar machen. ... rauchbar machen.
|
|
107 func Load(opts Options) (Config, er 142 func Load(opts Options) (Config, er
|
|
... ror) { ... ror) {
|
|
|
|
internal/config/config.go --- 7/7 --- Go
|
|
143 } 178 }
|
|
144 cfg.Server.Port = port 179 cfg.Server.Port = port
|
|
145 } 180 }
|
|
... 181 if v := getenv("TALK_RATE_LIMIT
|
|
... ... "); v != "" {
|
|
... 182 d, err := time.ParseDuratio
|
|
... ... n(v)
|
|
... 183 if err != nil || d < 0 {
|
|
... 184 return cfg, fmt.Errorf(
|
|
... ... "TALK_RATE_LIMIT: ungültiger Wert %
|
|
... ... q (Dauer, z. B. \"30s\"; \"0s\" = u
|
|
... ... nbegrenzt)", v)
|
|
... 185 }
|
|
... 186 cfg.Queue.RateLimit = d
|
|
... 187 }
|
|
... 188 if v := getenv("TALK_ALLOW_HIGH
|
|
... ... _BURST"); v != "" {
|
|
... 189 b, err := strconv.ParseBool
|
|
... ... (v)
|
|
... 190 if err != nil {
|
|
... 191 return cfg, fmt.Errorf(
|
|
... ... "TALK_ALLOW_HIGH_BURST: ungültiger
|
|
... ... Wert %q (true/false)", v)
|
|
... 192 }
|
|
... 193 cfg.Queue.AllowHighBurst =
|
|
... ... b
|
|
... 194 }
|
|
... 195 if v := getenv("TALK_QUIET_HOUR
|
|
... ... S"); v != "" {
|
|
... 196 if v == "off" {
|
|
... 197 cfg.QuietHours = ""
|
|
... 198 } else {
|
|
... 199 if _, err := queue.Pars
|
|
... ... eQuietHours(v); err != nil {
|
|
... 200 return cfg, fmt.Err
|
|
... ... orf("TALK_QUIET_HOURS: ungültiges F
|
|
... ... enster %q (HH:MM-HH:MM, \"off\" = d
|
|
... ... eaktiviert)", v)
|
|
... 201 }
|
|
... 202 cfg.QuietHours = v
|
|
... 203 }
|
|
... 204 }
|
|
... 205 if v := getenv("TALK_QUIET_MODE
|
|
... ... "); v != "" {
|
|
... 206 if v != queue.QuietModeDisc
|
|
... ... ard && v != queue.QuietModeToast {
|
|
... 207 return cfg, fmt.Errorf(
|
|
... ... "TALK_QUIET_MODE: ungültiger Wert %
|
|
... ... q (erlaubt: %q, %q)",
|
|
... 208 v, queue.QuietModeD
|
|
... ... iscard, queue.QuietModeToast)
|
|
... 209 }
|
|
... 210 cfg.QuietMode = v
|
|
... 211 }
|
|
146 switch cfg.Audio.Sink { 212 switch cfg.Audio.Sink {
|
|
147 case SinkOto, SinkNull: 213 case SinkOto, SinkNull:
|
|
148 return cfg, nil ...
|
|
149 default: 214 default:
|
|
150 return cfg, fmt.Errorf("aud 215 return cfg, fmt.Errorf("aud
|
|
... io.sink: ungültiger Wert %q (erlaub ... io.sink: ungültiger Wert %q (erlaub
|
|
... t: %q, %q)", ... t: %q, %q)",
|
|
151 cfg.Audio.Sink, SinkOto 216 cfg.Audio.Sink, SinkOto
|
|
... , SinkNull) ... , SinkNull)
|
|
152 } 217 }
|
|
... 218 // quiet_hours aus der Datei va
|
|
... ... lidieren (Env wurde oben geprüft) u
|
|
... ... nd
|
|
... 219 // quiet_mode leer (Datei ohne
|
|
... ... Wert) auf den Default heben.
|
|
... 220 if cfg.QuietHours != "" {
|
|
... 221 if _, err := queue.ParseQui
|
|
... ... etHours(cfg.QuietHours); err != nil
|
|
... ... {
|
|
... 222 return cfg, err
|
|
... 223 }
|
|
... 224 }
|
|
... 225 if cfg.QuietMode == "" {
|
|
... 226 cfg.QuietMode = queue.Quiet
|
|
... ... ModeDiscard
|
|
... 227 }
|
|
... 228 return cfg, nil
|
|
153 } 229 }
|
|
|
|
internal/config/config_test.go --- 1/2 --- Go
|
|
5 5 "path/filepath"
|
|
6 6 "strings"
|
|
7 7 "testing"
|
|
. 8 "time"
|
|
. 9
|
|
. 10 "code.m3ta.dev/m3tam3re/talk/internal/queue"
|
|
8 11 )
|
|
9 12
|
|
10 13 func env(kv map[string]string) func(string) string {
|
|
|
|
internal/config/config_test.go --- 2/2 --- Go
|
|
353 356 })
|
|
354 357 }
|
|
358
|
|
359 // TestLoadGesetze deckt die Task-05-Felder ab: Rate-Limit (Default 30 s,
|
|
360 // "0s" = unbegrenzt), High-Burst (Default an), Ruhezeiten (Default aus;
|
|
361 // Format validiert) samt Env-Overrides und klaren Fehlern.
|
|
362 func TestLoadGesetze(t *testing.T) {
|
|
363 writeTOML := func(t *testing.T, content string) string {
|
|
364 t.Helper()
|
|
365 path := filepath.Join(t.TempDir(), "config.toml")
|
|
366 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
367 t.Fatal(err)
|
|
368 }
|
|
369 return path
|
|
370 }
|
|
371 fehlendeDatei := func(t *testing.T) string {
|
|
372 t.Helper()
|
|
373 return filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
|
|
374 }
|
|
375
|
|
376 t.Run("Defaults: 30 s, Burst an, Ruhezeit aus, Modus discard", func(t *testing.T) {
|
|
377 cfg, err := Load(Options{File: fehlendeDatei(t), Getenv: env(nil)})
|
|
378 if err != nil {
|
|
379 t.Fatalf("Load: %v", err)
|
|
380 }
|
|
381 if cfg.Queue.RateLimit != DefaultRateLimit {
|
|
382 t.Errorf("Queue.RateLimit = %v, want Default %v", cfg.Queue.RateLimit, DefaultRateLimit)
|
|
383 }
|
|
384 if !cfg.Queue.AllowHighBurst {
|
|
385 t.Error("Queue.AllowHighBurst = false, want Default true")
|
|
386 }
|
|
387 if cfg.QuietHours != "" {
|
|
388 t.Errorf("QuietHours = %q, want Default leer (aus)", cfg.QuietHours)
|
|
389 }
|
|
390 if cfg.QuietMode != queue.QuietModeDiscard {
|
|
391 t.Errorf("QuietMode = %q, want Default %q", cfg.QuietMode, queue.QuietModeDiscard)
|
|
392 }
|
|
393 })
|
|
394
|
|
395 t.Run("TOML setzt alle Gesetz-Felder", func(t *testing.T) {
|
|
396 cfg, err := Load(Options{
|
|
397 File: writeTOML(t, `
|
|
398 quiet_hours = "18:00-08:00"
|
|
399 quiet_mode = "toast"
|
|
400
|
|
401 [queue]
|
|
402 rate_limit = "10s"
|
|
403 allow_high_burst = false
|
|
404 `),
|
|
405 Getenv: env(nil),
|
|
406 })
|
|
407 if err != nil {
|
|
408 t.Fatalf("Load: %v", err)
|
|
409 }
|
|
410 if cfg.Queue.RateLimit != 10*time.Second {
|
|
411 t.Errorf("Queue.RateLimit = %v, want 10s", cfg.Queue.RateLimit)
|
|
412 }
|
|
413 if cfg.Queue.AllowHighBurst {
|
|
414 t.Error("Queue.AllowHighBurst = true, want false aus TOML")
|
|
415 }
|
|
416 if cfg.QuietHours != "18:00-08:00" {
|
|
417 t.Errorf("QuietHours = %q, want Fenster aus TOML", cfg.QuietHours)
|
|
418 }
|
|
419 if cfg.QuietMode != queue.QuietModeToast {
|
|
420 t.Errorf("QuietMode = %q, want toast", cfg.QuietMode)
|
|
421 }
|
|
422 })
|
|
423
|
|
424 t.Run("partielle [queue]-Tabelle behält Defaults", func(t *testing.T) {
|
|
425 cfg, err := Load(Options{
|
|
426 File: writeTOML(t, "[queue]\nrate_limit = \"0s\"\n"),
|
|
427 Getenv: env(nil),
|
|
428 })
|
|
429 if err != nil {
|
|
430 t.Fatalf("Load: %v", err)
|
|
431 }
|
|
432 if cfg.Queue.RateLimit != 0 {
|
|
433 t.Errorf("Queue.RateLimit = %v, want 0 (unbegrenzt)", cfg.Queue.RateLimit)
|
|
434 }
|
|
435 if !cfg.Queue.AllowHighBurst {
|
|
436 t.Error("Queue.AllowHighBurst = false, want unveränderten Default true")
|
|
437 }
|
|
438 if cfg.QuietMode != queue.QuietModeDiscard {
|
|
439 t.Errorf("QuietMode = %q, want unveränderten Default discard", cfg.QuietMode)
|
|
440 }
|
|
441 })
|
|
442
|
|
443 t.Run("Env überschreibt Datei (inkl. off)", func(t *testing.T) {
|
|
444 cfg, err := Load(Options{
|
|
445 File: writeTOML(t, `
|
|
446 quiet_hours = "18:00-08:00"
|
|
447 quiet_mode = "discard"
|
|
448
|
|
449 [queue]
|
|
450 rate_limit = "10s"
|
|
451 allow_high_burst = false
|
|
452 `),
|
|
453 Getenv: env(map[string]string{
|
|
454 "TALK_RATE_LIMIT": "1s",
|
|
455 "TALK_ALLOW_HIGH_BURST": "true",
|
|
456 "TALK_QUIET_HOURS": "off",
|
|
457 "TALK_QUIET_MODE": "toast",
|
|
458 }),
|
|
459 })
|
|
460 if err != nil {
|
|
461 t.Fatalf("Load: %v", err)
|
|
462 }
|
|
463 if cfg.Queue.RateLimit != time.Second {
|
|
464 t.Errorf("Queue.RateLimit = %v, want 1s aus Env", cfg.Queue.RateLimit)
|
|
465 }
|
|
466 if !cfg.Queue.AllowHighBurst {
|
|
467 t.Error("Queue.AllowHighBurst = false, want true aus Env")
|
|
468 }
|
|
469 if cfg.QuietHours != "" {
|
|
470 t.Errorf("QuietHours = %q, want leer (off aus Env)", cfg.QuietHours)
|
|
471 }
|
|
472 if cfg.QuietMode != queue.QuietModeToast {
|
|
473 t.Errorf("QuietMode = %q, want toast aus Env", cfg.QuietMode)
|
|
474 }
|
|
475 })
|
|
476
|
|
477 t.Run("ungültige Gesetz-Werte sind klare Fehler", func(t *testing.T) {
|
|
478 cases := []struct {
|
|
479 name string
|
|
480 toml string
|
|
481 envs map[string]string
|
|
482 want string
|
|
483 }{
|
|
484 {
|
|
485 name: "TALK_RATE_LIMIT Müll",
|
|
486 envs: map[string]string{"TALK_RATE_LIMIT": "schnell"},
|
|
487 want: "TALK_RATE_LIMIT",
|
|
488 },
|
|
489 {
|
|
490 name: "TALK_RATE_LIMIT negativ",
|
|
491 envs: map[string]string{"TALK_RATE_LIMIT": "-5s"},
|
|
492 want: "TALK_RATE_LIMIT",
|
|
493 },
|
|
494 {
|
|
495 name: "rate_limit Müll in Datei",
|
|
496 toml: "[queue]\nrate_limit = \"müll\"\n",
|
|
497 want: "rate_limit",
|
|
498 },
|
|
499 {
|
|
500 name: "TALK_ALLOW_HIGH_BURST Müll",
|
|
501 envs: map[string]string{"TALK_ALLOW_HIGH_BURST": "vielleicht"},
|
|
502 want: "TALK_ALLOW_HIGH_BURST",
|
|
503 },
|
|
504 {
|
|
505 name: "quiet_hours Müll in Datei",
|
|
506 toml: "quiet_hours = \"abends\"\n",
|
|
507 want: "quiet_hours",
|
|
508 },
|
|
509 {
|
|
510 name: "TALK_QUIET_HOURS Müll",
|
|
511 envs: map[string]string{"TALK_QUIET_HOURS": "25:00-26:00"},
|
|
512 want: "TALK_QUIET_HOURS",
|
|
513 },
|
|
514 {
|
|
515 name: "TALK_QUIET_MODE Müll",
|
|
516 envs: map[string]string{"TALK_QUIET_MODE": "vibrieren"},
|
|
517 want: "TALK_QUIET_MODE",
|
|
518 },
|
|
519 }
|
|
520 for _, tc := range cases {
|
|
521 t.Run(tc.name, func(t *testing.T) {
|
|
522 file := fehlendeDatei(t)
|
|
523 if tc.toml != "" {
|
|
524 file = writeTOML(t, tc.toml)
|
|
525 }
|
|
526 _, err := Load(Options{File: file, Getenv: env(tc.envs)})
|
|
527 if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
528 t.Fatalf("Fehler = %v, will klaren Fehler mit %q", err, tc.want)
|
|
529 }
|
|
530 })
|
|
531 }
|
|
532 })
|
|
533 }
|
|
|
|
internal/queue/doc.go --- Go
|
|
1 // Package queue implementiert die An 1 // Package queue implementiert die An
|
|
. sage-Queue des Daemons: serialisierte . sage-Queue des Daemons: serialisierte
|
|
2 // Abarbeitung (eine Ansage gleichzei 2 // Abarbeitung (eine Ansage gleichzei
|
|
. tig), Prioritäten (high > normal > lo . tig), Prioritäten (high > normal > lo
|
|
. w, . w,
|
|
3 // FIFO je Level), now-Items springen 3 // FIFO je Level), now-Items springen
|
|
. vor, dazu den persistenten Mute-Stat . vor, dazu den persistenten Mute-Stat
|
|
. e . e
|
|
4 // (Datei mute im Config-Dir) und die 4 // (Datei mute im Config-Dir). Seit T
|
|
. Rate-Limit-Struktur. Das Rate-Limit . ask 05 unter dem Anti-Spam-Gesetz
|
|
5 // ist als Struktur vorhanden, aber n 5 // (rate.go/quiet.go): Rate-Limit — j
|
|
. och nicht angebunden — Wiring und . ede gestartete Ansage bucht das Fenst
|
|
. . er;
|
|
6 // Ruhezeiten folgen in Task 05. 6 // low über dem Budget verfällt, norm
|
|
. . al parkt bis zum Slot, high darf mit
|
|
. 7 // Burst-Recht verstoßen — und Ruheze
|
|
. . iten, die still verwerfen oder durch
|
|
. 8 // einen Toast ersetzen. now:true bri
|
|
. . cht bewusst aus beiden aus.
|
|
7 package queue 9 package queue
|
|
|
|
internal/queue/queue.go --- 1/4 --- Go
|
|
92 type Speaker func(ctx context.Conte 92 type Speaker func(ctx context.Conte
|
|
.. xt, item Item) error .. xt, item Item) error
|
|
93 93
|
|
94 // Queue ist die serialisierte Ansa 94 // Queue ist die serialisierte Ansa
|
|
.. ge-Queue des Daemons: genau eine An .. ge-Queue des Daemons: genau eine An
|
|
.. sage .. sage
|
|
95 // gleichzeitig, Reihenfolge now > 95 // gleichzeitig, Reihenfolge now >
|
|
.. high > normal > low (FIFO je Level) .. high > normal > low (FIFO je Level)
|
|
.. . .. —
|
|
.. 96 // seit Task 05 unter dem Anti-Spam
|
|
.. .. -Gesetz (Laws): normal parkt bis zu
|
|
.. .. m
|
|
.. 97 // Rate-Limit-Slot, low wird über d
|
|
.. .. em Budget verworfen, high darf mit
|
|
.. 98 // Burst-Recht verstoßen, now brich
|
|
.. .. t bewusst aus, und Ruhezeiten verwe
|
|
.. .. rfen
|
|
.. 99 // bzw. toasten statt Audio.
|
|
96 // 100 //
|
|
97 // Die Synchronisation ist bewusst 101 // Die Synchronisation ist bewusst
|
|
.. simpel und offensichtlich korrekt s ... simpel und offensichtlich korrekt s
|
|
.. tatt ... tatt
|
|
98 // clever: EIN Mutex + EIN Cond sch 102 // clever: EIN Mutex + EIN Cond sch
|
|
.. ützen alles (Items, aktuelles Item, ... ützen alles (Items, aktuelles Item,
|
|
99 // Closed-Flag, cancelCur). Der Wor 103 // Closed-Flag, cancelCur, Slot-Wec
|
|
.. ker ist der einzige, der Items entn ... ker). Der Worker ist der einzige, d
|
|
.. immt; ... er
|
|
100 // pop und das Setzen von current/c 104 // Items entnimmt; take und das Set
|
|
... ancelCur geschehen unter demselben ... zen von current/cancelCur geschehen
|
|
... Lock, ... unter
|
|
101 // damit ein Item nie zwischen „war 105 // demselben Lock, damit ein Item n
|
|
... tet" und „läuft" der Cancelierung ... ie zwischen „wartet" und „läuft" de
|
|
... ... r
|
|
102 // entwischen kann. Cancel/Close ru 106 // Cancelierung entwischen kann. Ca
|
|
... fen cancelCur unter dem Lock ab — d ... ncel/Close rufen cancelCur unter de
|
|
... er ... m Lock
|
|
103 // Worker bemerkt die Abbrechung in 107 // ab — der Worker bemerkt die Abbr
|
|
... seiner speak-Funktion über ctx. ... echung in seiner speak-Funktion übe
|
|
... ... r ctx.
|
|
... 108 // Geparkte Items bleiben in der Wa
|
|
... ... rteschlange liegen; ein Slot-Wecker
|
|
... 109 // (armWakeLocked) broadcastet zum
|
|
... ... Fenster-Ablauf, damit der Worker au
|
|
... ... ch ohne
|
|
... 110 // neuen Enqueue aufwacht — die Wai
|
|
... ... t-Bedingung prüft jedes Mal die Ges
|
|
... ... etze
|
|
... 111 // neu, verlorene Signale sind dadu
|
|
... ... rch harmlos.
|
|
104 type Queue struct { 112 type Queue struct {
|
|
... 113 laws Laws
|
|
... 114 limit *RateLimiter
|
|
105 speak Speaker 115 speak Speaker
|
|
106 log io.Writer 116 log io.Writer
|
|
107 117
|
|
108 mu sync.Mutex 118 mu sync.Mutex
|
|
109 notEmpty *sync.Cond // Signal: 119 notEmpty *sync.Cond // Signal:
|
|
... neues Item; Broadcast: Close ... neues Item; Broadcast: Close/Slot
|
|
... ... frei
|
|
110 items []Item 120 items []Item
|
|
111 current *Item 121 current *Item
|
|
112 cancelCur context.CancelFunc 122 cancelCur context.CancelFunc
|
|
113 seq uint64 123 seq uint64
|
|
114 closed bool 124 closed bool
|
|
... 125 wake *time.Timer // weck
|
|
... ... t den geparkten Worker zum Slot-Ter
|
|
... ... min
|
|
... 126 wakeAt time.Time // Term
|
|
... ... in des laufenden Weckers
|
|
115 done chan struct{} // gesc 127 done chan struct{} // gesc
|
|
... hlossen, wenn der Worker-Loop endet ... hlossen, wenn der Worker-Loop endet
|
|
116 } 128 }
|
|
117 129
|
|
118 // New startet die Queue mit ihrem 130 // New startet die Queue mit ihrem
|
|
... Worker-Loop (eine Ansage gleichzeit ... Worker-Loop (eine Ansage gleichzeit
|
|
... ig). ... ig).
|
|
... 131 // laws trägt die Anti-Spam-Gesetze
|
|
... ... (Nullwert = alles erlaubt; der Dae
|
|
... ... mon
|
|
119 // log ist optional (nil = still) u 132 // liefert die Config-Gesetze). log
|
|
... nd empfängt die Lebenslauf-Zeilen d ... ist optional (nil = still) und emp
|
|
... er ... fängt
|
|
120 // Items. Close beendet den Loop un 133 // die Lebenslauf-Zeilen der Items.
|
|
... d wartet auf sein Ende. ... Close beendet den Loop und wartet
|
|
... ... auf
|
|
... 134 // sein Ende.
|
|
121 func New(speak Speaker, log io.Writ 135 func New(laws Laws, speak Speaker,
|
|
... er) *Queue { ... log io.Writer) *Queue {
|
|
122 q := &Queue{speak: speak, log: 136 q := &Queue{
|
|
... log, done: make(chan struct{})} ...
|
|
... 137 laws: laws,
|
|
... 138 limit: &RateLimiter{Interva
|
|
... ... l: laws.RateLimit},
|
|
... 139 speak: speak,
|
|
... 140 log: log,
|
|
... 141 done: make(chan struct{}),
|
|
... 142 }
|
|
123 q.notEmpty = sync.NewCond(&q.mu 143 q.notEmpty = sync.NewCond(&q.mu
|
|
... ) ... )
|
|
124 go q.loop() 144 go q.loop()
|
|
125 return q 145 return q
|
|
126 } 146 }
|
|
127 147
|
|
128 // Enqueue stellt item in die Queue 148 // Enqueue stellt item in die Warte
|
|
... und liefert dessen ID (bei leerer ... schlange und liefert die Aufnahme-
|
|
... ID neu ...
|
|
... 149 // Entscheidung: Aufgenommene trage
|
|
... ... n ihre ID (bei leerer ID neu vergeb
|
|
... ... en:
|
|
... 150 // kurz und zufällig). Ruhezeit-Ver
|
|
... ... wurf/Toast und low über dem Fenster
|
|
... ... -Budget
|
|
... 151 // werden schon beim Eintritt entsc
|
|
... ... hieden (die 202-Antwort von POST /s
|
|
... ... peak
|
|
... 152 // nennt den Grund); alles andere e
|
|
... ... ntscheidet der Worker, wenn das Ite
|
|
... ... m
|
|
129 // vergeben: kurz und zufällig). Na 153 // fällig ist. Nach Close ist Enque
|
|
... ch Close ist Enqueue ein Fehler. ... ue ein Fehler.
|
|
130 func (q *Queue) Enqueue(item Item) 154 func (q *Queue) Enqueue(item Item)
|
|
... (string, error) { ... (Admission, error) {
|
|
131 q.mu.Lock() 155 q.mu.Lock()
|
|
132 if q.closed { 156 if q.closed {
|
|
133 q.mu.Unlock() 157 q.mu.Unlock()
|
|
134 return "", ErrClosed 158 return Admission{}, ErrClos
|
|
... ... ed
|
|
135 } 159 }
|
|
136 if item.Priority == "" { 160 if item.Priority == "" {
|
|
137 item.Priority = PriorityNor 161 item.Priority = PriorityNor
|
|
... mal ... mal
|
|
138 } 162 }
|
|
... 163 switch q.laws.decide(item, time
|
|
... ... .Now(), q.limit.Allow()) {
|
|
... 164 case verdictDiscardQuiet:
|
|
... 165 q.mu.Unlock()
|
|
... 166 q.logf("queue: ansage (%q,
|
|
... ... priorität %s) verworfen — ruhezeit
|
|
... ... %s aktiv (modus discard)",
|
|
... 167 item.Text, item.Priorit
|
|
... ... y, q.laws.Quiet)
|
|
... 168 return Admission{Discarded:
|
|
... ... DiscardQuietHours}, nil
|
|
... 169 case verdictToast:
|
|
... 170 q.mu.Unlock()
|
|
... 171 q.logf("queue: ansage (%q,
|
|
... ... priorität %s) durch toast ersetzt —
|
|
... ... ruhezeit %s aktiv (modus toast)",
|
|
... 172 item.Text, item.Priorit
|
|
... ... y, q.laws.Quiet)
|
|
... 173 return Admission{Discarded:
|
|
... ... DiscardQuietHours, Toast: true}, n
|
|
... ... il
|
|
... 174 case verdictDiscardRate:
|
|
... 175 q.mu.Unlock()
|
|
... 176 q.logf("queue: ansage (%q,
|
|
... ... priorität low) verworfen — rate-lim
|
|
... ... it %s",
|
|
... 177 item.Text, q.laws.RateL
|
|
... ... imit)
|
|
... 178 return Admission{Discarded:
|
|
... ... DiscardRateLimit}, nil
|
|
... 179 }
|
|
139 q.seq++ 180 q.seq++
|
|
140 item.seq = q.seq 181 item.seq = q.seq
|
|
141 if item.ID == "" { 182 if item.ID == "" {
|
|
|
|
internal/queue/queue.go --- 2/4 --- Go
|
|
144 185 q.items = append(q.items, item)
|
|
145 186 q.mu.Unlock()
|
|
146 187 q.notEmpty.Signal()
|
|
147 188 return Admission{ID: item.ID}, nil
|
|
148 189 }
|
|
149 190
|
|
150 191 // Pending liefert eine Momentaufnahme der wartenden Items in
|
|
|
|
internal/queue/queue.go --- 3/4 --- Go
|
|
208 if q.cancelCur != nil { 249 if q.cancelCur != nil {
|
|
209 q.cancelCur() 250 q.cancelCur()
|
|
210 } 251 }
|
|
... 252 if q.wake != nil {
|
|
... 253 q.wake.Stop()
|
|
... 254 q.wake = nil
|
|
... 255 }
|
|
211 q.mu.Unlock() 256 q.mu.Unlock()
|
|
212 q.notEmpty.Broadcast() 257 q.notEmpty.Broadcast()
|
|
213 <-q.done 258 <-q.done
|
|
214 } 259 }
|
|
... 260
|
|
... 261 // LawState liefert die /status-Sic
|
|
... ... ht auf die Anti-Spam-Gesetze: das
|
|
... 262 // konfigurierte Rate-Limit mit dem
|
|
... ... nächsten freien Slot (Nullzeit = f
|
|
... ... rei
|
|
... 263 // oder unbegrenzt) und das Ruhezei
|
|
... ... t-Fenster mit seinem Ende, wenn es
|
|
... 264 // gerade aktiv ist.
|
|
... 265 func (q *Queue) LawState() LawState
|
|
... ... {
|
|
... 266 now := time.Now()
|
|
... 267 return LawState{
|
|
... 268 RateLimit: q.laws.Rate
|
|
... ... Limit,
|
|
... 269 AllowHighBurst: q.laws.Allo
|
|
... ... wHighBurst,
|
|
... 270 NextSlot: q.limit.Nex
|
|
... ... t(),
|
|
... 271 Quiet: q.laws.Quie
|
|
... ... t,
|
|
... 272 QuietActive: q.laws.Quie
|
|
... ... t.Active(now),
|
|
... 273 QuietMode: q.laws.Quie
|
|
... ... tMode,
|
|
... 274 QuietUntil: q.laws.Quie
|
|
... ... t.Until(now),
|
|
... 275 }
|
|
... 276 }
|
|
215 277
|
|
216 // loop ist der Worker: wartet auf 278 // loop ist der Worker: wartet auf
|
|
... Items und spielt sie einzeln ab. Na ... fällige Items und spielt sie einzel
|
|
... ch ... n ab.
|
|
... 279 // „Fällig" heißt seit Task 05 auch
|
|
... ... gesetzkonform: Geparkte schlafen b
|
|
... ... is zum
|
|
... 280 // Rate-Limit-Slot (armWakeLocked w
|
|
... ... eckt), low- und Ruhezeit-Verwürfe l
|
|
... ... aufen
|
|
... 281 // ohne Audio durchs Protokoll, now
|
|
... ... und high-burst spielen sofort.
|
|
217 // Close (oder wenn Close während e 282 // Nach Close (oder wenn Close währ
|
|
... iner Ansage zuschlägt) beendet er s ... end einer Ansage zuschlägt) beendet
|
|
... ich, ... er
|
|
218 // sobald die laufende Ansage zurüc 283 // sich, sobald die laufende Ansage
|
|
... kgekehrt ist. ... zurückgekehrt ist.
|
|
219 func (q *Queue) loop() { 284 func (q *Queue) loop() {
|
|
220 defer close(q.done) 285 defer close(q.done)
|
|
221 for { 286 for {
|
|
222 q.mu.Lock() 287 q.mu.Lock()
|
|
223 for len(q.items) == 0 && !q 288 for !q.closed && !q.readyLo
|
|
... .closed { ... cked() {
|
|
... 289 if len(q.items) > 0 {
|
|
... 290 // Bestes Item park
|
|
... ... t auf den Slot: Wecker zum Termin a
|
|
... ... rmieren.
|
|
... 291 q.armWakeLocked(q.l
|
|
... ... imit.Next())
|
|
... 292 }
|
|
224 q.notEmpty.Wait() 293 q.notEmpty.Wait()
|
|
225 } 294 }
|
|
226 if q.closed { 295 if q.closed {
|
|
227 q.mu.Unlock() 296 q.mu.Unlock()
|
|
228 return 297 return
|
|
229 } 298 }
|
|
230 item := q.popLocked() 299 item, v := q.takeLocked()
|
|
... 300 if v == verdictWait {
|
|
... 301 // Kurz ohne Fortschrit
|
|
... ... t geweckt (z. B. verlorenes Signal)
|
|
... ... :
|
|
... 302 // wieder schlafen lege
|
|
... ... n. Kann nach readyLocked eigentlich
|
|
... ... nie
|
|
... 303 // eintreten — sicherhe
|
|
... ... itshalber geparkt statt gedreht.
|
|
... 304 q.mu.Unlock()
|
|
... 305 continue
|
|
... 306 }
|
|
... 307 if v == verdictPlay {
|
|
... 308 // Fenster buchen, BEVO
|
|
... ... R gesprochen wird: Sobald das Item
|
|
... ... als
|
|
... 309 // laufend gilt, steht
|
|
... ... der nächste Slot-Termin fest.
|
|
... 310 q.limit.Reserve()
|
|
231 ctx, cancel := context.With 311 ctx, cancel := context.
|
|
... Cancel(context.Background()) ... WithCancel(context.Background())
|
|
232 q.current = &item 312 q.current = &item
|
|
233 q.cancelCur = cancel 313 q.cancelCur = cancel
|
|
|
|
internal/queue/queue.go --- 4/4 --- Go
|
|
252 default: 332 default:
|
|
253 q.logf("queue: ansage % 333 q.logf("queue: ansa
|
|
... s fertig nach %s", item.ID, dur.Rou ... ge %s fertig nach %s", item.ID, dur
|
|
... nd(time.Millisecond)) ... .Round(time.Millisecond))
|
|
254 } 334 }
|
|
... 335 continue
|
|
... 336 }
|
|
... 337 q.mu.Unlock()
|
|
... 338 switch v {
|
|
... 339 case verdictDiscardRate:
|
|
... 340 q.logf("queue: ansage %
|
|
... ... s (%q, priorität low) verworfen — r
|
|
... ... ate-limit %s",
|
|
... 341 item.ID, item.Text,
|
|
... ... q.laws.RateLimit)
|
|
... 342 case verdictDiscardQuiet:
|
|
... 343 q.logf("queue: ansage %
|
|
... ... s (%q) verworfen — ruhezeit %s akti
|
|
... ... v (modus discard)",
|
|
... 344 item.ID, item.Text,
|
|
... ... q.laws.Quiet)
|
|
... 345 case verdictToast:
|
|
... 346 q.logf("queue: ansage %
|
|
... ... s (%q) durch toast ersetzt — ruheze
|
|
... ... it %s aktiv (modus toast)",
|
|
... 347 item.ID, item.Text,
|
|
... ... q.laws.Quiet)
|
|
... 348 }
|
|
255 } 349 }
|
|
256 } 350 }
|
|
... 351
|
|
... 352 // readyLocked meldet, ob der Worke
|
|
... ... r arbeiten kann (mu muss gehalten
|
|
... 353 // werden): geschlossen immer; sons
|
|
... ... t nur, wenn das beste Item nicht bl
|
|
... ... oß
|
|
... 354 // auf seinen Rate-Limit-Slot parkt
|
|
... ... . Verwürfe zählen als Arbeit — sie
|
|
... 355 // laufen ohne Audio, aber mit Prot
|
|
... ... okoll durch den Worker.
|
|
... 356 func (q *Queue) readyLocked() bool
|
|
... ... {
|
|
... 357 if q.closed || len(q.items) ==
|
|
... ... 0 {
|
|
... 358 return q.closed
|
|
... 359 }
|
|
... 360 return q.verdictLocked(q.items[
|
|
... ... q.bestIndexLocked()]) != verdictWai
|
|
... ... t
|
|
... 361 }
|
|
257 362
|
|
258 // popLocked entnimmt das als nächs 363 // takeLocked entnimmt das als näch
|
|
... tes fällige Item (mu muss gehalten ... stes fällige Item, wenn es spielbar
|
|
... werden). ... oder
|
|
... 364 // entscheidbar ist (mu muss gehalt
|
|
... ... en werden). Bei verdictWait wird NI
|
|
... ... CHT
|
|
... 365 // entnommen — das Item bleibt gepa
|
|
... ... rkt, bis der Slot frei ist (oder ei
|
|
... ... n
|
|
... 366 // besseres Item vorgezogen wird).
|
|
259 func (q *Queue) popLocked() Item { 367 func (q *Queue) takeLocked() (Item,
|
|
... ... verdict) {
|
|
260 best := 0 368 best := q.bestIndexLocked()
|
|
261 for i := 1; i < len(q.items); i ...
|
|
... ++ { ...
|
|
262 if itemBefore(q.items[i], q 369 item := q.items[best]
|
|
... .items[best]) { ...
|
|
263 best = i ...
|
|
264 } ...
|
|
265 } ...
|
|
266 item := q.items[best] 370 v := q.verdictLocked(item)
|
|
... 371 if v != verdictWait {
|
|
267 q.items = append(q.items[:best] 372 q.items = append(q.items[:b
|
|
... , q.items[best+1:]...) ... est], q.items[best+1:]...)
|
|
... 373 }
|
|
268 return item 374 return item, v
|
|
269 } 375 }
|
|
... 376
|
|
... 377 // bestIndexLocked liefert den Inde
|
|
... ... x des als nächstes fälligen Items
|
|
... 378 // (mu muss gehalten werden).
|
|
... 379 func (q *Queue) bestIndexLocked() i
|
|
... ... nt {
|
|
... 380 best := 0
|
|
... 381 for i := 1; i < len(q.items); i
|
|
... ... ++ {
|
|
... 382 if itemBefore(q.items[i], q
|
|
... ... .items[best]) {
|
|
... 383 best = i
|
|
... 384 }
|
|
... 385 }
|
|
... 386 return best
|
|
... 387 }
|
|
... 388
|
|
... 389 // verdictLocked wendet die Gesetze
|
|
... ... auf ein Item an (mu muss gehalten
|
|
... 390 // werden — die Lock-Ordnung Queue
|
|
... ... → RateLimiter bleibt gewahrt).
|
|
... 391 func (q *Queue) verdictLocked(item
|
|
... ... Item) verdict {
|
|
... 392 return q.laws.decide(item, time
|
|
... ... .Now(), q.limit.Allow())
|
|
... 393 }
|
|
... 394
|
|
... 395 // armWakeLocked armiert den Slot-W
|
|
... ... ecker auf until (mu muss gehalten
|
|
... 396 // werden): Er broadcastet die Cond
|
|
... ... zum Termin, damit der geparkte Wor
|
|
... ... ker
|
|
... 397 // auch ohne neuen Enqueue aufwacht
|
|
... ... . Ein bereits laufender Wecker mit
|
|
... 398 // früherem (oder gleichem) Termin
|
|
... ... reicht; sonst wird er ersetzt.
|
|
... 399 func (q *Queue) armWakeLocked(until
|
|
... ... time.Time) {
|
|
... 400 if q.wake != nil && !q.wakeAt.A
|
|
... ... fter(until) {
|
|
... 401 return
|
|
... 402 }
|
|
... 403 if q.wake != nil {
|
|
... 404 q.wake.Stop()
|
|
... 405 }
|
|
... 406 q.wakeAt = until
|
|
... 407 d := time.Until(until)
|
|
... 408 if d < 0 {
|
|
... 409 d = 0
|
|
... 410 }
|
|
... 411 q.wake = time.AfterFunc(d, func
|
|
... ... () {
|
|
... 412 q.mu.Lock()
|
|
... 413 q.wake = nil
|
|
... 414 q.mu.Unlock()
|
|
... 415 q.notEmpty.Broadcast()
|
|
... 416 })
|
|
... 417 }
|
|
270 418
|
|
271 // newID liefert eine kurze, kollis 419 // newID liefert eine kurze, kollis
|
|
... ionsarme ID (6 Hex-Zeichen). IDs mü ... ionsarme ID (6 Hex-Zeichen). IDs mü
|
|
... ssen ... ssen
|
|
272 // nur pro Daemon-Lauf eindeutig se 420 // nur pro Daemon-Lauf eindeutig se
|
|
... in (--wait-Polling); der Fallback i ... in (--wait-Polling); der Fallback i
|
|
... st ... st
|
|
|
|
internal/queue/queue_test.go --- 1/8 --- Go
|
|
67 // TestAbarbeitungsReihenfolge: now 67 // TestAbarbeitungsReihenfolge: now
|
|
.. vor high vor normal vor low, FIFO .. vor high vor normal vor low, FIFO
|
|
.. je .. je
|
|
68 // Level. Der erste Blocker besetzt 68 // Level. Der erste Blocker besetzt
|
|
.. den Worker; danach werden alle Lev .. den Worker; danach werden alle Lev
|
|
.. el .. el
|
|
69 // gleichzeitig eingereiht — die Ab 69 // gleichzeitig eingereiht — die Ab
|
|
.. arbeitung muss der Klasse folgen. .. arbeitung muss der Klasse folgen.
|
|
.. 70 // (Ohne Gesetze: Null-Laws = unbeg
|
|
.. .. renzt, keine Ruhezeit.)
|
|
70 func TestAbarbeitungsReihenfolge(t 71 func TestAbarbeitungsReihenfolge(t
|
|
.. *testing.T) { .. *testing.T) {
|
|
71 g := neueSpielerin() 72 g := neueSpielerin()
|
|
72 q := New(g.speak, nil) 73 q := New(Laws{}, g.speak, nil)
|
|
73 defer q.Close() 74 defer q.Close()
|
|
74 75
|
|
75 erster, err := q.Enqueue(Item{T 76 adm, err := q.Enqueue(Item{Text
|
|
.. ext: "blocker", Priority: PriorityN .. : "blocker", Priority: PriorityNorm
|
|
.. ormal}) .. al})
|
|
76 if err != nil { 77 if err != nil {
|
|
77 t.Fatal(err) 78 t.Fatal(err)
|
|
78 } 79 }
|
|
79 if erster == "" { 80 if adm.ID == "" || adm.Discarde
|
|
.. .. d != "" {
|
|
80 t.Fatal("Enqueue vergibt ke 81 t.Fatalf("Enqueue = %+v, wa
|
|
.. ine ID") .. nt Aufnahme mit ID", adm)
|
|
81 } 82 }
|
|
82 g.warteBis(t, 1) // Blocker läu 83 g.warteBis(t, 1) // Blocker läu
|
|
.. ft, Worker gebunden. .. ft, Worker gebunden.
|
|
83 84
|
|
|
|
internal/queue/queue_test.go --- 2/8 --- Go
|
|
111 112 // TestCurrentUndPending: Status-Sicht auf laufendes Item + Warteschlange.
|
|
112 113 func TestCurrentUndPending(t *testing.T) {
|
|
113 114 g := neueSpielerin()
|
|
114 115 q := New(Laws{}, g.speak, nil)
|
|
115 116 defer q.Close()
|
|
116 117
|
|
117 118 if _, ok := q.Current(); ok {
|
|
|
|
internal/queue/queue_test.go --- 3/8 --- Go
|
|
144 145 // spielbereit.
|
|
145 146 func TestCancel(t *testing.T) {
|
|
146 147 g := neueSpielerin()
|
|
147 148 q := New(Laws{}, g.speak, nil)
|
|
148 149 defer q.Close()
|
|
149 150
|
|
150 151 q.Enqueue(Item{Text: "blocker"})
|
|
|
|
internal/queue/queue_test.go --- 4/8 --- Go
|
|
165 } 166 }
|
|
166 167
|
|
167 // Queue nimmt wieder an und sp 168 // Queue nimmt wieder an und sp
|
|
... ielt: ... ielt:
|
|
168 id, err := q.Enqueue(Item{Text: 169 adm, err := q.Enqueue(Item{Text
|
|
... "danach"}) ... : "danach"})
|
|
169 if err != nil { 170 if err != nil {
|
|
170 t.Fatal(err) 171 t.Fatal(err)
|
|
171 } 172 }
|
|
172 g.step <- struct{}{} // gibt di 173 g.step <- struct{}{} // gibt di
|
|
... e wartende Ansage im Speaker frei ... e wartende Ansage im Speaker frei
|
|
173 gestartet := g.warteBis(t, 2) 174 gestartet := g.warteBis(t, 2)
|
|
174 if len(gestartet) != 1 || gesta 175 if len(gestartet) != 1 || gesta
|
|
... rtet[0].ID != id { ... rtet[0].ID != adm.ID {
|
|
175 t.Errorf("neue Ansage nicht 176 t.Errorf("neue Ansage nicht
|
|
... gestartet: %+v", gestartet) ... gestartet: %+v", gestartet)
|
|
176 } 177 }
|
|
177 } 178 }
|
|
|
|
internal/queue/queue_test.go --- 5/8 --- Go
|
|
181 182 // Enqueue ein Fehler (und Close idempotent).
|
|
182 183 func TestClose(t *testing.T) {
|
|
183 184 g := neueSpielerin()
|
|
184 185 q := New(Laws{}, g.speak, nil)
|
|
185 186
|
|
186 187 q.Enqueue(Item{Text: "blocker"})
|
|
187 188 g.warteBis(t, 1)
|
|
|
|
internal/queue/queue_test.go --- 6/8 --- Go
|
|
202 // TestEnqueueVergibtIDs: IDs sind 203 // TestEnqueueVergibtIDs: IDs sind
|
|
... kurz und eindeutig. ... kurz und eindeutig.
|
|
203 func TestEnqueueVergibtIDs(t *testi 204 func TestEnqueueVergibtIDs(t *testi
|
|
... ng.T) { ... ng.T) {
|
|
204 g := neueSpielerin() 205 g := neueSpielerin()
|
|
205 q := New(g.speak, nil) 206 q := New(Laws{}, g.speak, nil)
|
|
206 defer q.Close() 207 defer q.Close()
|
|
207 208
|
|
208 seen := map[string]bool{} 209 seen := map[string]bool{}
|
|
209 for i := 0; i < 20; i++ { 210 for i := 0; i < 20; i++ {
|
|
210 id, err := q.Enqueue(Item{T 211 adm, err := q.Enqueue(Item{
|
|
... ext: "x"}) ... Text: "x"})
|
|
211 if err != nil { 212 if err != nil {
|
|
212 t.Fatal(err) 213 t.Fatal(err)
|
|
213 } 214 }
|
|
214 if len(id) < 4 || seen[id] 215 if len(adm.ID) < 4 || seen[
|
|
... { ... adm.ID] {
|
|
215 t.Errorf("ID %q zu kurz 216 t.Errorf("ID %q zu kurz
|
|
... oder doppelt", id) ... oder doppelt", adm.ID)
|
|
216 } 217 }
|
|
217 seen[id] = true 218 seen[adm.ID] = true
|
|
218 } 219 }
|
|
219 } 220 }
|
|
220 221
|
|
|
|
internal/queue/queue_test.go --- 7/8 --- Go
|
|
279 } 280 }
|
|
280 } 281 }
|
|
281 282
|
|
282 // TestRateLimiter: Struktur-Semant 283 // TestRateLimiter: Struktur-Semant
|
|
... ik (Task 05 verdrahtet sie in den L ... ik (seit Task 05 in den Loop verwir
|
|
... oop). ... ed).
|
|
283 func TestRateLimiter(t *testing.T) 284 func TestRateLimiter(t *testing.T)
|
|
... { ... {
|
|
284 // Unbegrenzt (Task-04-Default) 285 // Unbegrenzt (Task-04-Default)
|
|
... : Interval 0 erlaubt immer. ... : Interval 0 erlaubt immer.
|
|
285 unlimited := &RateLimiter{} 286 unlimited := &RateLimiter{}
|
|
|
|
internal/queue/queue_test.go --- 8/8 --- Go
|
|
308 309 }
|
|
309 310 }
|
|
311
|
|
312 // fensterUm baut ein "HH:MM-HH:MM"-Ruhefenster, das t sicher einschließt
|
|
313 // (eine Minute davor bis eine Minute danach; Minute-Auflösung reicht).
|
|
314 func fensterUm(t time.Time) string {
|
|
315 return t.Add(-time.Minute).Format("15:04") + "-" + t.Add(time.Minute).Format("15:04")
|
|
316 }
|
|
317
|
|
318 // warteAuf verifies mit Pollen eine Bedingung (Zeit-Gesetze sind real, der
|
|
319 // gesteuerte Speaker allein sieht sie nicht).
|
|
320 func warteAuf(t *testing.T, timeout time.Duration, cond func() bool, was string) {
|
|
321 t.Helper()
|
|
322 deadline := time.Now().Add(timeout)
|
|
323 for !cond() {
|
|
324 if time.Now().After(deadline) {
|
|
325 t.Fatalf("%s innerhalb %s nicht eingetreten", was, timeout)
|
|
326 }
|
|
327 time.Sleep(5 * time.Millisecond)
|
|
328 }
|
|
329 }
|
|
330
|
|
331 // TestRateLimitLowVerworfenNormalParkt: mit kurzem Fenster spielt die
|
|
332 // erste Ansage sofort; low über dem Budget wird schon beim Eintritt
|
|
333 // verworfen (Admission nennt den Grund), normal parkt und spielt nach
|
|
334 // Fenster-Ablauf.
|
|
335 func TestRateLimitLowVerworfenNormalParkt(t *testing.T) {
|
|
336 g := neueSpielerin()
|
|
337 q := New(Laws{RateLimit: 400 * time.Millisecond}, g.speak, nil)
|
|
338 defer q.Close()
|
|
339
|
|
340 if adm, err := q.Enqueue(Item{Text: "erste"}); err != nil || adm.ID == "" || adm.Discarded != "" {
|
|
341 t.Fatalf("erste Enqueue = %+v (%v), want Aufnahme", adm, err)
|
|
342 }
|
|
343 g.warteBis(t, 1) // erste läuft — Fenster ist gebucht.
|
|
344
|
|
345 adm, err := q.Enqueue(Item{Text: "low-eins", Priority: PriorityLow})
|
|
346 if err != nil {
|
|
347 t.Fatal(err)
|
|
348 }
|
|
349 if adm.ID != "" || adm.Discarded != DiscardRateLimit || adm.Toast {
|
|
350 t.Errorf("low über Budget = %+v, want Verwurf %q", adm, DiscardRateLimit)
|
|
351 }
|
|
352 if adm, err = q.Enqueue(Item{Text: "low-zwei", Priority: PriorityLow}); err != nil || adm.Discarded != DiscardRateLimit {
|
|
353 t.Errorf("zweiter low-Verwurf = %+v (%v)", adm, err)
|
|
354 }
|
|
355
|
|
356 adm, err = q.Enqueue(Item{Text: "zweite"})
|
|
357 if err != nil || adm.ID == "" || adm.Discarded != "" {
|
|
358 t.Fatalf("normal = %+v (%v), want Aufnahme (parkt bis zum Slot)", adm, err)
|
|
359 }
|
|
360
|
|
361 // Status-Sicht während der erste läuft: zweite wartet (normal, geparkt).
|
|
362 warteAuf(t, time.Second, func() bool {
|
|
363 for _, it := range q.Pending() {
|
|
364 if it.Text == "zweite" {
|
|
365 return true
|
|
366 }
|
|
367 }
|
|
368 return false
|
|
369 }, "geparkte normale Ansage in Pending")
|
|
370
|
|
371 close(g.step) // erste beenden — zweite muss weiter parken …
|
|
372 g.keineWeitere(t)
|
|
373 // … und nach Fenster-Ablauf starten (Deadline deckt Jitter locker).
|
|
374 gestartet := g.warteBis(t, 2)
|
|
375 if len(gestartet) != 1 || gestartet[0].Text != "zweite" {
|
|
376 t.Errorf("nach Fenster-Ablauf = %+v, will zweite", gestartet)
|
|
377 }
|
|
378 // low-eins/low-zwei spielen nie: genau 2 Starts insgesamt.
|
|
379 g.keineWeitere(t)
|
|
380 }
|
|
381
|
|
382 // TestHighBurstEinUndAus: mit Burst-Recht startet high sofort nach der
|
|
383 // laufenden Ansage, obwohl das Fenster voll ist; ohne Recht parkt high
|
|
384 // wie normal (kein Verstoß).
|
|
385 func TestHighBurstEinUndAus(t *testing.T) {
|
|
386 g := neueSpielerin()
|
|
387 q := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: true}, g.speak, nil)
|
|
388
|
|
389 q.Enqueue(Item{Text: "erste"})
|
|
390 g.warteBis(t, 1)
|
|
391 if adm, err := q.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.ID == "" {
|
|
392 t.Fatalf("high mit Burst-Recht = %+v (%v), want Aufnahme", adm, err)
|
|
393 }
|
|
394 g.step <- struct{}{} // erste beenden
|
|
395 gestartet := g.warteBis(t, 2) // high startet trotz vollem Fenster
|
|
396 if len(gestartet) != 1 || gestartet[0].Text != "dringend" {
|
|
397 t.Errorf("burst = %+v, will dringend sofort", gestartet)
|
|
398 }
|
|
399 q.Close()
|
|
400
|
|
401 g2 := neueSpielerin()
|
|
402 q2 := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: false}, g2.speak, nil)
|
|
403 defer q2.Close()
|
|
404
|
|
405 q2.Enqueue(Item{Text: "erste"})
|
|
406 g2.warteBis(t, 1) // erste läuft — Fenster gebucht
|
|
407 if adm, err := q2.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.ID == "" {
|
|
408 t.Fatalf("high ohne Burst-Recht = %+v (%v), want Aufnahme (parkt)", adm, err)
|
|
409 }
|
|
410 g2.step <- struct{}{} // erste beenden
|
|
411 g2.keineWeitere(t) // high wartet den Slot ab — kein Verstoß ohne Recht
|
|
412 }
|
|
413
|
|
414 // TestNowBypassesRateLimit: now startet trotz vollem Fensters sofort —
|
|
415 // der dokumentierte Ausbruch (er startet trotzdem ein neues Fenster).
|
|
416 func TestNowBypassesRateLimit(t *testing.T) {
|
|
417 g := neueSpielerin()
|
|
418 q := New(Laws{RateLimit: 10 * time.Second}, g.speak, nil)
|
|
419 defer q.Close()
|
|
420
|
|
421 q.Enqueue(Item{Text: "erste"})
|
|
422 g.warteBis(t, 1) // Fenster gebucht (10 s)
|
|
423
|
|
424 if adm, err := q.Enqueue(Item{Text: "sofort", Now: true}); err != nil || adm.ID == "" || adm.Discarded != "" {
|
|
425 t.Fatalf("now über vollem Fenster = %+v (%v), want Aufnahme", adm, err)
|
|
426 }
|
|
427 g.step <- struct{}{}
|
|
428 gestartet := g.warteBis(t, 2) // lange vor Fenster-Ablauf (10 s) — sonst Timeout
|
|
429 if len(gestartet) != 1 || gestartet[0].Text != "sofort" {
|
|
430 t.Errorf("now = %+v, will sofort", gestartet)
|
|
431 }
|
|
432
|
|
433 // Der now-Bruch hat ein neues Fenster gebucht: normal parkt jetzt.
|
|
434 if adm, err := q.Enqueue(Item{Text: "danach"}); err != nil || adm.ID == "" {
|
|
435 t.Fatalf("normal nach now = %+v (%v)", adm, err)
|
|
436 }
|
|
437 close(g.step)
|
|
438 g.keineWeitere(t) // parkt bis zum Slot — nicht sofort
|
|
439 }
|
|
440
|
|
441 // TestRuhezeitDiscardUndToast: aktives Fenster verwirft normal und high
|
|
442 // (Burst ist ein Rate-Recht, kein Ruhe-Recht) — toast ersetzt stattdessen;
|
|
443 // now bricht bewusst aus.
|
|
444 func TestRuhezeitDiscardUndToast(t *testing.T) {
|
|
445 fenster := fensterUm(time.Now())
|
|
446
|
|
447 g := neueSpielerin()
|
|
448 q := New(Laws{Quiet: mustQuiet(t, fenster), QuietMode: QuietModeDiscard}, g.speak, nil)
|
|
449 defer q.Close()
|
|
450
|
|
451 adm, err := q.Enqueue(Item{Text: "normal"})
|
|
452 if err != nil || adm.ID != "" || adm.Discarded != DiscardQuietHours || adm.Toast {
|
|
453 t.Errorf("normal in Ruhezeit = %+v (%v), want stiller Verwurf", adm, err)
|
|
454 }
|
|
455 if adm, err = q.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.Discarded != DiscardQuietHours {
|
|
456 t.Errorf("high in Ruhezeit = %+v (%v), want auch Verwurf", adm, err)
|
|
457 }
|
|
458 adm, err = q.Enqueue(Item{Text: "sofort", Now: true})
|
|
459 if err != nil || adm.ID == "" || adm.Discarded != "" {
|
|
460 t.Fatalf("now in Ruhezeit = %+v (%v), want Ausbruch", adm, err)
|
|
461 }
|
|
462 gestartet := g.warteBis(t, 1)
|
|
463 if len(gestartet) != 1 || gestartet[0].Text != "sofort" {
|
|
464 t.Errorf("in Ruhezeit = %+v, will nur sofort", gestartet)
|
|
465 }
|
|
466 g.keineWeitere(t)
|
|
467
|
|
468 g2 := neueSpielerin()
|
|
469 q2 := New(Laws{Quiet: mustQuiet(t, fenster), QuietMode: QuietModeToast}, g2.speak, nil)
|
|
470 defer q2.Close()
|
|
471
|
|
472 adm, err = q2.Enqueue(Item{Text: "normal"})
|
|
473 if err != nil || adm.Discarded != DiscardQuietHours || !adm.Toast {
|
|
474 t.Errorf("normal in Ruhezeit (toast) = %+v (%v), want Toast-Ersetzung", adm, err)
|
|
475 }
|
|
476 g2.keineWeitere(t)
|
|
477 }
|
|
478
|
|
479 // mustQuiet parst ein Ruhefenster oder bricht den Test ab.
|
|
480 func mustQuiet(t *testing.T, fenster string) QuietHours {
|
|
481 t.Helper()
|
|
482 q, err := ParseQuietHours(fenster)
|
|
483 if err != nil {
|
|
484 t.Fatal(err)
|
|
485 }
|
|
486 return q
|
|
487 }
|
|
488
|
|
489 // TestLawState: unbegrenzt liefert freien Slot; nach gebuchtem Fenster
|
|
490 // liegt der nächste Slot in der Zukunft; Ruhezeit-Fenster ist sichtbar.
|
|
491 func TestLawState(t *testing.T) {
|
|
492 g := neueSpielerin()
|
|
493 q := New(Laws{}, g.speak, nil)
|
|
494 st := q.LawState()
|
|
495 if !st.NextSlot.IsZero() || st.RateLimit != 0 {
|
|
496 t.Errorf("unbegrenzt = %+v, want Nullzeit-Slot", st)
|
|
497 }
|
|
498 q.Close()
|
|
499
|
|
500 fenster := mustQuiet(t, "18:00-08:00")
|
|
501 q2 := New(Laws{RateLimit: time.Hour, AllowHighBurst: true, Quiet: fenster, QuietMode: QuietModeToast}, g.speak, nil)
|
|
502 defer q2.Close()
|
|
503 st2 := q2.LawState()
|
|
504 if st2.RateLimit != time.Hour || !st2.AllowHighBurst {
|
|
505 t.Errorf("LawState = %+v, want 1h-Fenster mit Burst-Recht", st2)
|
|
506 }
|
|
507 if st2.Quiet.String() != "18:00-08:00" || st2.QuietMode != QuietModeToast {
|
|
508 t.Errorf("Ruhezeit in LawState = %q/%q", st2.Quiet, st2.QuietMode)
|
|
509 }
|
|
510
|
|
511 q2.Enqueue(Item{Text: "erste"})
|
|
512 g.warteBis(t, 1)
|
|
513 if next := q2.LawState().NextSlot; !next.After(time.Now()) {
|
|
514 t.Errorf("nach Start = %v, want Slot in der Zukunft", next)
|
|
515 }
|
|
516 g.step <- struct{}{}
|
|
517 }
|
|
|
|
internal/queue/quiet.go --- Go
|
|
1 package queue
|
|
2
|
|
3 import (
|
|
4 "fmt"
|
|
5 "strconv"
|
|
6 "strings"
|
|
7 "time"
|
|
8 )
|
|
9
|
|
10 // Erlaubte Werte für quiet_mode (öffentlicher Config-Wert).
|
|
11 const (
|
|
12 QuietModeDiscard = "discard"
|
|
13 QuietModeToast = "toast"
|
|
14 )
|
|
15
|
|
16 // clockTime ist eine Tageszeit in Minuten-Auflösung (lokale Zeit, 24 h).
|
|
17 type clockTime struct{ hour, minute int }
|
|
18
|
|
19 // minutes liefert Minuten seit Mitternacht.
|
|
20 func (c clockTime) minutes() int { return c.hour*60 + c.minute }
|
|
21
|
|
22 // String rendert die Uhrzeit als "HH:MM".
|
|
23 func (c clockTime) String() string { return fmt.Sprintf("%02d:%02d", c.hour, c.minute) }
|
|
24
|
|
25 // QuietHours ist ein Ruhezeit-Fenster in lokaler Zeit, z. B. "18:00-08:00"
|
|
26 // (überspannt Mitternacht). Der Nullwert (Start == End) ist deaktiviert —
|
|
27 // rund-um-die-Uhr-Stumm ist mute, nicht quiet_hours.
|
|
28 type QuietHours struct {
|
|
29 Start, End clockTime
|
|
30 }
|
|
31
|
|
32 // String rendert das Fenster als "HH:MM-HH:MM" (Nullwert: "").
|
|
33 func (q QuietHours) String() string {
|
|
34 if q.Start == q.End {
|
|
35 return ""
|
|
36 }
|
|
37 return q.Start.String() + "-" + q.End.String()
|
|
38 }
|
|
39
|
|
40 // ParseQuietHours parst ein Ruhezeit-Fenster "HH:MM-HH:MM" (lokale Zeit,
|
|
41 // 24 h; Stunden ein- oder zweistellig). Fenster über Mitternacht
|
|
42 // (Start > End) sind erlaubt; Start == End deaktiviert das Fenster. Der
|
|
43 // Leerstring ist das deaktivierte Fenster (kein Fehler).
|
|
44 func ParseQuietHours(s string) (QuietHours, error) {
|
|
45 if s == "" {
|
|
46 return QuietHours{}, nil
|
|
47 }
|
|
48 startS, endS, ok := strings.Cut(s, "-")
|
|
49 if !ok {
|
|
50 return QuietHours{}, fmt.Errorf("quiet_hours: %q ist nicht HH:MM-HH:MM", s)
|
|
51 }
|
|
52 start, err := parseClock(startS)
|
|
53 if err != nil {
|
|
54 return QuietHours{}, fmt.Errorf("quiet_hours: %v", err)
|
|
55 }
|
|
56 end, err := parseClock(endS)
|
|
57 if err != nil {
|
|
58 return QuietHours{}, fmt.Errorf("quiet_hours: %v", err)
|
|
59 }
|
|
60 return QuietHours{Start: start, End: end}, nil
|
|
61 }
|
|
62
|
|
63 // parseClock parst "H[H]:MM" in Uhrzeit-Bestandteile.
|
|
64 func parseClock(s string) (clockTime, error) {
|
|
65 hourS, minuteS, ok := strings.Cut(s, ":")
|
|
66 if !ok || len(hourS) < 1 || len(hourS) > 2 || len(minuteS) != 2 {
|
|
67 return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
|
|
68 }
|
|
69 hour, err := strconv.Atoi(hourS)
|
|
70 if err != nil {
|
|
71 return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
|
|
72 }
|
|
73 minute, err := strconv.Atoi(minuteS)
|
|
74 if err != nil {
|
|
75 return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
|
|
76 }
|
|
77 if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
|
78 return clockTime{}, fmt.Errorf("%q ist keine gültige Uhrzeit (24 h)", s)
|
|
79 }
|
|
80 return clockTime{hour: hour, minute: minute}, nil
|
|
81 }
|
|
82
|
|
83 // Active meldet, ob der lokale Zeitpunkt t im Ruhefenster liegt.
|
|
84 // Tagfenster (Start < End) gelten ab Start bis ausschließlich End;
|
|
85 // Nachtfenster (Start > End) überspannen Mitternacht.
|
|
86 func (q QuietHours) Active(t time.Time) bool {
|
|
87 if q.Start == q.End {
|
|
88 return false
|
|
89 }
|
|
90 m := t.Hour()*60 + t.Minute()
|
|
91 s, e := q.Start.minutes(), q.End.minutes()
|
|
92 if s < e {
|
|
93 return m >= s && m < e
|
|
94 }
|
|
95 return m >= s || m < e
|
|
96 }
|
|
97
|
|
98 // Until liefert das lokale Ende des gerade aktiven Fensters — Nullzeit,
|
|
99 // wenn gerade kein Ruhefenster aktiv ist. Basis für GET /status.
|
|
100 func (q QuietHours) Until(t time.Time) time.Time {
|
|
101 if !q.Active(t) {
|
|
102 return time.Time{}
|
|
103 }
|
|
104 end := time.Date(t.Year(), t.Month(), t.Day(), q.End.hour, q.End.minute, 0, 0, t.Location())
|
|
105 if q.Start.minutes() > q.End.minutes() && t.Hour()*60+t.Minute() >= q.Start.minutes() {
|
|
106 end = end.AddDate(0, 0, 1) // Nachtfenster: das Ende ist am Folgetag
|
|
107 }
|
|
108 return end
|
|
109 }
|
|
110
|
|
|
|
internal/queue/quiet_test.go --- Go
|
|
1 package queue
|
|
2
|
|
3 import (
|
|
4 "testing"
|
|
5 "time"
|
|
6 )
|
|
7
|
|
8 // lokal baut eine lokale Test-Zeit (Stunde/Minute reichen — Ruhefenster
|
|
9 // arbeiten auf Minuten-Auflösung).
|
|
10 func lokal(hour, minute int) time.Time {
|
|
11 return time.Date(2026, 10, 18, hour, minute, 0, 0, time.Local)
|
|
12 }
|
|
13
|
|
14 // TestParseQuietHours: Format HH:MM-HH:MM (überspannt Mitternacht),
|
|
15 // klare Fehler für Müll.
|
|
16 func TestParseQuietHours(t *testing.T) {
|
|
17 cases := []struct {
|
|
18 in string
|
|
19 start clockTime
|
|
20 end clockTime
|
|
21 bad bool
|
|
22 }{
|
|
23 {in: "18:00-08:00", start: clockTime{18, 0}, end: clockTime{8, 0}},
|
|
24 {in: "9:30-17:45", start: clockTime{9, 30}, end: clockTime{17, 45}},
|
|
25 {in: "22:00-06:00", start: clockTime{22, 0}, end: clockTime{6, 0}},
|
|
26 {in: "08:00-08:00", start: clockTime{8, 0}, end: clockTime{8, 0}}, // deaktiviert, aber gültig
|
|
27 {in: ""}, // Leerstring = deaktiviert, kein Fehler
|
|
28 {in: "18:00", bad: true},
|
|
29 {in: "18:00-", bad: true},
|
|
30 {in: "-08:00", bad: true},
|
|
31 {in: "24:00-08:00", bad: true},
|
|
32 {in: "18:60-08:00", bad: true},
|
|
33 {in: "1800-0800", bad: true},
|
|
34 {in: "ab:00-08:00", bad: true},
|
|
35 }
|
|
36 for _, tc := range cases {
|
|
37 q, err := ParseQuietHours(tc.in)
|
|
38 if tc.bad {
|
|
39 if err == nil {
|
|
40 t.Errorf("ParseQuietHours(%q) = %+v, want Fehler", tc.in, q)
|
|
41 }
|
|
42 continue
|
|
43 }
|
|
44 if err != nil {
|
|
45 t.Errorf("ParseQuietHours(%q): unerwarteter Fehler %v", tc.in, err)
|
|
46 continue
|
|
47 }
|
|
48 if q.Start != tc.start || q.End != tc.end {
|
|
49 t.Errorf("ParseQuietHours(%q) = %v-%v, want %v-%v", tc.in, q.Start, q.End, tc.start, tc.end)
|
|
50 }
|
|
51 }
|
|
52 }
|
|
53
|
|
54 // TestQuietHoursActive: Tagfenster, Nachtfenster über Mitternacht und der
|
|
55 // deaktivierte Nullwert über alle Grenzen (Start inklusiv, End exklusiv).
|
|
56 func TestQuietHoursActive(t *testing.T) {
|
|
57 cases := []struct {
|
|
58 fenster string
|
|
59 hour, minute int
|
|
60 want bool
|
|
61 }{
|
|
62 // Tagfenster 09:00-17:00
|
|
63 {"09:00-17:00", 8, 59, false},
|
|
64 {"09:00-17:00", 9, 0, true}, // Start inklusiv
|
|
65 {"09:00-17:00", 12, 30, true},
|
|
66 {"09:00-17:00", 16, 59, true},
|
|
67 {"09:00-17:00", 17, 0, false}, // End exklusiv
|
|
68 {"09:00-17:00", 23, 0, false},
|
|
69 // Nachtfenster 18:00-08:00 (überspannt Mitternacht)
|
|
70 {"18:00-08:00", 17, 59, false},
|
|
71 {"18:00-08:00", 18, 0, true},
|
|
72 {"18:00-08:00", 23, 59, true},
|
|
73 {"18:00-08:00", 0, 0, true}, // über Mitternacht
|
|
74 {"18:00-08:00", 7, 59, true},
|
|
75 {"18:00-08:00", 8, 0, false}, // End exklusiv
|
|
76 {"18:00-08:00", 12, 0, false},
|
|
77 // Zweites Nachtfenster
|
|
78 {"22:00-06:00", 3, 0, true},
|
|
79 {"22:00-06:00", 21, 59, false},
|
|
80 // Start == End: deaktiviert
|
|
81 {"08:00-08:00", 8, 0, false},
|
|
82 {"08:00-08:00", 20, 0, false},
|
|
83 }
|
|
84 for _, tc := range cases {
|
|
85 q := mustQuiet(t, tc.fenster)
|
|
86 if got := q.Active(lokal(tc.hour, tc.minute)); got != tc.want {
|
|
87 t.Errorf("%s um %02d:%02d: Active = %v, want %v", tc.fenster, tc.hour, tc.minute, got, tc.want)
|
|
88 }
|
|
89 }
|
|
90 }
|
|
91
|
|
92 // TestQuietHoursUntil: Fenster-Ende (heute bzw. morgen bei Nachtfenstern),
|
|
93 // Nullzeit wenn inaktiv.
|
|
94 func TestQuietHoursUntil(t *testing.T) {
|
|
95 // Tagfenster: Ende heute.
|
|
96 q := mustQuiet(t, "09:00-17:00")
|
|
97 if want := lokal(17, 0); !q.Until(lokal(12, 0)).Equal(want) {
|
|
98 t.Errorf("Until(12:00) = %v, want %v (heute 17:00)", q.Until(lokal(12, 0)), want)
|
|
99 }
|
|
100 if !q.Until(lokal(3, 0)).IsZero() {
|
|
101 t.Error("Until außerhalb des Fensters: want Nullzeit")
|
|
102 }
|
|
103
|
|
104 // Nachtfenster: ab 18:00 ist das Ende morgen früh.
|
|
105 n := mustQuiet(t, "18:00-08:00")
|
|
106 if want := lokal(0, 0).Add(32 * time.Hour); !n.Until(lokal(20, 0)).Equal(want) {
|
|
107 t.Errorf("Until(20:00) = %v, want %v (morgen 08:00)", n.Until(lokal(20, 0)), want)
|
|
108 }
|
|
109 // Nach Mitternacht (00:30) ist das Ende heute 08:00.
|
|
110 if want := lokal(8, 0); !n.Until(lokal(0, 30)).Equal(want) {
|
|
111 t.Errorf("Until(00:30) = %v, want %v (heute 08:00)", n.Until(lokal(0, 30)), want)
|
|
112 }
|
|
113 }
|
|
114
|
|
115 // TestQuietHoursStringRoundtrip: String liefert die konfigurierte Form
|
|
116 // zurück und parst identisch; der Nullwert rendert leer.
|
|
117 func TestQuietHoursStringRoundtrip(t *testing.T) {
|
|
118 for _, s := range []string{"18:00-08:00", "09:00-17:00", "22:00-06:00"} {
|
|
119 q := mustQuiet(t, s)
|
|
120 if q.String() != s {
|
|
121 t.Errorf("String() = %q, want %q", q.String(), s)
|
|
122 }
|
|
123 q2 := mustQuiet(t, q.String())
|
|
124 if q2 != q {
|
|
125 t.Errorf("Roundtrip von %q: %+v ≠ %+v", s, q2, q)
|
|
126 }
|
|
127 }
|
|
128 if (QuietHours{}).String() != "" {
|
|
129 t.Error("Nullwert-String nicht leer")
|
|
130 }
|
|
131 }
|
|
132
|
|
|
|
internal/queue/rate.go --- 1/3 --- Go
|
|
5 "time" 5 "time"
|
|
6 ) 6 )
|
|
. 7
|
|
. 8 // Verwurfs-Gründe im HTTP-Vertrag
|
|
. . von POST /speak
|
|
. 9 // (202 {"id":"","discarded":"…"}).
|
|
. 10 const (
|
|
. 11 DiscardRateLimit = "rate_limit
|
|
. .. " // priority=low über dem Fenster
|
|
. .. -Budget
|
|
. 12 DiscardQuietHours = "quiet_hour
|
|
. .. s" // Ruhezeit aktiv (discard oder
|
|
. .. toast)
|
|
. 13 )
|
|
7 14
|
|
8 // RateLimiter ist die Struktur für 15 // RateLimiter ist das Zeitfenster
|
|
. das Daemon-Gesetz gegen Ansage-Spa .. des Daemon-Gesetzes gegen Ansage-Sp
|
|
. m .. am:
|
|
9 // (Spec: Default ca. 1 Ansage / 30 16 // Spec-Default ca. 1 Ansage / 30 s
|
|
. s, konfigurierbar). .. (queue.rate_limit), 0 = unbegrenzt
|
|
. .. .
|
|
. 17 // Jede gestartete Ansage bucht das
|
|
. .. Fenster neu (Reserve) — auch eine
|
|
. .. per
|
|
. 18 // now oder high-burst ausgebrochen
|
|
. .. e: Das Fenster zählt Abspiel-Starts
|
|
. .. ,
|
|
. 19 // nicht deren Legitimität.
|
|
10 // 20 //
|
|
11 // SEAM FÜR TASK 05: Struktur und S 21 // Lock-Ordnung: RateLimiter.mu ist
|
|
.. emantik stehen und sind getestet, s .. immer die innere Sperre — Aufrufer
|
|
.. ind ..
|
|
12 // aber noch NICHT an den Worker-Lo 22 // halten beim Allow/Reserve/Next d
|
|
.. op angebunden — Task 04 erzeugt kei .. ie Queue-sperre (niemals umgekehrt)
|
|
.. nen .. .
|
|
13 // Limiter (unbegrenzt, so verlangt ..
|
|
.. es der Task-04-Brief: „Default une ..
|
|
.. ndlich ..
|
|
14 // in T04-Tests, dokumentiert"). Ta ..
|
|
.. sk 05 verdrahtet den Limiter (confi ..
|
|
.. g ..
|
|
15 // queue.rate_limit) in den Loop un ..
|
|
.. d ergänzt die prioritätsbewusste ..
|
|
16 // Entscheidung: low verwerfen, nor ..
|
|
.. mal parken, high konfigurierbar ver ..
|
|
.. stoßen ..
|
|
17 // (queue.allow_high_burst) — dafür ..
|
|
.. ist Next() bereits Teil der Strukt ..
|
|
.. ur. ..
|
|
18 type RateLimiter struct { 23 type RateLimiter struct {
|
|
19 // Interval ist der Mindestabst 24 // Interval ist der Mindestabst
|
|
.. and zwischen zwei Ansage-Starts. .. and zwischen zwei Ansage-Starten.
|
|
20 // 0 oder negativ = unbegrenzt 25 // 0 oder negativ = unbegrenzt.
|
|
.. (Task-04-Default). ..
|
|
21 Interval time.Duration 26 Interval time.Duration
|
|
22 27
|
|
23 mu sync.Mutex 28 mu sync.Mutex
|
|
|
|
internal/queue/rate.go --- 2/3 --- Go
|
|
34 return time.Since(l.last) >= l. 39 return time.Since(l.last) >= l.
|
|
.. Interval .. Interval
|
|
35 } 40 }
|
|
36 41
|
|
37 // Reserve bucht einen Ansage-Slot 42 // Reserve bucht einen Ansage-Slot
|
|
.. (direkt nach Allow aufrufen). .. (direkt nach der Play-Entscheidung
|
|
.. 43 // aufrufen, vor dem eigentlichen A
|
|
.. .. bspielen — auch fehlgeschlagene Ans
|
|
.. .. agen
|
|
.. 44 // verbrauchen das Fenster: Ein kap
|
|
.. .. utter Provider darf nicht zum Masch
|
|
.. .. inengewehr
|
|
.. 45 // werden).
|
|
38 func (l *RateLimiter) Reserve() { 46 func (l *RateLimiter) Reserve() {
|
|
39 if l == nil || l.Interval <= 0 47 if l == nil || l.Interval <= 0
|
|
.. { .. {
|
|
40 return 48 return
|
|
|
|
internal/queue/rate.go --- 3/3 --- Go
|
|
44 l.last = time.Now() 52 l.last = time.Now()
|
|
45 } 53 }
|
|
46 54
|
|
47 // Next liefert, wann der nächste A 55 // Next liefert, wann der nächste A
|
|
.. nsage-Slot frei wird (Nullzeit, wen .. nsage-Slot frei wird — Nullzeit, we
|
|
.. n .. nn
|
|
48 // Allow sofort true liefern würde) 56 // Allow sofort true liefern würde
|
|
.. — Basis für das Parken wartender I .. (unbegrenzt oder Fenster abgelaufen
|
|
.. tems .. ).
|
|
49 // in Task 05. 57 // Basis für das Parken wartender I
|
|
.. .. tems und den Status-Endpoint.
|
|
50 func (l *RateLimiter) Next() time.T 58 func (l *RateLimiter) Next() time.T
|
|
.. ime { .. ime {
|
|
51 if l == nil || l.Interval <= 0 59 if l == nil || l.Interval <= 0
|
|
.. { .. {
|
|
52 return time.Time{} 60 return time.Time{}
|
|
53 } 61 }
|
|
54 l.mu.Lock() 62 l.mu.Lock()
|
|
55 defer l.mu.Unlock() 63 defer l.mu.Unlock()
|
|
.. 64 if l.last.IsZero() {
|
|
.. 65 return time.Time{}
|
|
.. 66 }
|
|
56 return l.last.Add(l.Interval) 67 return l.last.Add(l.Interval)
|
|
57 } 68 }
|
|
69
|
|
70 // Laws sind die Anti-Spam-Gesetze
|
|
.. .. des Daemons (Task 05): Rate-Limit m
|
|
.. .. it
|
|
71 // verkonfigurierbarem Verstoß-Rech
|
|
.. .. t für high, plus Ruhezeiten. Der Nu
|
|
.. .. llwert
|
|
72 // erlaubt alles (unbegrenzt, kein
|
|
.. .. Ruhefenster) — der Daemon liefert d
|
|
.. .. ie
|
|
73 // Gesetze aus der Config; Default
|
|
.. .. siehe internal/config.Defaults.
|
|
74 type Laws struct {
|
|
75 // RateLimit ist der Mindestabs
|
|
.. .. tand zwischen zwei Ansage-Starten
|
|
76 // (0 = unbegrenzt).
|
|
77 RateLimit time.Duration
|
|
78
|
|
79 // AllowHighBurst erlaubt prior
|
|
.. .. ity=high, das Rate-Limit bewusst zu
|
|
80 // verstoßen (Default true). Oh
|
|
.. .. ne Burst-Recht wartet high wie norm
|
|
.. .. al.
|
|
81 // Die Ruhezeit gilt davon unbe
|
|
.. .. rührt auch für high.
|
|
82 AllowHighBurst bool
|
|
83
|
|
84 // Quiet ist das Ruhezeit-Fenst
|
|
.. .. er (lokale Zeit; Nullwert = deaktiv
|
|
.. .. iert).
|
|
85 Quiet QuietHours
|
|
86
|
|
87 // QuietMode bestimmt das Verha
|
|
.. .. lten in der Ruhezeit: "discard" ver
|
|
.. .. wirft
|
|
88 // still, "toast" ersetzt Audio
|
|
.. .. durch eine Benachrichtigung — aktu
|
|
.. .. ell
|
|
89 // eine Protokoll-Zeile über de
|
|
.. .. n Notification-Pfad des Loggers; de
|
|
.. .. r
|
|
90 // echte OS-Toast folgt mit der
|
|
.. .. Notification-Anbindung (dokumentie
|
|
.. .. rte
|
|
91 // Einschränkung, siehe Task-05
|
|
.. .. -Report).
|
|
92 QuietMode string
|
|
93 }
|
|
94
|
|
95 // verdict ist die Gesetzes-Entsche
|
|
.. .. idung über ein fälliges Item.
|
|
96 type verdict int
|
|
97
|
|
98 const (
|
|
99 verdictPlay verdict = i
|
|
.. .. ota // abspielen
|
|
100 verdictWait
|
|
.. ... // parken bis zum freien Slot
|
|
101 verdictDiscardRate
|
|
.. ... // verwerfen: low über dem Budg
|
|
.. ... et
|
|
102 verdictDiscardQuiet
|
|
.. ... // verwerfen: Ruhezeit (discard
|
|
.. ... )
|
|
103 verdictToast
|
|
.. ... // Toast statt Audio (Ruhezeit)
|
|
104 )
|
|
105
|
|
106 // decide wendet die Gesetze auf ei
|
|
.. ... n fälliges Item an (rein, ohne Zust
|
|
.. ... and):
|
|
107 // slotFree sagt, ob der Rate-Limit
|
|
.. ... -Slot gerade frei ist. Rangordnung:
|
|
108 //
|
|
109 // 1. now:true bricht bewusst aus
|
|
.. ... — überspringt Rate-Limit UND Ruheze
|
|
.. ... it
|
|
110 // (dokumentierter Ausbruch für
|
|
.. ... Dringendes; startet trotzdem ein n
|
|
.. ... eues
|
|
111 // Rate-Fenster).
|
|
112 // 2. Ruhezeit: discard verwirft,
|
|
.. ... toast ersetzt — gilt auch für high,
|
|
113 // denn Burst ist ein Rate-Rech
|
|
.. ... t, kein Ruhe-Recht.
|
|
114 // 3. Rate-Limit: freier Slot spie
|
|
.. ... lt; high spielt mit Burst-Recht; lo
|
|
.. ... w
|
|
115 // wird verworfen; normal (und
|
|
.. ... high ohne Recht) parken bis zum Slo
|
|
.. ... t.
|
|
116 func (l Laws) decide(item Item, now
|
|
.. ... time.Time, slotFree bool) verdict
|
|
.. ... {
|
|
117 if item.Now {
|
|
118 return verdictPlay
|
|
119 }
|
|
120 if l.Quiet.Active(now) {
|
|
121 if l.QuietMode == QuietMode
|
|
.. ... Toast {
|
|
122 return verdictToast
|
|
123 }
|
|
124 return verdictDiscardQuiet
|
|
125 }
|
|
126 if slotFree {
|
|
127 return verdictPlay
|
|
128 }
|
|
129 if item.Priority == PriorityHig
|
|
.. ... h && l.AllowHighBurst {
|
|
130 return verdictPlay
|
|
131 }
|
|
132 if item.Priority == PriorityLow
|
|
.. ... {
|
|
133 return verdictDiscardRate
|
|
134 }
|
|
135 return verdictWait
|
|
136 }
|
|
137
|
|
138 // Admission ist das Ergebnis von E
|
|
.. ... nqueue: Aufgenommene tragen ihre ID
|
|
.. ... ,
|
|
139 // verworfene nennen den Grund (die
|
|
.. ... 202-Antwort von POST /speak führt
|
|
.. ... ihn).
|
|
140 type Admission struct {
|
|
141 // ID der aufgenommenen Ansage
|
|
.. ... (leer, wenn verworfen).
|
|
142 ID string
|
|
143
|
|
144 // Discarded ist der Verwurfs-G
|
|
.. ... rund (DiscardRateLimit bzw.
|
|
145 // DiscardQuietHours); leer, we
|
|
.. ... nn die Ansage aufgenommen wurde.
|
|
146 Discarded string
|
|
147
|
|
148 // Toast meldet, dass die Ansag
|
|
.. ... e in der Ruhezeit durch eine
|
|
149 // Benachrichtigung ersetzt wur
|
|
.. ... de (quiet_mode = "toast").
|
|
150 Toast bool
|
|
151 }
|
|
152
|
|
153 // LawState ist die /status-Sicht a
|
|
.. ... uf die Anti-Spam-Gesetze: konfiguri
|
|
.. ... ertes
|
|
154 // Rate-Limit mit dem nächsten frei
|
|
.. ... en Slot sowie das Ruhezeit-Fenster.
|
|
155 type LawState struct {
|
|
156 RateLimit time.Duration
|
|
157 AllowHighBurst bool
|
|
158 NextSlot time.Time // Nul
|
|
.. ... lzeit = Slot frei (oder unbegrenzt)
|
|
159 Quiet QuietHours
|
|
160 QuietActive bool
|
|
161 QuietMode string
|
|
162 QuietUntil time.Time // Nul
|
|
.. ... lzeit = Ruhezeit inaktiv
|
|
163 }
|
|
|
|
test/harness_test.go --- 1/3 --- Go
|
|
188 188 t.Fatalf("nur %d von %d Ankünften am Fake innerhalb 5 s", len(f.snapshot()), n)
|
|
189 189 }
|
|
... 190
|
|
... 191 // arrivedCount zählt die angekommten Synthese-Requests (auch abgebrochene)
|
|
... 192 // — Beobachtungspunkt für „genau N spielen, der Rest parkt/verfällt".
|
|
... 193 func (f *fakeProvider) arrivedCount() int {
|
|
... 194 f.mu.Lock()
|
|
... 195 defer f.mu.Unlock()
|
|
... 196 return len(f.arrived)
|
|
... 197 }
|
|
... 198
|
|
... 199 // assertNoFurtherArrivals prüft, dass innerhalb der Frist kein weiterer
|
|
... 200 // Synthese-Request ankommt (geparkte/verworfene Ansagen bleiben stumm).
|
|
... 201 func (f *fakeProvider) assertNoFurtherArrivals(t *testing.T, wait time.Duration) {
|
|
... 202 t.Helper()
|
|
... 203 n := f.arrivedCount()
|
|
... 204 time.Sleep(wait)
|
|
... 205 if got := f.arrivedCount(); got != n {
|
|
... 206 t.Fatalf("%d zusätzliche Ankünfte (want keine) — Treffer: %+v", got-n, f.snapshot())
|
|
... 207 }
|
|
... 208 }
|
|
190 209
|
|
191 210 // waitHits pollt, bis n Requests abgeschlossen wurden (Abbrüche zählen).
|
|
192 211 func (f *fakeProvider) waitHits(t *testing.T, n int) []fakeHit {
|
|
|
|
test/harness_test.go --- 2/3 --- Go
|
|
296 315 // damit unter <root>/talk), audio.sink=null und der Fake-Provider als
|
|
297 316 // ElevenLabs-Endpunkt. port=1 heißt „garantiert toter Daemon" (Verbindung
|
|
298 317 // wird sofort abgelehnt).
|
|
... 318 //
|
|
... 319 // TALK_RATE_LIMIT=0s ist der Basis-Default der Prozessgrenzen-Tests: Die
|
|
... 320 // Task-04-Semantik-Tests (Serialisierung, Priorität, …) enqueueen dicht
|
|
... 321 // hintereinander und erwarten sofortiges Durchspielen — das Gesetz
|
|
... 322 // (Default 30 s) würde sie parken. Die Gesetz-Tests in ratelimit_test.go
|
|
... 323 // überschreiben den Wert bewusst per extra.
|
|
299 324 func daemonEnv(root string, port int, fakeURL string, extra map[string]string) []string {
|
|
300 325 env := make([]string, 0, 32)
|
|
301 326 for _, kv := range os.Environ() {
|
|
|
|
test/harness_test.go --- 3/3 --- Go
|
|
312 337 "TALK_ELEVENLABS_ENDPOINT": fakeURL,
|
|
313 338 "TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen
|
|
314 339 "TALK_PORT": strconv.Itoa(port),
|
|
... 340 "TALK_RATE_LIMIT": "0s", // Gesetz aus, bis ein Test es anstellt (siehe oben)
|
|
315 341 }
|
|
316 342 for k, v := range extra {
|
|
317 343 set[k] = v
|
|
|
|
test/ratelimit_test.go --- Go
|
|
1 package test
|
|
2
|
|
3 // Prozessgrenzen-Tests für das Anti-Spam-Gesetz (Task 05): Rate-Limit
|
|
4 // (low verworfen, normal geparkt, high-burst, now-Ausbruch) und Ruhezeiten
|
|
5 // (discard/toast) — beobachtet als Black Box über HTTP/CLI, Fake-Provider
|
|
6 // und Daemon-Protokoll. Der Harness stellt dafür TALK_RATE_LIMIT=0s als
|
|
7 // Basis; diese Tests stellen das Gesetz bewusst per extra an.
|
|
8
|
|
9 import (
|
|
10 "encoding/json"
|
|
11 "net/http"
|
|
12 "strings"
|
|
13 "testing"
|
|
14 "time"
|
|
15 )
|
|
16
|
|
17 // fensterUm legt ein Ruhezeit-Fenster um die aktuelle lokale Zeit (±1 Min,
|
|
18 // Minute-Auflösung reicht) — so ist das Fenster unabhängig von der Uhrzeit
|
|
19 // des Testlaufs sicher aktiv.
|
|
20 func fensterUm() string {
|
|
21 now := time.Now()
|
|
22 return now.Add(-time.Minute).Format("15:04") + "-" + now.Add(time.Minute).Format("15:04")
|
|
23 }
|
|
24
|
|
25 // lawStatus ist die Gesetz-Sicht aus GET /status.
|
|
26 type lawStatus struct {
|
|
27 RateLimit struct {
|
|
28 Interval string `json:"interval"`
|
|
29 AllowHighBurst bool `json:"allow_high_burst"`
|
|
30 NextSlot *string `json:"next_slot"`
|
|
31 } `json:"rate_limit"`
|
|
32 QuietHours struct {
|
|
33 Window string `json:"window"`
|
|
34 Active bool `json:"active"`
|
|
35 Mode string `json:"mode"`
|
|
36 Until *string `json:"until"`
|
|
37 } `json:"quiet_hours"`
|
|
38 }
|
|
39
|
|
40 // getLawStatus ruft GET /status und dekodiert nur die Gesetz-Felder.
|
|
41 func getLawStatus(t *testing.T, d *daemonProc) lawStatus {
|
|
42 t.Helper()
|
|
43 resp, err := http.Get(d.baseURL + "/status")
|
|
44 if err != nil {
|
|
45 t.Fatalf("GET /status: %v — stderr:\n%s", err, d.stderr.String())
|
|
46 }
|
|
47 defer resp.Body.Close()
|
|
48 var st lawStatus
|
|
49 if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
|
|
50 t.Fatalf("GET /status dekodieren: %v", err)
|
|
51 }
|
|
52 return st
|
|
53 }
|
|
54
|
|
55 // discardedOf liest das discarded-Feld einer /speak-Antwort-Map — fehlt der
|
|
56 // Schlüssel (JSON-Decode in map[string]any liefert nil), ist er leer.
|
|
57 func discardedOf(m map[string]any) string {
|
|
58 if v, ok := m["discarded"].(string); ok {
|
|
59 return v
|
|
60 }
|
|
61 return ""
|
|
62 }
|
|
63
|
|
64 // TestRateLimitGesetz (Abnahmekriterium): kurzes Rate-Limit (1/s) — von
|
|
65 // fünf Ansagen spielt genau die erste sofort; low verfällt noch beim
|
|
66 // Eintritt (202 nennt den Grund), normal parkt und spielt nach dem Fenster.
|
|
67 func TestRateLimitGesetz(t *testing.T) {
|
|
68 root := t.TempDir()
|
|
69 fake := newFakeProvider(t, 100*time.Millisecond)
|
|
70 d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "1s"})
|
|
71 env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "1s"})
|
|
72
|
|
73 code, erste := speak(t, d, `{"text":"erste ansage"}`)
|
|
74 if code != 202 || erste["id"] == "" {
|
|
75 t.Fatalf("erste: Status=%d Body=%v, want 202 mit id", code, erste)
|
|
76 }
|
|
77 fake.waitArrived(t, 1) // erste läuft — das Fenster (1 s) ist gebucht.
|
|
78
|
|
79 // CLI-Beobachtung: talk say --priority low meldet den Verwurf plain.
|
|
80 res := runTalk(t, env, "say", "--priority", "low", "spam eins")
|
|
81 if res.code != 0 {
|
|
82 t.Fatalf("talk say --priority low: Exit %d, stderr: %s", res.code, res.stderr)
|
|
83 }
|
|
84 if res.stdout != "discarded: rate_limit\n" {
|
|
85 t.Errorf("stdout = %q, want %q", res.stdout, "discarded: rate_limit\n")
|
|
86 }
|
|
87
|
|
88 // Zwei weitere low-Ansagen verfallen ebenso beim Eintritt.
|
|
89 for _, text := range []string{"spam zwei", "spam drei"} {
|
|
90 code, m := speak(t, d, `{"text":"`+text+`","priority":"low"}`)
|
|
91 if code != 202 || m["id"] != "" || m["discarded"] != "rate_limit" {
|
|
92 t.Fatalf("low %s: Status=%d Body=%v, want 202 mit leerer id und discarded=rate_limit", text, code, m)
|
|
93 }
|
|
94 }
|
|
95
|
|
96 // Normal wird aufgenommen …
|
|
97 code, geparkte := speak(t, d, `{"text":"geparkte ansage"}`)
|
|
98 if code != 202 || geparkte["id"] == "" || discardedOf(geparkte) != "" {
|
|
99 t.Fatalf("normal: Status=%d Body=%v, want 202 mit id", code, geparkte)
|
|
100 }
|
|
101
|
|
102 // … und sichtbar geparkt: Queue zeigt sie, der nächste Slot liegt in
|
|
103 // der Zukunft.
|
|
104 st := getStatus(t, d)
|
|
105 if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != geparkte["id"] {
|
|
106 t.Fatalf("queue = %v (%d), will die geparkte Ansage", st.QueueIDs, st.Queue)
|
|
107 }
|
|
108 law := getLawStatus(t, d)
|
|
109 if law.RateLimit.Interval != "1s" || !law.RateLimit.AllowHighBurst {
|
|
110 t.Errorf("rate_limit = %+v, want Interval 1s mit Burst-Recht", law.RateLimit)
|
|
111 }
|
|
112 if law.RateLimit.NextSlot == nil {
|
|
113 t.Errorf("next_slot = null während geparkter Ansage, want Termin")
|
|
114 }
|
|
115
|
|
116 // Genau eine ist bislang angekommen: die geparkte spielt NICHT vor
|
|
117 // dem Fenster-Ablauf (Abstand zum Fenster-Rand reichlich).
|
|
118 fake.assertNoFurtherArrivals(t, 400*time.Millisecond)
|
|
119
|
|
120 // Nach dem Fenster spielt die geparkte — und zwar frühestens zum Slot.
|
|
121 hits := fake.waitCompleted(t, 2)
|
|
122 if hits[1].Text != "geparkte ansage" {
|
|
123 t.Errorf("zweiter Treffer = %q, will die geparkte Ansage", hits[1].Text)
|
|
124 }
|
|
125 if gap := hits[1].Start.Sub(hits[0].Start); gap < 850*time.Millisecond {
|
|
126 t.Errorf("zweiter Start nach %v, want >= Fenster (1 s, Toleranz 150 ms)", gap)
|
|
127 }
|
|
128 waitStatusIdle(t, d, 3*time.Second)
|
|
129 }
|
|
130
|
|
131 // TestHighBurstProzess: mit Burst-Recht (Default) startet high trotz
|
|
132 // vollem Fensters sofort nach der laufenden Ansage; ohne Recht parkt high
|
|
133 // wie normal. (Das spätere Abspielen nach langem Fenster deckt der
|
|
134 // Park-Test oben mit kurzem Fenster.)
|
|
135 func TestHighBurstProzess(t *testing.T) {
|
|
136 t.Run("burst an (Default)", func(t *testing.T) {
|
|
137 root := t.TempDir()
|
|
138 fake := newFakeProvider(t, 50*time.Millisecond)
|
|
139 d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "8s"})
|
|
140
|
|
141 speak(t, d, `{"text":"erste"}`)
|
|
142 fake.waitArrived(t, 1) // Fenster für 8 s gebucht.
|
|
143
|
|
144 code, m := speak(t, d, `{"text":"dringend","priority":"high"}`)
|
|
145 if code != 202 || m["id"] == "" {
|
|
146 t.Fatalf("high: Status=%d Body=%v, want 202 mit id", code, m)
|
|
147 }
|
|
148
|
|
149 // high startet, sobald die erste fertig ist — bewusst innerhalb
|
|
150 // des Fensters (Verstoß mit Recht).
|
|
151 hits := fake.waitCompleted(t, 2)
|
|
152 if hits[1].Text != "dringend" {
|
|
153 t.Errorf("zweiter Treffer = %q, will dringend", hits[1].Text)
|
|
154 }
|
|
155 if gap := hits[1].Start.Sub(hits[0].Start); gap >= 4*time.Second {
|
|
156 t.Errorf("high startete nach %v — Burst greift nicht (Fenster 8 s)", gap)
|
|
157 }
|
|
158 waitStatusIdle(t, d, 3*time.Second)
|
|
159 })
|
|
160
|
|
161 t.Run("burst aus", func(t *testing.T) {
|
|
162 root := t.TempDir()
|
|
163 fake := newFakeProvider(t, 0)
|
|
164 d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
|
165 "TALK_RATE_LIMIT": "8s",
|
|
166 "TALK_ALLOW_HIGH_BURST": "false",
|
|
167 })
|
|
168
|
|
169 speak(t, d, `{"text":"erste"}`)
|
|
170 fake.waitArrived(t, 1)
|
|
171
|
|
172 code, m := speak(t, d, `{"text":"dringend","priority":"high"}`)
|
|
173 if code != 202 || m["id"] == "" {
|
|
174 t.Fatalf("high: Status=%d Body=%v, want 202 mit id (parkt)", code, m)
|
|
175 }
|
|
176
|
|
177 // high bleibt geparkt: keine zweite Ankunft, Queue zeigt es.
|
|
178 fake.assertNoFurtherArrivals(t, 700*time.Millisecond)
|
|
179 st := getStatus(t, d)
|
|
180 if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != m["id"] {
|
|
181 t.Fatalf("queue = %v (%d), will geparktes high", st.QueueIDs, st.Queue)
|
|
182 }
|
|
183 if law := getLawStatus(t, d); law.RateLimit.AllowHighBurst {
|
|
184 t.Error("allow_high_burst = true, want false")
|
|
185 }
|
|
186
|
|
187 // Aufräumen über cancel — und der Daemon bleibt gesund (now
|
|
188 // umgeht das weiter laufende 8-s-Fenster).
|
|
189 code, cm := postPath(t, d, "/cancel")
|
|
190 if code != 200 || cm["cancelled"] != float64(1) {
|
|
191 t.Fatalf("cancel: %d %v, want 1 geparkte Ansage", code, cm)
|
|
192 }
|
|
193 speak(t, d, `{"text":"danach","now":true}`)
|
|
194 hits := fake.waitCompleted(t, 2)
|
|
195 if hits[1].Text != "danach" {
|
|
196 t.Errorf("Treffer nach cancel = %q, want danach", hits[1].Text)
|
|
197 }
|
|
198 })
|
|
199 }
|
|
200
|
|
201 // TestNowBypassesRateUndRuhe: now ist der dokumentierte Ausbruch — es
|
|
202 // spielt trotz vollem Rate-Fensters UND aktiver Ruhezeit.
|
|
203 func TestNowBypassesRateUndRuhe(t *testing.T) {
|
|
204 root := t.TempDir()
|
|
205 fake := newFakeProvider(t, 0)
|
|
206 d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
|
207 "TALK_RATE_LIMIT": "30s",
|
|
208 "TALK_QUIET_HOURS": fensterUm(),
|
|
209 })
|
|
210
|
|
211 // Normal verfällt in der Ruhezeit (hier: still).
|
|
212 code, m := speak(t, d, `{"text":"ruhig bitte"}`)
|
|
213 if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" {
|
|
214 t.Fatalf("normal in Ruhezeit: Status=%d Body=%v, want 202 mit leerer id und discarded=quiet_hours", code, m)
|
|
215 }
|
|
216
|
|
217 // now bricht durch: Aufnahme mit ID, Synthese trotz beider Gesetze.
|
|
218 code, m = speak(t, d, `{"text":"notfall","now":true}`)
|
|
219 if code != 202 || m["id"] == "" || discardedOf(m) != "" {
|
|
220 t.Fatalf("now: Status=%d Body=%v, want 202 mit id", code, m)
|
|
221 }
|
|
222 hits := fake.waitCompleted(t, 1)
|
|
223 if hits[0].Text != "notfall" {
|
|
224 t.Errorf("Treffer = %q, will notfall", hits[0].Text)
|
|
225 }
|
|
226 fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
|
|
227
|
|
228 if law := getLawStatus(t, d); !law.QuietHours.Active {
|
|
229 t.Errorf("quiet_hours.active = false, want true (Fenster um jetzt): %+v", law.QuietHours)
|
|
230 }
|
|
231 }
|
|
232
|
|
233 // TestRuhezeitDiscard: aktives Fenster verwirft normal UND high still
|
|
234 // (Burst ist ein Rate-Recht, kein Ruhe-Recht) — ohne Provider-Kontakt,
|
|
235 // mit Verwurf-Zeile im Daemon-Protokoll und Fenster-Ende in /status.
|
|
236 func TestRuhezeitDiscard(t *testing.T) {
|
|
237 root := t.TempDir()
|
|
238 fake := newFakeProvider(t, 0)
|
|
239 fenster := fensterUm()
|
|
240 d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_QUIET_HOURS": fenster})
|
|
241
|
|
242 for _, body := range []string{
|
|
243 `{"text":"abends eins"}`,
|
|
244 `{"text":"abends dringend","priority":"high"}`,
|
|
245 } {
|
|
246 code, m := speak(t, d, body)
|
|
247 if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != nil {
|
|
248 t.Fatalf("%s: Status=%d Body=%v, want 202 mit leerer id und discarded=quiet_hours", body, code, m)
|
|
249 }
|
|
250 }
|
|
251 fake.assertNoHits(t, 400*time.Millisecond)
|
|
252
|
|
253 log := d.stderr.String()
|
|
254 if !strings.Contains(log, "ruhezeit") || !strings.Contains(log, "verworfen") {
|
|
255 t.Errorf("Daemon-Log will Verwurf-Zeile mit ruhezeit, hat:\n%s", log)
|
|
256 }
|
|
257
|
|
258 law := getLawStatus(t, d)
|
|
259 if law.QuietHours.Window != fenster || !law.QuietHours.Active || law.QuietHours.Mode != "discard" {
|
|
260 t.Errorf("quiet_hours = %+v, want Fenster %q aktiv im Modus discard", law.QuietHours, fenster)
|
|
261 }
|
|
262 if law.QuietHours.Until == nil {
|
|
263 t.Error("quiet_hours.until = null im aktiven Fenster, want Termin")
|
|
264 }
|
|
265 }
|
|
266
|
|
267 // TestRuhezeitToast: quiet_mode=toast ersetzt Audio durch eine
|
|
268 // Benachrichtigung — derzeit eine Daemon-Protokoll-Zeile über den
|
|
269 // Notification-Pfad (dokumentierte Einschränkung bis zur OS-Anbindung).
|
|
270 func TestRuhezeitToast(t *testing.T) {
|
|
271 root := t.TempDir()
|
|
272 fake := newFakeProvider(t, 0)
|
|
273 d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
|
274 "TALK_QUIET_HOURS": fensterUm(),
|
|
275 "TALK_QUIET_MODE": "toast",
|
|
276 })
|
|
277
|
|
278 code, m := speak(t, d, `{"text":"abend kurz"}`)
|
|
279 if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != true {
|
|
280 t.Fatalf("toast: Status=%d Body=%v, want 202 mit leerer id, discarded=quiet_hours und toast=true", code, m)
|
|
281 }
|
|
282 fake.assertNoHits(t, 300*time.Millisecond)
|
|
283
|
|
284 if log := d.stderr.String(); !strings.Contains(log, "toast") {
|
|
285 t.Errorf("Daemon-Log will Toast-Zeile (Notification-Pfad), hat:\n%s", log)
|
|
286 }
|
|
287 }
|
|
288
|
|
289 // TestStatusGesetzeFelder (Abnahmekriterium): /status und `talk status`
|
|
290 // zeigen Rate-Limit- und Ruhezeit-State — Defaults im Leerlauf, gebuchter
|
|
291 // Slot nach einer Ansage.
|
|
292 func TestStatusGesetzeFelder(t *testing.T) {
|
|
293 root := t.TempDir()
|
|
294 fake := newFakeProvider(t, 0)
|
|
295 d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"})
|
|
296 env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"})
|
|
297
|
|
298 law := getLawStatus(t, d)
|
|
299 if law.RateLimit.Interval != "30s" || !law.RateLimit.AllowHighBurst || law.RateLimit.NextSlot != nil {
|
|
300 t.Errorf("rate_limit = %+v, want Default 30s, Burst an, Slot frei", law.RateLimit)
|
|
301 }
|
|
302 if law.QuietHours.Window != "" || law.QuietHours.Active || law.QuietHours.Mode != "discard" || law.QuietHours.Until != nil {
|
|
303 t.Errorf("quiet_hours = %+v, want deaktiviert im Modus discard", law.QuietHours)
|
|
304 }
|
|
305
|
|
306 res := runTalk(t, env, "status")
|
|
307 if res.code != 0 {
|
|
308 t.Fatalf("talk status: Exit %d — stderr: %s", res.code, res.stderr)
|
|
309 }
|
|
310 for _, want := range []string{
|
|
311 "rate-limit: 30s",
|
|
312 "high-burst erlaubt",
|
|
313 "ruhezeit: aus",
|
|
314 "modus discard",
|
|
315 } {
|
|
316 if !strings.Contains(res.stdout, want) {
|
|
317 t.Errorf("talk status stdout = %q, will %q", res.stdout, want)
|
|
318 }
|
|
319 }
|
|
320
|
|
321 // Nach einer Ansage steht der nächste Slot: HTTP-Feld + CLI-Zeile.
|
|
322 speak(t, d, `{"text":"einmal reicht"}`)
|
|
323 fake.waitArrived(t, 1)
|
|
324 if law := getLawStatus(t, d); law.RateLimit.NextSlot == nil {
|
|
325 t.Error("next_slot = null nach abgespielter Ansage, want Termin")
|
|
326 }
|
|
327 res = runTalk(t, env, "status")
|
|
328 if res.code != 0 || !strings.Contains(res.stdout, "nächster slot") {
|
|
329 t.Errorf("talk status nach Ansage = %q (Exit %d), will Slot-Zeile", res.stdout, res.code)
|
|
330 }
|
|
331 }
|
|
332
|
|
|