diff --git a/.vscode/launch.json b/.vscode/launch.json index 7298133..a686855 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,9 +12,11 @@ "console": "internalConsole" }, { - // Wie oben, aber /api/config zeigt auf den eingebauten SwyxTray-Mock statt - // auf 192.168.180.135:17654 – so lässt sich die Startseite ohne echte - // Telefonanlage ausprobieren (eingehenden Anruf auslösen: siehe README). + // Wie oben, aber der eingebaute SwyxTray-Mock wird über das Profil "mock" + // eingeschaltet (ohne das Profil gibt es ihn nicht) und /api/config zeigt + // auf ihn statt auf 192.168.180.135:17654 – so lässt sich die Startseite + // ohne echte Telefonanlage ausprobieren (eingehenden Anruf auslösen: + // siehe README). "type": "java", "name": "Backend (lokaler Test gegen SwyxTray-Mock)", "request": "launch", @@ -22,7 +24,11 @@ "projectName": "backend", "cwd": "${workspaceFolder}/backend", "console": "internalConsole", - "args": ["--app.websocket.host=localhost", "--app.websocket.port=8080"] + "args": [ + "--spring.profiles.active=mock", + "--app.websocket.host=localhost", + "--app.websocket.port=8080" + ] }, { "type": "node-terminal", diff --git a/README.md b/README.md index d1580c1..a180d77 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,21 @@ Das Backend baut **keine** eigene WebSocket-Verbindung auf; es hat zwei Aufgaben 1. `GET /api/config` liefert dem Frontend die Zieladresse, damit sie nicht im JS-Bundle fest verdrahtet ist (konfigurierbar in [application.properties](backend/src/main/resources/application.properties)). -2. Es stellt unter seinem eigenen Port einen **SwyxTray-Mock** bereit, um die Startseite ohne - echte Telefonanlage testen zu können (siehe unten). Im Normalbetrieb wird er nicht benutzt. +2. Es nimmt unter `POST /api/webhook` **Adressdaten fremder Systeme** entgegen und reicht sie + an die offenen Browser weiter (siehe [Webhook](#webhook-für-adressdaten-fremder-systeme)). +3. Es stellt unter seinem eigenen Port einen **SwyxTray-Mock** bereit, um die Startseite ohne + echte Telefonanlage testen zu können (siehe unten). Der läuft **nur mit dem Spring-Profil + `mock`**; im Normalbetrieb – und damit auch im Container – gibt es ihn nicht. -**Zwei getrennte Ports:** `8080` ist der Port dieser Anwendung (REST-API und Mock), +**Zwei getrennte Ports:** `8080` ist der Port dieser Anwendung (REST-API und, mit Profil `mock`, der Mock), `17654` der Port der SwyxTray-App auf 192.168.180.135. Sie sind unabhängig voneinander. ## Protokoll der SwyxTray-App Quelle: Mitschnitt gegen die laufende App (Version 1.0.0.0, `protocol: 1`) am 13.08.2026. -Seit der Tab-Verwaltung meldet die App im `hello` **`protocol: 6`**; die älteren Nachrichten -sind unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und +Mit der Tab-Verwaltung meldete die App im `hello` `protocol: 6`, mit der Adresssuche +`protocol: 7`, mit dem Adress-Cache **`protocol: 8`**; die älteren Nachrichten sind +unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und [SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts). Ein Anruf wird über die **1-basierte Leitungsnummer `line`** identifiziert – so, wie sie auch @@ -95,6 +99,8 @@ Wählbereich darauf hin. | `tabs` | – | Offene Tabs des Firefox-Plugins auflisten | `tabs` | | `opentab` | `url` | Neuen Tab öffnen | `tabId` | | `closetab` | `tabId` | Tab schließen | – | +| `contacts` | `query` | Adressdaten des Swyx-Clients durchsuchen | `contacts` | +| `addresses`| – | Adress-Cache der App am Stück abrufen | `addresses` | Alles andere quittiert die App mit `"Unbekanntes Kommando '…'."`, ein fehlendes `cmd` mit `"Feld 'cmd' fehlt."`. @@ -154,16 +160,122 @@ wartet die Startseite auf diese drei Kommandos 15 statt 10 Sekunden Auch hier gilt: **die App meldet Tab-Änderungen nicht von selbst.** Die Liste wird nach jedem Öffnen und Schließen neu über `tabs` geholt. -Alle Regeln sind in [protocol.test.ts](frontend/src/swyx/protocol.test.ts) festgehalten – +### Adressdaten des Swyx-Clients + +Seit Protokoll 7 gibt die App das globale Telefonbuch der Anlage heraus – dieselben +Einträge, die SwyxIt! im Adressbuch zeigt. + +```jsonc +// -> +{ "id": 20, "cmd": "contacts", "query": "abt" } +// <- +{ "type": "result", "id": 20, "ok": true, "contacts": [ + { "name": "Abt, Bettina", "number": "7587", "description": "S-SB" }, + { "name": "Abdul, Rokhsareh", "number": "5215", "description": "" } ] } +``` + +Wichtig für die Anzeige – alles gegen die laufende App sondiert: + +- **`query` ist Pflicht.** Ein fehlender, leerer oder nur aus Leerzeichen bestehender + Begriff und einer über 128 Zeichen ergeben dieselbe Meldung: + `"Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen)."` Ein **vollständiges Auflisten + gibt es nicht**; der Bereich der Startseite ist deshalb eine Suche. +- Gesucht wird als **Teilzeichenkette in Name und Rufnummer**, Groß-/Kleinschreibung egal. + Die `description` (Standortkürzel wie „HH-MT") wird **nicht** durchsucht. +- Sortiert nach Namen, **höchstens 100 Treffer** – gekürzt wird stillschweigend, es gibt + kein Feld dafür. Ein `limit` im Kommando ignoriert die App. +- `description` ist bei vielen Einträgen `""`. Namen sind **nicht eindeutig**: dieselbe + Person kommt mit mehreren Durchwahlen vor. +- `query` muss eine **Zeichenkette** sein; eine Zahl quittiert die App mit + `"Ungueltiges JSON."` – und zwar **ohne `id`**, die Antwort lässt sich also keinem + Kommando zuordnen. `SwyxTrayClient.searchContacts()` schickt deshalb immer Text. + +Die `number` ist die Durchwahl in der Form, die `call` direkt annimmt – die Einträge im +Bereich „Adressdaten" haben darum einen „Anrufen"-Knopf. + +### Adress-Cache (Protokoll 8) + +Seit Protokoll 8 hält die App die Adressdaten selbst vor und gibt sie **am Stück** heraus – +ohne Suchbegriff und ohne Deckel. Das ersetzt im Normalfall die 110 Einzelabfragen des +nächsten Abschnitts. + +```jsonc +// -> +{ "id": 5, "cmd": "addresses" } +// <- +{ "type": "result", "id": 5, "ok": true, + "addresses": [ { "name": "Muster GmbH", "number": "+493012345", + "description": "Globales Telefonbuch" } ] } +``` + +Dieselben Daten schickt die App außerdem **unaufgefordert**, direkt nach dem ersten Snapshot +und wenn sich ihr Cache ändert: + +```json +{ "type": "addresses", "addresses": [ … ] } +``` + +Diese Push-Nachricht ist die **einzige Nachricht der App ohne `id` und ohne `ok`** – sie ist +damit keine Quittung und wird von [parseAddresses](frontend/src/swyx/protocol.ts) gelesen, +nicht von `parseResult`. Die Einträge tragen dieselben Felder wie bei `contacts`. + +Ein **leeres** `addresses` ist ein gültiger Zustand und heißt „Cache ist leer" – nicht +„keine Antwort". Stand 21.08.2026 meldet die Anlage genau das: das Kommando wird mit +`ok: true` beantwortet, der Cache ist aber leer, und der Push beim Verbinden trägt `[]`. +Deshalb bleiben die Einzelabfragen als Rückfallebene bestehen. + +### Rückfallebene: den Gesamtbestand aus Einzelabfragen holen + +Kennt die App das Kommando `addresses` nicht oder ist ihr Cache leer, setzt +[directory.ts](frontend/src/swyx/directory.ts) den Bestand aus vielen `contacts`-Abfragen +zusammen. Der Trick steckt in der Wahl der Suchbegriffe: + +> Jeder Eintrag hat eine Rufnummer, und die ist eine **reine Ziffernfolge**. Eine Nummer mit +> mindestens zwei Ziffern enthält deshalb mindestens eines der hundert Paare `"00"`…`"99"`. +> Die Vereinigung dieser hundert Abfragen ist damit **nachweislich vollständig** – solange +> keine davon am Deckel hängt. + +Dazu kommen die zehn einstelligen Abfragen `"0"`…`"9"`: eine einstellige Rufnummer (etwa `0` +für eine Zentrale) käme in keinem Paar vor. Macht **110 Abfragen**. + +Gegen die Anlage gemessen (21.08.2026): + +| | | +|---|---| +| Einträge insgesamt | 404 | +| Abfragen | 110 (keine Verfeinerung nötig) | +| größte Trefferzahl eines Ziffernpaars | 59 – also weit unter dem Deckel von 100 | +| einstellige Abfragen am Deckel | 7 von 10 (deshalb genügen sie allein nicht) | +| Dauer | rund 6,7 s | +| Rufnummern | durchgehend 4- bis 6-stellig, rein numerisch | + +Die App beantwortet die Abfragen **nacheinander**; mehrere gleichzeitig zu schicken bringt +kaum etwas (110 Abfragen: 7,2 s einzeln, 6,3 s zu viert). Deshalb der Wartedialog. + +**Was passiert, wenn doch eine Abfrage gekürzt wird?** Dann verfeinert der Durchlauf sie – +vorn *und* hinten je eine Ziffer angehängt, denn eine Teilzeichenkette kann an beiden Enden +weitergehen. Das erfasst jeden Eintrag, dessen Rufnummer **länger** ist als die gekappte +Abfrage. Bleibt eine Abfrage auch dreistellig noch gekürzt, meldet der Bereich den Bestand +ausdrücklich als unvollständig, statt eine gekürzte Liste als vollständig auszugeben. + +Die eine verbleibende Lücke, offen benannt: Ein Eintrag, dessen Rufnummer **genau** einer +gekappten Abfrage entspricht, ließe sich nicht nachladen. Bei vier- bis sechsstelligen +Rufnummern betrifft das nur den Fall einer einstelligen Rufnummer bei gleichzeitig gekappter +einstelliger Abfrage. + +Die Regeln des Protokolls stehen in [protocol.test.ts](frontend/src/swyx/protocol.test.ts), +die des Gesamtabrufs in [directory.test.ts](frontend/src/swyx/directory.test.ts) – darunter die wörtlich mitgeschnittene Nachricht eines echten eingehenden Anrufs. ## Funktionen der Startseite -Die Seite ist in drei Bereiche aufgeteilt ([Tabs.tsx](frontend/src/components/Tabs.tsx)): +Die Seite ist in fünf Bereiche aufgeteilt ([Tabs.tsx](frontend/src/components/Tabs.tsx)): | Tab | Inhalt | |---|---| | **Anrufe** | Rufnummer wählen (`call`), belegte Leitungen mit Aktionen und dem Zustand der App (`statusText`, angemeldeter Benutzer, Warnung bei `connected: false`), Verlauf der Anrufereignisse | +| **Adressdaten** | Der **gesamte** Bestand des Telefonbuchs. Zuerst aus dem Adress-Cache, den die App beim Verbinden von selbst schickt – dann steht er sofort und ohne Wartedialog. Sonst über `addresses`, und erst wenn auch das nichts liefert, über rund 110 Einzelabfragen hinter einem Wartedialog mit Fortschritt und „Abbrechen". Die Anzeige nennt die Herkunft. Danach liegt alles im Browser: das Filtern über Name, Durchwahl **und** Standortkürzel läuft ohne weitere Abfrage. Ein Klick auf „Anrufen" wählt die Durchwahl (`call`) | +| **Webhook** | Was ein fremdes System an `POST /api/webhook` geschickt hat – jüngste Nachricht zuerst, jede mit ihrem JSON im Original. Die Seite hört über Server-Sent Events mit, **unabhängig vom geöffneten Bereich**: Eine Nachricht erscheint sofort, und ist gerade ein anderer Bereich offen, zeigt der Tab die Zahl der ungesehenen Nachrichten | | **Tabs** | Offene Tabs des Firefox-Plugins anzeigen (`tabs`), einzeln schließen (`closetab`) und eine URL als neuen Tab öffnen (`opentab`); die Liste wird beim ersten Öffnen des Bereichs und nach jeder Änderung geholt, die eingetippte URL übersteht einen Reload (`localStorage`) | | **Verbindung** | Adresse, Verbinden/Trennen, Benachrichtigungen erlauben, Diagnose: Rohnachrichten-Log, Status abfragen, Freitext senden | @@ -178,6 +290,83 @@ Weiteres: „nicht erkannt" vermerkt statt still verworfen. - Automatischer Reconnect mit exponentiellem Backoff, nur nach unerwartetem Abbruch. +## Webhook für Adressdaten fremder Systeme + +Ein anderes System kann Adressdaten per POST an die Anwendung schicken; die Startseite zeigt +sie im Bereich **Webhook** an. Anders als alles Übrige läuft das **nicht** über die SwyxTray-App, +sondern allein über das Backend. + +```bash +curl -X POST https://swyxweb.appcreation.de/api/webhook \ + -H "Content-Type: application/json" \ + -H "X-Webhook-Token: …" \ + -d '{"name":"Muster GmbH","number":"+493012345","description":"CRM-Import"}' +``` + +Die Quittung nennt die vergebene Nummer und die Eingangszeit: + +```json +{ "id": 1, "receivedAt": "2026-08-24T12:38:01.563742294Z" } +``` + +**Angenommen wird beliebiges JSON** – Objekt wie Liste. Das aufrufende System muss sich an kein +Schema halten; die Startseite zeigt die Nutzlast unverändert. Nur die Kopfzeile eines Eintrags +fasst `name` und `number` zusammen, wenn es sie gibt, und zählt sonst Felder bzw. Einträge +([summarize](frontend/src/webhook.ts)). + +| Endpunkt | Zweck | +|---|---| +| `POST /api/webhook` | Nachricht abliefern. `415` bei falschem Content-Type, `400` bei kaputtem JSON, `413` über der Größengrenze, `401` bei falschem Token | +| `GET /api/webhook/events` | Strom für die Startseite (Server-Sent Events) | +| `GET /api/webhook/history` | Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten sieht | +| `DELETE /api/webhook/history` | Verlauf leeren – das macht der Knopf „Leeren" | + +### Wie die Nachricht in den Browser kommt + +Über **Server-Sent Events**, nicht über den WebSocket: Der gehört der SwyxTray-App. Die +Startseite hält eine `EventSource` auf `/api/webhook/events` offen +([useWebhook.ts](frontend/src/hooks/useWebhook.ts)) – einseitig, über dieselbe HTTPS-Verbindung +wie die Seite, und der Browser baut sie nach einem Abbruch von selbst wieder auf. + +Zwei Vorkehrungen, damit dabei nichts verloren geht: + +- Das Backend hält die letzten **50 Nachrichten** im Speicher ([WebhookService](backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java)). + Beim Wiederverbinden schickt der Browser die `Last-Event-ID` mit, und alles Jüngere wird + nachgeliefert. Über einen **Neustart hinaus wird nichts aufgehoben**. +- Alle 25 Sekunden geht ein Kommentar (`:ping`) durch die Leitung, damit ein Reverse Proxy die + ruhende Verbindung nicht kappt. + +Die Antwort trägt außerdem `X-Accel-Buffering: no` und `Cache-Control: no-cache`. **Ohne den +ersten Kopf sammelt nginx die Ereignisse in seinem Puffer** und gibt sie erst später aus – die +Nachricht erschiene dann verspätet oder scheinbar gar nicht. nginx wertet ihn aus, andere Proxys +ignorieren ihn folgenlos. Steht vor der Anwendung etwas anderes als nginx, muss dort die +Pufferung für `/api/webhook/events` abgeschaltet sein (bei Apache `mod_proxy`: `flushpackets=on`). + +Doppelte Nachrichten sind dadurch normal – der Bereich holt beim Start den Verlauf *und* hört +auf den Strom. Entschieden wird über die `id` ([mergeEvent](frontend/src/webhook.ts)). + +### Token + +```properties +app.webhook.token= # leer ⇒ jeder Aufruf wird angenommen +app.webhook.history=50 # so viele Nachrichten bleiben im Speicher +app.webhook.max-size=262144 # größte Nutzlast in Byte +``` + +> **Für die öffentlich erreichbare Instanz gehört hier ein Geheimnis hinein.** `/api/webhook` +> liegt unter derselben Adresse wie die Startseite und ist damit aus dem Internet erreichbar; +> ohne Token kann jeder beliebiges JSON in die Anzeige schreiben. Ist der Wert leer, weist das +> Backend beim Start ausdrücklich darauf hin (`WARN`). + +Im Container: + +```bash +docker run -d -p 8080:8080 -e APP_WEBHOOK_TOKEN=… … gitea.appcreation.de/sven/swyxweb:0.9.13 +``` + +Das Token gilt nur für `POST`. Verlauf und Ereignisstrom bleiben offen – sie liefern dasselbe, +was die Seite ohnehin jedem Betrachter zeigt. + ## Starten ### Aus VS Code @@ -187,8 +376,9 @@ Im Debug-Panel die Compound-Konfiguration **„SwyxWeb starten (Backend + Fronte Vite-Dev-Server und öffnet Chrome, sobald dieser bereit ist – Breakpoints funktionieren auf beiden Seiten. „Stop" beendet beides zusammen. -Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: `/api/config` liefert dann -`ws://localhost:8080/ws`, die Startseite spricht also mit dem SwyxTray-Mock. +Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: startet das Backend mit dem +Profil `mock`, `/api/config` liefert dann `ws://localhost:8080/ws`, die Startseite spricht also +mit dem SwyxTray-Mock. Benötigte Extensions: *Extension Pack for Java* (Backend) und *JavaScript Debugger* (im VS Code enthalten, für Vite und Chrome). @@ -223,10 +413,29 @@ diesen Proxy – der Browser verbindet sich direkt mit der SwyxTray-App. ## Ohne echte Telefonanlage testen +> **Der Mock ist Opt-in.** Sein WebSocket-Endpunkt `/ws` und seine Steuer-Endpunkte +> `/api/mock/**` hängen am Spring-Profil **`mock`** +> ([MockProfile](backend/src/main/java/de/appcreation/swyxweb/config/MockProfile.java)) – +> ohne das Profil existieren die Bohnen nicht und beide Pfade antworten mit 404. So kann der +> Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das Container-Image setzt das +> Profil bewusst nicht. Einschalten: +> +> ```bash +> cd backend && ./mvnw spring-boot:run -Dspring-boot.run.profiles=mock +> java -jar app.jar --spring.profiles.active=mock # oder SPRING_PROFILES_ACTIVE=mock +> ``` +> +> Abgesichert ist beides durch je einen Test in +> [BackendApplicationTests](backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java) +> (ohne Profil keine Mock-Bohne) und +> [MockProfileTests](backend/src/test/java/de/appcreation/swyxweb/MockProfileTests.java) +> (mit Profil alle da). + Der [SwyxTrayMockHandler](backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java) -spricht dasselbe Protokoll: `hello` (mit `protocol: 6`) und Snapshot beim Verbinden, Quittungen -auf `call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab` und nach jeder -Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen: +spricht dasselbe Protokoll: `hello` (mit `protocol: 8`), Snapshot und Adress-Cache beim +Verbinden, Quittungen auf +`call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab`/`contacts`/`addresses` +und nach jeder Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen: ```bash curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH" @@ -244,8 +453,30 @@ liefert dessen `tabId`, `closetab` entfernt ihn; eine unbekannte Kennung ergibt Geöffnet wird auch hier nichts. Der Mock bildet den Fall „kein Plugin verbunden" **nicht** nach; er antwortet immer sofort. +Für `contacts` führt der Mock ein Telefonbuch mit **154 Einträgen** – groß genug, dass der +Bereich „Adressdaten" dieselbe Arbeit leisten muss wie gegen die Anlage. Drei Sonderfälle +sind absichtlich dabei: zweimal **„Muster, Max"** mit verschiedenen Durchwahlen (Namen sind +nicht eindeutig), Einträge **ohne Beschreibung**, und **„Zentrale" mit der Rufnummer `0`** – +die steckt in keinem Ziffernpaar und wird nur über die einstelligen Abfragen gefunden. + +Er hält sich an dieselben Regeln wie die echte App: Teilzeichenkette in Name und Rufnummer, +Schreibweise egal, nach Namen sortiert, gekappt bei 100 Treffern, und derselbe Fehlertext +für einen fehlenden, leeren oder zu langen Suchbegriff. + +Denselben Bestand gibt der Mock als Adress-Cache heraus – auf `addresses` und beim Verbinden +als Push. Der Cache lässt sich leeren, um die Rückfallebene zu prüfen; dann verhält er sich +wie die Anlage zurzeit: + +```bash +curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false" # -> Einzelabfragen +curl -X POST "http://localhost:8080/api/mock/address-cache?filled=true" # -> Cache +``` + +Jedes Umschalten schickt allen Verbundenen sofort einen neuen Push. + Die Startseite muss dazu auf `ws://localhost:8080/ws` zeigen – entweder im Adressfeld -eintragen oder das Backend mit `--app.websocket.host=localhost --app.websocket.port=8080` +eintragen oder das Backend mit +`--spring.profiles.active=mock --app.websocket.host=localhost --app.websocket.port=8080` starten (macht die VS-Code-Konfiguration „lokaler Test" automatisch). ## Konfiguration der WebSocket-Adresse @@ -280,6 +511,58 @@ cd frontend && npm test && npm run build Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach `backend/src/main/resources/static/` kopieren und neu packen. +## Deployment als Docker-Container + +Das [Dockerfile](Dockerfile) im Wurzelverzeichnis erledigt genau das in drei Stufen: Frontend +mit Node bauen, das Ergebnis in `backend/src/main/resources/static/` legen und mit Maven +zusammen zum Jar packen, dieses in ein schlankes JRE-Image kopieren. Herauskommt **ein** +Container, der Startseite und REST-API über Port 8080 ausliefert – als Benutzer `swyx`, nicht +als `root`, mit einem Healthcheck gegen `/api/config`. + +```bash +docker build -t swyxweb . +docker run -d -p 8080:8080 \ + -e APP_WEBSOCKET_HOST=192.168.180.135 \ + -e APP_WEBSOCKET_PORT=17654 \ + --name swyxweb swyxweb +``` + +Alle `app.websocket.*`-Werte sind zur Laufzeit über Umgebungsvariablen setzbar +(`APP_WEBSOCKET_HOST`, `_PORT`, `_PATH`, `_SECURE`), das Bundle muss dafür nicht neu gebaut +werden. Der Heap wächst über `-XX:MaxRAMPercentage=75` mit dem Speicherlimit des Containers +mit; überschreiben lässt sich das mit `-e JAVA_OPTS=…`. Soll die WebSocket-Adresse ausnahmsweise +fest ins Bundle, geht das beim Bauen mit `--build-arg VITE_WS_URL=ws://…/ws`. + +In die Registry schieben erledigt [docker_push.sh](docker_push.sh) – es baut für `linux/amd64` +und pusht in die Paket-Registry des Gitea nach `gitea.appcreation.de/sven/swyxweb` +(`REGISTRY_IMAGE` im Skript). Die **Versionsnummer ist Pflicht** und wird zum Tag +des Images; sie wird bewusst nicht aus der `pom.xml` abgeleitet: + +```bash +docker login gitea.appcreation.de +./docker_push.sh 0.9.13 # baut und pusht :0.9.13 +./docker_push.sh 0.9.13 --dry-run # nur bauen, nicht pushen +``` + +Ohne Argument bricht das Skript mit der Kurzhilfe ab. Zulässig ist das Format `x.y.z`. + +Ein lokaler Maven- oder npm-Lauf ist vorher **nicht** nötig; beides passiert im Image-Build. +Die beiden Build-Stufen laufen dabei nativ auf der Architektur des bauenden Rechners +(`--platform=$BUILDPLATFORM`) – JS-Bundle und Jar sind architekturunabhängig, nur das +Laufzeit-Image wird für `linux/amd64` gezogen. Auf einem ARM-Mac spart das die Emulation. + +**Der SwyxTray-Mock ist nicht dabei**: `SPRING_PROFILES_ACTIVE` bleibt leer, also fehlen `/ws` +und `/api/mock/**` (siehe [Ohne echte Telefonanlage testen](#ohne-echte-telefonanlage-testen)). +Für eine Vorführung ohne Telefonanlage lässt er sich am einzelnen Container einschalten: +`-e SPRING_PROFILES_ACTIVE=mock`. + +Zwei Dinge, die der Container nicht lösen kann: + +- **Läuft die Seite hinter HTTPS**, blockiert der Browser die unverschlüsselte Verbindung zur + SwyxTray-App. Dann braucht die App selbst TLS und `APP_WEBSOCKET_SECURE=true` (⇒ `wss://`). +- **Die WebSocket-Verbindung baut der Browser des Anwenders auf**, nicht der Container. Port + 17654 muss also vom Arbeitsplatz aus erreichbar sein, nicht vom Docker-Host. + ## Hinweise - **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte diff --git a/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java b/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java index 7f8b389..8d96739 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java +++ b/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java @@ -1,12 +1,13 @@ package de.appcreation.swyxweb; import de.appcreation.swyxweb.config.WebSocketProperties; +import de.appcreation.swyxweb.config.WebhookProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; @SpringBootApplication -@EnableConfigurationProperties(WebSocketProperties.class) +@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class }) public class BackendApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/de/appcreation/swyxweb/config/MockProfile.java b/backend/src/main/java/de/appcreation/swyxweb/config/MockProfile.java new file mode 100644 index 0000000..9e6e198 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/config/MockProfile.java @@ -0,0 +1,23 @@ +package de.appcreation.swyxweb.config; + +/** + * Name des Spring-Profils, unter dem der eingebaute SwyxTray-Mock läuft. + * + *

Der Mock ist bewusst abgeschaltet, solange dieses Profil nicht gesetzt + * ist – so kann er nicht versehentlich in einer Produktivumgebung + * mitlaufen. Eingeschaltet wird er über + * {@code --spring.profiles.active=mock} bzw. {@code SPRING_PROFILES_ACTIVE=mock}. + * + *

Betroffen sind {@code SwyxTrayMockHandler} (der WebSocket-Endpunkt unter + * {@code /ws}), {@code WebSocketConfig} (seine Registrierung) und + * {@code MockController} ({@code /api/mock/**}). Ohne das Profil gibt es weder + * den einen noch die anderen; beide antworten dann mit 404. + */ +public final class MockProfile { + + /** Name des Profils, siehe Klassenkommentar. */ + public static final String NAME = "mock"; + + private MockProfile() { + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java b/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java index ac36fc9..3f904e4 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java +++ b/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java @@ -2,6 +2,7 @@ package de.appcreation.swyxweb.config; import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; @@ -9,7 +10,10 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry /** * Stellt den SwyxTray-Mock unter dem konfigurierten Pfad bereit, damit sich die * Startseite auch ohne die echte SwyxTray-App testen lässt. + * + *

Nur mit dem Profil {@link MockProfile#NAME} – siehe dort. */ +@Profile(MockProfile.NAME) @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { diff --git a/backend/src/main/java/de/appcreation/swyxweb/config/WebhookProperties.java b/backend/src/main/java/de/appcreation/swyxweb/config/WebhookProperties.java new file mode 100644 index 0000000..cd718bd --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/config/WebhookProperties.java @@ -0,0 +1,27 @@ +package de.appcreation.swyxweb.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * Einstellungen des Webhooks, über den ein fremdes System Adressdaten an die + * Startseite schickt. + * + * @param token Gemeinsames Geheimnis. Ist es gesetzt, muss der Aufrufer es im + * Kopf {@code X-Webhook-Token} mitschicken; ist es leer, nimmt + * der Webhook jeden Aufruf an. + * @param history So viele Nachrichten hält das Backend vor, damit ein später + * geöffneter Bereich die vorigen noch sieht. + * @param maxSize Größte erlaubte Nutzlast in Byte. + */ +@ConfigurationProperties(prefix = "app.webhook") +public record WebhookProperties( + @DefaultValue("") String token, + @DefaultValue("50") int history, + @DefaultValue("262144") int maxSize) { + + /** Ob der Webhook ein Geheimnis verlangt. */ + public boolean isSecured() { + return !token.isBlank(); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/MockController.java b/backend/src/main/java/de/appcreation/swyxweb/web/MockController.java index da6d884..c17fd82 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/web/MockController.java +++ b/backend/src/main/java/de/appcreation/swyxweb/web/MockController.java @@ -1,7 +1,9 @@ package de.appcreation.swyxweb.web; +import de.appcreation.swyxweb.config.MockProfile; import de.appcreation.swyxweb.websocket.LineState; import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler; +import org.springframework.context.annotation.Profile; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -9,10 +11,16 @@ import org.springframework.web.bind.annotation.RestController; /** * Auslöser für den SwyxTray-Mock: simuliert einen eingehenden Anruf, damit sich - * die Anrufmeldung der Startseite ohne echte Telefonanlage prüfen lässt. + * die Anrufmeldung der Startseite ohne echte Telefonanlage prüfen lässt, und + * schaltet den Adress-Cache um. * - *

curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"
+ *
curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"
+ * curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false"
+ * + *

Nur mit dem Profil {@link MockProfile#NAME} vorhanden – im Normalbetrieb + * gibt es {@code /api/mock/**} nicht. */ +@Profile(MockProfile.NAME) @RestController @RequestMapping("/api/mock") public class MockController { @@ -29,4 +37,18 @@ public class MockController { @RequestParam(required = false) String name) { return mock.simulateIncomingCall(number, name); } + + /** + * Füllt oder leert den Adress-Cache des Mocks. Mit leerem Cache verhält er + * sich wie die zurzeit laufende Anlage, und der Bereich „Adressdaten" muss + * auf die Einzelabfragen ausweichen. + * + *

curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false"
+ * + * @return Anzahl der Einträge im Cache danach + */ + @PostMapping("/address-cache") + public int addressCache(@RequestParam(defaultValue = "true") boolean filled) { + return mock.setAddressCacheFilled(filled); + } } diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java b/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java new file mode 100644 index 0000000..37cb5cd --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/web/WebhookController.java @@ -0,0 +1,132 @@ +package de.appcreation.swyxweb.web; + +import java.time.Instant; +import java.util.List; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import de.appcreation.swyxweb.config.WebhookProperties; +import de.appcreation.swyxweb.webhook.WebhookEvent; +import de.appcreation.swyxweb.webhook.WebhookService; + +/** + * Webhook für Adressdaten aus einem fremden System. + * + *
curl -X POST https://swyxweb.appcreation.de/api/webhook \
+ *   -H "Content-Type: application/json" \
+ *   -H "X-Webhook-Token: …" \
+ *   -d '{"name":"Muster GmbH","number":"+493012345"}'
+ * + *

Angenommen wird beliebiges JSON – Objekt wie Liste. Die Startseite + * zeigt es im Bereich „Webhook" unverändert an; das aufrufende System muss sich + * also an kein festes Schema halten. + * + *

Der Weg zum Browser läuft über {@code GET /api/webhook/events} + * (Server-Sent Events), siehe {@link WebhookService}. + */ +@RestController +@RequestMapping("/api/webhook") +public class WebhookController { + + private final WebhookService service; + private final WebhookProperties properties; + private final ObjectMapper mapper; + + public WebhookController(WebhookService service, WebhookProperties properties, ObjectMapper mapper) { + this.service = service; + this.properties = properties; + this.mapper = mapper; + } + + /** Quittung an das aufrufende System. */ + public record Receipt(long id, Instant receivedAt) { + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public Receipt receive( + @RequestBody JsonNode payload, + @RequestHeader(name = "X-Webhook-Token", required = false) String token) { + + requireToken(token); + + if (payload == null || payload.isNull()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Leere Nutzlast."); + } + + // Die Größe wird erst nach dem Parsen geprüft; das Feld begrenzt vor allem, + // was sich im Verlauf ansammeln und im Browser anzeigen lässt. + int size = mapper.writeValueAsString(payload).getBytes(java.nio.charset.StandardCharsets.UTF_8).length; + if (size > properties.maxSize()) { + throw new ResponseStatusException( + HttpStatus.PAYLOAD_TOO_LARGE, + "Nutzlast ist zu groß (%d Byte, erlaubt sind %d).".formatted(size, properties.maxSize())); + } + + WebhookEvent event = service.record(payload); + return new Receipt(event.id(), event.receivedAt()); + } + + /** + * Strom der eingehenden Nachrichten für die Startseite. Der Browser schickt + * beim Wiederverbinden von selbst {@code Last-Event-ID} mit; alles Jüngere + * wird dann nachgeliefert. + */ + @GetMapping(path = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity events( + @RequestHeader(name = "Last-Event-ID", required = false) String lastEventId) { + + Long since = null; + try { + if (lastEventId != null && !lastEventId.isBlank()) since = Long.parseLong(lastEventId.trim()); + } catch (NumberFormatException e) { + // Unbrauchbare Id: dann eben ohne Nachlieferung, statt den Aufruf abzulehnen. + since = null; + } + + return ResponseEntity.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .cacheControl(org.springframework.http.CacheControl.noCache().mustRevalidate()) + // Ohne diesen Kopf sammelt nginx die Ereignisse im Puffer und gibt sie erst + // aus, wenn genug beisammen ist – die Nachricht erschiene dann verspätet + // oder gar nicht. nginx wertet ihn aus, andere Proxys ignorieren ihn. + .header("X-Accel-Buffering", "no") + .header(HttpHeaders.CONNECTION, "keep-alive") + .body(service.subscribe(since)); + } + + /** Verlauf, damit ein später geöffneter Bereich die vorigen Nachrichten zeigt. */ + @GetMapping("/history") + public List history() { + return service.history(); + } + + /** Leert den Verlauf; die Startseite bietet das als „Leeren" an. */ + @DeleteMapping("/history") + public int clear() { + return service.clear(); + } + + private void requireToken(String token) { + if (!properties.isSecured()) return; + // Konstante Laufzeit ist hier zweitrangig; der Vergleich läuft gegen ein + // Geheimnis fester Länge und hinter einer Netzgrenze. + if (token == null || !token.equals(properties.token())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Ungültiges oder fehlendes X-Webhook-Token."); + } + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookEvent.java b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookEvent.java new file mode 100644 index 0000000..a5c8198 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookEvent.java @@ -0,0 +1,16 @@ +package de.appcreation.swyxweb.webhook; + +import java.time.Instant; + +import tools.jackson.databind.JsonNode; + +/** + * Eine über den Webhook eingegangene Nachricht. + * + * @param id fortlaufend ab 1; der Browser meldet sie beim Wiederverbinden + * als {@code Last-Event-ID} zurück, damit nichts verloren geht + * @param receivedAt Eingangszeit im Backend + * @param payload das empfangene JSON, unverändert + */ +public record WebhookEvent(long id, Instant receivedAt, JsonNode payload) { +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java new file mode 100644 index 0000000..baa84e8 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/webhook/WebhookService.java @@ -0,0 +1,204 @@ +package de.appcreation.swyxweb.webhook; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import jakarta.annotation.PreDestroy; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import de.appcreation.swyxweb.config.WebhookProperties; + +/** + * Nimmt die Nachrichten des Webhooks entgegen und verteilt sie an die offenen + * Browser. + * + *

Der Weg zum Browser sind Server-Sent Events: Die Startseite hält + * eine {@code EventSource} auf {@code /api/webhook/events} offen. Das ist + * einseitig – genau das, was hier gebraucht wird –, läuft über dieselbe + * HTTPS-Verbindung wie die Seite und wird vom Browser nach einem Abbruch von + * selbst wieder aufgebaut. Die WebSocket-Verbindung der Startseite gehört + * dagegen der SwyxTray-App und steht dafür nicht zur Verfügung. + * + *

Die letzten {@link WebhookProperties#history()} Nachrichten bleiben im + * Speicher. Das hat zwei Gründe: Ein erst später geöffneter Bereich zeigt + * trotzdem, was vorher ankam, und nach einem Verbindungsabbruch lässt sich + * anhand der {@code Last-Event-ID} nachliefern, was in der Zwischenzeit + * eingegangen ist. Über einen Neustart hinaus wird nichts aufgehoben. + */ +@Service +public class WebhookService { + + private static final Logger log = LoggerFactory.getLogger(WebhookService.class); + + /** Ein Doppelpunkt-Kommentar hält die Verbindung durch Proxys hindurch offen. */ + private static final long HEARTBEAT_SECONDS = 25; + + private final WebhookProperties properties; + private final ObjectMapper mapper; + + private final AtomicLong nextId = new AtomicLong(1); + /** Jüngste Nachricht zuletzt. Zugriff immer unter dem Monitor dieses Feldes. */ + private final Deque events = new ArrayDeque<>(); + private final List subscribers = new CopyOnWriteArrayList<>(); + + private final ScheduledExecutorService heartbeat = + Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "webhook-heartbeat"); + thread.setDaemon(true); + return thread; + }); + + public WebhookService(WebhookProperties properties, ObjectMapper mapper) { + this.properties = properties; + this.mapper = mapper; + heartbeat.scheduleWithFixedDelay( + this::sendHeartbeat, HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS); + + if (properties.isSecured()) { + log.info("Webhook: Aufrufe müssen den Kopf X-Webhook-Token mitschicken."); + } else { + log.warn("Webhook: app.webhook.token ist nicht gesetzt – POST /api/webhook nimmt " + + "jeden Aufruf an. Für eine öffentlich erreichbare Instanz ein Token setzen."); + } + } + + @PreDestroy + void shutdown() { + heartbeat.shutdownNow(); + subscribers.forEach(SseEmitter::complete); + subscribers.clear(); + } + + /** + * Nimmt eine Nachricht an, hängt sie an den Verlauf und schickt sie sofort + * an alle offenen Browser. + * + * @return das angelegte Ereignis, damit der Aufrufer die Id quittieren kann + */ + public WebhookEvent record(JsonNode payload) { + WebhookEvent event = new WebhookEvent(nextId.getAndIncrement(), Instant.now(), payload); + + synchronized (events) { + events.addLast(event); + while (events.size() > Math.max(1, properties.history())) { + events.removeFirst(); + } + } + + log.debug("Webhook: Nachricht {} angenommen ({} Empfänger).", event.id(), subscribers.size()); + broadcast(event); + return event; + } + + /** Verlauf, älteste zuerst. */ + public List history() { + synchronized (events) { + return List.copyOf(events); + } + } + + /** Leert den Verlauf. Die offenen Browser behalten, was sie schon zeigen. */ + public int clear() { + synchronized (events) { + int removed = events.size(); + events.clear(); + return removed; + } + } + + /** + * Meldet einen Browser an. + * + * @param lastEventId Id der zuletzt beim Browser angekommenen Nachricht oder + * {@code null}. Alles Jüngere wird sofort nachgeliefert – + * so überbrückt ein Wiederverbinden die Lücke. + */ + public SseEmitter subscribe(Long lastEventId) { + // Kein Zeitlimit: Der Browser hält die Verbindung, der Heartbeat hält sie am Leben. + SseEmitter emitter = new SseEmitter(0L); + emitter.onCompletion(() -> subscribers.remove(emitter)); + emitter.onTimeout(() -> { + subscribers.remove(emitter); + emitter.complete(); + }); + emitter.onError(e -> subscribers.remove(emitter)); + + List missed = new ArrayList<>(); + if (lastEventId != null) { + for (WebhookEvent event : history()) { + if (event.id() > lastEventId) missed.add(event); + } + } + + try { + // Ohne erste Nachricht bliebe die Antwort ohne Kopfzeilen hängen. + emitter.send(SseEmitter.event().comment("verbunden")); + for (WebhookEvent event : missed) { + emitter.send(toSse(event)); + } + } catch (IOException | IllegalStateException e) { + emitter.completeWithError(e); + return emitter; + } + + subscribers.add(emitter); + return emitter; + } + + /** Anzahl der offenen Browser-Verbindungen – für Diagnose und Test. */ + public int subscriberCount() { + return subscribers.size(); + } + + private void broadcast(WebhookEvent event) { + SseEmitter.SseEventBuilder message = toSse(event); + for (SseEmitter emitter : subscribers) { + try { + emitter.send(message); + } catch (IOException | IllegalStateException e) { + // Browser weg oder Verbindung tot: abräumen, der Rest bekommt trotzdem alles. + subscribers.remove(emitter); + emitter.completeWithError(e); + } + } + } + + private void sendHeartbeat() { + for (SseEmitter emitter : subscribers) { + try { + emitter.send(SseEmitter.event().comment("ping")); + } catch (IOException | IllegalStateException e) { + subscribers.remove(emitter); + emitter.completeWithError(e); + } + } + } + + /** + * Die Nutzlast wird hier selbst zu Text gemacht und als solcher gesendet – + * über {@code MediaType.APPLICATION_JSON} würde Spring den String ein + * zweites Mal in Anführungszeichen setzen. + */ + private SseEmitter.SseEventBuilder toSse(WebhookEvent event) { + return SseEmitter.event() + .id(Long.toString(event.id())) + .name("webhook") + .data(mapper.writeValueAsString(event)); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/websocket/MockContact.java b/backend/src/main/java/de/appcreation/swyxweb/websocket/MockContact.java new file mode 100644 index 0000000..d07d865 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/websocket/MockContact.java @@ -0,0 +1,17 @@ +package de.appcreation.swyxweb.websocket; + +/** + * Adresseintrag im Mock – Feldnamen wie in der Quittung auf {@code contacts}: + * {@code {"name":"Abt, Bettina","number":"7587","description":"S-SB"}}. + * + *

Die echte SwyxTray-App liest diese Einträge aus dem Telefonbuch des + * Swyx-Clients; leere Felder schickt sie als {@code ""}, nicht als {@code null}. + */ +public record MockContact(String name, String number, String description) { + + /** Trifft der Suchbegriff? Teilzeichenkette in Name und Rufnummer, Schreibweise egal. */ + public boolean matches(String lowerCaseQuery) { + return name.toLowerCase().contains(lowerCaseQuery) + || number.toLowerCase().contains(lowerCaseQuery); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java b/backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java index 894edbe..c369b53 100644 --- a/backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java +++ b/backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.net.URI; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -13,8 +14,11 @@ import java.util.concurrent.atomic.AtomicInteger; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import de.appcreation.swyxweb.config.MockProfile; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; @@ -38,7 +42,24 @@ import org.springframework.web.socket.handler.TextWebSocketHandler; *

Seit Protokoll 6 beantwortet der Mock zusätzlich {@code tabs}, {@code opentab} und * {@code closetab}. Die echte App reicht diese Kommandos an das Firefox-Plugin durch; * der Mock spielt das Plugin und führt eine Liste vorgetäuschter Tabs im Speicher. + * + *

Seit Protokoll 7 beantwortet er {@code contacts} aus einem fest eingebauten + * Telefonbuch – mit denselben Regeln wie die echte App: Teilzeichenkette in Name und + * Rufnummer, Schreibweise egal, nach Namen sortiert und bei + * {@link #CONTACT_RESULT_LIMIT} Treffern stillschweigend gekürzt. + * + *

Seit Protokoll 8 gibt er dasselbe Telefonbuch als Adress-Cache am Stück + * heraus: auf {@code addresses} und zusätzlich unaufgefordert als + * {@code {"type":"addresses","addresses":[…]}} direkt nach dem ersten Snapshot. + * Über {@code POST /api/mock/address-cache?filled=false} lässt sich der Cache leeren – + * dann verhält sich der Mock wie die zurzeit laufende Anlage, deren Cache leer ist, + * und die Startseite muss auf die Einzelabfragen ausweichen. + * + *

Nur mit dem Profil {@link MockProfile#NAME}. Ohne dieses Profil gibt es + * die Bean nicht, {@code /ws} ist dann nicht belegt – der Mock kann also nicht + * versehentlich in einer Produktivumgebung mitlaufen. */ +@Profile(MockProfile.NAME) @Component public class SwyxTrayMockHandler extends TextWebSocketHandler { @@ -51,8 +72,17 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler { private static final List MOCK_WINDOWS = List.of( "SwyxIt!", "SwyxWeb – Google Chrome", "Kundenakte Muster GmbH"); - /** Protokollstand der echten App seit der Tab-Verwaltung. */ - private static final int PROTOCOL_VERSION = 6; + /** Protokollstand der echten App seit dem Adress-Cache. */ + private static final int PROTOCOL_VERSION = 8; + + /** So viele Treffer meldet die echte App höchstens; gekürzt wird ohne Hinweis. */ + private static final int CONTACT_RESULT_LIMIT = 100; + + /** Längere Suchbegriffe weist die echte App mit einem Fehler zurück. */ + private static final int CONTACT_QUERY_MAX_LENGTH = 128; + + /** Vorgetäuschtes Telefonbuch; siehe {@link #buildDirectory()}. */ + private static final List MOCK_CONTACTS = buildDirectory(); private final ObjectMapper mapper; private final Map sessions = new ConcurrentHashMap<>(); @@ -61,6 +91,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler { private final Map tabs = Collections.synchronizedMap(new LinkedHashMap<>()); private final AtomicInteger nextTabId = new AtomicInteger(41); private final AtomicInteger nextSession = new AtomicInteger(); + // Ist der Adress-Cache gefüllt? Zum Umschalten auf die Rückfallebene. + private volatile boolean addressCacheFilled = true; // Serialisiert Begrüßung und Snapshot, damit die Reihenfolge garantiert ist. private final Object sendLock = new Object(); @@ -81,6 +113,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler { synchronized (sendLock) { sendText(session, toJson(hello())); sendText(session, toJson(snapshot())); + // Der Cache kommt unaufgefordert hinterher – wie bei der echten App. + sendText(session, toJson(addressesPush())); } } @@ -134,6 +168,15 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler { sendText(session, toJson(result(id, true, null, null))); return; } + // Adressdaten des Swyx-Clients; auch sie rühren keine Leitung an. + case "contacts" -> { + sendText(session, toJson(contactsResult(id, searchContacts(request.path("query"))))); + return; + } + case "addresses" -> { + sendText(session, toJson(addressesResult(id, addressCache()))); + return; + } default -> { /* weiter unten */ } } @@ -262,6 +305,89 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler { return tab; } + /** + * Baut das vorgetäuschte Telefonbuch. Es ist bewusst groß genug, dass der + * Bereich „Adressdaten" wie gegen die echte Anlage arbeiten muss: einstellige + * Abfragen hängen am Deckel und werden verfeinert, zweistellige nicht. + * + *

Drei Sonderfälle sind absichtlich dabei: + *

+ */ + private static List buildDirectory() { + List surnames = List.of( + "Abt", "Ahrens", "Bauer", "Becker", "Beyer", "Cordes", "Dahme", "Diehl", + "Erbert", "Franke", "Gast", "Geier", "Grote", "Hansper", "Heymann", "Hill", + "Kaiser", "Lorenz", "Meyer", "Neumann", "Otto", "Peters", "Richter", "Schulz", + "Thiel", "Ulrich", "Vogel", "Weber", "Zeller", "Zimmer"); + List given = List.of("Anna", "Bernd", "Claudia", "Dirk", "Erika"); + List sites = List.of("HH-MT", "B-SB", "HB-GT", "L-SB", "S-MT"); + + List entries = new ArrayList<>(); + int number = 4100; + for (String surname : surnames) { + for (String first : given) { + entries.add(new MockContact(surname + ", " + first, String.valueOf(number), + sites.get(entries.size() % sites.size()))); + // Lücken wie in einer gewachsenen Anlage – sonst lägen die + // Nummern so dicht, dass auch Ziffernpaare am Deckel hingen. + number += 7; + } + } + entries.add(new MockContact("Hotline_HH-MT_Verwaltung", "41240", "HH-MT")); + entries.add(new MockContact("Muster, Max", "4711", "")); + entries.add(new MockContact("Muster, Max", "53119", "B-GT")); + entries.add(new MockContact("Zentrale", "0", "")); + return List.copyOf(entries); + } + + /** + * Simuliert {@code contacts}. Die echte App verlangt einen nicht leeren + * Suchbegriff von höchstens {@value #CONTACT_QUERY_MAX_LENGTH} Zeichen und + * beantwortet beide Verstöße mit derselben Meldung. + */ + private List searchContacts(JsonNode query) { + String wanted = query.isString() ? query.stringValue().trim() : ""; + if (wanted.isEmpty() || wanted.length() > CONTACT_QUERY_MAX_LENGTH) { + throw new IllegalArgumentException( + "Feld 'query' fehlt oder ist zu lang (max. " + CONTACT_QUERY_MAX_LENGTH + " Zeichen)."); + } + String needle = wanted.toLowerCase(); + List hits = MOCK_CONTACTS.stream() + .filter(contact -> contact.matches(needle)) + .sorted(Comparator.comparing(MockContact::name).thenComparing(MockContact::number)) + .limit(CONTACT_RESULT_LIMIT) + .toList(); + log.info("SwyxTray-Mock: {} Adresseintrag/-einträge zu '{}'", hits.size(), wanted); + return hits; + } + + /** Der Adress-Cache – leer, solange er abgeschaltet ist. */ + private List addressCache() { + return addressCacheFilled ? MOCK_CONTACTS : List.of(); + } + + /** + * Schaltet den Adress-Cache um und schickt ihn allen Verbundenen neu – so + * lässt sich beides prüfen: der Cache-Weg und die Rückfallebene über + * {@code contacts}. + * + * @return Anzahl der Einträge im Cache danach + */ + public int setAddressCacheFilled(boolean filled) { + addressCacheFilled = filled; + log.info("SwyxTray-Mock: Adress-Cache {}", filled ? "gefüllt" : "geleert"); + String json = toJson(addressesPush()); + synchronized (sendLock) { + sessions.values().forEach(session -> sendText(session, json)); + } + return addressCache().size(); + } + /** Löst einen eingehenden Anruf aus: Leitung belegen, Snapshot verschicken. */ public LineState simulateIncomingCall(String number, String name) { int line = freeLine(); @@ -364,6 +490,34 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler { return message; } + /** Quittung auf {@code contacts}; trägt die gefundenen Adressdaten. */ + private Map contactsResult(int id, List entries) { + Map message = new LinkedHashMap<>(); + message.put("id", id); + message.put("ok", true); + message.put("contacts", entries); + message.put("type", "result"); + return message; + } + + /** Quittung auf {@code addresses}; trägt den gesamten Cache. */ + private Map addressesResult(int id, List entries) { + Map message = new LinkedHashMap<>(); + message.put("id", id); + message.put("ok", true); + message.put("addresses", entries); + message.put("type", "result"); + return message; + } + + /** Unaufgeforderte Cache-Nachricht – ohne {@code id}, ohne {@code ok}. */ + private Map addressesPush() { + Map message = new LinkedHashMap<>(); + message.put("addresses", addressCache()); + message.put("type", "addresses"); + return message; + } + private Map result(int id, boolean ok, Integer line, String error) { Map message = new LinkedHashMap<>(); message.put("id", id); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index d2e668d..5188c2d 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,6 +1,13 @@ spring.application.name=swyxweb-backend -# Port dieser Anwendung (REST-API und der eingebaute Test-Endpunkt /ws). +# Der eingebaute SwyxTray-Mock (/ws und /api/mock/**) läuft nur mit dem Profil +# "mock" – bewusst nicht voreingestellt, damit er nicht in einer Produktiv- +# umgebung mitläuft. Einschalten zum Testen ohne Telefonanlage: +# ./mvnw spring-boot:run -Dspring-boot.run.profiles=mock +# java -jar app.jar --spring.profiles.active=mock +# SPRING_PROFILES_ACTIVE=mock + +# Port dieser Anwendung (REST-API und, mit Profil "mock", der Endpunkt /ws). server.address=0.0.0.0 server.port=8080 @@ -11,4 +18,16 @@ app.websocket.port=17654 app.websocket.path=/ws app.websocket.secure=false +# Webhook, über den ein fremdes System Adressdaten an die Startseite schickt: +# POST /api/webhook. Ist ein Token gesetzt, muss der Aufrufer es im Kopf +# X-Webhook-Token mitschicken; leer heißt, dass jeder Aufruf angenommen wird. +# Für die öffentlich erreichbare Instanz gehört hier ein Geheimnis hinein +# (oder von außen: APP_WEBHOOK_TOKEN). +app.webhook.token= +# So viele Nachrichten hält das Backend vor, damit ein später geöffneter +# Bereich die vorigen noch sieht. Nach einem Neustart ist der Verlauf leer. +app.webhook.history=50 +# Größte erlaubte Nutzlast in Byte. +app.webhook.max-size=262144 + logging.level.de.appcreation.swyxweb=DEBUG diff --git a/backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java b/backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java index 489631c..98b0091 100644 --- a/backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java +++ b/backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java @@ -1,13 +1,32 @@ package de.appcreation.swyxweb; +import static org.assertj.core.api.Assertions.assertThat; + +import de.appcreation.swyxweb.web.MockController; +import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; @SpringBootTest class BackendApplicationTests { + @Autowired + ApplicationContext context; + @Test void contextLoads() { } + /** + * Ohne das Profil „mock" – also so, wie die Anwendung im Container läuft – + * darf der SwyxTray-Mock nicht im Kontext stehen. + */ + @Test + void mockIsAbsentWithoutItsProfile() { + assertThat(context.getBeansOfType(SwyxTrayMockHandler.class)).isEmpty(); + assertThat(context.getBeansOfType(MockController.class)).isEmpty(); + } + } diff --git a/backend/src/test/java/de/appcreation/swyxweb/MockProfileTests.java b/backend/src/test/java/de/appcreation/swyxweb/MockProfileTests.java new file mode 100644 index 0000000..26a1900 --- /dev/null +++ b/backend/src/test/java/de/appcreation/swyxweb/MockProfileTests.java @@ -0,0 +1,30 @@ +package de.appcreation.swyxweb; + +import static org.assertj.core.api.Assertions.assertThat; + +import de.appcreation.swyxweb.web.MockController; +import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; + +/** + * Gegenstück zu {@link BackendApplicationTests#mockIsAbsentWithoutItsProfile()}: + * mit dem Profil „mock" muss der SwyxTray-Mock vollständig da sein. + */ +@SpringBootTest +@ActiveProfiles("mock") +class MockProfileTests { + + @Autowired + ApplicationContext context; + + @Test + void mockIsPresentWithItsProfile() { + assertThat(context.getBeansOfType(SwyxTrayMockHandler.class)).isNotEmpty(); + assertThat(context.getBeansOfType(MockController.class)).isNotEmpty(); + } + +} diff --git a/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java b/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java new file mode 100644 index 0000000..79ad1b0 --- /dev/null +++ b/backend/src/test/java/de/appcreation/swyxweb/WebhookControllerTests.java @@ -0,0 +1,134 @@ +package de.appcreation.swyxweb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.appcreation.swyxweb.webhook.WebhookService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Der Webhook nimmt beliebiges JSON an und hält es im Verlauf vor. Ohne + * gesetztes Token ist er offen – so läuft er ohne Zutun des aufrufenden Systems. + */ +@SpringBootTest +@AutoConfigureMockMvc +class WebhookControllerTests { + + @Autowired + MockMvc mvc; + + @Autowired + WebhookService service; + + @BeforeEach + void emptyHistory() { + service.clear(); + } + + @Test + void acceptsJsonAndReturnsReceipt() throws Exception { + mvc.perform(post("/api/webhook") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Muster GmbH\",\"number\":\"+493012345\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.receivedAt").exists()); + + assertThat(service.history()).hasSize(1); + } + + /** Kein festes Schema: Auch eine Liste ist gültige Nutzlast. */ + @Test + void acceptsAnyJsonShape() throws Exception { + mvc.perform(post("/api/webhook") + .contentType(MediaType.APPLICATION_JSON) + .content("[{\"name\":\"Abt, Bettina\",\"number\":\"7587\"}]")) + .andExpect(status().isOk()); + + assertThat(service.history()).hasSize(1); + assertThat(service.history().getFirst().payload().isArray()).isTrue(); + } + + @Test + void historyKeepsOrderAndCanBeCleared() throws Exception { + mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{\"n\":1}")) + .andExpect(status().isOk()); + mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{\"n\":2}")) + .andExpect(status().isOk()); + + mvc.perform(get("/api/webhook/history")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].payload.n").value(1)) + .andExpect(jsonPath("$[1].payload.n").value(2)); + + mvc.perform(delete("/api/webhook/history")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").value(2)); + + assertThat(service.history()).isEmpty(); + } + + /** Ohne Token in der Konfiguration darf der Kopf fehlen. */ + @Test + void tokenIsNotRequiredWhenUnset() throws Exception { + mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()); + } + + @Test + void rejectsPayloadAboveTheLimit() throws Exception { + // max-size steht auf 256 KiB; ein Wert deutlich darüber muss abgelehnt werden. + String big = "{\"v\":\"" + "x".repeat(300_000) + "\"}"; + mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content(big)) + .andExpect(status().isPayloadTooLarge()); + + assertThat(service.history()).isEmpty(); + } + + @Nested + @SpringBootTest + @AutoConfigureMockMvc + @TestPropertySource(properties = "app.webhook.token=geheim") + class WithToken { + + @Autowired + MockMvc mvc; + + @Test + void rejectsCallWithoutToken() throws Exception { + mvc.perform(post("/api/webhook").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void rejectsWrongToken() throws Exception { + mvc.perform(post("/api/webhook") + .contentType(MediaType.APPLICATION_JSON) + .header("X-Webhook-Token", "falsch") + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void acceptsCorrectToken() throws Exception { + mvc.perform(post("/api/webhook") + .contentType(MediaType.APPLICATION_JSON) + .header("X-Webhook-Token", "geheim") + .content("{}")) + .andExpect(status().isOk()); + } + } +} diff --git a/frontend/src/components/ContactsPanel.tsx b/frontend/src/components/ContactsPanel.tsx new file mode 100644 index 0000000..2b24642 --- /dev/null +++ b/frontend/src/components/ContactsPanel.tsx @@ -0,0 +1,262 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { describeContact, type Contact } from '../swyx/protocol' +import { + filterContacts, + sortContacts, + type Directory, + type DirectoryProgress, + type SweepOptions, +} from '../swyx/directory' +import LoadingDialog from './LoadingDialog' + +interface Props { + /** Panel ist sichtbar – erst dann wird geladen. */ + active: boolean + disabled: boolean + /** + * Adress-Cache, den die App unaufgefordert schickt. `null` heißt „noch keine + * Cache-Nachricht"; eine leere Liste heißt „Cache ist leer". + */ + cache: Contact[] | null + onLoad: (options: SweepOptions) => Promise + onDial: (number: string) => void +} + +const STORAGE_FILTER = 'swyxweb.contacts.filter' + +function stored(key: string): string { + try { + return localStorage.getItem(key) ?? '' + } catch { + // Privater Modus o. Ä. – dann eben ohne Gedächtnis. + return '' + } +} + +function remember(key: string, value: string): void { + try { + localStorage.setItem(key, value) + } catch { + // absichtlich still + } +} + +/** + * Adressdaten des Swyx-Clients – der **gesamte** Bestand des globalen + * Telefonbuchs. + * + * Drei Wege, in dieser Reihenfolge: + * + * 1. Der **Cache**, den die App seit Protokoll 8 beim Verbinden von selbst + * schickt. Er liegt dann schon vor, bevor der Bereich geöffnet wird – kein + * Laden, kein Wartedialog. + * 2. Sonst beim Öffnen das Kommando `addresses`: derselbe Cache auf Anfrage. + * 3. Kennt die App das Kommando nicht oder ist der Cache leer, werden rund 110 + * Einzelabfragen zusammengesetzt. Das dauert einige Sekunden und läuft hinter + * einem Wartedialog. + * + * Danach liegt der Bestand im Browser: gefiltert wird ohne weitere Abfrage. + */ +export default function ContactsPanel({ active, disabled, cache, onLoad, onDial }: Props) { + const [directory, setDirectory] = useState(null) + const [progress, setProgress] = useState(null) + const [filter, setFilter] = useState(() => stored(STORAGE_FILTER)) + + // Nach einem Verbindungsabbruch soll beim nächsten Öffnen neu geladen werden. + const loadedRef = useRef(false) + const abortRef = useRef(null) + // Zählt die Ladevorgänge. Trifft der Cache ein, während die Einzelabfragen + // noch laufen, darf deren abgebrochenes Ergebnis den Cache nicht überschreiben. + const loadIdRef = useRef(0) + + /** Macht einen laufenden Ladevorgang ungültig und beendet ihn. */ + const supersede = useCallback(() => { + loadIdRef.current += 1 + abortRef.current?.abort() + abortRef.current = null + setProgress(null) + }, []) + + const load = useCallback(async () => { + // Ein zweiter Durchlauf würde nur dieselben Abfragen doppelt stellen. + supersede() + const ticket = loadIdRef.current + const controller = new AbortController() + abortRef.current = controller + + setProgress({ done: 0, total: 0, found: 0 }) + try { + const result = await onLoad({ + signal: controller.signal, + onProgress: (next) => { + if (ticket === loadIdRef.current) setProgress(next) + }, + }) + if (ticket !== loadIdRef.current) return + // null heißt: Fehler – der steht bereits in der Fehleranzeige der Seite. + if (result) setDirectory(result) + } finally { + if (ticket === loadIdRef.current) { + abortRef.current = null + setProgress(null) + } + } + }, [onLoad, supersede]) + + // Der Cache der App hat Vorrang: Er kommt unaufgefordert und ist damit oft + // schon da, bevor der Bereich überhaupt geöffnet wird. Ein leerer Cache ist + // dagegen keine Antwort – dann bleibt es beim Laden weiter unten. + useEffect(() => { + if (!cache || cache.length === 0) return + loadedRef.current = true + supersede() + setDirectory({ + contacts: sortContacts(cache), + source: 'cache', + queries: 0, + complete: true, + aborted: false, + }) + }, [cache, supersede]) + + useEffect(() => { + if (disabled) { + // Ohne Verbindung ist der Bestand veraltet; er wird beim nächsten Mal neu geholt. + supersede() + loadedRef.current = false + setDirectory(null) + return + } + if (!active || loadedRef.current) return + loadedRef.current = true + void load() + }, [active, disabled, load, supersede]) + + // Ein laufender Durchlauf soll nicht weiterfragen, wenn die Seite verschwindet. + useEffect(() => () => abortRef.current?.abort(), []) + + const visible = useMemo( + () => (directory ? filterContacts(directory.contacts, filter) : []), + [directory, filter], + ) + + const loading = progress !== null + + return ( + <> +
+

Adressdaten

+ +
+ + {disabled ? ( +

Erst mit der SwyxTray-App verbinden.

+ ) : ( + <> + {/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`) + und würde als direktes Kind der Karte – einer Spalte – in die *Höhe* + wachsen und den freien Platz aufsaugen. In der Zeile wächst es in + die Breite, wie in allen anderen Bereichen auch. */} +
+ +
+ + {directory === null ? ( +

{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}

+ ) : ( + <> +

+ {filter.trim() + ? `${visible.length} von ${directory.contacts.length} Einträgen` + : `${directory.contacts.length} Einträge`} + {directory.source === 'cache' + ? ' · aus dem Adress-Cache der App' + : ` · aus ${directory.queries} Einzelabfragen zusammengesetzt`} +

+ + {!directory.complete && ( +

+ {directory.aborted + ? 'Abgebrochen – der Bestand ist unvollständig.' + : 'Die App hat mindestens eine Abfrage gekürzt; es können Einträge fehlen.'}{' '} + „Neu laden" versucht es erneut. +

+ )} + + {visible.length === 0 ? ( +

Kein Eintrag zu „{filter.trim()}".

+ ) : ( +
    + {visible.map((contact, index) => ( + // Namen sind nicht eindeutig – dieselbe Person kommt mit + // mehreren Durchwahlen vor; deshalb die Position mit hinein. +
  • + + {describeContact(contact)} + {contact.description && ( + {contact.description} + )} + + {contact.number && {contact.number}} + {contact.number && ( + + )} +
  • + ))} +
+ )} + + )} + + )} + +

+ Der Bestand kommt aus dem Telefonbuch des Swyx-Clients auf dem Rechner der SwyxTray-App – + aus deren Adress-Cache, den sie beim Verbinden von selbst schickt. Kennt die App den Cache + noch nicht oder ist er leer, wird der Bestand ersatzweise aus rund 110 Suchabfragen + zusammengesetzt. Danach liegt er vollständig im Browser; das Filtern läuft ohne weitere + Abfrage. +

+ + {loading && ( + 0 ? `${progress.found} Einträge bisher` : undefined} + onCancel={() => abortRef.current?.abort()} + /> + )} + + ) +} diff --git a/frontend/src/components/LoadingDialog.tsx b/frontend/src/components/LoadingDialog.tsx new file mode 100644 index 0000000..ef16c96 --- /dev/null +++ b/frontend/src/components/LoadingDialog.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef, type ReactNode } from 'react' + +interface Props { + title: string + /** Zusatzzeile unter dem Balken, z. B. „287 Einträge bisher". */ + detail?: ReactNode + done: number + /** 0 heißt: Umfang noch unbekannt – dann läuft der Balken unbestimmt. */ + total: number + onCancel?: () => void +} + +/** + * Wartedialog mit Fortschritt. Bewusst ein eigenes Overlay statt ``: + * die Panels der Startseite liegen in `hidden`-Bereichen, und `showModal()` + * verhielte sich darin je nach Browser unterschiedlich. + */ +export default function LoadingDialog({ title, detail, done, total, onCancel }: Props) { + const cancelRef = useRef(null) + + // Der Abbrechen-Knopf ist das einzige Bedienelement – er bekommt den Fokus, + // damit Escape und Leertaste sofort greifen. + useEffect(() => { + cancelRef.current?.focus() + }, []) + + useEffect(() => { + if (!onCancel) return + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') onCancel!() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [onCancel]) + + const percent = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0 + + return ( +
+
+

+ {title} +

+ +
0 ? done : undefined} + aria-valuemin={0} + aria-valuemax={total > 0 ? total : undefined} + aria-valuetext={total > 0 ? `${done} von ${total}` : 'läuft'} + > +
0 ? '' : ' progress__bar--unknown'}`} + style={total > 0 ? { width: `${percent}%` } : undefined} + /> +
+ +

+ {total > 0 ? `${done} von ${total} Abfragen` : 'Abfragen laufen …'} + {detail && {detail}} +

+ + {onCancel && ( +
+ +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx index 49415eb..3c638db 100644 --- a/frontend/src/components/Tabs.tsx +++ b/frontend/src/components/Tabs.tsx @@ -1,6 +1,8 @@ export interface Tab { id: Id label: string + /** Anzahl noch nicht gesehener Nachrichten; 0 oder fehlend blendet ihn aus. */ + badge?: number } interface Props { @@ -35,6 +37,11 @@ export default function Tabs({ tabs, active, onChange }: Prop onClick={() => onChange(tab.id)} > {tab.label} + {tab.badge ? ( + + {tab.badge > 99 ? '99+' : tab.badge} + + ) : null} ))} diff --git a/frontend/src/components/WebhookPanel.tsx b/frontend/src/components/WebhookPanel.tsx new file mode 100644 index 0000000..dfe7c3b --- /dev/null +++ b/frontend/src/components/WebhookPanel.tsx @@ -0,0 +1,94 @@ +import { formatPayload, summarize, type WebhookEvent } from '../webhook' +import type { WebhookStatus } from '../hooks/useWebhook' + +interface Props { + events: WebhookEvent[] + status: WebhookStatus + error: string | null + onClear: () => void +} + +const timeFormat = new Intl.DateTimeFormat('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}) + +const dateFormat = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', +}) + +/** Eingangszeit; das Datum nur, wenn die Nachricht nicht von heute ist. */ +function describeTime(receivedAt: string): string { + const at = new Date(receivedAt) + if (Number.isNaN(at.getTime())) return receivedAt + const today = new Date() + const sameDay = + at.getFullYear() === today.getFullYear() && + at.getMonth() === today.getMonth() && + at.getDate() === today.getDate() + return sameDay ? timeFormat.format(at) : `${dateFormat.format(at)} ${timeFormat.format(at)}` +} + +const STATUS_TEXT: Record = { + connecting: 'Verbindung wird aufgebaut …', + open: 'Verbunden – neue Nachrichten erscheinen sofort.', + closed: 'Nicht verbunden. Seite neu laden, um den Strom wieder zu öffnen.', +} + +/** + * Zeigt die über den Webhook eingegangenen Nachrichten – jüngste zuerst, jede + * mit ihrem JSON im Original. + * + * Bewusst ohne Deutung der Nutzlast: Das aufrufende System muss sich an kein + * Schema halten, deshalb wird angezeigt, was ankam. Nur die Kopfzeile fasst + * `name` und `number` zusammen, wenn es sie gibt. + */ +export default function WebhookPanel({ events, status, error, onClear }: Props) { + return ( + <> +
+

Webhook

+ +
+ +

{STATUS_TEXT[status]}

+ {error &&

{error}

} + + {events.length === 0 ? ( +

+ Noch keine Nachricht eingegangen. Ein fremdes System schickt Adressdaten an{' '} + POST /api/webhook; sie erscheinen hier ohne Zutun. +

+ ) : ( +
    + {events.map((event) => ( +
  • +
    + {describeTime(event.receivedAt)} + #{event.id} + {summarize(event.payload)} +
    +
    {formatPayload(event.payload)}
    +
  • + ))} +
+ )} + +

+ Der Webhook nimmt beliebiges JSON an – Objekt wie Liste – und zeigt es hier unverändert. + Der Verlauf liegt im Speicher des Backends und ist nach dessen Neustart leer; „Leeren" + verwirft ihn auch dort. +

+ + ) +} diff --git a/frontend/src/hooks/useSwyxTray.ts b/frontend/src/hooks/useSwyxTray.ts index e55523b..92d6ba5 100644 --- a/frontend/src/hooks/useSwyxTray.ts +++ b/frontend/src/hooks/useSwyxTray.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { SwyxTrayClient, type ConnectionStatus, type RawMessage } from '../swyx/SwyxTrayClient' -import { mergeSnapshot, type CallEvent, type SnapshotMessage } from '../swyx/protocol' +import { mergeSnapshot, type CallEvent, type Contact, type SnapshotMessage } from '../swyx/protocol' +import { loadDirectory as loadDirectoryFrom, type SweepOptions } from '../swyx/directory' export interface LogEntry extends RawMessage { id: number @@ -31,6 +32,8 @@ export function useSwyxTray() { // Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu. const [lines, setLines] = useState>({}) const [tray, setTray] = useState({}) + // Adress-Cache der App; null heißt „noch keine Cache-Nachricht erhalten". + const [addressCache, setAddressCache] = useState(null) const [busy, setBusy] = useState(false) const clientRef = useRef(null) @@ -54,9 +57,14 @@ export function useSwyxTray() { linesRef.current = {} setLines({}) setTray({}) + // Auch der Cache gehörte zur alten Verbindung; die App schickt ihn + // beim Verbinden erneut. + setAddressCache(null) } }), client.on('error', (message) => setError(message)), + // Unaufgefordert: beim Verbinden und wenn die App ihren Cache erneuert. + client.on('addresses', (contacts) => setAddressCache(contacts)), client.on('raw', (message) => { setLog((entries) => { const next = [...entries, { ...message, id: logIdRef.current++ }] @@ -123,6 +131,26 @@ export function useSwyxTray() { const openTab = useCallback((url: string) => run(() => client.openTab(url)), [client, run]) const closeTab = useCallback((tabId: number) => run(() => client.closeTab(tabId)), [client, run]) + /** + * Holt den **gesamten** Adressbestand – über den Adress-Cache der App + * (`addresses`), sonst über rund 110 Einzelabfragen. Der zweite Weg braucht + * einige Sekunden; daher Fortschritt und Abbruchmöglichkeit. Der Busy-Zustand + * gilt für den ganzen Vorgang, nicht für jede Einzelabfrage. + */ + const loadDirectory = useCallback( + (options: SweepOptions = {}) => + run(() => + loadDirectoryFrom( + { + listAddresses: async () => (await client.listAddresses()).addresses ?? [], + search: async (query) => (await client.searchContacts(query)).contacts ?? [], + }, + options, + ), + ), + [client, run], + ) + const sendRaw = useCallback( (text: string) => { try { @@ -157,6 +185,7 @@ export function useSwyxTray() { calls, ringingCall, tray, + addressCache, busy, connect, disconnect, @@ -168,6 +197,7 @@ export function useSwyxTray() { listTabs, openTab, closeTab, + loadDirectory, sendRaw, clearLog, } diff --git a/frontend/src/hooks/useWebhook.ts b/frontend/src/hooks/useWebhook.ts new file mode 100644 index 0000000..f8417db --- /dev/null +++ b/frontend/src/hooks/useWebhook.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + clearHistory, + fetchHistory, + isWebhookEvent, + mergeEvent, + mergeEvents, + type WebhookEvent, +} from '../webhook' + +export type WebhookStatus = 'connecting' | 'open' | 'closed' + +export interface WebhookState { + events: WebhookEvent[] + status: WebhookStatus + error: string | null + clear: () => Promise +} + +/** + * Hört auf die Nachrichten des Webhooks. + * + * Der Strom läuft über eine `EventSource` auf `/api/webhook/events` und wird + * **unabhängig vom geöffneten Bereich** gehalten: So ist schon alles da, wenn + * der Bereich „Webhook" aufgeschlagen wird. Bricht die Verbindung ab, verbindet + * der Browser von selbst neu und schickt dabei die `Last-Event-ID` mit – das + * Backend liefert dann nach, was in der Lücke ankam. + * + * Zusätzlich wird beim Start der Verlauf geholt, damit auch Nachrichten von vor + * dem Laden der Seite erscheinen. + */ +export function useWebhook(): WebhookState { + const [events, setEvents] = useState([]) + const [status, setStatus] = useState('connecting') + const [error, setError] = useState(null) + + const sourceRef = useRef(null) + + useEffect(() => { + const controller = new AbortController() + + fetchHistory(controller.signal) + .then((history) => { + if (controller.signal.aborted) return + setEvents((current) => mergeEvents(current, history)) + }) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof Error ? e.message : String(e)) + }) + + const source = new EventSource('/api/webhook/events') + sourceRef.current = source + + source.onopen = () => { + setStatus('open') + setError(null) + } + + source.addEventListener('webhook', (event) => { + try { + const parsed: unknown = JSON.parse((event as MessageEvent).data) + if (isWebhookEvent(parsed)) setEvents((current) => mergeEvent(current, parsed)) + } catch { + // Unlesbare Nachricht: lieber übergehen als die Anzeige abstürzen lassen. + } + }) + + source.onerror = () => { + // Der Browser verbindet von selbst neu; nur der endgültige Abbruch ist final. + setStatus(source.readyState === EventSource.CLOSED ? 'closed' : 'connecting') + } + + return () => { + controller.abort() + source.close() + sourceRef.current = null + } + }, []) + + const clear = useCallback(async () => { + try { + await clearHistory() + setEvents([]) + setError(null) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } + }, []) + + return { events, status, error, clear } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 791852c..af44552 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -547,6 +547,127 @@ code { white-space: nowrap; } +.overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgb(0 0 0 / 55%); +} + +.dialog { + width: min(420px, 100%); + padding: 24px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--surface); + box-shadow: 0 24px 60px rgb(0 0 0 / 45%); +} + +.dialog__title { + margin: 0 0 16px; + font-size: 18px; +} + +.dialog__status { + margin: 12px 0 0; + font-size: 14px; + color: var(--text-muted); +} + +.dialog__detail { + display: block; +} + +.dialog__actions { + display: flex; + justify-content: flex-end; + margin-top: 20px; +} + +.progress { + height: 8px; + border-radius: 999px; + background: var(--border); + overflow: hidden; +} + +.progress__bar { + height: 100%; + border-radius: 999px; + background: var(--accent); + transition: width 120ms linear; +} + +/* Umfang noch unbekannt: ein wanderndes Stück statt eines Füllstands. */ +.progress__bar--unknown { + width: 35%; + animation: progress-slide 1.1s ease-in-out infinite; +} + +@keyframes progress-slide { + from { + transform: translateX(-100%); + } + to { + transform: translateX(300%); + } +} + +.contacts { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + /* Mehrere hundert Einträge – die Liste darf die Seite nicht sprengen. */ + max-height: 520px; + overflow-y: auto; +} + +.contacts__item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-2); +} + +.contacts__info { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + +.contacts__name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.contacts__description { + font-size: 13px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.contacts__number { + flex: 0 0 auto; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 14px; + font-weight: 600; +} + .log { height: 300px; overflow-y: auto; @@ -595,3 +716,82 @@ code { .log__entry--system { color: var(--text-muted); } + +/* Webhook: eingegangene Nachrichten, jüngste zuerst. */ +.webhook { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.webhook__item { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-2); +} + +.webhook__meta { + display: flex; + align-items: baseline; + gap: 8px; + /* Bei schmalem Fenster darf die Kopfzeile umbrechen, statt zu überlaufen. */ + flex-wrap: wrap; +} + +.webhook__time { + font-variant-numeric: tabular-nums; + color: var(--text-muted); + font-size: 13px; +} + +.webhook__id { + padding: 1px 8px; + border-radius: 6px; + background: var(--border); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + font-weight: 600; +} + +.webhook__summary { + flex: 1 1 auto; + min-width: 0; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.webhook__json { + margin: 0; + padding: 10px 12px; + border-radius: 6px; + background: var(--surface); + border: 1px solid var(--border); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; + line-height: 1.45; + /* Lange Zeilen scrollen im Block, statt die Seite breit zu ziehen. */ + overflow-x: auto; + /* Ein einzelner Riesenwert soll die Karte nicht sprengen. */ + max-height: 320px; + overflow-y: auto; +} + +.tabs__badge { + margin-left: 8px; + padding: 1px 7px; + border-radius: 999px; + background: var(--accent); + color: var(--surface); + font-size: 12px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 62100f7..22eb094 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -2,18 +2,24 @@ import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'rea import { DEFAULT_WS_URL, fetchClientConfig } from '../config' import { useSwyxTray } from '../hooks/useSwyxTray' import { useCallNotifications } from '../hooks/useCallNotifications' +import { useWebhook } from '../hooks/useWebhook' +import { countUnseen } from '../webhook' import StatusBadge from '../components/StatusBadge' import MessageLog from '../components/MessageLog' import IncomingCallCard from '../components/IncomingCallCard' import DialPanel from '../components/DialPanel' import BrowserTabsPanel from '../components/BrowserTabsPanel' +import ContactsPanel from '../components/ContactsPanel' +import WebhookPanel from '../components/WebhookPanel' import Tabs, { panelId, tabId, type Tab } from '../components/Tabs' import { ActiveCallList, CallHistory } from '../components/CallList' -type TabName = 'calls' | 'browsertabs' | 'connection' +type TabName = 'calls' | 'contacts' | 'webhook' | 'browsertabs' | 'connection' const TABS: Tab[] = [ { id: 'calls', label: 'Anrufe' }, + { id: 'contacts', label: 'Adressdaten' }, + { id: 'webhook', label: 'Webhook' }, { id: 'browsertabs', label: 'Tabs' }, { id: 'connection', label: 'Verbindung' }, ] @@ -40,6 +46,25 @@ export default function HomePage() { const { permission, requestPermission } = useCallNotifications(ringingCall) + // Läuft unabhängig vom geöffneten Bereich: Der Webhook hängt am Backend, nicht + // an der SwyxTray-Verbindung, und soll auch dann mitschreiben, wenn gerade ein + // anderer Bereich offen ist. + const webhook = useWebhook() + + // Eine Webhook-Nachricht kommt unabhängig davon an, welcher Bereich offen ist. + // Damit sie nicht unbemerkt bleibt, trägt der Tab die Zahl der noch nicht + // gesehenen Nachrichten; gesehen ist alles, was bei offenem Bereich ankam. + const [seenWebhookId, setSeenWebhookId] = useState(0) + const newestWebhookId = webhook.events[0]?.id ?? 0 + useEffect(() => { + if (tab === 'webhook') setSeenWebhookId(newestWebhookId) + }, [tab, newestWebhookId]) + const unseenWebhook = countUnseen(webhook.events, seenWebhookId) + + const tabsWithBadges = TABS.map((entry) => + entry.id === 'webhook' ? { ...entry, badge: unseenWebhook } : entry, + ) + // Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren. const urlTouched = useRef(false) @@ -93,7 +118,7 @@ export default function HomePage() { /> )} - +
@@ -130,6 +155,32 @@ export default function HomePage() {
+ +
+ void tray.dial(number)} + /> + {error &&

{error}

} +
+
+ + +
+ void webhook.clear()} + /> +
+
+
SwyxTray · Kommandos call / answer / hangup /{' '} - tabs / opentab / closetab · Zustand über{' '} + tabs / opentab / closetab / contacts · + Zustand über{' '} snapshot · Standardziel {DEFAULT_WS_URL}
diff --git a/frontend/src/swyx/SwyxTrayClient.ts b/frontend/src/swyx/SwyxTrayClient.ts index 2d564a6..f4b9d83 100644 --- a/frontend/src/swyx/SwyxTrayClient.ts +++ b/frontend/src/swyx/SwyxTrayClient.ts @@ -1,8 +1,11 @@ import { COMMANDS, + CONTACT_QUERY_MAX_LENGTH, + parseAddresses, parseHello, parseResult, parseSnapshot, + type Contact, type HelloMessage, type ResultMessage, type SnapshotMessage, @@ -20,6 +23,8 @@ interface Listeners { status: (status: ConnectionStatus) => void hello: (hello: HelloMessage) => void snapshot: (snapshot: SnapshotMessage) => void + /** Unaufgeforderter Adress-Cache der App; auch eine leere Liste zählt. */ + addresses: (contacts: Contact[]) => void raw: (message: RawMessage) => void error: (message: string) => void } @@ -50,6 +55,7 @@ export class SwyxTrayClient { status: new Set(), hello: new Set(), snapshot: new Set(), + addresses: new Set(), raw: new Set(), error: new Set(), } @@ -179,6 +185,35 @@ export class SwyxTrayClient { return this.send(COMMANDS.closeTab, { tabId }, TAB_TIMEOUT_MS) } + /** + * Durchsucht die Adressdaten des Swyx-Clients. Gesucht wird als + * Teilzeichenkette in Name und Rufnummer; die App liefert höchstens 100 + * Treffer und kürzt ohne Hinweis. Ein leerer + * Suchbegriff wäre ein Fehler der App – er wird hier gar nicht erst gesendet. + */ + searchContacts(query: string): Promise { + const wanted = query.trim() + if (!wanted) { + return Promise.reject(new Error('Bitte einen Suchbegriff angeben.')) + } + if (wanted.length > CONTACT_QUERY_MAX_LENGTH) { + return Promise.reject( + new Error(`Der Suchbegriff darf höchstens ${CONTACT_QUERY_MAX_LENGTH} Zeichen lang sein.`), + ) + } + return this.send(COMMANDS.contacts, { query: wanted }) + } + + /** + * Holt den Adress-Cache der App am Stück – ohne Suchbegriff und ohne Deckel. + * Ältere App-Versionen kennen das Kommando nicht und antworten mit + * `"Unbekanntes Kommando 'addresses'."`; die Startseite weicht dann auf die + * Einzelabfragen aus. + */ + listAddresses(): Promise { + return this.send(COMMANDS.addresses) + } + ping(): Promise { return this.send(COMMANDS.ping) } @@ -270,14 +305,22 @@ export class SwyxTrayClient { return } - // Die App schickt keine Ereignisse: Jede Änderung – auch ein eingehender - // Anruf – kommt als vollständiger Snapshot. + // Die App schickt keine Anruf-Ereignisse: Jede Änderung – auch ein + // eingehender Anruf – kommt als vollständiger Snapshot. const snapshot = parseSnapshot(parsed) if (snapshot) { this.emit('snapshot', snapshot) return } + // Der Adress-Cache dagegen kommt unaufgefordert – beim Verbinden und wenn + // die App ihn erneuert. + const addresses = parseAddresses(parsed) + if (addresses) { + this.emit('addresses', addresses) + return + } + const hello = parseHello(parsed) if (hello) { this.emit('hello', hello) diff --git a/frontend/src/swyx/directory.test.ts b/frontend/src/swyx/directory.test.ts new file mode 100644 index 0000000..941745f --- /dev/null +++ b/frontend/src/swyx/directory.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from 'vitest' +import { CONTACT_RESULT_LIMIT, type Contact } from './protocol' +import { + contactKey, + filterContacts, + initialQueries, + loadDirectory, + sortContacts, + sweepDirectory, + type DirectoryProgress, +} from './directory' + +/** + * Spielt die SwyxTray-App nach – mit genau den Regeln, die gegen die laufende + * App sondiert wurden: Teilzeichenkette in **Name und Rufnummer** (nicht in der + * Beschreibung), Schreibweise egal, nach Namen sortiert und stillschweigend bei + * {@link CONTACT_RESULT_LIMIT} Treffern gekürzt. + */ +function fakeApp(contacts: Contact[]) { + const calls: string[] = [] + const search = async (query: string): Promise => { + calls.push(query) + const needle = query.toLowerCase() + return contacts + .filter( + (c) => + (c.name ?? '').toLowerCase().includes(needle) || + (c.number ?? '').toLowerCase().includes(needle), + ) + .sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) + .slice(0, CONTACT_RESULT_LIMIT) + } + return { search, calls } +} + +/** + * Bestand mit vierstelligen Rufnummern – wie in der Anlage. Die Namen bestehen + * bewusst nur aus Buchstaben: eine Ziffer im Namen ließe die Ziffernabfragen + * auch über den Namen treffen, was es in der Anlage nicht gibt. + */ +function directoryOf(count: number, step = 1, from = 4000): Contact[] { + const letter = (i: number) => String.fromCharCode(97 + (i % 26)) + return Array.from({ length: count }, (_, i) => ({ + name: `Muster${letter(Math.floor(i / 676))}${letter(Math.floor(i / 26))}${letter(i)}, Max`, + number: String(from + i * step), + description: 'HH-MT', + })) +} + +describe('initialQueries', () => { + it('fragt zehn Ziffern und hundert Ziffernpaare ab', () => { + const queries = initialQueries() + expect(queries).toHaveLength(110) + expect(new Set(queries).size).toBe(110) + expect(queries).toContain('0') + expect(queries).toContain('00') + expect(queries).toContain('99') + }) +}) + +describe('sweepDirectory', () => { + it('findet den gesamten Bestand', async () => { + // 404 Einträge – der Umfang der echten Anlage, hier aber auf einem + // lückenlosen Nummernblock: dann hängt "40" am Deckel und der Durchlauf + // muss verfeinern. Vollständig wird er trotzdem. + const contacts = directoryOf(404) + const { search } = fakeApp(contacts) + + const result = await sweepDirectory(search) + + expect(result.contacts).toHaveLength(404) + expect(result.complete).toBe(true) + expect(result.aborted).toBe(false) + expect(new Set(result.contacts.map(contactKey))).toEqual(new Set(contacts.map(contactKey))) + }) + + it('kommt ohne Verfeinerung aus, solange keine Abfrage gekürzt wird', async () => { + // Rufnummern mit Lücken, wie in einer gewachsenen Anlage. + const { search, calls } = fakeApp(directoryOf(404, 7)) + const result = await sweepDirectory(search) + // Genau die 110 Abfragen der Grundliste – gemessen ist bei der echten + // Anlage keine davon am Deckel (Höchstwert 59 Treffer). + expect(calls).toHaveLength(110) + expect(result.queries).toBe(110) + }) + + it('meldet jeden Eintrag nur einmal, auch wenn ihn mehrere Abfragen finden', async () => { + // "4123" wird von "41", "12", "23", "1", "2", "3", "4" gefunden. + const { search } = fakeApp([{ name: 'Muster, Max', number: '4123' }]) + const result = await sweepDirectory(search) + expect(result.contacts).toHaveLength(1) + }) + + it('findet auch eine einstellige Rufnummer – dafür sind die Ziffernabfragen da', async () => { + // "0" steckt in keinem Ziffernpaar; ohne die einstelligen Abfragen fehlte + // die Zentrale im Bestand. + const contacts = [...directoryOf(20), { name: 'Zentrale', number: '0', description: '' }] + const { search } = fakeApp(contacts) + const result = await sweepDirectory(search) + expect(result.contacts.map((c) => c.number)).toContain('0') + }) + + it('verfeinert eine gekürzte Abfrage und wird trotzdem vollständig', async () => { + // Alle 105 Nummern beginnen mit "12"; diese Abfrage hängt also am Deckel. + const contacts = Array.from({ length: 105 }, (_, i) => ({ + name: `Muster${String.fromCharCode(97 + (i % 26))}${i}, Max`.replace(/\d/g, ''), + number: `12${i % 10}${String(i).padStart(3, '0')}`, + })) + const { search, calls } = fakeApp(contacts) + + const result = await sweepDirectory(search) + + expect(result.contacts).toHaveLength(105) + expect(result.complete).toBe(true) + // Verfeinert wurde vorn und hinten – beides gehört dazu, weil eine + // Teilzeichenkette an beiden Enden weitergehen kann. + expect(calls.length).toBeGreaterThan(110) + expect(calls).toContain('120') + expect(calls).toContain('012') + }) + + it('gibt auf, wenn sich eine gekürzte Abfrage nicht mehr verfeinern lässt', async () => { + // Über hundert Einträge mit derselben dreistelligen Nummer: die Abfrage + // "123" bleibt gekürzt, egal wie oft verfeinert wird. + const contacts = directoryOf(130, 1).map((c) => ({ ...c, number: '123' })) + const { search } = fakeApp(contacts) + + const result = await sweepDirectory(search) + + expect(result.complete).toBe(false) + expect(result.contacts.length).toBeLessThan(130) + }) + + it('meldet den Fortschritt und zählt bis zum Ende hoch', async () => { + const onProgress = vi.fn<(p: DirectoryProgress) => void>() + const { search } = fakeApp(directoryOf(30)) + + const result = await sweepDirectory(search, { onProgress }) + + const reports = onProgress.mock.calls.map(([p]) => p) + expect(reports[0]).toEqual({ done: 0, total: 110, found: 0 }) + expect(reports.at(-1)).toEqual({ done: 110, total: 110, found: 30 }) + // Monoton – sonst spränge der Balken im Wartedialog zurück. + expect(reports.map((p) => p.done)).toEqual([...reports.map((p) => p.done)].sort((a, b) => a - b)) + expect(result.queries).toBe(110) + }) + + it('bricht ab, ohne den bisherigen Stand wegzuwerfen', async () => { + const contacts = directoryOf(404) + const controller = new AbortController() + const { search, calls } = fakeApp(contacts) + + const result = await sweepDirectory( + async (query) => { + // Nach zwanzig Abfragen abbrechen – wie ein Klick auf „Abbrechen". + if (calls.length >= 20) controller.abort() + return search(query) + }, + { signal: controller.signal, concurrency: 1 }, + ) + + expect(result.aborted).toBe(true) + expect(result.complete).toBe(false) + // Kein Fehler, kein leeres Ergebnis: das bereits Geladene bleibt erhalten. + expect(result.contacts.length).toBeGreaterThan(0) + expect(result.contacts.length).toBeLessThan(404) + expect(calls.length).toBeLessThan(110) + }) + + it('reicht den Fehler einer Abfrage durch – ein Abbruch ist etwas anderes', async () => { + await expect( + sweepDirectory(async () => { + throw new Error('Keine Verbindung zur SwyxTray-App.') + }), + ).rejects.toThrow('Keine Verbindung zur SwyxTray-App.') + }) +}) + +describe('loadDirectory', () => { + const cached: Contact[] = [ + { name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' }, + { name: 'Abt, Bettina', number: '7587', description: 'S-SB' }, + ] + + it('nimmt den Adress-Cache der App und fragt dann nichts mehr', async () => { + const { search, calls } = fakeApp(directoryOf(404, 7)) + + const result = await loadDirectory({ listAddresses: async () => cached, search }) + + expect(result.source).toBe('cache') + expect(result.queries).toBe(1) + expect(result.complete).toBe(true) + // Eine einzige Nachricht statt 110 – darum geht es beim Cache. + expect(calls).toHaveLength(0) + expect(result.contacts.map((c) => c.name)).toEqual(['Abt, Bettina', 'Muster GmbH']) + }) + + it('meldet jeden Eintrag des Caches nur einmal', async () => { + const doppelt = [...cached, { ...cached[0] }] + const { search } = fakeApp([]) + const result = await loadDirectory({ listAddresses: async () => doppelt, search }) + expect(result.contacts).toHaveLength(2) + }) + + it('weicht auf die Einzelabfragen aus, wenn die App das Kommando nicht kennt', async () => { + // Genau der Fall der Anlage am 21.08.2026. + const { search, calls } = fakeApp(directoryOf(404, 7)) + + const result = await loadDirectory({ + listAddresses: async () => { + throw new Error("Unbekanntes Kommando 'addresses'.") + }, + search, + }) + + expect(result.source).toBe('sweep') + expect(result.contacts).toHaveLength(404) + expect(calls).toHaveLength(110) + }) + + it('weicht auch bei leerem Cache aus – so meldet sich die Anlage zurzeit', async () => { + const { search } = fakeApp(directoryOf(40, 7)) + const result = await loadDirectory({ listAddresses: async () => [], search }) + expect(result.source).toBe('sweep') + expect(result.contacts).toHaveLength(40) + }) + + it('fragt nach einem Abbruch gar nicht erst', async () => { + const controller = new AbortController() + controller.abort() + const listAddresses = vi.fn(async () => cached) + const { search, calls } = fakeApp(directoryOf(10)) + + const result = await loadDirectory({ listAddresses, search }, { signal: controller.signal }) + + expect(listAddresses).not.toHaveBeenCalled() + expect(calls).toHaveLength(0) + expect(result.aborted).toBe(true) + }) +}) + +describe('sortContacts', () => { + it('sortiert nach Namen, bei gleichem Namen nach Rufnummer', () => { + const sorted = sortContacts([ + { name: 'Bandorf, Volker', number: '53119' }, + { name: 'Abt, Bettina', number: '7587' }, + { name: 'Bandorf, Volker', number: '41295' }, + ]) + expect(sorted.map((c) => `${c.name} ${c.number}`)).toEqual([ + 'Abt, Bettina 7587', + 'Bandorf, Volker 41295', + 'Bandorf, Volker 53119', + ]) + }) + + it('lässt die Vorlage unangetastet', () => { + const input = [{ name: 'Z' }, { name: 'A' }] + sortContacts(input) + expect(input.map((c) => c.name)).toEqual(['Z', 'A']) + }) +}) + +describe('filterContacts', () => { + const contacts: Contact[] = [ + { name: 'Abt, Bettina', number: '7587', description: 'S-SB' }, + { name: 'Muster, Max', number: '4711', description: 'HH-MT' }, + ] + + it('gibt ohne Filter alles zurück', () => { + expect(filterContacts(contacts, ' ')).toEqual(contacts) + }) + + it('sucht in Namen und Rufnummer, Schreibweise egal', () => { + expect(filterContacts(contacts, 'bettina')).toHaveLength(1) + expect(filterContacts(contacts, '471')).toEqual([contacts[1]]) + }) + + it('sucht auch in der Beschreibung – anders als die App', () => { + // Der Bestand liegt vollständig im Browser; das Kürzel ist damit filterbar. + expect(filterContacts(contacts, 'hh-mt')).toEqual([contacts[1]]) + }) +}) diff --git a/frontend/src/swyx/directory.ts b/frontend/src/swyx/directory.ts new file mode 100644 index 0000000..f478353 Binary files /dev/null and b/frontend/src/swyx/directory.ts differ diff --git a/frontend/src/swyx/protocol.test.ts b/frontend/src/swyx/protocol.test.ts index ef36c62..2fd6dcc 100644 --- a/frontend/src/swyx/protocol.test.ts +++ b/frontend/src/swyx/protocol.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest' import { callStateOf, describeCall, + describeContact, describeTab, + parseAddresses, mergeSnapshot, parseHello, parseResult, + parseContacts, parseSnapshot, parseTabs, type CallEvent, @@ -79,6 +82,22 @@ describe('parseHello', () => { ?.protocol, ).toBe(6) }) + + it('liest die Protokollversion des Adress-Caches', () => { + // Wörtlich mitgeschnitten am 21.08.2026 gegen die laufende Anlage. + expect( + parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":8,"session":3,"type":"hello"}')) + ?.protocol, + ).toBe(8) + }) + + it('liest die Protokollversion der Adressdaten', () => { + // Wörtlich mitgeschnitten, nachdem die App `contacts` gelernt hatte. + expect( + parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":7,"session":3,"type":"hello"}')) + ?.protocol, + ).toBe(7) + }) }) describe('callStateOf', () => { @@ -296,6 +315,56 @@ describe('parseResult', () => { const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}')) expect(result?.tabs).toBeUndefined() expect(result?.tabId).toBeUndefined() + expect(result?.contacts).toBeUndefined() + }) + + it('liest die Quittung auf contacts', () => { + // Wörtlich mitgeschnitten – Suchbegriff "abt". + const raw = + '{"id":20,"ok":true,"contacts":[' + + '{"name":"Abt, Bettina","number":"7587","description":"S-SB"},' + + '{"name":"Abdul, Rokhsareh","number":"5215","description":""}],"type":"result"}' + expect(parseResult(JSON.parse(raw))?.contacts).toEqual([ + { name: 'Abt, Bettina', number: '7587', description: 'S-SB' }, + // Die leere Beschreibung der App wird zu undefined. + { name: 'Abdul, Rokhsareh', number: '5215', description: undefined }, + ]) + }) + + it('liest die leere Trefferliste als leere Liste, nicht als "nicht gefragt"', () => { + expect(parseResult(JSON.parse('{"id":21,"ok":true,"contacts":[],"type":"result"}'))?.contacts).toEqual( + [], + ) + }) + + it('liest die Fehlerquittung auf einen leeren Suchbegriff', () => { + const raw = + '{"id":22,"ok":false,' + + '"error":"Feld \'query\' fehlt oder ist zu lang (max. 128 Zeichen).","type":"result"}' + expect(parseResult(JSON.parse(raw))).toMatchObject({ + id: 22, + ok: false, + error: "Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen).", + }) + }) + + it('liest die Quittung auf addresses', () => { + const raw = + '{"type":"result","id":5,"ok":true,"addresses":[' + + '{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}]}' + expect(parseResult(JSON.parse(raw))?.addresses).toEqual([ + { name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' }, + ]) + }) + + it('liest die Fehlerquittung älterer App-Versionen auf addresses', () => { + // Genau das antwortet die Anlage am 21.08.2026 noch. + const raw = '{"id":5,"ok":false,"error":"Unbekanntes Kommando \'addresses\'.","type":"result"}' + expect(parseResult(JSON.parse(raw))).toMatchObject({ + id: 5, + ok: false, + error: "Unbekanntes Kommando 'addresses'.", + }) }) it('hält einen Snapshot für keine Quittung', () => { @@ -324,6 +393,59 @@ describe('describeTab', () => { }) }) +describe('parseAddresses', () => { + it('liest die unaufgeforderte Cache-Nachricht', () => { + const raw = + '{"type":"addresses","addresses":[' + + '{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}]}' + expect(parseAddresses(JSON.parse(raw))).toEqual([ + { name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' }, + ]) + }) + + it('hält den leeren Cache für eine Antwort, nicht für ein Versehen', () => { + // Wörtlich mitgeschnitten: genau das schickt die Anlage zurzeit. + expect(parseAddresses(JSON.parse('{"addresses":[],"type":"addresses"}'))).toEqual([]) + }) + + it('hält andere Nachrichten für keine Cache-Nachricht', () => { + expect(parseAddresses(JSON.parse(HELLO))).toBeNull() + expect(parseAddresses(snapshot())).toBeNull() + }) + + it('ist keine Quittung – sie trägt weder id noch ok', () => { + expect(parseResult(JSON.parse('{"addresses":[],"type":"addresses"}'))).toBeNull() + }) +}) + +describe('parseContacts', () => { + it('nimmt einen Eintrag ohne Namen hin – die Rufnummer genügt', () => { + expect(parseContacts([{ number: '4711' }])).toEqual([ + { name: undefined, number: '4711', description: undefined }, + ]) + }) + + it('verwirft Einträge ohne Namen und ohne Rufnummer', () => { + // Anzeigen ließe sich so ein Eintrag nicht, wählen erst recht nicht. + expect(parseContacts([{ description: 'HH-MT' }, 'Unfug', null, { name: 'Muster, Max' }])).toEqual([ + { name: 'Muster, Max', number: undefined, description: undefined }, + ]) + }) + + it('hält die Reihenfolge der App ein – sie sortiert bereits nach Namen', () => { + const entries = [{ name: 'Abt, Bettina', number: '7587' }, { name: 'Zentrale', number: '0' }] + expect(parseContacts(entries).map((c) => c.name)).toEqual(['Abt, Bettina', 'Zentrale']) + }) +}) + +describe('describeContact', () => { + it('nimmt den Namen, sonst die Rufnummer', () => { + expect(describeContact({ name: 'Muster, Max', number: '4711' })).toBe('Muster, Max') + expect(describeContact({ number: '4711' })).toBe('4711') + expect(describeContact({})).toBe('Ohne Namen') + }) +}) + describe('describeCall', () => { it('bevorzugt die fertige Anzeigeform der App', () => { expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe( diff --git a/frontend/src/swyx/protocol.ts b/frontend/src/swyx/protocol.ts index 95924fb..7cf05b0 100644 --- a/frontend/src/swyx/protocol.ts +++ b/frontend/src/swyx/protocol.ts @@ -1,7 +1,7 @@ /** * Protokoll der SwyxTray-App, aufgezeichnet gegen `ws://192.168.180.135:17654/ws` - * (App-Version 1.0.0.0, `protocol: 1`; seit der Tab-Verwaltung meldet die App - * `protocol: 6`). + * (App-Version 1.0.0.0, `protocol: 1`; mit der Tab-Verwaltung meldete die App + * `protocol: 6`, mit der Adresssuche `protocol: 7`, mit dem Adress-Cache `protocol: 8`). * * App → Seite: * { "app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello" } @@ -31,7 +31,30 @@ * { "id":14,"cmd":"closetab","tabId":43 } → { …,"ok":true } * Ist kein Plugin verbunden, antwortet die App nach rund fünf Sekunden mit `ok:false`. * - * **Die App kennt keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein + * Protokoll 7 bringt die Adressdaten des Swyx-Clients – das globale Telefonbuch: + * { "id":20,"cmd":"contacts","query":"abt" } + * → { "type":"result","id":20,"ok":true, + * "contacts":[{"name":"Abt, Bettina","number":"7587","description":"S-SB"}, …] } + * Gesucht wird als Teilzeichenkette in **Name und Rufnummer**, Schreibweise egal; + * die Beschreibung wird nicht durchsucht. Sortiert nach Namen, höchstens + * {@link CONTACT_RESULT_LIMIT} Treffer – gekürzt wird stillschweigend. `query` ist + * Pflicht, darf nicht leer sein und höchstens {@link CONTACT_QUERY_MAX_LENGTH} + * Zeichen lang; sonst antwortet die App mit + * `"Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen)."`. + * + * Protokoll 8 bringt den **Adress-Cache**: die App hält die Adressdaten selbst vor + * und gibt sie am Stück heraus – ohne Suchbegriff und ohne Deckel. + * { "id":5,"cmd":"addresses" } + * → { "type":"result","id":5,"ok":true, + * "addresses":[{"name":"Muster GmbH","number":"+493012345", + * "description":"Globales Telefonbuch"}] } + * Dieselben Daten schickt die App außerdem **unaufgefordert** als eigene Nachricht, + * direkt nach dem ersten Snapshot und wenn sich ihr Cache ändert: + * { "type":"addresses","addresses":[…] } + * Beide tragen dieselben Einträge wie `contacts` – Name, Rufnummer, Beschreibung. + * Der Push ist die einzige Nachricht der App **ohne** `id` und ohne `ok`. + * + * **Sonst kennt die App keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein * eingehender Anruf – kommt als vollständiger `snapshot` über *alle* Leitungen. * Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots * (siehe {@link mergeSnapshot}). @@ -66,6 +89,10 @@ export interface ResultMessage { tabs?: BrowserTab[] /** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */ tabId?: number + /** Nur bei `contacts`: die gefundenen Adressdaten. */ + contacts?: Contact[] + /** Nur bei `addresses`: der gesamte Adress-Cache der App. */ + addresses?: Contact[] error?: string } @@ -79,6 +106,26 @@ export interface BrowserTab { active: boolean } +/** + * Ein Eintrag aus den Adressdaten des Swyx-Clients, wie ihn die Quittung auf + * `contacts` meldet. Leere Felder schickt die App als `""`; hier bleiben sie + * `undefined`. + */ +export interface Contact { + /** Anzeigename, meist „Nachname, Vorname". */ + name?: string + /** Rufnummer, meist die interne Durchwahl – so, wie sie an `call` geht. */ + number?: string + /** Zusatz der Telefonanlage, z. B. der Standort („HH-MT"). */ + description?: string +} + +/** Mehr Treffer meldet die App nicht; sie kürzt ohne Hinweis. */ +export const CONTACT_RESULT_LIMIT = 100 + +/** Längere Suchbegriffe weist die App mit einem Fehler zurück. */ +export const CONTACT_QUERY_MAX_LENGTH = 128 + /** Begrüßung beim Verbindungsaufbau. */ export interface HelloMessage { app?: string @@ -115,6 +162,8 @@ export const COMMANDS = { tabs: 'tabs', openTab: 'opentab', closeTab: 'closetab', + contacts: 'contacts', + addresses: 'addresses', } as const function isRecord(value: unknown): value is Record { @@ -207,6 +256,8 @@ export function parseResult(raw: unknown): ResultMessage | null { focused: asBoolean(raw.focused), tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined, tabId: asLine(raw.tabId), + contacts: Array.isArray(raw.contacts) ? parseContacts(raw.contacts) : undefined, + addresses: Array.isArray(raw.addresses) ? parseContacts(raw.addresses) : undefined, error: asString(raw.error), } } @@ -236,6 +287,35 @@ export function describeTab(tab: BrowserTab): string { return tab.title ?? tab.url ?? `Tab ${tab.id}` } +/** + * Liest die Adressdaten aus der Quittung auf `contacts`. Einträge ohne Namen + * *und* ohne Rufnummer fallen weg – sie ließen sich weder anzeigen noch wählen. + */ +export function parseContacts(raw: unknown[]): Contact[] { + return raw.flatMap((entry) => { + if (!isRecord(entry)) return [] + const name = asString(entry.name) + const number = asString(entry.number) + if (name === undefined && number === undefined) return [] + return [{ name, number, description: asString(entry.description) }] + }) +} + +/** Anzeigename eines Adresseintrags: Name, sonst die Rufnummer. */ +export function describeContact(contact: Contact): string { + return contact.name ?? contact.number ?? 'Ohne Namen' +} + +/** + * Liest die unaufgeforderte Cache-Nachricht `{"type":"addresses","addresses":[…]}`. + * Ein leeres Feld ist ein gültiger Zustand – die App meldet damit einen leeren + * Cache – und darf deshalb nicht wie „keine Nachricht" behandelt werden. + */ +export function parseAddresses(raw: unknown): Contact[] | null { + if (!isRecord(raw) || raw.type !== 'addresses') return null + return Array.isArray(raw.addresses) ? parseContacts(raw.addresses) : [] +} + /** Liest die Begrüßung. */ export function parseHello(raw: unknown): HelloMessage | null { if (!isRecord(raw) || raw.type !== 'hello') return null diff --git a/frontend/src/webhook.test.ts b/frontend/src/webhook.test.ts new file mode 100644 index 0000000..dee95ca --- /dev/null +++ b/frontend/src/webhook.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { + countUnseen, + formatPayload, + isWebhookEvent, + mergeEvent, + mergeEvents, + summarize, + type WebhookEvent, +} from './webhook' + +function event(id: number, payload: unknown = {}): WebhookEvent { + return { id, receivedAt: '2026-08-24T10:00:00Z', payload } +} + +describe('isWebhookEvent', () => { + it('erkennt die Form des Backends', () => { + expect(isWebhookEvent(event(1))).toBe(true) + }) + + it('lässt jede Nutzlast durchgehen – auch null', () => { + expect(isWebhookEvent({ id: 1, receivedAt: '2026-08-24T10:00:00Z', payload: null })).toBe(true) + }) + + it('weist alles ohne id oder Zeitstempel ab', () => { + expect(isWebhookEvent(null)).toBe(false) + expect(isWebhookEvent({ id: '1', receivedAt: 'x' })).toBe(false) + expect(isWebhookEvent({ id: 1 })).toBe(false) + }) +}) + +describe('mergeEvent', () => { + it('stellt die jüngste Nachricht nach vorn', () => { + const merged = mergeEvent([event(1)], event(2)) + expect(merged.map((e) => e.id)).toEqual([2, 1]) + }) + + it('nimmt eine bereits bekannte id nicht doppelt auf', () => { + // Genau dieser Fall tritt im Betrieb auf: Der Verlauf wird geholt, während + // dieselbe Nachricht schon über den Ereignisstrom kam. + const merged = mergeEvent([event(2), event(1)], event(2, { neu: true })) + expect(merged.map((e) => e.id)).toEqual([2, 1]) + expect(merged[0].payload).toEqual({ neu: true }) + }) + + it('deckelt die Liste', () => { + const merged = mergeEvent([event(3), event(2), event(1)], event(4), 3) + expect(merged.map((e) => e.id)).toEqual([4, 3, 2]) + }) + + it('sortiert auch dann nach id, wenn Nachrichten verspätet nachkommen', () => { + // Nach einem Wiederverbinden liefert das Backend Verpasstes nach. + const merged = mergeEvents([event(5)], [event(3), event(4)]) + expect(merged.map((e) => e.id)).toEqual([5, 4, 3]) + }) +}) + +describe('summarize', () => { + it('fasst Adressdaten aus name und number zusammen', () => { + expect(summarize({ name: 'Muster GmbH', number: '+493012345' })).toBe('Muster GmbH · +493012345') + }) + + it('kommt mit nur einem der beiden Felder aus', () => { + expect(summarize({ name: 'Abt, Bettina' })).toBe('Abt, Bettina') + expect(summarize({ number: '7587' })).toBe('7587') + }) + + it('zählt die Einträge einer Liste', () => { + expect(summarize([{ name: 'A' }, { name: 'B' }])).toBe('2 Einträge') + expect(summarize([{ name: 'A' }])).toBe('1 Eintrag') + }) + + it('beschreibt fremde Formen, statt zu raten', () => { + expect(summarize({ kunde: 4711, quelle: 'CRM' })).toBe('2 Felder: kunde, quelle') + expect(summarize({})).toBe('leeres Objekt') + expect(summarize(null)).toBe('null') + expect(summarize(42)).toBe('42') + }) +}) + +describe('formatPayload', () => { + it('rückt ein', () => { + expect(formatPayload({ a: 1 })).toBe('{\n "a": 1\n}') + }) + + it('kommt mit einfachen Werten zurecht', () => { + expect(formatPayload('text')).toBe('"text"') + expect(formatPayload(null)).toBe('null') + expect(formatPayload(undefined)).toBe('undefined') + }) +}) + +describe('countUnseen', () => { + it('zählt, was seit dem letzten Blick dazukam', () => { + expect(countUnseen([event(3), event(2), event(1)], 1)).toBe(2) + }) + + it('zählt ohne vorherigen Blick alles', () => { + expect(countUnseen([event(2), event(1)], 0)).toBe(2) + }) + + it('zählt nichts, wenn die jüngste Nachricht gesehen ist', () => { + expect(countUnseen([event(2), event(1)], 2)).toBe(0) + expect(countUnseen([], 0)).toBe(0) + }) +}) diff --git a/frontend/src/webhook.ts b/frontend/src/webhook.ts new file mode 100644 index 0000000..d91abf8 --- /dev/null +++ b/frontend/src/webhook.ts @@ -0,0 +1,120 @@ +/** + * Webhook: Adressdaten, die ein fremdes System per POST an das Backend schickt. + * + * Das Backend nimmt unter `POST /api/webhook` **beliebiges** JSON an und reicht + * es über Server-Sent Events (`/api/webhook/events`) an die offenen Browser + * weiter. Dieses Modul enthält nur die reine Logik – das Holen und Anzeigen + * stehen in `hooks/useWebhook.ts` und `components/WebhookPanel.tsx`. + */ + +/** Eine über den Webhook eingegangene Nachricht, so wie das Backend sie liefert. */ +export interface WebhookEvent { + /** Fortlaufend ab 1. Eindeutig bis zum Neustart des Backends. */ + id: number + /** Eingangszeit im Backend, ISO-8601. */ + receivedAt: string + /** Das empfangene JSON, unverändert. Objekt, Liste oder ein einfacher Wert. */ + payload: unknown +} + +/** So viele Nachrichten hält die Anzeige vor; das Backend deckelt zusätzlich. */ +export const MAX_EVENTS = 50 + +/** + * Prüft grob, ob eine Nachricht die Form des Backends hat. Die Nutzlast bleibt + * bewusst ungeprüft – sie darf alles sein. + */ +export function isWebhookEvent(value: unknown): value is WebhookEvent { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Partial + return typeof candidate.id === 'number' && typeof candidate.receivedAt === 'string' +} + +/** + * Nimmt eine Nachricht in die Liste auf: jüngste zuerst, ohne Doppel und + * gedeckelt. + * + * Doppel entstehen im Normalbetrieb: Der Bereich holt beim Öffnen den Verlauf + * und hört gleichzeitig auf den Ereignisstrom, und nach einem Abbruch liefert + * das Backend anhand der `Last-Event-ID` nach. Entschieden wird über die `id`. + */ +export function mergeEvent( + events: WebhookEvent[], + incoming: WebhookEvent, + max = MAX_EVENTS, +): WebhookEvent[] { + const without = events.filter((event) => event.id !== incoming.id) + return [incoming, ...without].sort((a, b) => b.id - a.id).slice(0, max) +} + +/** Wie {@link mergeEvent}, aber für den Verlauf am Stück. */ +export function mergeEvents( + events: WebhookEvent[], + incoming: WebhookEvent[], + max = MAX_EVENTS, +): WebhookEvent[] { + return incoming.reduce((all, event) => mergeEvent(all, event, max), events) +} + +/** + * Wie viele Nachrichten jünger sind als die zuletzt gesehene. + * + * Der Bereich „Webhook" ist nicht immer offen, die Nachrichten kommen aber + * trotzdem an. Daraus wird der Zähler am Tab. + */ +export function countUnseen(events: WebhookEvent[], seenId: number): number { + return events.filter((event) => event.id > seenId).length +} + +/** Die Nutzlast lesbar eingerückt. */ +export function formatPayload(payload: unknown): string { + try { + return JSON.stringify(payload, null, 2) ?? String(payload) + } catch { + // Zirkuläre Strukturen kann es über JSON nicht geben; bleibt die Notbremse. + return String(payload) + } +} + +/** + * Kurzfassung für die Kopfzeile eines Eintrags. Adressdaten tragen üblicherweise + * `name` und `number`; alles andere wird der Form nach beschrieben, statt zu + * raten. + */ +export function summarize(payload: unknown): string { + if (Array.isArray(payload)) { + return payload.length === 1 ? '1 Eintrag' : `${payload.length} Einträge` + } + if (typeof payload === 'object' && payload !== null) { + const record = payload as Record + const name = typeof record.name === 'string' ? record.name.trim() : '' + const number = typeof record.number === 'string' ? record.number.trim() : '' + if (name && number) return `${name} · ${number}` + if (name) return name + if (number) return number + const keys = Object.keys(record) + if (keys.length === 0) return 'leeres Objekt' + return keys.length === 1 ? `1 Feld: ${keys[0]}` : `${keys.length} Felder: ${keys.join(', ')}` + } + if (payload === null) return 'null' + return String(payload) +} + +/** Holt den Verlauf des Backends – die Nachrichten vor dem Öffnen des Bereichs. */ +export async function fetchHistory(signal?: AbortSignal): Promise { + const response = await fetch('/api/webhook/history', { signal }) + if (!response.ok) { + throw new Error(`/api/webhook/history antwortete mit HTTP ${response.status}`) + } + const data: unknown = await response.json() + if (!Array.isArray(data)) return [] + return data.filter(isWebhookEvent) +} + +/** Leert den Verlauf im Backend. */ +export async function clearHistory(): Promise { + const response = await fetch('/api/webhook/history', { method: 'DELETE' }) + if (!response.ok) { + throw new Error(`Verlauf konnte nicht geleert werden (HTTP ${response.status})`) + } +}