Adressdaten-Bereich, Mock hinter einem Profil, Webhook
Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css, README, application.properties) und deshalb nicht getrennt committet werden können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen: Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon- buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen. Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen fest. Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an und reicht es über Server-Sent Events an die offenen Browser weiter, wo es der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten 50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token) ist vorgesehen, aber nicht voreingestellt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vendored
+10
-4
@@ -12,9 +12,11 @@
|
|||||||
"console": "internalConsole"
|
"console": "internalConsole"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Wie oben, aber /api/config zeigt auf den eingebauten SwyxTray-Mock statt
|
// Wie oben, aber der eingebaute SwyxTray-Mock wird über das Profil "mock"
|
||||||
// auf 192.168.180.135:17654 – so lässt sich die Startseite ohne echte
|
// eingeschaltet (ohne das Profil gibt es ihn nicht) und /api/config zeigt
|
||||||
// Telefonanlage ausprobieren (eingehenden Anruf auslösen: siehe README).
|
// 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",
|
"type": "java",
|
||||||
"name": "Backend (lokaler Test gegen SwyxTray-Mock)",
|
"name": "Backend (lokaler Test gegen SwyxTray-Mock)",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
@@ -22,7 +24,11 @@
|
|||||||
"projectName": "backend",
|
"projectName": "backend",
|
||||||
"cwd": "${workspaceFolder}/backend",
|
"cwd": "${workspaceFolder}/backend",
|
||||||
"console": "internalConsole",
|
"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",
|
"type": "node-terminal",
|
||||||
|
|||||||
@@ -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
|
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)).
|
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
|
2. Es nimmt unter `POST /api/webhook` **Adressdaten fremder Systeme** entgegen und reicht sie
|
||||||
echte Telefonanlage testen zu können (siehe unten). Im Normalbetrieb wird er nicht benutzt.
|
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.
|
`17654` der Port der SwyxTray-App auf 192.168.180.135. Sie sind unabhängig voneinander.
|
||||||
|
|
||||||
## Protokoll der SwyxTray-App
|
## Protokoll der SwyxTray-App
|
||||||
|
|
||||||
Quelle: Mitschnitt gegen die laufende App (Version 1.0.0.0, `protocol: 1`) am 13.08.2026.
|
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
|
Mit der Tab-Verwaltung meldete die App im `hello` `protocol: 6`, mit der Adresssuche
|
||||||
sind unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und
|
`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).
|
[SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts).
|
||||||
|
|
||||||
Ein Anruf wird über die **1-basierte Leitungsnummer `line`** identifiziert – so, wie sie auch
|
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` |
|
| `tabs` | – | Offene Tabs des Firefox-Plugins auflisten | `tabs` |
|
||||||
| `opentab` | `url` | Neuen Tab öffnen | `tabId` |
|
| `opentab` | `url` | Neuen Tab öffnen | `tabId` |
|
||||||
| `closetab` | `tabId` | Tab schließen | – |
|
| `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
|
Alles andere quittiert die App mit `"Unbekanntes Kommando '…'."`, ein fehlendes `cmd` mit
|
||||||
`"Feld 'cmd' fehlt."`.
|
`"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
|
Auch hier gilt: **die App meldet Tab-Änderungen nicht von selbst.** Die Liste wird nach jedem
|
||||||
Öffnen und Schließen neu über `tabs` geholt.
|
Ö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.
|
darunter die wörtlich mitgeschnittene Nachricht eines echten eingehenden Anrufs.
|
||||||
|
|
||||||
## Funktionen der Startseite
|
## 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 |
|
| 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 |
|
| **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`) |
|
| **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 |
|
| **Verbindung** | Adresse, Verbinden/Trennen, Benachrichtigungen erlauben, Diagnose: Rohnachrichten-Log, Status abfragen, Freitext senden |
|
||||||
|
|
||||||
@@ -178,6 +290,83 @@ Weiteres:
|
|||||||
„nicht erkannt" vermerkt statt still verworfen.
|
„nicht erkannt" vermerkt statt still verworfen.
|
||||||
- Automatischer Reconnect mit exponentiellem Backoff, nur nach unerwartetem Abbruch.
|
- 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
|
## Starten
|
||||||
|
|
||||||
### Aus VS Code
|
### 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
|
Vite-Dev-Server und öffnet Chrome, sobald dieser bereit ist – Breakpoints funktionieren auf
|
||||||
beiden Seiten. „Stop" beendet beides zusammen.
|
beiden Seiten. „Stop" beendet beides zusammen.
|
||||||
|
|
||||||
Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: `/api/config` liefert dann
|
Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: startet das Backend mit dem
|
||||||
`ws://localhost:8080/ws`, die Startseite spricht also mit dem SwyxTray-Mock.
|
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
|
Benötigte Extensions: *Extension Pack for Java* (Backend) und *JavaScript Debugger* (im
|
||||||
VS Code enthalten, für Vite und Chrome).
|
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
|
## 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)
|
Der [SwyxTrayMockHandler](backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java)
|
||||||
spricht dasselbe Protokoll: `hello` (mit `protocol: 6`) und Snapshot beim Verbinden, Quittungen
|
spricht dasselbe Protokoll: `hello` (mit `protocol: 8`), Snapshot und Adress-Cache beim
|
||||||
auf `call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab` und nach jeder
|
Verbinden, Quittungen auf
|
||||||
Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen:
|
`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
|
```bash
|
||||||
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"
|
||||||
@@ -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;
|
Geöffnet wird auch hier nichts. Der Mock bildet den Fall „kein Plugin verbunden" **nicht** nach;
|
||||||
er antwortet immer sofort.
|
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
|
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).
|
starten (macht die VS-Code-Konfiguration „lokaler Test" automatisch).
|
||||||
|
|
||||||
## Konfiguration der WebSocket-Adresse
|
## 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
|
Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach
|
||||||
`backend/src/main/resources/static/` kopieren und neu packen.
|
`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
|
## Hinweise
|
||||||
|
|
||||||
- **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte
|
- **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package de.appcreation.swyxweb;
|
package de.appcreation.swyxweb;
|
||||||
|
|
||||||
import de.appcreation.swyxweb.config.WebSocketProperties;
|
import de.appcreation.swyxweb.config.WebSocketProperties;
|
||||||
|
import de.appcreation.swyxweb.config.WebhookProperties;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableConfigurationProperties(WebSocketProperties.class)
|
@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class })
|
||||||
public class BackendApplication {
|
public class BackendApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package de.appcreation.swyxweb.config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name des Spring-Profils, unter dem der eingebaute SwyxTray-Mock läuft.
|
||||||
|
*
|
||||||
|
* <p>Der Mock ist bewusst <b>abgeschaltet, solange dieses Profil nicht gesetzt
|
||||||
|
* ist</b> – so kann er nicht versehentlich in einer Produktivumgebung
|
||||||
|
* mitlaufen. Eingeschaltet wird er über
|
||||||
|
* {@code --spring.profiles.active=mock} bzw. {@code SPRING_PROFILES_ACTIVE=mock}.
|
||||||
|
*
|
||||||
|
* <p>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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package de.appcreation.swyxweb.config;
|
|||||||
|
|
||||||
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
|
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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.EnableWebSocket;
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
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
|
* Stellt den SwyxTray-Mock unter dem konfigurierten Pfad bereit, damit sich die
|
||||||
* Startseite auch ohne die echte SwyxTray-App testen lässt.
|
* Startseite auch ohne die echte SwyxTray-App testen lässt.
|
||||||
|
*
|
||||||
|
* <p>Nur mit dem Profil {@link MockProfile#NAME} – siehe dort.
|
||||||
*/
|
*/
|
||||||
|
@Profile(MockProfile.NAME)
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSocket
|
@EnableWebSocket
|
||||||
public class WebSocketConfig implements WebSocketConfigurer {
|
public class WebSocketConfig implements WebSocketConfigurer {
|
||||||
|
|||||||
@@ -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 <b>jeden</b> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package de.appcreation.swyxweb.web;
|
package de.appcreation.swyxweb.web;
|
||||||
|
|
||||||
|
import de.appcreation.swyxweb.config.MockProfile;
|
||||||
import de.appcreation.swyxweb.websocket.LineState;
|
import de.appcreation.swyxweb.websocket.LineState;
|
||||||
import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
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
|
* 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.
|
||||||
*
|
*
|
||||||
* <pre>curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"</pre>
|
* <pre>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"</pre>
|
||||||
|
*
|
||||||
|
* <p>Nur mit dem Profil {@link MockProfile#NAME} vorhanden – im Normalbetrieb
|
||||||
|
* gibt es {@code /api/mock/**} nicht.
|
||||||
*/
|
*/
|
||||||
|
@Profile(MockProfile.NAME)
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/mock")
|
@RequestMapping("/api/mock")
|
||||||
public class MockController {
|
public class MockController {
|
||||||
@@ -29,4 +37,18 @@ public class MockController {
|
|||||||
@RequestParam(required = false) String name) {
|
@RequestParam(required = false) String name) {
|
||||||
return mock.simulateIncomingCall(number, 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.
|
||||||
|
*
|
||||||
|
* <pre>curl -X POST "http://localhost:8080/api/mock/address-cache?filled=false"</pre>
|
||||||
|
*
|
||||||
|
* @return Anzahl der Einträge im Cache danach
|
||||||
|
*/
|
||||||
|
@PostMapping("/address-cache")
|
||||||
|
public int addressCache(@RequestParam(defaultValue = "true") boolean filled) {
|
||||||
|
return mock.setAddressCacheFilled(filled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <pre>curl -X POST https://swyxweb.appcreation.de/api/webhook \
|
||||||
|
* -H "Content-Type: application/json" \
|
||||||
|
* -H "X-Webhook-Token: …" \
|
||||||
|
* -d '{"name":"Muster GmbH","number":"+493012345"}'</pre>
|
||||||
|
*
|
||||||
|
* <p>Angenommen wird <b>beliebiges</b> 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.
|
||||||
|
*
|
||||||
|
* <p>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<SseEmitter> 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<WebhookEvent> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Der Weg zum Browser sind <b>Server-Sent Events</b>: 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.
|
||||||
|
*
|
||||||
|
* <p>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<WebhookEvent> events = new ArrayDeque<>();
|
||||||
|
private final List<SseEmitter> 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<WebhookEvent> 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<WebhookEvent> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}}.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import java.io.IOException;
|
|||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -13,8 +14,11 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||||||
import tools.jackson.databind.JsonNode;
|
import tools.jackson.databind.JsonNode;
|
||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import de.appcreation.swyxweb.config.MockProfile;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.socket.CloseStatus;
|
import org.springframework.web.socket.CloseStatus;
|
||||||
import org.springframework.web.socket.TextMessage;
|
import org.springframework.web.socket.TextMessage;
|
||||||
@@ -38,7 +42,24 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
|
|||||||
* <p>Seit Protokoll 6 beantwortet der Mock zusätzlich {@code tabs}, {@code opentab} und
|
* <p>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;
|
* {@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.
|
* der Mock spielt das Plugin und führt eine Liste vorgetäuschter Tabs im Speicher.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>Seit Protokoll 8 gibt er dasselbe Telefonbuch als <b>Adress-Cache</b> 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.
|
||||||
|
*
|
||||||
|
* <p><b>Nur mit dem Profil {@link MockProfile#NAME}.</b> 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
|
@Component
|
||||||
public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||||
|
|
||||||
@@ -51,8 +72,17 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
|||||||
private static final List<String> MOCK_WINDOWS = List.of(
|
private static final List<String> MOCK_WINDOWS = List.of(
|
||||||
"SwyxIt!", "SwyxWeb – Google Chrome", "Kundenakte Muster GmbH");
|
"SwyxIt!", "SwyxWeb – Google Chrome", "Kundenakte Muster GmbH");
|
||||||
|
|
||||||
/** Protokollstand der echten App seit der Tab-Verwaltung. */
|
/** Protokollstand der echten App seit dem Adress-Cache. */
|
||||||
private static final int PROTOCOL_VERSION = 6;
|
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<MockContact> MOCK_CONTACTS = buildDirectory();
|
||||||
|
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
|
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
|
||||||
@@ -61,6 +91,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
|||||||
private final Map<Integer, MockTab> tabs = Collections.synchronizedMap(new LinkedHashMap<>());
|
private final Map<Integer, MockTab> tabs = Collections.synchronizedMap(new LinkedHashMap<>());
|
||||||
private final AtomicInteger nextTabId = new AtomicInteger(41);
|
private final AtomicInteger nextTabId = new AtomicInteger(41);
|
||||||
private final AtomicInteger nextSession = new AtomicInteger();
|
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.
|
// Serialisiert Begrüßung und Snapshot, damit die Reihenfolge garantiert ist.
|
||||||
private final Object sendLock = new Object();
|
private final Object sendLock = new Object();
|
||||||
|
|
||||||
@@ -81,6 +113,8 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
|||||||
synchronized (sendLock) {
|
synchronized (sendLock) {
|
||||||
sendText(session, toJson(hello()));
|
sendText(session, toJson(hello()));
|
||||||
sendText(session, toJson(snapshot()));
|
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)));
|
sendText(session, toJson(result(id, true, null, null)));
|
||||||
return;
|
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 */ }
|
default -> { /* weiter unten */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +305,89 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
|||||||
return tab;
|
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.
|
||||||
|
*
|
||||||
|
* <p>Drei Sonderfälle sind absichtlich dabei:
|
||||||
|
* <ul>
|
||||||
|
* <li>dieselbe Person mit <b>zwei Durchwahlen</b> – Namen sind nicht eindeutig,</li>
|
||||||
|
* <li>Einträge <b>ohne Beschreibung</b> (die echte App schickt dafür {@code ""}),</li>
|
||||||
|
* <li>eine <b>einstellige</b> Rufnummer, die in keinem Ziffernpaar vorkommt und
|
||||||
|
* deshalb nur über die einstelligen Abfragen gefunden wird.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private static List<MockContact> buildDirectory() {
|
||||||
|
List<String> 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<String> given = List.of("Anna", "Bernd", "Claudia", "Dirk", "Erika");
|
||||||
|
List<String> sites = List.of("HH-MT", "B-SB", "HB-GT", "L-SB", "S-MT");
|
||||||
|
|
||||||
|
List<MockContact> 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<MockContact> 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<MockContact> 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<MockContact> 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. */
|
/** Löst einen eingehenden Anruf aus: Leitung belegen, Snapshot verschicken. */
|
||||||
public LineState simulateIncomingCall(String number, String name) {
|
public LineState simulateIncomingCall(String number, String name) {
|
||||||
int line = freeLine();
|
int line = freeLine();
|
||||||
@@ -364,6 +490,34 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Quittung auf {@code contacts}; trägt die gefundenen Adressdaten. */
|
||||||
|
private Map<String, Object> contactsResult(int id, List<MockContact> entries) {
|
||||||
|
Map<String, Object> 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<String, Object> addressesResult(int id, List<MockContact> entries) {
|
||||||
|
Map<String, Object> 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<String, Object> addressesPush() {
|
||||||
|
Map<String, Object> message = new LinkedHashMap<>();
|
||||||
|
message.put("addresses", addressCache());
|
||||||
|
message.put("type", "addresses");
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, Object> result(int id, boolean ok, Integer line, String error) {
|
private Map<String, Object> result(int id, boolean ok, Integer line, String error) {
|
||||||
Map<String, Object> message = new LinkedHashMap<>();
|
Map<String, Object> message = new LinkedHashMap<>();
|
||||||
message.put("id", id);
|
message.put("id", id);
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
spring.application.name=swyxweb-backend
|
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.address=0.0.0.0
|
||||||
server.port=8080
|
server.port=8080
|
||||||
|
|
||||||
@@ -11,4 +18,16 @@ app.websocket.port=17654
|
|||||||
app.websocket.path=/ws
|
app.websocket.path=/ws
|
||||||
app.websocket.secure=false
|
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
|
logging.level.de.appcreation.swyxweb=DEBUG
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
package de.appcreation.swyxweb;
|
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.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
class BackendApplicationTests {
|
class BackendApplicationTests {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
ApplicationContext context;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void contextLoads() {
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Directory | null>
|
||||||
|
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<Directory | null>(null)
|
||||||
|
const [progress, setProgress] = useState<DirectoryProgress | null>(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<AbortController | null>(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 (
|
||||||
|
<>
|
||||||
|
<div className="card__header">
|
||||||
|
<h2>Adressdaten</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--ghost"
|
||||||
|
onClick={() => void load()}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Lädt …' : 'Neu laden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{disabled ? (
|
||||||
|
<p className="note">Erst mit der SwyxTray-App verbinden.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 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. */}
|
||||||
|
<div className="row">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field__label">Filtern</span>
|
||||||
|
<input
|
||||||
|
className="field__input"
|
||||||
|
type="search"
|
||||||
|
value={filter}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Name, Rufnummer oder Kürzel"
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilter(e.target.value)
|
||||||
|
remember(STORAGE_FILTER, e.target.value.trim())
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{directory === null ? (
|
||||||
|
<p className="note">{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="note">
|
||||||
|
{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`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!directory.complete && (
|
||||||
|
<p className="note note--error">
|
||||||
|
{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.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<p className="note">Kein Eintrag zu „{filter.trim()}".</p>
|
||||||
|
) : (
|
||||||
|
<ul className="contacts">
|
||||||
|
{visible.map((contact, index) => (
|
||||||
|
// Namen sind nicht eindeutig – dieselbe Person kommt mit
|
||||||
|
// mehreren Durchwahlen vor; deshalb die Position mit hinein.
|
||||||
|
<li
|
||||||
|
key={`${contact.number ?? ''}-${contact.name ?? ''}-${index}`}
|
||||||
|
className="contacts__item"
|
||||||
|
>
|
||||||
|
<span className="contacts__info">
|
||||||
|
<span className="contacts__name">{describeContact(contact)}</span>
|
||||||
|
{contact.description && (
|
||||||
|
<span className="contacts__description">{contact.description}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{contact.number && <span className="contacts__number">{contact.number}</span>}
|
||||||
|
{contact.number && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--small"
|
||||||
|
onClick={() => onDial(contact.number!)}
|
||||||
|
>
|
||||||
|
Anrufen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="note">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<LoadingDialog
|
||||||
|
title="Adressdaten werden geladen"
|
||||||
|
done={progress.done}
|
||||||
|
total={progress.total}
|
||||||
|
detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined}
|
||||||
|
onCancel={() => abortRef.current?.abort()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 `<dialog>`:
|
||||||
|
* 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<HTMLButtonElement>(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 (
|
||||||
|
<div className="overlay">
|
||||||
|
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="loading-dialog-title">
|
||||||
|
<h2 className="dialog__title" id="loading-dialog-title">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="progress"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={total > 0 ? done : undefined}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={total > 0 ? total : undefined}
|
||||||
|
aria-valuetext={total > 0 ? `${done} von ${total}` : 'läuft'}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`progress__bar${total > 0 ? '' : ' progress__bar--unknown'}`}
|
||||||
|
style={total > 0 ? { width: `${percent}%` } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="dialog__status" aria-live="polite">
|
||||||
|
{total > 0 ? `${done} von ${total} Abfragen` : 'Abfragen laufen …'}
|
||||||
|
{detail && <span className="dialog__detail">{detail}</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{onCancel && (
|
||||||
|
<div className="dialog__actions">
|
||||||
|
<button ref={cancelRef} type="button" className="button" onClick={onCancel}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export interface Tab<Id extends string> {
|
export interface Tab<Id extends string> {
|
||||||
id: Id
|
id: Id
|
||||||
label: string
|
label: string
|
||||||
|
/** Anzahl noch nicht gesehener Nachrichten; 0 oder fehlend blendet ihn aus. */
|
||||||
|
badge?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props<Id extends string> {
|
interface Props<Id extends string> {
|
||||||
@@ -35,6 +37,11 @@ export default function Tabs<Id extends string>({ tabs, active, onChange }: Prop
|
|||||||
onClick={() => onChange(tab.id)}
|
onClick={() => onChange(tab.id)}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
{tab.badge ? (
|
||||||
|
<span className="tabs__badge" aria-label={`${tab.badge} neu`}>
|
||||||
|
{tab.badge > 99 ? '99+' : tab.badge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -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<WebhookStatus, string> = {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="card__header">
|
||||||
|
<h2>Webhook</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--ghost"
|
||||||
|
onClick={onClear}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
>
|
||||||
|
Leeren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={`note${status === 'closed' ? ' note--error' : ''}`}>{STATUS_TEXT[status]}</p>
|
||||||
|
{error && <p className="note note--error">{error}</p>}
|
||||||
|
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="note">
|
||||||
|
Noch keine Nachricht eingegangen. Ein fremdes System schickt Adressdaten an{' '}
|
||||||
|
<code>POST /api/webhook</code>; sie erscheinen hier ohne Zutun.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="webhook">
|
||||||
|
{events.map((event) => (
|
||||||
|
<li key={event.id} className="webhook__item">
|
||||||
|
<div className="webhook__meta">
|
||||||
|
<span className="webhook__time">{describeTime(event.receivedAt)}</span>
|
||||||
|
<span className="webhook__id">#{event.id}</span>
|
||||||
|
<span className="webhook__summary">{summarize(event.payload)}</span>
|
||||||
|
</div>
|
||||||
|
<pre className="webhook__json">{formatPayload(event.payload)}</pre>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="note">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { SwyxTrayClient, type ConnectionStatus, type RawMessage } from '../swyx/SwyxTrayClient'
|
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 {
|
export interface LogEntry extends RawMessage {
|
||||||
id: number
|
id: number
|
||||||
@@ -31,6 +32,8 @@ export function useSwyxTray() {
|
|||||||
// Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu.
|
// Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu.
|
||||||
const [lines, setLines] = useState<Record<number, CallEvent>>({})
|
const [lines, setLines] = useState<Record<number, CallEvent>>({})
|
||||||
const [tray, setTray] = useState<TrayState>({})
|
const [tray, setTray] = useState<TrayState>({})
|
||||||
|
// Adress-Cache der App; null heißt „noch keine Cache-Nachricht erhalten".
|
||||||
|
const [addressCache, setAddressCache] = useState<Contact[] | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
const clientRef = useRef<SwyxTrayClient | null>(null)
|
const clientRef = useRef<SwyxTrayClient | null>(null)
|
||||||
@@ -54,9 +57,14 @@ export function useSwyxTray() {
|
|||||||
linesRef.current = {}
|
linesRef.current = {}
|
||||||
setLines({})
|
setLines({})
|
||||||
setTray({})
|
setTray({})
|
||||||
|
// Auch der Cache gehörte zur alten Verbindung; die App schickt ihn
|
||||||
|
// beim Verbinden erneut.
|
||||||
|
setAddressCache(null)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
client.on('error', (message) => setError(message)),
|
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) => {
|
client.on('raw', (message) => {
|
||||||
setLog((entries) => {
|
setLog((entries) => {
|
||||||
const next = [...entries, { ...message, id: logIdRef.current++ }]
|
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 openTab = useCallback((url: string) => run(() => client.openTab(url)), [client, run])
|
||||||
const closeTab = useCallback((tabId: number) => run(() => client.closeTab(tabId)), [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(
|
const sendRaw = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -157,6 +185,7 @@ export function useSwyxTray() {
|
|||||||
calls,
|
calls,
|
||||||
ringingCall,
|
ringingCall,
|
||||||
tray,
|
tray,
|
||||||
|
addressCache,
|
||||||
busy,
|
busy,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
@@ -168,6 +197,7 @@ export function useSwyxTray() {
|
|||||||
listTabs,
|
listTabs,
|
||||||
openTab,
|
openTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
loadDirectory,
|
||||||
sendRaw,
|
sendRaw,
|
||||||
clearLog,
|
clearLog,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<WebhookEvent[]>([])
|
||||||
|
const [status, setStatus] = useState<WebhookStatus>('connecting')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const sourceRef = useRef<EventSource | null>(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<string>).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 }
|
||||||
|
}
|
||||||
@@ -547,6 +547,127 @@ code {
|
|||||||
white-space: nowrap;
|
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 {
|
.log {
|
||||||
height: 300px;
|
height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -595,3 +716,82 @@ code {
|
|||||||
.log__entry--system {
|
.log__entry--system {
|
||||||
color: var(--text-muted);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,18 +2,24 @@ import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'rea
|
|||||||
import { DEFAULT_WS_URL, fetchClientConfig } from '../config'
|
import { DEFAULT_WS_URL, fetchClientConfig } from '../config'
|
||||||
import { useSwyxTray } from '../hooks/useSwyxTray'
|
import { useSwyxTray } from '../hooks/useSwyxTray'
|
||||||
import { useCallNotifications } from '../hooks/useCallNotifications'
|
import { useCallNotifications } from '../hooks/useCallNotifications'
|
||||||
|
import { useWebhook } from '../hooks/useWebhook'
|
||||||
|
import { countUnseen } from '../webhook'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
import MessageLog from '../components/MessageLog'
|
import MessageLog from '../components/MessageLog'
|
||||||
import IncomingCallCard from '../components/IncomingCallCard'
|
import IncomingCallCard from '../components/IncomingCallCard'
|
||||||
import DialPanel from '../components/DialPanel'
|
import DialPanel from '../components/DialPanel'
|
||||||
import BrowserTabsPanel from '../components/BrowserTabsPanel'
|
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 Tabs, { panelId, tabId, type Tab } from '../components/Tabs'
|
||||||
import { ActiveCallList, CallHistory } from '../components/CallList'
|
import { ActiveCallList, CallHistory } from '../components/CallList'
|
||||||
|
|
||||||
type TabName = 'calls' | 'browsertabs' | 'connection'
|
type TabName = 'calls' | 'contacts' | 'webhook' | 'browsertabs' | 'connection'
|
||||||
|
|
||||||
const TABS: Tab<TabName>[] = [
|
const TABS: Tab<TabName>[] = [
|
||||||
{ id: 'calls', label: 'Anrufe' },
|
{ id: 'calls', label: 'Anrufe' },
|
||||||
|
{ id: 'contacts', label: 'Adressdaten' },
|
||||||
|
{ id: 'webhook', label: 'Webhook' },
|
||||||
{ id: 'browsertabs', label: 'Tabs' },
|
{ id: 'browsertabs', label: 'Tabs' },
|
||||||
{ id: 'connection', label: 'Verbindung' },
|
{ id: 'connection', label: 'Verbindung' },
|
||||||
]
|
]
|
||||||
@@ -40,6 +46,25 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const { permission, requestPermission } = useCallNotifications(ringingCall)
|
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.
|
// Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren.
|
||||||
const urlTouched = useRef(false)
|
const urlTouched = useRef(false)
|
||||||
|
|
||||||
@@ -93,7 +118,7 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
<Tabs tabs={tabsWithBadges} active={tab} onChange={setTab} />
|
||||||
|
|
||||||
<TabPanel id="calls" active={tab === 'calls'}>
|
<TabPanel id="calls" active={tab === 'calls'}>
|
||||||
<section className="card">
|
<section className="card">
|
||||||
@@ -130,6 +155,32 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel id="contacts" active={tab === 'contacts'}>
|
||||||
|
<section className="card">
|
||||||
|
<ContactsPanel
|
||||||
|
active={tab === 'contacts'}
|
||||||
|
disabled={!isConnected}
|
||||||
|
cache={tray.addressCache}
|
||||||
|
// Unverpackt weitergereicht: der Rückruf ist stabil, damit das
|
||||||
|
// Panel nicht bei jedem Rendern neu lädt.
|
||||||
|
onLoad={tray.loadDirectory}
|
||||||
|
onDial={(number) => void tray.dial(number)}
|
||||||
|
/>
|
||||||
|
{error && <p className="note note--error">{error}</p>}
|
||||||
|
</section>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel id="webhook" active={tab === 'webhook'}>
|
||||||
|
<section className="card">
|
||||||
|
<WebhookPanel
|
||||||
|
events={webhook.events}
|
||||||
|
status={webhook.status}
|
||||||
|
error={webhook.error}
|
||||||
|
onClear={() => void webhook.clear()}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel id="browsertabs" active={tab === 'browsertabs'}>
|
<TabPanel id="browsertabs" active={tab === 'browsertabs'}>
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<BrowserTabsPanel
|
<BrowserTabsPanel
|
||||||
@@ -240,7 +291,8 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<footer className="page__footer">
|
<footer className="page__footer">
|
||||||
SwyxTray · Kommandos <code>call</code> / <code>answer</code> / <code>hangup</code> /{' '}
|
SwyxTray · Kommandos <code>call</code> / <code>answer</code> / <code>hangup</code> /{' '}
|
||||||
<code>tabs</code> / <code>opentab</code> / <code>closetab</code> · Zustand über{' '}
|
<code>tabs</code> / <code>opentab</code> / <code>closetab</code> / <code>contacts</code> ·
|
||||||
|
Zustand über{' '}
|
||||||
<code>snapshot</code> · Standardziel <code>{DEFAULT_WS_URL}</code>
|
<code>snapshot</code> · Standardziel <code>{DEFAULT_WS_URL}</code>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
COMMANDS,
|
COMMANDS,
|
||||||
|
CONTACT_QUERY_MAX_LENGTH,
|
||||||
|
parseAddresses,
|
||||||
parseHello,
|
parseHello,
|
||||||
parseResult,
|
parseResult,
|
||||||
parseSnapshot,
|
parseSnapshot,
|
||||||
|
type Contact,
|
||||||
type HelloMessage,
|
type HelloMessage,
|
||||||
type ResultMessage,
|
type ResultMessage,
|
||||||
type SnapshotMessage,
|
type SnapshotMessage,
|
||||||
@@ -20,6 +23,8 @@ interface Listeners {
|
|||||||
status: (status: ConnectionStatus) => void
|
status: (status: ConnectionStatus) => void
|
||||||
hello: (hello: HelloMessage) => void
|
hello: (hello: HelloMessage) => void
|
||||||
snapshot: (snapshot: SnapshotMessage) => void
|
snapshot: (snapshot: SnapshotMessage) => void
|
||||||
|
/** Unaufgeforderter Adress-Cache der App; auch eine leere Liste zählt. */
|
||||||
|
addresses: (contacts: Contact[]) => void
|
||||||
raw: (message: RawMessage) => void
|
raw: (message: RawMessage) => void
|
||||||
error: (message: string) => void
|
error: (message: string) => void
|
||||||
}
|
}
|
||||||
@@ -50,6 +55,7 @@ export class SwyxTrayClient {
|
|||||||
status: new Set(),
|
status: new Set(),
|
||||||
hello: new Set(),
|
hello: new Set(),
|
||||||
snapshot: new Set(),
|
snapshot: new Set(),
|
||||||
|
addresses: new Set(),
|
||||||
raw: new Set(),
|
raw: new Set(),
|
||||||
error: new Set(),
|
error: new Set(),
|
||||||
}
|
}
|
||||||
@@ -179,6 +185,35 @@ export class SwyxTrayClient {
|
|||||||
return this.send(COMMANDS.closeTab, { tabId }, TAB_TIMEOUT_MS)
|
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<ResultMessage> {
|
||||||
|
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<ResultMessage> {
|
||||||
|
return this.send(COMMANDS.addresses)
|
||||||
|
}
|
||||||
|
|
||||||
ping(): Promise<ResultMessage> {
|
ping(): Promise<ResultMessage> {
|
||||||
return this.send(COMMANDS.ping)
|
return this.send(COMMANDS.ping)
|
||||||
}
|
}
|
||||||
@@ -270,14 +305,22 @@ export class SwyxTrayClient {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die App schickt keine Ereignisse: Jede Änderung – auch ein eingehender
|
// Die App schickt keine Anruf-Ereignisse: Jede Änderung – auch ein
|
||||||
// Anruf – kommt als vollständiger Snapshot.
|
// eingehender Anruf – kommt als vollständiger Snapshot.
|
||||||
const snapshot = parseSnapshot(parsed)
|
const snapshot = parseSnapshot(parsed)
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
this.emit('snapshot', snapshot)
|
this.emit('snapshot', snapshot)
|
||||||
return
|
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)
|
const hello = parseHello(parsed)
|
||||||
if (hello) {
|
if (hello) {
|
||||||
this.emit('hello', hello)
|
this.emit('hello', hello)
|
||||||
|
|||||||
@@ -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<Contact[]> => {
|
||||||
|
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]])
|
||||||
|
})
|
||||||
|
})
|
||||||
Binary file not shown.
@@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
callStateOf,
|
callStateOf,
|
||||||
describeCall,
|
describeCall,
|
||||||
|
describeContact,
|
||||||
describeTab,
|
describeTab,
|
||||||
|
parseAddresses,
|
||||||
mergeSnapshot,
|
mergeSnapshot,
|
||||||
parseHello,
|
parseHello,
|
||||||
parseResult,
|
parseResult,
|
||||||
|
parseContacts,
|
||||||
parseSnapshot,
|
parseSnapshot,
|
||||||
parseTabs,
|
parseTabs,
|
||||||
type CallEvent,
|
type CallEvent,
|
||||||
@@ -79,6 +82,22 @@ describe('parseHello', () => {
|
|||||||
?.protocol,
|
?.protocol,
|
||||||
).toBe(6)
|
).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', () => {
|
describe('callStateOf', () => {
|
||||||
@@ -296,6 +315,56 @@ describe('parseResult', () => {
|
|||||||
const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))
|
const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))
|
||||||
expect(result?.tabs).toBeUndefined()
|
expect(result?.tabs).toBeUndefined()
|
||||||
expect(result?.tabId).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', () => {
|
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', () => {
|
describe('describeCall', () => {
|
||||||
it('bevorzugt die fertige Anzeigeform der App', () => {
|
it('bevorzugt die fertige Anzeigeform der App', () => {
|
||||||
expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe(
|
expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Protokoll der SwyxTray-App, aufgezeichnet gegen `ws://192.168.180.135:17654/ws`
|
* 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
|
* (App-Version 1.0.0.0, `protocol: 1`; mit der Tab-Verwaltung meldete die App
|
||||||
* `protocol: 6`).
|
* `protocol: 6`, mit der Adresssuche `protocol: 7`, mit dem Adress-Cache `protocol: 8`).
|
||||||
*
|
*
|
||||||
* App → Seite:
|
* App → Seite:
|
||||||
* { "app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello" }
|
* { "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 }
|
* { "id":14,"cmd":"closetab","tabId":43 } → { …,"ok":true }
|
||||||
* Ist kein Plugin verbunden, antwortet die App nach rund fünf Sekunden mit `ok:false`.
|
* 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.
|
* eingehender Anruf – kommt als vollständiger `snapshot` über *alle* Leitungen.
|
||||||
* Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots
|
* Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots
|
||||||
* (siehe {@link mergeSnapshot}).
|
* (siehe {@link mergeSnapshot}).
|
||||||
@@ -66,6 +89,10 @@ export interface ResultMessage {
|
|||||||
tabs?: BrowserTab[]
|
tabs?: BrowserTab[]
|
||||||
/** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */
|
/** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */
|
||||||
tabId?: number
|
tabId?: number
|
||||||
|
/** Nur bei `contacts`: die gefundenen Adressdaten. */
|
||||||
|
contacts?: Contact[]
|
||||||
|
/** Nur bei `addresses`: der gesamte Adress-Cache der App. */
|
||||||
|
addresses?: Contact[]
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +106,26 @@ export interface BrowserTab {
|
|||||||
active: boolean
|
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. */
|
/** Begrüßung beim Verbindungsaufbau. */
|
||||||
export interface HelloMessage {
|
export interface HelloMessage {
|
||||||
app?: string
|
app?: string
|
||||||
@@ -115,6 +162,8 @@ export const COMMANDS = {
|
|||||||
tabs: 'tabs',
|
tabs: 'tabs',
|
||||||
openTab: 'opentab',
|
openTab: 'opentab',
|
||||||
closeTab: 'closetab',
|
closeTab: 'closetab',
|
||||||
|
contacts: 'contacts',
|
||||||
|
addresses: 'addresses',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -207,6 +256,8 @@ export function parseResult(raw: unknown): ResultMessage | null {
|
|||||||
focused: asBoolean(raw.focused),
|
focused: asBoolean(raw.focused),
|
||||||
tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined,
|
tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined,
|
||||||
tabId: asLine(raw.tabId),
|
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),
|
error: asString(raw.error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +287,35 @@ export function describeTab(tab: BrowserTab): string {
|
|||||||
return tab.title ?? tab.url ?? `Tab ${tab.id}`
|
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. */
|
/** Liest die Begrüßung. */
|
||||||
export function parseHello(raw: unknown): HelloMessage | null {
|
export function parseHello(raw: unknown): HelloMessage | null {
|
||||||
if (!isRecord(raw) || raw.type !== 'hello') return null
|
if (!isRecord(raw) || raw.type !== 'hello') return null
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<WebhookEvent>
|
||||||
|
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<string, unknown>
|
||||||
|
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<WebhookEvent[]> {
|
||||||
|
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<void> {
|
||||||
|
const response = await fetch('/api/webhook/history', { method: 'DELETE' })
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Verlauf konnte nicht geleert werden (HTTP ${response.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user