Adressdaten aus dem Swyx-Client; core-Modul und Firefox-Plugin ausgelagert
SwyxTray liest jetzt die Telefonbuecher (global und persoenlich) des SwyxIt!-Clients in einen Adress-Cache, bietet Kontaktsuche ueber FulltextSearchInContactsEx und Nummernaufloesung ueber DispResolveNumber; alles ueber den WebSocket-Zugang abrufbar. Das Spring-Boot-Modul core/ und das SwyxFFPlugin werden als eigene Projekte gepflegt und aus diesem Repository entfernt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vendored
+3
-55
@@ -1,62 +1,10 @@
|
|||||||
{
|
{
|
||||||
// Startkonfigurationen aller Teilprojekte: Java-Core, Flutter-App und
|
// Startkonfigurationen fuer die SwyxTray-App (C#/.NET, nur Windows).
|
||||||
// SwyxTray. Die SwyxTray-App hat kein Fenster: sichtbar ist nur das Symbol
|
// Die App hat kein Fenster: sichtbar ist nur das Symbol im Infobereich der
|
||||||
// im Infobereich der Taskleiste, alles Weitere steht im Protokoll unter
|
// Taskleiste, alles Weitere steht im Protokoll unter
|
||||||
// %LOCALAPPDATA%\SwyxTray\swyxtray.log.
|
// %LOCALAPPDATA%\SwyxTray\swyxtray.log.
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
|
||||||
"type": "java",
|
|
||||||
"name": "Spring Boot: Swyx Core",
|
|
||||||
"request": "launch",
|
|
||||||
"cwd": "${workspaceFolder}/core",
|
|
||||||
"mainClass": "de.assecutor.swyx.SwyxCoreApplication",
|
|
||||||
"projectName": "swyx-core",
|
|
||||||
"args": [],
|
|
||||||
"vmArgs": "-Dspring.profiles.active=dev -Dspring.devtools.restart.enabled=true",
|
|
||||||
"env": {
|
|
||||||
"SERVER_PORT": "8080"
|
|
||||||
},
|
|
||||||
"console": "internalConsole"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "java",
|
|
||||||
"name": "Spring Boot: Swyx Core (Production)",
|
|
||||||
"request": "launch",
|
|
||||||
"cwd": "${workspaceFolder}/core",
|
|
||||||
"mainClass": "de.assecutor.swyx.SwyxCoreApplication",
|
|
||||||
"projectName": "swyx-core",
|
|
||||||
"args": [],
|
|
||||||
"vmArgs": "-Dspring.profiles.active=prod -Dvaadin.productionMode=true",
|
|
||||||
"env": {
|
|
||||||
"SERVER_PORT": "8080"
|
|
||||||
},
|
|
||||||
"console": "internalConsole"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "dart",
|
|
||||||
"name": "Flutter: Swyx App (Debug)",
|
|
||||||
"request": "launch",
|
|
||||||
"program": "${workspaceFolder}/app/lib/main.dart",
|
|
||||||
"cwd": "${workspaceFolder}/app",
|
|
||||||
"flutterMode": "debug"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "dart",
|
|
||||||
"name": "Flutter: Swyx App (Profile)",
|
|
||||||
"request": "launch",
|
|
||||||
"program": "${workspaceFolder}/app/lib/main.dart",
|
|
||||||
"cwd": "${workspaceFolder}/app",
|
|
||||||
"flutterMode": "profile"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "dart",
|
|
||||||
"name": "Flutter: Swyx App (Release)",
|
|
||||||
"request": "launch",
|
|
||||||
"program": "${workspaceFolder}/app/lib/main.dart",
|
|
||||||
"cwd": "${workspaceFolder}/app",
|
|
||||||
"flutterMode": "release"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
// Der Standardweg — funktioniert auch, wenn VS Code remote verbunden
|
// Der Standardweg — funktioniert auch, wenn VS Code remote verbunden
|
||||||
// ist: eine geplante Aufgabe startet die App in der Desktop-Sitzung des
|
// ist: eine geplante Aufgabe startet die App in der Desktop-Sitzung des
|
||||||
|
|||||||
@@ -26,18 +26,8 @@ laufenden SwyxIt!-Client verbindet.
|
|||||||
Browser laufende Seite wählen, annehmen und auflegen kann und im Gegenzug
|
Browser laufende Seite wählen, annehmen und auflegen kann und im Gegenzug
|
||||||
jede Zustandsänderung zugestellt bekommt (siehe unten).
|
jede Zustandsänderung zugestellt bekommt (siehe unten).
|
||||||
- Kontextmenü (Rechtsklick): Status, belegte Leitungen, *WebSocket-Zugang*,
|
- Kontextmenü (Rechtsklick): Status, belegte Leitungen, *WebSocket-Zugang*,
|
||||||
*Lese Tabs*, *Tab öffnen*, *Tab schliessen*, *Neu verbinden*,
|
*Neu verbinden*, *Protokoll öffnen*, *Beenden*. Doppelklick zeigt den
|
||||||
*Protokoll öffnen*, *Beenden*. Doppelklick zeigt den Status als Sprechblase.
|
Status als Sprechblase.
|
||||||
|
|
||||||
Die drei Tab-Punkte laufen über den Tab-Verwaltungskanal zum Firefox-Plugin
|
|
||||||
(siehe unten); ohne verbundenes Plugin (oder mit `PluginPort: 0`) bleiben
|
|
||||||
sie ohne Ergebnis bzw. grau. *Lese Tabs* holt die Titel der offenen Tabs
|
|
||||||
und zeigt sie als Sprechblase — die vollständige Liste samt Adressen landet
|
|
||||||
im Protokoll. *Tab öffnen* öffnet einen Tab mit `https://www.assecutor.de`,
|
|
||||||
*Tab schliessen* schließt alle Tabs, deren Titel „Assecutor Data Service
|
|
||||||
GmbH" enthält — als Komposition aus `list` und `close` je Treffer, wie beim
|
|
||||||
`focus`-Kommando (beide Werte sind Konstanten in
|
|
||||||
[TrayApplicationContext.cs](src/SwyxTray/TrayApplicationContext.cs)).
|
|
||||||
|
|
||||||
## Verwendetes SDK
|
## Verwendetes SDK
|
||||||
|
|
||||||
@@ -59,8 +49,16 @@ Genutzte Schnittstellen:
|
|||||||
|
|
||||||
- `IClientLineMgrDisp` — `DispInit`, `DispRegisterUser`, `DispNumberOfLines`,
|
- `IClientLineMgrDisp` — `DispInit`, `DispRegisterUser`, `DispNumberOfLines`,
|
||||||
`DispGetLine`, `DispSelectedLineNumber`, `DispIsServerUp`,
|
`DispGetLine`, `DispSelectedLineNumber`, `DispIsServerUp`,
|
||||||
`DispGetCurrentUser`, `DispGetCurrentServer`, `DispReleaseUser`
|
`DispGetCurrentUser`, `DispGetCurrentServer`, `DispReleaseUser`,
|
||||||
|
`FulltextSearchInContactsEx`, `DispResolveNumber`, `DispClientConfig`
|
||||||
- `IClientLineDisp` — `DispState`, `DispPeerNumber`, `DispPeerName`
|
- `IClientLineDisp` — `DispState`, `DispPeerNumber`, `DispPeerName`
|
||||||
|
- `IClientConfig` — `PbxPhoneBookEnumerator`, `UserPhoneBookEnumerator`
|
||||||
|
(globales und persönliches Telefonbuch für den Adress-Cache)
|
||||||
|
- `INameNumberSearchResult(Collection)` — Ergebnistyp der Kontaktsuche.
|
||||||
|
Die Telefonbuch-Sammlungen und ihre Einträge kommen dagegen **nicht** als
|
||||||
|
`IDispCollection`/`IPbxPhoneBookEntryDisp` an, sondern als reine
|
||||||
|
IDispatch-Objekte — sie werden per `IEnumerable` und Late-Binding gelesen
|
||||||
|
(siehe „Stand der Prüfung")
|
||||||
- `IClientLineMgrEventsPub_Event` — `PubOnLineMgrNotification(msg, param)`
|
- `IClientLineMgrEventsPub_Event` — `PubOnLineMgrNotification(msg, param)`
|
||||||
- `CLMgrLineStates` — die Leitungszustände als typisierte Aufzählung
|
- `CLMgrLineStates` — die Leitungszustände als typisierte Aufzählung
|
||||||
|
|
||||||
@@ -83,7 +81,6 @@ Genutzte Schnittstellen:
|
|||||||
| [Web/Protocol.cs](src/SwyxTray/Web/Protocol.cs) | die JSON-Nachrichten |
|
| [Web/Protocol.cs](src/SwyxTray/Web/Protocol.cs) | die JSON-Nachrichten |
|
||||||
| [Web/WebSocketConfig.cs](src/SwyxTray/Web/WebSocketConfig.cs) | Port, Token, erlaubte Origins |
|
| [Web/WebSocketConfig.cs](src/SwyxTray/Web/WebSocketConfig.cs) | Port, Token, erlaubte Origins |
|
||||||
| [examples/browser-client.html](examples/browser-client.html) | Beispielseite zum Erproben des Zugangs |
|
| [examples/browser-client.html](examples/browser-client.html) | Beispielseite zum Erproben des Zugangs |
|
||||||
| [src/SwyxFFPlugin/](src/SwyxFFPlugin/) | die Firefox-Erweiterung: [manifest.json](src/SwyxFFPlugin/manifest.json) und [background.js](src/SwyxFFPlugin/background.js) |
|
|
||||||
| [Log.cs](src/SwyxTray/Log.cs) | Dateiprotokoll unter `%LOCALAPPDATA%\SwyxTray\swyxtray.log` |
|
| [Log.cs](src/SwyxTray/Log.cs) | Dateiprotokoll unter `%LOCALAPPDATA%\SwyxTray\swyxtray.log` |
|
||||||
|
|
||||||
### Zwei Entwurfsentscheidungen
|
### Zwei Entwurfsentscheidungen
|
||||||
@@ -207,13 +204,12 @@ Hauptports (`focus`, `tabs`, `opentab`, `closetab`) enden dann mit
|
|||||||
SwyxTray einen vorhandenen Tab als Komposition nach vorn: `list`, passenden
|
SwyxTray einen vorhandenen Tab als Komposition nach vorn: `list`, passenden
|
||||||
Tab per `close` schließen und die URL per `open` neu öffnen.
|
Tab per `close` schließen und die URL per `open` neu öffnen.
|
||||||
|
|
||||||
Das Plugin selbst liegt in [src/SwyxFFPlugin/](src/SwyxFFPlugin/) —
|
Das Plugin selbst (SwyxFFPlugin) wird als **eigenes Projekt** gepflegt und ist
|
||||||
[background.js](src/SwyxFFPlugin/background.js) setzt genau diese drei
|
nicht Teil dieses Repositories. Sein `background.js` setzt genau diese drei
|
||||||
Aufträge um und verbindet sich selbsttätig neu (1 s Abstand, je Fehlversuch
|
Aufträge um und verbindet sich selbsttätig neu (1 s Abstand, je Fehlversuch
|
||||||
verdoppelt bis 30 s), falls SwyxTray noch nicht oder nicht mehr läuft.
|
verdoppelt bis 30 s), falls SwyxTray noch nicht oder nicht mehr läuft.
|
||||||
Installation zum Erproben: `about:debugging#/runtime/this-firefox` →
|
Installation zum Erproben: `about:debugging#/runtime/this-firefox` →
|
||||||
*Temporäres Add-on laden…* → die `manifest.json` des Ordners wählen (Näheres
|
*Temporäres Add-on laden…* → die `manifest.json` des Plugin-Projekts wählen.
|
||||||
im [README des Plugins](src/SwyxFFPlugin/README.md)).
|
|
||||||
|
|
||||||
Sind die Prüfungen wieder eingeschaltet, braucht die Adresse zusätzlich das
|
Sind die Prüfungen wieder eingeschaltet, braucht die Adresse zusätzlich das
|
||||||
Token (`ws://127.0.0.1:17655/?token=…`), und in `AllowedOrigins` muss der
|
Token (`ws://127.0.0.1:17655/?token=…`), und in `AllowedOrigins` muss der
|
||||||
@@ -233,7 +229,10 @@ wobei die UUID je Installation zufällig vergeben wird — sie steht in
|
|||||||
| `tabs` | — | liest die offenen Firefox-Tabs über das Plugin aus |
|
| `tabs` | — | liest die offenen Firefox-Tabs über das Plugin aus |
|
||||||
| `opentab` | `url` | öffnet über das Plugin einen neuen Firefox-Tab |
|
| `opentab` | `url` | öffnet über das Plugin einen neuen Firefox-Tab |
|
||||||
| `closetab` | `tabId` | schließt über das Plugin den Tab mit dieser Id |
|
| `closetab` | `tabId` | schließt über das Plugin den Tab mit dieser Id |
|
||||||
| `status` | — | fordert den vollen Zustand an |
|
| `addresses` | — | liefert die zwischengespeicherten Adressdaten (Telefonbücher), Antwort im Feld `addresses` |
|
||||||
|
| `contacts` | `query` | durchsucht die Adressdaten des Swyx-Clients (Telefonbücher und Kontakt-Plugins), Antwort im Feld `contacts` |
|
||||||
|
| `resolve` | `number` | löst eine Rufnummer über die Adressdaten in einen Namen auf, Antwort im Feld `name` (leer = unbekannt) |
|
||||||
|
| `status` | — | fordert den vollen Zustand samt der zwischengespeicherten Adressdaten an |
|
||||||
| `reconnect` | — | verwirft die CLMgr-Verbindung und baut sie neu auf |
|
| `reconnect` | — | verwirft die CLMgr-Verbindung und baut sie neu auf |
|
||||||
| `ping` | — | Lebenszeichen |
|
| `ping` | — | Lebenszeichen |
|
||||||
|
|
||||||
@@ -292,9 +291,10 @@ Tab-Liste von selbst — wer sie aktuell braucht, fragt mit `tabs` nach:
|
|||||||
|
|
||||||
### Meldungen (App → Seite)
|
### Meldungen (App → Seite)
|
||||||
|
|
||||||
Direkt nach dem Verbinden kommen `hello` und ein `snapshot`, danach ein
|
Direkt nach dem Verbinden kommen `hello`, ein `snapshot` und die
|
||||||
`snapshot` bei **jeder** Zustandsänderung. Die Seite muss also nie pollen und
|
zwischengespeicherten Adressdaten als `addresses`, danach ein `snapshot` bei
|
||||||
nie selbst Zustand mitführen:
|
**jeder** Zustandsänderung. Die Seite muss also nie pollen und nie selbst
|
||||||
|
Zustand mitführen:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"snapshot","connected":true,"serverUp":true,"overall":"Ringing",
|
{"type":"snapshot","connected":true,"serverUp":true,"overall":"Ringing",
|
||||||
@@ -306,6 +306,22 @@ nie selbst Zustand mitführen:
|
|||||||
|
|
||||||
`overall` ist derselbe Gesamtzustand, den auch die Farbe des Tray-Symbols zeigt.
|
`overall` ist derselbe Gesamtzustand, den auch die Farbe des Tray-Symbols zeigt.
|
||||||
|
|
||||||
|
**Adressdaten** (seit Protokollversion 8): Die App liest beim Verbindungsaufbau
|
||||||
|
zu SwyxIt! — also beim Start und bei jeder Neuverbindung — sowie danach **alle
|
||||||
|
60 Minuten** das globale und das persönliche Telefonbuch aus dem Swyx-Client
|
||||||
|
und hält sie im Speicher vor. Jede verbundene Seite bekommt den Stand als
|
||||||
|
`addresses`-Nachricht zugestellt: direkt nach dem `snapshot` beim Verbinden,
|
||||||
|
auf ein `status`-Kommando und unaufgefordert **nach jedem Neueinlesen**. Wer
|
||||||
|
den Cache selbst abfragen will, sendet das Kommando `addresses` und erhält
|
||||||
|
die Einträge im gleichnamigen Feld der Antwort. Bei einem Lesefehler behält
|
||||||
|
der Cache den letzten erfolgreichen Stand.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"addresses","addresses":[
|
||||||
|
{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"},
|
||||||
|
{"name":"Schmidt, Anna","number":"123","description":"Persoenliches Telefonbuch"}]}
|
||||||
|
```
|
||||||
|
|
||||||
Zusätzlich zum `snapshot` meldet die App **Anruf-Ereignisse** als eigene
|
Zusätzlich zum `snapshot` meldet die App **Anruf-Ereignisse** als eigene
|
||||||
Nachricht vom Typ `call` — damit muss eine Seite keine zwei Zustände
|
Nachricht vom Typ `call` — damit muss eine Seite keine zwei Zustände
|
||||||
vergleichen, um einen eingehenden Ruf zu erkennen:
|
vergleichen, um einen eingehenden Ruf zu erkennen:
|
||||||
@@ -536,6 +552,16 @@ der Ereignissenke, Auslesen aller vier Leitungen samt Zustand und ausgewählter
|
|||||||
Leitung, Ableitung des Gesamtzustands, Symbolerzeugung, Änderungserkennung
|
Leitung, Ableitung des Gesamtzustands, Symbolerzeugung, Änderungserkennung
|
||||||
(keine Doppelmeldungen über mehrere Zyklen) und das Dateiprotokoll.
|
(keine Doppelmeldungen über mehrere Zyklen) und das Dateiprotokoll.
|
||||||
|
|
||||||
|
**Adressdaten gegen den laufenden Client verifiziert:** Das globale Telefonbuch
|
||||||
|
wird mit allen **404 Einträgen** gelesen (`Adressdaten gelesen: 404 Einträge`,
|
||||||
|
knapp eine Sekunde nach dem Verbindungsaufbau). Dabei zeigte sich, dass CLMgr
|
||||||
|
die Telefonbücher als reine IDispatch-Objekte liefert: weder die Sammlung noch
|
||||||
|
ihre Einträge implementieren die typisierten Interop-Schnittstellen
|
||||||
|
(`IDispCollection`, `IPbxPhoneBookEntryDisp`) — ein Cast darauf scheitert still
|
||||||
|
und ergibt 0 Einträge. Gelesen wird deshalb per `IEnumerable` und Late-Binding
|
||||||
|
(`dynamic`), siehe `AppendPhoneBook` in
|
||||||
|
[Swyx/SwyxClient.cs](src/SwyxTray/Swyx/SwyxClient.cs).
|
||||||
|
|
||||||
**WebSocket-Zugang gegen die laufende App geprüft** (mit einem
|
**WebSocket-Zugang gegen die laufende App geprüft** (mit einem
|
||||||
`ClientWebSocket` als Gegenstelle):
|
`ClientWebSocket` als Gegenstelle):
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# Template — copy to `.env` and fill in your values.
|
|
||||||
# Do NOT commit `.env` (contains secrets).
|
|
||||||
|
|
||||||
SERVER_PORT=8080
|
|
||||||
|
|
||||||
SWYX_ADMIN_USERNAME=admin
|
|
||||||
SWYX_ADMIN_PASSWORD=change-me
|
|
||||||
|
|
||||||
SWYX_PLUGINS_OUTLOOK_ENABLED=false
|
|
||||||
SWYX_PLUGINS_OUTLOOK_TENANT_ID=
|
|
||||||
SWYX_PLUGINS_OUTLOOK_CLIENT_ID=
|
|
||||||
SWYX_PLUGINS_OUTLOOK_CLIENT_SECRET=
|
|
||||||
SWYX_PLUGINS_OUTLOOK_BASE_URL=https://graph.microsoft.com/v1.0
|
|
||||||
SWYX_PLUGINS_OUTLOOK_SCOPE=https://graph.microsoft.com/.default
|
|
||||||
|
|
||||||
MONGODB_URI=
|
|
||||||
MONGODB_HOST=localhost
|
|
||||||
MONGODB_PORT=27017
|
|
||||||
MONGODB_DATABASE=swyx
|
|
||||||
MONGODB_USERNAME=
|
|
||||||
MONGODB_PASSWORD=
|
|
||||||
MONGODB_AUTH_DATABASE=admin
|
|
||||||
|
|
||||||
LOGGING_LEVEL_DE_ASSECUTOR_SWYX=INFO
|
|
||||||
Generated
-15039
File diff suppressed because it is too large
Load Diff
@@ -1,104 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "no-name",
|
|
||||||
"license": "UNLICENSED",
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@polymer/polymer": "3.5.2",
|
|
||||||
"@vaadin/bundles": "24.6.4",
|
|
||||||
"@vaadin/common-frontend": "0.0.19",
|
|
||||||
"@vaadin/polymer-legacy-adapter": "24.6.4",
|
|
||||||
"@vaadin/react-components": "24.6.4",
|
|
||||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
|
||||||
"@vaadin/vaadin-lumo-styles": "24.6.4",
|
|
||||||
"@vaadin/vaadin-material-styles": "24.6.4",
|
|
||||||
"@vaadin/vaadin-themable-mixin": "24.6.4",
|
|
||||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
|
||||||
"construct-style-sheets-polyfill": "3.1.0",
|
|
||||||
"date-fns": "2.29.3",
|
|
||||||
"lit": "3.2.1",
|
|
||||||
"react": "18.3.1",
|
|
||||||
"react-dom": "18.3.1",
|
|
||||||
"react-router-dom": "6.29.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/preset-react": "7.26.3",
|
|
||||||
"@preact/signals-react-transform": "0.5.1",
|
|
||||||
"@rollup/plugin-replace": "6.0.2",
|
|
||||||
"@rollup/pluginutils": "5.1.4",
|
|
||||||
"@types/react": "18.3.18",
|
|
||||||
"@types/react-dom": "18.3.5",
|
|
||||||
"@vitejs/plugin-react": "4.3.4",
|
|
||||||
"async": "3.2.6",
|
|
||||||
"glob": "10.4.5",
|
|
||||||
"rollup-plugin-brotli": "3.1.0",
|
|
||||||
"rollup-plugin-visualizer": "5.14.0",
|
|
||||||
"strip-css-comments": "5.0.0",
|
|
||||||
"transform-ast": "2.4.4",
|
|
||||||
"typescript": "5.7.3",
|
|
||||||
"vite": "6.0.11",
|
|
||||||
"vite-plugin-checker": "0.8.0",
|
|
||||||
"workbox-build": "7.3.0",
|
|
||||||
"workbox-core": "7.3.0",
|
|
||||||
"workbox-precaching": "7.3.0"
|
|
||||||
},
|
|
||||||
"vaadin": {
|
|
||||||
"dependencies": {
|
|
||||||
"@polymer/polymer": "3.5.2",
|
|
||||||
"@vaadin/bundles": "24.6.4",
|
|
||||||
"@vaadin/common-frontend": "0.0.19",
|
|
||||||
"@vaadin/polymer-legacy-adapter": "24.6.4",
|
|
||||||
"@vaadin/react-components": "24.6.4",
|
|
||||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
|
||||||
"@vaadin/vaadin-lumo-styles": "24.6.4",
|
|
||||||
"@vaadin/vaadin-material-styles": "24.6.4",
|
|
||||||
"@vaadin/vaadin-themable-mixin": "24.6.4",
|
|
||||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
|
||||||
"construct-style-sheets-polyfill": "3.1.0",
|
|
||||||
"date-fns": "2.29.3",
|
|
||||||
"lit": "3.2.1",
|
|
||||||
"react": "18.3.1",
|
|
||||||
"react-dom": "18.3.1",
|
|
||||||
"react-router-dom": "6.29.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/preset-react": "7.26.3",
|
|
||||||
"@preact/signals-react-transform": "0.5.1",
|
|
||||||
"@rollup/plugin-replace": "6.0.2",
|
|
||||||
"@rollup/pluginutils": "5.1.4",
|
|
||||||
"@types/react": "18.3.18",
|
|
||||||
"@types/react-dom": "18.3.5",
|
|
||||||
"@vitejs/plugin-react": "4.3.4",
|
|
||||||
"async": "3.2.6",
|
|
||||||
"glob": "10.4.5",
|
|
||||||
"rollup-plugin-brotli": "3.1.0",
|
|
||||||
"rollup-plugin-visualizer": "5.14.0",
|
|
||||||
"strip-css-comments": "5.0.0",
|
|
||||||
"transform-ast": "2.4.4",
|
|
||||||
"typescript": "5.7.3",
|
|
||||||
"vite": "6.0.11",
|
|
||||||
"vite-plugin-checker": "0.8.0",
|
|
||||||
"workbox-build": "7.3.0",
|
|
||||||
"workbox-core": "7.3.0",
|
|
||||||
"workbox-precaching": "7.3.0"
|
|
||||||
},
|
|
||||||
"hash": "eb4789b12aacb7dfe73ac33a6dd463d0f265e60c4e8b5123fb0bd291e624727e"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"@vaadin/bundles": "$@vaadin/bundles",
|
|
||||||
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
|
|
||||||
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
|
|
||||||
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
|
|
||||||
"@vaadin/react-components": "$@vaadin/react-components",
|
|
||||||
"@vaadin/common-frontend": "$@vaadin/common-frontend",
|
|
||||||
"react-dom": "$react-dom",
|
|
||||||
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
|
|
||||||
"react-router-dom": "$react-router-dom",
|
|
||||||
"lit": "$lit",
|
|
||||||
"@polymer/polymer": "$@polymer/polymer",
|
|
||||||
"react": "$react",
|
|
||||||
"date-fns": "$date-fns",
|
|
||||||
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
|
|
||||||
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
|
|
||||||
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-161
@@ -1,161 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
|
||||||
<version>3.4.1</version>
|
|
||||||
<relativePath/>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<groupId>de.assecutor.swyx</groupId>
|
|
||||||
<artifactId>swyx-core</artifactId>
|
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
|
||||||
<name>swyx-core</name>
|
|
||||||
<description>Swyx Webhook Service für Anrufsignalisierung mit Admin-Oberfläche</description>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<java.version>21</java.version>
|
|
||||||
<vaadin.version>24.6.5</vaadin.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencyManagement>
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.vaadin</groupId>
|
|
||||||
<artifactId>vaadin-bom</artifactId>
|
|
||||||
<version>${vaadin.version}</version>
|
|
||||||
<type>pom</type>
|
|
||||||
<scope>import</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
</dependencyManagement>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.vaadin</groupId>
|
|
||||||
<artifactId>vaadin-spring-boot-starter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-security</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-data-mongodb</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
<version>1.18.38</version>
|
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-devtools</artifactId>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
|
||||||
<configuration>
|
|
||||||
<annotationProcessorPaths>
|
|
||||||
<path>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
<version>1.18.38</version>
|
|
||||||
</path>
|
|
||||||
</annotationProcessorPaths>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
|
||||||
<configuration>
|
|
||||||
<excludes>
|
|
||||||
<exclude>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
</exclude>
|
|
||||||
</excludes>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
<plugin>
|
|
||||||
<groupId>com.vaadin</groupId>
|
|
||||||
<artifactId>vaadin-maven-plugin</artifactId>
|
|
||||||
<version>${vaadin.version}</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<goals>
|
|
||||||
<goal>prepare-frontend</goal>
|
|
||||||
</goals>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
|
|
||||||
<profiles>
|
|
||||||
<profile>
|
|
||||||
<id>production</id>
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>com.vaadin</groupId>
|
|
||||||
<artifactId>vaadin-maven-plugin</artifactId>
|
|
||||||
<version>${vaadin.version}</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<goals>
|
|
||||||
<goal>build-frontend</goal>
|
|
||||||
</goals>
|
|
||||||
<phase>compile</phase>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
<configuration>
|
|
||||||
<productionMode>true</productionMode>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</profile>
|
|
||||||
</profiles>
|
|
||||||
</project>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<!--
|
|
||||||
This file is auto-generated by Vaadin.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
||||||
<style>
|
|
||||||
body, #outlet {
|
|
||||||
height: 100vh;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- This outlet div is where the views are rendered -->
|
|
||||||
<div id="outlet"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/* swyx-admin theme styles */
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"lumoImports": ["typography", "color", "spacing", "badge", "utility"]
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package de.assecutor.swyx;
|
|
||||||
|
|
||||||
import com.vaadin.flow.component.page.AppShellConfigurator;
|
|
||||||
import com.vaadin.flow.component.page.Push;
|
|
||||||
import com.vaadin.flow.theme.Theme;
|
|
||||||
import org.springframework.boot.SpringApplication;
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
|
||||||
|
|
||||||
@Push
|
|
||||||
@Theme("swyx-admin")
|
|
||||||
@EnableScheduling
|
|
||||||
@SpringBootApplication
|
|
||||||
public class SwyxCoreApplication implements AppShellConfigurator {
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
SpringApplication.run(SwyxCoreApplication.class, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package de.assecutor.swyx.plugins;
|
|
||||||
|
|
||||||
public interface Plugin {
|
|
||||||
|
|
||||||
String name();
|
|
||||||
|
|
||||||
void start();
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package de.assecutor.swyx.plugins;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
|
||||||
import org.springframework.context.event.EventListener;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class PluginManager {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(PluginManager.class);
|
|
||||||
|
|
||||||
private final List<Plugin> plugins;
|
|
||||||
|
|
||||||
public PluginManager(List<Plugin> plugins) {
|
|
||||||
this.plugins = plugins;
|
|
||||||
}
|
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
|
||||||
public void startAll() {
|
|
||||||
log.info("Starting {} plugin(s)", plugins.size());
|
|
||||||
for (Plugin plugin : plugins) {
|
|
||||||
try {
|
|
||||||
log.info("Starting plugin '{}'", plugin.name());
|
|
||||||
plugin.start();
|
|
||||||
} catch (Exception ex) {
|
|
||||||
log.error("Failed to start plugin '{}'", plugin.name(), ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Plugin> getPlugins() {
|
|
||||||
return List.copyOf(plugins);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package de.assecutor.swyx.plugins.outlook;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
public record OutlookContact(
|
|
||||||
String id,
|
|
||||||
String userPrincipalName,
|
|
||||||
String displayName,
|
|
||||||
String givenName,
|
|
||||||
String surname,
|
|
||||||
String mail,
|
|
||||||
String mobilePhone,
|
|
||||||
@JsonProperty("businessPhones") List<String> businessPhones,
|
|
||||||
String jobTitle,
|
|
||||||
String department) {
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package de.assecutor.swyx.plugins.outlook;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.util.LinkedMultiValueMap;
|
|
||||||
import org.springframework.util.MultiValueMap;
|
|
||||||
import org.springframework.web.client.RestClient;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class OutlookContactService {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(OutlookContactService.class);
|
|
||||||
|
|
||||||
private final OutlookProperties properties;
|
|
||||||
private final RestClient restClient = RestClient.create();
|
|
||||||
private final AtomicReference<List<OutlookContact>> cache = new AtomicReference<>(List.of());
|
|
||||||
private volatile Instant lastSync;
|
|
||||||
|
|
||||||
public OutlookContactService(OutlookProperties properties) {
|
|
||||||
this.properties = properties;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<OutlookContact> getContacts() {
|
|
||||||
return cache.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getLastSync() {
|
|
||||||
return lastSync;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<OutlookContact> sync() {
|
|
||||||
if (!properties.enabled()) {
|
|
||||||
log.debug("Outlook plugin disabled, skipping sync");
|
|
||||||
return cache.get();
|
|
||||||
}
|
|
||||||
if (isBlank(properties.tenantId()) || isBlank(properties.clientId()) || isBlank(properties.clientSecret())) {
|
|
||||||
log.warn("Outlook credentials incomplete — skipping sync");
|
|
||||||
return cache.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
String accessToken = requestAccessToken();
|
|
||||||
List<OutlookContact> contacts = fetchAllContacts(accessToken);
|
|
||||||
|
|
||||||
cache.set(List.copyOf(contacts));
|
|
||||||
lastSync = Instant.now();
|
|
||||||
log.info("Outlook sync completed: {} contacts loaded", contacts.size());
|
|
||||||
return contacts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String requestAccessToken() {
|
|
||||||
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
|
||||||
form.add("client_id", properties.clientId());
|
|
||||||
form.add("client_secret", properties.clientSecret());
|
|
||||||
form.add("scope", properties.scope());
|
|
||||||
form.add("grant_type", "client_credentials");
|
|
||||||
|
|
||||||
TokenResponse response = restClient.post()
|
|
||||||
.uri(properties.tokenUrl())
|
|
||||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
|
||||||
.body(form)
|
|
||||||
.retrieve()
|
|
||||||
.body(TokenResponse.class);
|
|
||||||
|
|
||||||
if (response == null || isBlank(response.accessToken())) {
|
|
||||||
throw new IllegalStateException("Outlook token endpoint returned no access_token");
|
|
||||||
}
|
|
||||||
return response.accessToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<OutlookContact> fetchAllContacts(String accessToken) {
|
|
||||||
List<OutlookContact> all = new ArrayList<>();
|
|
||||||
String url = properties.baseUrl() + "/users?$select=id,userPrincipalName,displayName,givenName,surname,mail,mobilePhone,businessPhones,jobTitle,department&$top=100";
|
|
||||||
|
|
||||||
while (url != null && !url.isBlank()) {
|
|
||||||
UsersResponse response = restClient.get()
|
|
||||||
.uri(url)
|
|
||||||
.header("Authorization", "Bearer " + accessToken)
|
|
||||||
.retrieve()
|
|
||||||
.body(UsersResponse.class);
|
|
||||||
|
|
||||||
if (response == null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (response.value() != null) {
|
|
||||||
all.addAll(response.value());
|
|
||||||
}
|
|
||||||
url = response.nextLink();
|
|
||||||
}
|
|
||||||
return Collections.unmodifiableList(all);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isBlank(String value) {
|
|
||||||
return value == null || value.isBlank();
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
private record TokenResponse(@JsonProperty("access_token") String accessToken,
|
|
||||||
@JsonProperty("token_type") String tokenType,
|
|
||||||
@JsonProperty("expires_in") Long expiresIn) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
private record UsersResponse(List<OutlookContact> value,
|
|
||||||
@JsonProperty("@odata.nextLink") String nextLink) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package de.assecutor.swyx.plugins.outlook;
|
|
||||||
|
|
||||||
import de.assecutor.swyx.plugins.Plugin;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@Configuration
|
|
||||||
@EnableConfigurationProperties(OutlookProperties.class)
|
|
||||||
public class OutlookPlugin implements Plugin {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(OutlookPlugin.class);
|
|
||||||
|
|
||||||
private final OutlookProperties properties;
|
|
||||||
private final OutlookContactService contactService;
|
|
||||||
|
|
||||||
public OutlookPlugin(OutlookProperties properties, OutlookContactService contactService) {
|
|
||||||
this.properties = properties;
|
|
||||||
this.contactService = contactService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String name() {
|
|
||||||
return "outlook";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void start() {
|
|
||||||
if (!properties.enabled()) {
|
|
||||||
log.info("Outlook plugin disabled via configuration");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.info("Outlook plugin starting — performing initial contact sync");
|
|
||||||
try {
|
|
||||||
contactService.sync();
|
|
||||||
} catch (Exception ex) {
|
|
||||||
log.error("Initial Outlook contact sync failed", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(fixedRate = 60L * 60L * 1000L)
|
|
||||||
public void scheduledSync() {
|
|
||||||
if (!properties.enabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
contactService.sync();
|
|
||||||
} catch (Exception ex) {
|
|
||||||
log.error("Scheduled Outlook contact sync failed", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package de.assecutor.swyx.plugins.outlook;
|
|
||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
||||||
|
|
||||||
@ConfigurationProperties(prefix = "swyx.plugins.outlook")
|
|
||||||
public record OutlookProperties(
|
|
||||||
boolean enabled,
|
|
||||||
String tenantId,
|
|
||||||
String clientId,
|
|
||||||
String clientSecret,
|
|
||||||
String baseUrl,
|
|
||||||
String tokenUrl,
|
|
||||||
String scope) {
|
|
||||||
|
|
||||||
public OutlookProperties {
|
|
||||||
if (baseUrl == null || baseUrl.isBlank()) {
|
|
||||||
baseUrl = "https://graph.microsoft.com/v1.0";
|
|
||||||
}
|
|
||||||
if (tokenUrl == null || tokenUrl.isBlank()) {
|
|
||||||
tokenUrl = tenantId != null
|
|
||||||
? "https://login.microsoftonline.com/" + tenantId + "/oauth2/v2.0/token"
|
|
||||||
: "";
|
|
||||||
}
|
|
||||||
if (scope == null || scope.isBlank()) {
|
|
||||||
scope = "https://graph.microsoft.com/.default";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package de.assecutor.swyx.security;
|
|
||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
||||||
|
|
||||||
@ConfigurationProperties(prefix = "swyx.admin")
|
|
||||||
public record AdminAccountProperties(String username, String password) {
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package de.assecutor.swyx.security;
|
|
||||||
|
|
||||||
import com.vaadin.flow.spring.security.VaadinWebSecurity;
|
|
||||||
import de.assecutor.swyx.ui.view.LoginView;
|
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
|
||||||
import org.springframework.security.core.userdetails.User;
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
||||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
|
||||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@EnableWebSecurity
|
|
||||||
@EnableConfigurationProperties(AdminAccountProperties.class)
|
|
||||||
public class SecurityConfig extends VaadinWebSecurity {
|
|
||||||
|
|
||||||
private final AdminAccountProperties adminAccount;
|
|
||||||
|
|
||||||
public SecurityConfig(AdminAccountProperties adminAccount) {
|
|
||||||
this.adminAccount = adminAccount;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public PasswordEncoder passwordEncoder() {
|
|
||||||
return new BCryptPasswordEncoder();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
|
|
||||||
UserDetails admin = User.builder()
|
|
||||||
.username(adminAccount.username())
|
|
||||||
.password(encoder.encode(adminAccount.password()))
|
|
||||||
.roles("ADMIN")
|
|
||||||
.build();
|
|
||||||
return new InMemoryUserDetailsManager(admin);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void configure(HttpSecurity http) throws Exception {
|
|
||||||
http.authorizeHttpRequests(auth -> auth
|
|
||||||
.requestMatchers(new AntPathRequestMatcher("/api/**")).permitAll()
|
|
||||||
.requestMatchers(new AntPathRequestMatcher("/ws/**")).permitAll()
|
|
||||||
.requestMatchers(new AntPathRequestMatcher("/actuator/health")).permitAll());
|
|
||||||
http.csrf(csrf -> csrf
|
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/**"))
|
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/ws/**")));
|
|
||||||
super.configure(http);
|
|
||||||
setLoginView(http, LoginView.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package de.assecutor.swyx.security;
|
|
||||||
|
|
||||||
import com.vaadin.flow.spring.security.AuthenticationContext;
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class SecurityService {
|
|
||||||
|
|
||||||
private final AuthenticationContext authenticationContext;
|
|
||||||
|
|
||||||
public SecurityService(AuthenticationContext authenticationContext) {
|
|
||||||
this.authenticationContext = authenticationContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<UserDetails> getAuthenticatedUser() {
|
|
||||||
return authenticationContext.getAuthenticatedUser(UserDetails.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void logout() {
|
|
||||||
authenticationContext.logout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package de.assecutor.swyx.ui;
|
|
||||||
|
|
||||||
import com.vaadin.flow.component.applayout.AppLayout;
|
|
||||||
import com.vaadin.flow.component.applayout.DrawerToggle;
|
|
||||||
import com.vaadin.flow.component.button.Button;
|
|
||||||
import com.vaadin.flow.component.button.ButtonVariant;
|
|
||||||
import com.vaadin.flow.component.html.H1;
|
|
||||||
import com.vaadin.flow.component.html.Span;
|
|
||||||
import com.vaadin.flow.component.icon.VaadinIcon;
|
|
||||||
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
|
||||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
|
||||||
import com.vaadin.flow.component.sidenav.SideNav;
|
|
||||||
import com.vaadin.flow.component.sidenav.SideNavItem;
|
|
||||||
import com.vaadin.flow.router.Layout;
|
|
||||||
import com.vaadin.flow.theme.lumo.LumoUtility;
|
|
||||||
import de.assecutor.swyx.security.SecurityService;
|
|
||||||
import de.assecutor.swyx.ui.view.DashboardView;
|
|
||||||
import de.assecutor.swyx.ui.view.EventsView;
|
|
||||||
import jakarta.annotation.security.PermitAll;
|
|
||||||
|
|
||||||
@Layout
|
|
||||||
@PermitAll
|
|
||||||
public class MainLayout extends AppLayout {
|
|
||||||
|
|
||||||
private final SecurityService securityService;
|
|
||||||
|
|
||||||
public MainLayout(SecurityService securityService) {
|
|
||||||
this.securityService = securityService;
|
|
||||||
setPrimarySection(Section.DRAWER);
|
|
||||||
addHeaderContent();
|
|
||||||
addDrawerContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addHeaderContent() {
|
|
||||||
DrawerToggle toggle = new DrawerToggle();
|
|
||||||
toggle.setAriaLabel("Menü umschalten");
|
|
||||||
|
|
||||||
H1 title = new H1("Swyx Admin");
|
|
||||||
title.addClassNames(LumoUtility.FontSize.LARGE, LumoUtility.Margin.NONE);
|
|
||||||
|
|
||||||
Span status = new Span(VaadinIcon.CIRCLE.create(), new Span(" Webhook aktiv"));
|
|
||||||
status.getStyle().set("color", "var(--lumo-success-color)");
|
|
||||||
status.getStyle().set("font-size", "var(--lumo-font-size-s)");
|
|
||||||
|
|
||||||
HorizontalLayout rightSection = new HorizontalLayout(status);
|
|
||||||
rightSection.setAlignItems(FlexComponent.Alignment.CENTER);
|
|
||||||
rightSection.setSpacing(true);
|
|
||||||
|
|
||||||
securityService.getAuthenticatedUser().ifPresent(user -> {
|
|
||||||
Span userName = new Span(user.getUsername());
|
|
||||||
userName.addClassNames(LumoUtility.FontWeight.MEDIUM);
|
|
||||||
|
|
||||||
Button logoutBtn = new Button(VaadinIcon.SIGN_OUT.create(), e -> securityService.logout());
|
|
||||||
logoutBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
|
||||||
logoutBtn.setTooltipText("Abmelden");
|
|
||||||
|
|
||||||
rightSection.add(userName, logoutBtn);
|
|
||||||
});
|
|
||||||
|
|
||||||
HorizontalLayout header = new HorizontalLayout(toggle, title, rightSection);
|
|
||||||
header.setAlignItems(FlexComponent.Alignment.CENTER);
|
|
||||||
header.setWidthFull();
|
|
||||||
header.setPadding(true);
|
|
||||||
header.expand(title);
|
|
||||||
|
|
||||||
addToNavbar(true, header);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addDrawerContent() {
|
|
||||||
Span appName = new Span("Swyx Core");
|
|
||||||
appName.addClassNames(LumoUtility.FontWeight.SEMIBOLD, LumoUtility.Padding.MEDIUM);
|
|
||||||
|
|
||||||
SideNav nav = new SideNav();
|
|
||||||
nav.addItem(new SideNavItem("Dashboard", DashboardView.class, VaadinIcon.DASHBOARD.create()));
|
|
||||||
nav.addItem(new SideNavItem("Anruf-Events", EventsView.class, VaadinIcon.PHONE.create()));
|
|
||||||
|
|
||||||
addToDrawer(appName, nav);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package de.assecutor.swyx.ui.view;
|
|
||||||
|
|
||||||
import com.vaadin.flow.component.AttachEvent;
|
|
||||||
import com.vaadin.flow.component.DetachEvent;
|
|
||||||
import com.vaadin.flow.component.grid.Grid;
|
|
||||||
import com.vaadin.flow.component.html.H2;
|
|
||||||
import com.vaadin.flow.component.html.Span;
|
|
||||||
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
|
||||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
|
||||||
import com.vaadin.flow.router.PageTitle;
|
|
||||||
import com.vaadin.flow.router.Route;
|
|
||||||
import com.vaadin.flow.theme.lumo.LumoUtility;
|
|
||||||
import de.assecutor.swyx.webhook.CallEventStore;
|
|
||||||
import de.assecutor.swyx.webhook.CallEventType;
|
|
||||||
import de.assecutor.swyx.webhook.StoredCallEvent;
|
|
||||||
import jakarta.annotation.security.PermitAll;
|
|
||||||
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Route("")
|
|
||||||
@PageTitle("Dashboard")
|
|
||||||
@PermitAll
|
|
||||||
public class DashboardView extends VerticalLayout {
|
|
||||||
|
|
||||||
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
|
|
||||||
|
|
||||||
private final CallEventStore store;
|
|
||||||
private final Grid<StoredCallEvent> grid = new Grid<>(StoredCallEvent.class, false);
|
|
||||||
private final StatCard totalCard = new StatCard("Events gesamt");
|
|
||||||
private final StatCard ringingCard = new StatCard("Eingehend (Ringing)");
|
|
||||||
private final StatCard answeredCard = new StatCard("Angenommen");
|
|
||||||
private final StatCard missedCard = new StatCard("Verpasst");
|
|
||||||
|
|
||||||
private CallEventStore.Registration registration;
|
|
||||||
|
|
||||||
public DashboardView(CallEventStore store) {
|
|
||||||
this.store = store;
|
|
||||||
setSizeFull();
|
|
||||||
setPadding(true);
|
|
||||||
setSpacing(true);
|
|
||||||
|
|
||||||
add(new H2("Live Übersicht"));
|
|
||||||
add(buildStatsRow());
|
|
||||||
add(buildRecentGrid());
|
|
||||||
expand(grid);
|
|
||||||
|
|
||||||
refresh(store.snapshot());
|
|
||||||
}
|
|
||||||
|
|
||||||
private HorizontalLayout buildStatsRow() {
|
|
||||||
HorizontalLayout row = new HorizontalLayout(totalCard, ringingCard, answeredCard, missedCard);
|
|
||||||
row.setWidthFull();
|
|
||||||
row.setSpacing(true);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Grid<StoredCallEvent> buildRecentGrid() {
|
|
||||||
grid.setAllRowsVisible(false);
|
|
||||||
grid.setSizeFull();
|
|
||||||
grid.addColumn(e -> e.receivedAt().format(TIME_FORMAT)).setHeader("Zeit").setAutoWidth(true).setFlexGrow(0);
|
|
||||||
grid.addColumn(e -> e.eventType().name()).setHeader("Typ").setAutoWidth(true).setFlexGrow(0);
|
|
||||||
grid.addColumn(StoredCallEvent::from).setHeader("Von").setAutoWidth(true);
|
|
||||||
grid.addColumn(StoredCallEvent::to).setHeader("An").setAutoWidth(true);
|
|
||||||
grid.addColumn(StoredCallEvent::user).setHeader("Nebenstelle").setAutoWidth(true);
|
|
||||||
grid.addColumn(StoredCallEvent::callId).setHeader("Call ID").setAutoWidth(true);
|
|
||||||
return grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onAttach(AttachEvent attachEvent) {
|
|
||||||
registration = store.register(event -> attachEvent.getUI().access(() -> refresh(store.snapshot())));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onDetach(DetachEvent detachEvent) {
|
|
||||||
if (registration != null) {
|
|
||||||
registration.remove();
|
|
||||||
registration = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void refresh(List<StoredCallEvent> events) {
|
|
||||||
List<StoredCallEvent> recent = events.size() > 20 ? events.subList(0, 20) : events;
|
|
||||||
grid.setItems(recent);
|
|
||||||
totalCard.setValue(events.size());
|
|
||||||
ringingCard.setValue(count(events, CallEventType.RINGING));
|
|
||||||
answeredCard.setValue(count(events, CallEventType.ANSWERED));
|
|
||||||
missedCard.setValue(count(events, CallEventType.MISSED));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long count(List<StoredCallEvent> events, CallEventType type) {
|
|
||||||
return events.stream().filter(e -> e.eventType() == type).count();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class StatCard extends VerticalLayout {
|
|
||||||
private final Span value = new Span("0");
|
|
||||||
|
|
||||||
StatCard(String label) {
|
|
||||||
Span title = new Span(label);
|
|
||||||
title.addClassNames(LumoUtility.FontSize.SMALL, LumoUtility.TextColor.SECONDARY);
|
|
||||||
value.addClassNames(LumoUtility.FontSize.XXLARGE, LumoUtility.FontWeight.BOLD);
|
|
||||||
add(title, value);
|
|
||||||
setSpacing(false);
|
|
||||||
setPadding(true);
|
|
||||||
getStyle().set("background", "var(--lumo-contrast-5pct)");
|
|
||||||
getStyle().set("border-radius", "var(--lumo-border-radius-l)");
|
|
||||||
setAlignItems(FlexComponent.Alignment.START);
|
|
||||||
setWidthFull();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setValue(long number) {
|
|
||||||
value.setText(Long.toString(number));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
package de.assecutor.swyx.ui.view;
|
|
||||||
|
|
||||||
import com.vaadin.flow.component.AttachEvent;
|
|
||||||
import com.vaadin.flow.component.DetachEvent;
|
|
||||||
import com.vaadin.flow.component.button.Button;
|
|
||||||
import com.vaadin.flow.component.combobox.ComboBox;
|
|
||||||
import com.vaadin.flow.component.dialog.Dialog;
|
|
||||||
import com.vaadin.flow.component.grid.Grid;
|
|
||||||
import com.vaadin.flow.component.html.H3;
|
|
||||||
import com.vaadin.flow.component.html.Span;
|
|
||||||
import com.vaadin.flow.component.icon.VaadinIcon;
|
|
||||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
|
||||||
import com.vaadin.flow.component.textfield.TextField;
|
|
||||||
import com.vaadin.flow.data.provider.ListDataProvider;
|
|
||||||
import com.vaadin.flow.data.value.ValueChangeMode;
|
|
||||||
import com.vaadin.flow.router.PageTitle;
|
|
||||||
import com.vaadin.flow.router.Route;
|
|
||||||
import com.vaadin.flow.theme.lumo.LumoUtility;
|
|
||||||
import de.assecutor.swyx.webhook.CallEventStore;
|
|
||||||
import de.assecutor.swyx.webhook.CallEventType;
|
|
||||||
import de.assecutor.swyx.webhook.StoredCallEvent;
|
|
||||||
import jakarta.annotation.security.PermitAll;
|
|
||||||
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
@Route("events")
|
|
||||||
@PageTitle("Anruf-Events")
|
|
||||||
@PermitAll
|
|
||||||
public class EventsView extends VerticalLayout {
|
|
||||||
|
|
||||||
private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss");
|
|
||||||
|
|
||||||
private final CallEventStore store;
|
|
||||||
private final Grid<StoredCallEvent> grid = new Grid<>(StoredCallEvent.class, false);
|
|
||||||
private final ListDataProvider<StoredCallEvent> dataProvider = new ListDataProvider<>(new ArrayList<>());
|
|
||||||
|
|
||||||
private final TextField searchField = new TextField();
|
|
||||||
private final ComboBox<CallEventType> typeFilter = new ComboBox<>();
|
|
||||||
|
|
||||||
private CallEventStore.Registration registration;
|
|
||||||
|
|
||||||
public EventsView(CallEventStore store) {
|
|
||||||
this.store = store;
|
|
||||||
setSizeFull();
|
|
||||||
setPadding(true);
|
|
||||||
setSpacing(true);
|
|
||||||
|
|
||||||
add(new H3("Alle empfangenen Events"));
|
|
||||||
add(buildToolbar());
|
|
||||||
add(grid);
|
|
||||||
expand(grid);
|
|
||||||
|
|
||||||
configureGrid();
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
private HorizontalLayout buildToolbar() {
|
|
||||||
searchField.setPlaceholder("Suchen (Rufnummer, Call-ID, Nebenstelle)");
|
|
||||||
searchField.setPrefixComponent(VaadinIcon.SEARCH.create());
|
|
||||||
searchField.setValueChangeMode(ValueChangeMode.LAZY);
|
|
||||||
searchField.setClearButtonVisible(true);
|
|
||||||
searchField.addValueChangeListener(e -> applyFilters());
|
|
||||||
searchField.setWidth("360px");
|
|
||||||
|
|
||||||
typeFilter.setPlaceholder("Alle Typen");
|
|
||||||
typeFilter.setItems(Arrays.asList(CallEventType.values()));
|
|
||||||
typeFilter.setClearButtonVisible(true);
|
|
||||||
typeFilter.addValueChangeListener(e -> applyFilters());
|
|
||||||
|
|
||||||
Button refresh = new Button("Aktualisieren", VaadinIcon.REFRESH.create(), e -> refresh());
|
|
||||||
|
|
||||||
HorizontalLayout toolbar = new HorizontalLayout(searchField, typeFilter, refresh);
|
|
||||||
toolbar.setWidthFull();
|
|
||||||
return toolbar;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void configureGrid() {
|
|
||||||
grid.setSizeFull();
|
|
||||||
grid.addColumn(e -> e.receivedAt().format(DATE_TIME_FORMAT)).setHeader("Empfangen").setAutoWidth(true).setFlexGrow(0);
|
|
||||||
grid.addColumn(e -> e.eventType().name()).setHeader("Typ").setAutoWidth(true).setFlexGrow(0);
|
|
||||||
grid.addColumn(StoredCallEvent::from).setHeader("Von").setAutoWidth(true);
|
|
||||||
grid.addColumn(StoredCallEvent::to).setHeader("An").setAutoWidth(true);
|
|
||||||
grid.addColumn(StoredCallEvent::user).setHeader("Nebenstelle").setAutoWidth(true);
|
|
||||||
grid.addColumn(e -> e.durationSeconds() != null ? e.durationSeconds() + " s" : "")
|
|
||||||
.setHeader("Dauer").setAutoWidth(true).setFlexGrow(0);
|
|
||||||
grid.addColumn(StoredCallEvent::callId).setHeader("Call ID").setAutoWidth(true);
|
|
||||||
grid.setDataProvider(dataProvider);
|
|
||||||
grid.addItemClickListener(e -> showDetails(e.getItem()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void applyFilters() {
|
|
||||||
String query = searchField.getValue() == null ? "" : searchField.getValue().trim().toLowerCase();
|
|
||||||
CallEventType type = typeFilter.getValue();
|
|
||||||
dataProvider.setFilter(event -> {
|
|
||||||
if (type != null && event.eventType() != type) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (query.isEmpty()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return matches(event.from(), query)
|
|
||||||
|| matches(event.to(), query)
|
|
||||||
|| matches(event.callId(), query)
|
|
||||||
|| matches(event.user(), query);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean matches(String value, String query) {
|
|
||||||
return value != null && value.toLowerCase().contains(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onAttach(AttachEvent attachEvent) {
|
|
||||||
registration = store.register(event -> attachEvent.getUI().access(this::refresh));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onDetach(DetachEvent detachEvent) {
|
|
||||||
if (registration != null) {
|
|
||||||
registration.remove();
|
|
||||||
registration = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void refresh() {
|
|
||||||
dataProvider.getItems().clear();
|
|
||||||
dataProvider.getItems().addAll(store.snapshot());
|
|
||||||
dataProvider.refreshAll();
|
|
||||||
applyFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showDetails(StoredCallEvent event) {
|
|
||||||
Dialog dialog = new Dialog();
|
|
||||||
dialog.setHeaderTitle("Event-Details");
|
|
||||||
dialog.setWidth("480px");
|
|
||||||
|
|
||||||
VerticalLayout content = new VerticalLayout();
|
|
||||||
content.setPadding(false);
|
|
||||||
content.setSpacing(false);
|
|
||||||
content.add(row("Call ID", event.callId()));
|
|
||||||
content.add(row("Event-Typ", event.eventType().name()));
|
|
||||||
content.add(row("Von", event.from()));
|
|
||||||
content.add(row("An", event.to()));
|
|
||||||
content.add(row("Nebenstelle", event.user()));
|
|
||||||
content.add(row("Empfangen", event.receivedAt().format(DATE_TIME_FORMAT)));
|
|
||||||
content.add(row("Zeitstempel (Swyx)",
|
|
||||||
event.timestamp() != null ? event.timestamp().format(DATE_TIME_FORMAT) : null));
|
|
||||||
content.add(row("Dauer", event.durationSeconds() != null ? event.durationSeconds() + " s" : null));
|
|
||||||
|
|
||||||
dialog.add(content);
|
|
||||||
dialog.getFooter().add(new Button("Schließen", e -> dialog.close()));
|
|
||||||
dialog.open();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HorizontalLayout row(String label, String value) {
|
|
||||||
Span labelSpan = new Span(label);
|
|
||||||
labelSpan.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL);
|
|
||||||
labelSpan.setWidth("140px");
|
|
||||||
Span valueSpan = new Span(Objects.toString(value, "—"));
|
|
||||||
valueSpan.addClassNames(LumoUtility.FontWeight.MEDIUM);
|
|
||||||
HorizontalLayout row = new HorizontalLayout(labelSpan, valueSpan);
|
|
||||||
row.setPadding(false);
|
|
||||||
row.getStyle().set("padding", "var(--lumo-space-xs) 0");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package de.assecutor.swyx.ui.view;
|
|
||||||
|
|
||||||
import com.vaadin.flow.component.html.H1;
|
|
||||||
import com.vaadin.flow.component.login.LoginForm;
|
|
||||||
import com.vaadin.flow.component.login.LoginI18n;
|
|
||||||
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
|
||||||
import com.vaadin.flow.router.BeforeEnterEvent;
|
|
||||||
import com.vaadin.flow.router.BeforeEnterObserver;
|
|
||||||
import com.vaadin.flow.router.PageTitle;
|
|
||||||
import com.vaadin.flow.router.Route;
|
|
||||||
import com.vaadin.flow.server.auth.AnonymousAllowed;
|
|
||||||
|
|
||||||
@Route(value = "login", autoLayout = false)
|
|
||||||
@PageTitle("Anmelden — Swyx Admin")
|
|
||||||
@AnonymousAllowed
|
|
||||||
public class LoginView extends VerticalLayout implements BeforeEnterObserver {
|
|
||||||
|
|
||||||
private final LoginForm loginForm = new LoginForm();
|
|
||||||
|
|
||||||
public LoginView() {
|
|
||||||
setSizeFull();
|
|
||||||
setAlignItems(FlexComponent.Alignment.CENTER);
|
|
||||||
setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
|
|
||||||
|
|
||||||
H1 title = new H1("Swyx Admin");
|
|
||||||
title.getStyle().set("margin-bottom", "var(--lumo-space-m)");
|
|
||||||
|
|
||||||
LoginI18n i18n = LoginI18n.createDefault();
|
|
||||||
LoginI18n.Form form = i18n.getForm();
|
|
||||||
form.setTitle("Anmelden");
|
|
||||||
form.setUsername("Benutzername");
|
|
||||||
form.setPassword("Passwort");
|
|
||||||
form.setSubmit("Anmelden");
|
|
||||||
i18n.setForm(form);
|
|
||||||
|
|
||||||
LoginI18n.ErrorMessage errorMessage = i18n.getErrorMessage();
|
|
||||||
errorMessage.setTitle("Anmeldung fehlgeschlagen");
|
|
||||||
errorMessage.setMessage("Benutzername oder Passwort ist ungültig.");
|
|
||||||
i18n.setErrorMessage(errorMessage);
|
|
||||||
|
|
||||||
loginForm.setI18n(i18n);
|
|
||||||
loginForm.setAction("login");
|
|
||||||
loginForm.setForgotPasswordButtonVisible(false);
|
|
||||||
|
|
||||||
add(title, loginForm);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void beforeEnter(BeforeEnterEvent event) {
|
|
||||||
if (event.getLocation().getQueryParameters().getParameters().containsKey("error")) {
|
|
||||||
loginForm.setError(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package de.assecutor.swyx.webhook;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
public class CallEventRequest {
|
|
||||||
|
|
||||||
@NotBlank
|
|
||||||
@JsonAlias({"callId", "call_id", "CallId"})
|
|
||||||
private String callId;
|
|
||||||
|
|
||||||
@JsonAlias({"eventType", "event", "type"})
|
|
||||||
private CallEventType eventType;
|
|
||||||
|
|
||||||
@JsonAlias({"from", "caller", "callerNumber", "CallingPartyNumber"})
|
|
||||||
private String from;
|
|
||||||
|
|
||||||
@JsonAlias({"to", "callee", "calleeNumber", "CalledPartyNumber"})
|
|
||||||
private String to;
|
|
||||||
|
|
||||||
@JsonAlias({"user", "userName", "extension"})
|
|
||||||
private String user;
|
|
||||||
|
|
||||||
@JsonAlias({"timestamp", "eventTime", "time"})
|
|
||||||
private OffsetDateTime timestamp;
|
|
||||||
|
|
||||||
@JsonAlias({"durationSeconds", "duration"})
|
|
||||||
private Integer durationSeconds;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package de.assecutor.swyx.webhook;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CallEventService {
|
|
||||||
|
|
||||||
private final CallEventStore store;
|
|
||||||
|
|
||||||
public void handle(CallEventRequest event) {
|
|
||||||
store.add(event);
|
|
||||||
CallEventType type = event.getEventType() != null ? event.getEventType() : CallEventType.UNKNOWN;
|
|
||||||
switch (type) {
|
|
||||||
case RINGING -> onRinging(event);
|
|
||||||
case ANSWERED -> onAnswered(event);
|
|
||||||
case ENDED -> onEnded(event);
|
|
||||||
case TRANSFERRED -> onTransferred(event);
|
|
||||||
case MISSED -> onMissed(event);
|
|
||||||
default -> log.warn("Unhandled Swyx event type for callId={}", event.getCallId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onRinging(CallEventRequest event) {
|
|
||||||
log.info("Ringing: {} -> {} (user={})", event.getFrom(), event.getTo(), event.getUser());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onAnswered(CallEventRequest event) {
|
|
||||||
log.info("Answered: callId={} user={}", event.getCallId(), event.getUser());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onEnded(CallEventRequest event) {
|
|
||||||
log.info("Ended: callId={} duration={}s", event.getCallId(), event.getDurationSeconds());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onTransferred(CallEventRequest event) {
|
|
||||||
log.info("Transferred: callId={}", event.getCallId());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onMissed(CallEventRequest event) {
|
|
||||||
log.info("Missed: {} -> {}", event.getFrom(), event.getTo());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package de.assecutor.swyx.webhook;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.ArrayDeque;
|
|
||||||
import java.util.Deque;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class CallEventStore {
|
|
||||||
|
|
||||||
private static final int MAX_ENTRIES = 500;
|
|
||||||
|
|
||||||
private final Deque<StoredCallEvent> buffer = new ArrayDeque<>(MAX_ENTRIES);
|
|
||||||
private final List<Consumer<StoredCallEvent>> listeners = new CopyOnWriteArrayList<>();
|
|
||||||
|
|
||||||
public synchronized StoredCallEvent add(CallEventRequest request) {
|
|
||||||
StoredCallEvent entry = StoredCallEvent.of(request, OffsetDateTime.now());
|
|
||||||
buffer.addFirst(entry);
|
|
||||||
while (buffer.size() > MAX_ENTRIES) {
|
|
||||||
buffer.removeLast();
|
|
||||||
}
|
|
||||||
listeners.forEach(l -> l.accept(entry));
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized List<StoredCallEvent> snapshot() {
|
|
||||||
return List.copyOf(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Registration register(Consumer<StoredCallEvent> listener) {
|
|
||||||
listeners.add(listener);
|
|
||||||
return () -> listeners.remove(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface Registration {
|
|
||||||
void remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package de.assecutor.swyx.webhook;
|
|
||||||
|
|
||||||
public enum CallEventType {
|
|
||||||
RINGING,
|
|
||||||
ANSWERED,
|
|
||||||
ENDED,
|
|
||||||
TRANSFERRED,
|
|
||||||
MISSED,
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package de.assecutor.swyx.webhook;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record StoredCallEvent(
|
|
||||||
String id,
|
|
||||||
OffsetDateTime receivedAt,
|
|
||||||
String callId,
|
|
||||||
CallEventType eventType,
|
|
||||||
String from,
|
|
||||||
String to,
|
|
||||||
String user,
|
|
||||||
OffsetDateTime timestamp,
|
|
||||||
Integer durationSeconds
|
|
||||||
) {
|
|
||||||
public static StoredCallEvent of(CallEventRequest request, OffsetDateTime receivedAt) {
|
|
||||||
return new StoredCallEvent(
|
|
||||||
UUID.randomUUID().toString(),
|
|
||||||
receivedAt,
|
|
||||||
request.getCallId(),
|
|
||||||
request.getEventType() != null ? request.getEventType() : CallEventType.UNKNOWN,
|
|
||||||
request.getFrom(),
|
|
||||||
request.getTo(),
|
|
||||||
request.getUser(),
|
|
||||||
request.getTimestamp(),
|
|
||||||
request.getDurationSeconds()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package de.assecutor.swyx.webhook;
|
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/swyx")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SwyxWebhookController {
|
|
||||||
|
|
||||||
private final CallEventService callEventService;
|
|
||||||
|
|
||||||
@PostMapping("/webhook/call")
|
|
||||||
public ResponseEntity<Map<String, Object>> receiveCallEvent(@Valid @RequestBody CallEventRequest request) {
|
|
||||||
log.info("Swyx call event received: callId={}, type={}, from={}, to={}",
|
|
||||||
request.getCallId(), request.getEventType(), request.getFrom(), request.getTo());
|
|
||||||
callEventService.handle(request);
|
|
||||||
return ResponseEntity.ok(Map.of(
|
|
||||||
"status", "ok",
|
|
||||||
"callId", request.getCallId()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package de.assecutor.swyx.ws;
|
|
||||||
|
|
||||||
import de.assecutor.swyx.webhook.CallEventStore;
|
|
||||||
import de.assecutor.swyx.webhook.StoredCallEvent;
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import jakarta.annotation.PreDestroy;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
public class CallEventBroadcaster {
|
|
||||||
|
|
||||||
public static final String TOPIC_ALL = "/topic/calls";
|
|
||||||
public static final String TOPIC_PER_TYPE_PREFIX = "/topic/calls/";
|
|
||||||
|
|
||||||
private final CallEventStore store;
|
|
||||||
private final SimpMessagingTemplate messaging;
|
|
||||||
private CallEventStore.Registration registration;
|
|
||||||
|
|
||||||
public CallEventBroadcaster(CallEventStore store, SimpMessagingTemplate messaging) {
|
|
||||||
this.store = store;
|
|
||||||
this.messaging = messaging;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
void subscribe() {
|
|
||||||
registration = store.register(this::publish);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreDestroy
|
|
||||||
void unsubscribe() {
|
|
||||||
if (registration != null) {
|
|
||||||
registration.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void publish(StoredCallEvent event) {
|
|
||||||
messaging.convertAndSend(TOPIC_ALL, event);
|
|
||||||
if (event.eventType() != null) {
|
|
||||||
messaging.convertAndSend(TOPIC_PER_TYPE_PREFIX + event.eventType().name().toLowerCase(), event);
|
|
||||||
}
|
|
||||||
log.debug("Broadcast STOMP call event: callId={} type={}", event.callId(), event.eventType());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package de.assecutor.swyx.ws;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
|
||||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
|
||||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@EnableWebSocketMessageBroker
|
|
||||||
public class StompConfig implements WebSocketMessageBrokerConfigurer {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
|
||||||
registry.addEndpoint("/ws/stomp").setAllowedOriginPatterns("*");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configureMessageBroker(MessageBrokerRegistry registry) {
|
|
||||||
registry.enableSimpleBroker("/topic", "/queue");
|
|
||||||
registry.setApplicationDestinationPrefixes("/app");
|
|
||||||
registry.setUserDestinationPrefix("/user");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package de.assecutor.swyx.ws;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
|
||||||
import org.springframework.messaging.handler.annotation.Payload;
|
|
||||||
import org.springframework.messaging.handler.annotation.SendTo;
|
|
||||||
import org.springframework.stereotype.Controller;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Controller
|
|
||||||
public class StompMessageController {
|
|
||||||
|
|
||||||
@MessageMapping("/ping")
|
|
||||||
@SendTo("/topic/pong")
|
|
||||||
public Map<String, Object> ping(@Payload Map<String, Object> payload) {
|
|
||||||
log.info("STOMP /app/ping: {}", payload);
|
|
||||||
Map<String, Object> response = new HashMap<>(payload);
|
|
||||||
response.put("pongAt", OffsetDateTime.now().toString());
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
@MessageMapping("/broadcast")
|
|
||||||
@SendTo("/topic/messages")
|
|
||||||
public Map<String, Object> broadcast(@Payload Map<String, Object> payload) {
|
|
||||||
log.info("STOMP /app/broadcast: {}", payload);
|
|
||||||
Map<String, Object> response = new HashMap<>(payload);
|
|
||||||
response.put("broadcastAt", OffsetDateTime.now().toString());
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
spring:
|
|
||||||
application:
|
|
||||||
name: swyx-core
|
|
||||||
jackson:
|
|
||||||
default-property-inclusion: non_null
|
|
||||||
data:
|
|
||||||
mongodb:
|
|
||||||
uri: ${MONGODB_URI:}
|
|
||||||
host: ${MONGODB_HOST:localhost}
|
|
||||||
port: ${MONGODB_PORT:27017}
|
|
||||||
database: ${MONGODB_DATABASE:swyx}
|
|
||||||
username: ${MONGODB_USERNAME:}
|
|
||||||
password: ${MONGODB_PASSWORD:}
|
|
||||||
authentication-database: ${MONGODB_AUTH_DATABASE:admin}
|
|
||||||
|
|
||||||
server:
|
|
||||||
port: ${SERVER_PORT:8080}
|
|
||||||
|
|
||||||
management:
|
|
||||||
endpoints:
|
|
||||||
web:
|
|
||||||
exposure:
|
|
||||||
include: health,info
|
|
||||||
endpoint:
|
|
||||||
health:
|
|
||||||
show-details: when-authorized
|
|
||||||
|
|
||||||
swyx:
|
|
||||||
admin:
|
|
||||||
username: ${SWYX_ADMIN_USERNAME:admin}
|
|
||||||
password: ${SWYX_ADMIN_PASSWORD:admin}
|
|
||||||
plugins:
|
|
||||||
outlook:
|
|
||||||
enabled: ${SWYX_PLUGINS_OUTLOOK_ENABLED:false}
|
|
||||||
tenant-id: ${SWYX_PLUGINS_OUTLOOK_TENANT_ID:}
|
|
||||||
client-id: ${SWYX_PLUGINS_OUTLOOK_CLIENT_ID:}
|
|
||||||
client-secret: ${SWYX_PLUGINS_OUTLOOK_CLIENT_SECRET:}
|
|
||||||
base-url: ${SWYX_PLUGINS_OUTLOOK_BASE_URL:https://graph.microsoft.com/v1.0}
|
|
||||||
scope: ${SWYX_PLUGINS_OUTLOOK_SCOPE:https://graph.microsoft.com/.default}
|
|
||||||
|
|
||||||
logging:
|
|
||||||
level:
|
|
||||||
de.assecutor.swyx: ${LOGGING_LEVEL_DE_ASSECUTOR_SWYX:INFO}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package de.assecutor.swyx.webhook;
|
|
||||||
|
|
||||||
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.web.server.LocalServerPort;
|
|
||||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
|
||||||
import org.springframework.http.HttpEntity;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
|
||||||
class SwyxWebhookControllerTest {
|
|
||||||
|
|
||||||
@LocalServerPort
|
|
||||||
private int port;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TestRestTemplate rest;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void acceptsCallEventPayload() {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
String body = """
|
|
||||||
{
|
|
||||||
"callId": "abc-123",
|
|
||||||
"eventType": "RINGING",
|
|
||||||
"from": "+4930111111",
|
|
||||||
"to": "+4930222222",
|
|
||||||
"user": "200",
|
|
||||||
"timestamp": "2026-04-16T10:00:00+02:00"
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
|
|
||||||
ResponseEntity<String> response = rest.postForEntity(
|
|
||||||
"http://localhost:" + port + "/api/swyx/webhook/call",
|
|
||||||
new HttpEntity<>(body, headers),
|
|
||||||
String.class);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
assertThat(response.getBody()).contains("abc-123");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// This TypeScript configuration file is generated by vaadin-maven-plugin.
|
|
||||||
// This is needed for TypeScript compiler to compile your TypeScript code in the project.
|
|
||||||
// It is recommended to commit this file to the VCS.
|
|
||||||
// You might want to change the configurations to fit your preferences
|
|
||||||
// For more information about the configurations, please refer to http://www.typescriptlang.org/docs/handbook/tsconfig-json.html
|
|
||||||
{
|
|
||||||
"_version": "9.1",
|
|
||||||
"compilerOptions": {
|
|
||||||
"sourceMap": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"inlineSources": true,
|
|
||||||
"module": "esNext",
|
|
||||||
"target": "es2020",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noImplicitReturns": true,
|
|
||||||
"noImplicitAny": true,
|
|
||||||
"noImplicitThis": true,
|
|
||||||
"noUnusedLocals": false,
|
|
||||||
"noUnusedParameters": false,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"useDefineForClassFields": false,
|
|
||||||
"paths": {
|
|
||||||
"@vaadin/flow-frontend": ["./src/main/frontend/generated/jar-resources"],
|
|
||||||
"@vaadin/flow-frontend/*": ["./src/main/frontend/generated/jar-resources/*"],
|
|
||||||
"Frontend/*": ["./src/main/frontend/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/main/frontend/**/*",
|
|
||||||
"types.d.ts"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"src/main/frontend/generated/jar-resources/**"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Vendored
-17
@@ -1,17 +0,0 @@
|
|||||||
// This TypeScript modules definition file is generated by vaadin-maven-plugin.
|
|
||||||
// You can not directly import your different static files into TypeScript,
|
|
||||||
// This is needed for TypeScript compiler to declare and export as a TypeScript module.
|
|
||||||
// It is recommended to commit this file to the VCS.
|
|
||||||
// You might want to change the configurations to fit your preferences
|
|
||||||
declare module '*.css?inline' {
|
|
||||||
import type { CSSResultGroup } from 'lit';
|
|
||||||
const content: CSSResultGroup;
|
|
||||||
export default content;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow any CSS Custom Properties
|
|
||||||
declare module 'csstype' {
|
|
||||||
interface Properties {
|
|
||||||
[index: `--${string}`]: any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { UserConfigFn } from 'vite';
|
|
||||||
import { overrideVaadinConfig } from './vite.generated';
|
|
||||||
|
|
||||||
const customConfig: UserConfigFn = (env) => ({
|
|
||||||
// Here you can add custom Vite parameters
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
});
|
|
||||||
|
|
||||||
export default overrideVaadinConfig(customConfig);
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# SwyxFFPlugin
|
|
||||||
|
|
||||||
Firefox-Erweiterung zur Tab-Verwaltung fuer SwyxTray. Das Hintergrundskript
|
|
||||||
haelt eine WebSocket-Verbindung zum **Plugin-Port** von SwyxTray
|
|
||||||
(voreingestellt `ws://127.0.0.1:17655/`, einstellbar ueber `PluginPort` in
|
|
||||||
`%LOCALAPPDATA%\SwyxTray\websocket.json`) und beantwortet drei Auftraege:
|
|
||||||
|
|
||||||
| Auftrag | Antwort |
|
|
||||||
|---|---|
|
|
||||||
| `{"type":"tab","id":…,"action":"list"}` | `{"cmd":"tabresult","id":…,"ok":true,"tabs":[{id,title,url,active},…]}` |
|
|
||||||
| `{"type":"tab","id":…,"action":"open","url":…}` | `{"cmd":"tabresult","id":…,"ok":true,"tabId":…}` |
|
|
||||||
| `{"type":"tab","id":…,"action":"close","tabId":…}` | `{"cmd":"tabresult","id":…,"ok":true}` |
|
|
||||||
|
|
||||||
Das Protokoll im Einzelnen steht im [Haupt-README](../../README.md) unter
|
|
||||||
„Firefox-Plugin (SwyxFFPlugin)".
|
|
||||||
|
|
||||||
Auf diesem Kanal fragt SwyxTray, das Plugin antwortet — genutzt vom
|
|
||||||
`focus`-Kommando des Hauptports und von den Kontextmenuepunkten **Lese Tabs**,
|
|
||||||
**Tab oeffnen** und **Tab schliessen** des Tray-Symbols. Laeuft SwyxTray (noch) nicht, verbindet das Skript sich selbst
|
|
||||||
neu: beginnend mit 1 s Abstand, je Fehlversuch verdoppelt bis hoechstens 30 s.
|
|
||||||
|
|
||||||
## Installieren (zum Erproben)
|
|
||||||
|
|
||||||
1. In Firefox `about:debugging#/runtime/this-firefox` oeffnen.
|
|
||||||
2. **Temporaeres Add-on laden…** und die [manifest.json](manifest.json) dieses
|
|
||||||
Ordners auswaehlen.
|
|
||||||
3. Im Protokoll von SwyxTray erscheint `WebSocket … verbunden (…, Plugin-Port)`.
|
|
||||||
|
|
||||||
Ein temporaeres Add-on ueberlebt den Firefox-Neustart nicht. Fuer eine
|
|
||||||
dauerhafte Installation muss die Erweiterung signiert werden (etwa unsigniert
|
|
||||||
nur in Firefox Developer Edition/ESR mit `xpinstall.signatures.required=false`).
|
|
||||||
|
|
||||||
Sind in SwyxTray die Handshake-Pruefungen eingeschaltet (zur Zeit sind sie
|
|
||||||
abgeschaltet, siehe Haupt-README), braucht die Adresse in
|
|
||||||
[background.js](background.js) zusaetzlich das Token
|
|
||||||
(`ws://127.0.0.1:17655/?token=…`), und in `AllowedOrigins` muss der Origin
|
|
||||||
`moz-extension://<UUID>` der Erweiterung stehen — die UUID zeigt
|
|
||||||
`about:debugging` als „Interne UUID".
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// SwyxFFPlugin — Hintergrundskript.
|
|
||||||
//
|
|
||||||
// Haelt eine WebSocket-Verbindung zum Plugin-Port von SwyxTray (voreingestellt
|
|
||||||
// 17655, siehe %LOCALAPPDATA%\SwyxTray\websocket.json). Auf diesem Kanal ist
|
|
||||||
// die Richtung umgekehrt zum Hauptport: SwyxTray fragt, das Plugin antwortet.
|
|
||||||
// Jeder Auftrag {"type":"tab","id":…,"action":…} wird mit genau einem
|
|
||||||
// {"cmd":"tabresult","id":…,"ok":…} beantwortet, die id unveraendert.
|
|
||||||
|
|
||||||
const ENDPOINT = "ws://127.0.0.1:17655/";
|
|
||||||
const SUBPROTOCOL = "swyxtray.v1";
|
|
||||||
|
|
||||||
// Wiederverbindung mit Verdopplung bis zur Obergrenze — SwyxTray laeuft
|
|
||||||
// womoeglich noch nicht, wenn Firefox startet, oder wird zwischendurch beendet.
|
|
||||||
const RECONNECT_MIN_MS = 1000;
|
|
||||||
const RECONNECT_MAX_MS = 30000;
|
|
||||||
let reconnectDelay = RECONNECT_MIN_MS;
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
const ws = new WebSocket(ENDPOINT, [SUBPROTOCOL]);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
reconnectDelay = RECONNECT_MIN_MS;
|
|
||||||
console.info(`SwyxFFPlugin: verbunden mit ${ENDPOINT}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (e) => handleMessage(ws, e.data);
|
|
||||||
|
|
||||||
// onerror liefert keine Einzelheiten und wird stets von onclose gefolgt —
|
|
||||||
// die Wiederverbindung haengt deshalb allein an onclose.
|
|
||||||
ws.onclose = () => {
|
|
||||||
setTimeout(connect, reconnectDelay);
|
|
||||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMessage(ws, data) {
|
|
||||||
let m;
|
|
||||||
try {
|
|
||||||
m = JSON.parse(data);
|
|
||||||
} catch {
|
|
||||||
return; // kaputtes JSON — dazu gibt es keine id, also auch keine Antwort
|
|
||||||
}
|
|
||||||
|
|
||||||
// hello beim Verbinden und result auf ein ping kommen ohne type "tab" —
|
|
||||||
// beantwortet wird nur, was eine Auftrags-id traegt.
|
|
||||||
if (m.type !== "tab") return;
|
|
||||||
|
|
||||||
const reply = (fields) =>
|
|
||||||
ws.send(JSON.stringify({ cmd: "tabresult", id: m.id, ...fields }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (m.action === "list") {
|
|
||||||
const tabs = await browser.tabs.query({});
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
tabs: tabs.map((t) => ({
|
|
||||||
id: t.id,
|
|
||||||
title: t.title,
|
|
||||||
url: t.url,
|
|
||||||
active: t.active,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
} else if (m.action === "open") {
|
|
||||||
const tab = await browser.tabs.create({ url: m.url });
|
|
||||||
reply({ ok: true, tabId: tab.id });
|
|
||||||
} else if (m.action === "close") {
|
|
||||||
if (m.tabId == null) {
|
|
||||||
reply({ ok: false });
|
|
||||||
} else {
|
|
||||||
await browser.tabs.remove(m.tabId);
|
|
||||||
reply({ ok: true });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reply({ ok: false });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`SwyxFFPlugin: Auftrag '${m.action}' fehlgeschlagen:`, err);
|
|
||||||
reply({ ok: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connect();
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"manifest_version": 2,
|
|
||||||
"name": "SwyxFFPlugin",
|
|
||||||
"version": "1.0",
|
|
||||||
"description": "Tab-Verwaltung fuer SwyxTray: verbindet sich mit ws://127.0.0.1:17655/ und beantwortet die Auftraege list, open und close.",
|
|
||||||
"permissions": ["tabs"],
|
|
||||||
"background": {
|
|
||||||
"scripts": ["background.js"],
|
|
||||||
"persistent": true
|
|
||||||
},
|
|
||||||
"browser_specific_settings": {
|
|
||||||
"gecko": {
|
|
||||||
"id": "swyxffplugin@appcreation.de",
|
|
||||||
"strict_min_version": "115.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using IpPbx.CLMgrLib;
|
using IpPbx.CLMgrLib;
|
||||||
@@ -36,8 +37,23 @@ internal sealed class SwyxClient : IDisposable
|
|||||||
/// <summary>Wird nur ausgeloest, wenn sich der Zustand tatsaechlich geaendert hat.</summary>
|
/// <summary>Wird nur ausgeloest, wenn sich der Zustand tatsaechlich geaendert hat.</summary>
|
||||||
public event Action<SwyxSnapshot, SwyxSnapshot>? SnapshotChanged;
|
public event Action<SwyxSnapshot, SwyxSnapshot>? SnapshotChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wird nach jedem erfolgreichen Neueinlesen der Adressdaten ausgeloest —
|
||||||
|
/// auch ohne inhaltliche Aenderung, damit die Abnehmer den frischen Stand
|
||||||
|
/// zugestellt bekommen.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<IReadOnlyList<SwyxContact>>? AddressesChanged;
|
||||||
|
|
||||||
public SwyxSnapshot Current { get; private set; } = SwyxSnapshot.Offline;
|
public SwyxSnapshot Current { get; private set; } = SwyxSnapshot.Offline;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Die Adressdaten aus dem Swyx-Client (globales und persoenliches
|
||||||
|
/// Telefonbuch), gelesen beim Verbindungsaufbau und danach zyklisch.
|
||||||
|
/// Bleibt bei Lesefehlern auf dem letzten erfolgreichen Stand — ein
|
||||||
|
/// veralteter Cache ist besser als ein leerer.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<SwyxContact> Addresses { get; private set; } = Array.Empty<SwyxContact>();
|
||||||
|
|
||||||
public bool IsConnected => _disp is not null;
|
public bool IsConnected => _disp is not null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -91,6 +107,10 @@ internal sealed class SwyxClient : IDisposable
|
|||||||
|
|
||||||
Log.Info($"Mit CLMgr verbunden (UserId {_userId}, Benutzer '{disp.DispGetCurrentUser}', " +
|
Log.Info($"Mit CLMgr verbunden (UserId {_userId}, Benutzer '{disp.DispGetCurrentUser}', " +
|
||||||
$"Server '{disp.DispGetCurrentServer}', {disp.DispNumberOfLines} Leitungen).");
|
$"Server '{disp.DispGetCurrentServer}', {disp.DispNumberOfLines} Leitungen).");
|
||||||
|
|
||||||
|
// Adressdaten direkt nach dem Verbindungsaufbau einlesen — damit
|
||||||
|
// deckt derselbe Weg den Programmstart und jede Neuverbindung ab.
|
||||||
|
RefreshAddresses();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is COMException or UnauthorizedAccessException)
|
catch (Exception ex) when (ex is COMException or UnauthorizedAccessException)
|
||||||
@@ -347,6 +367,233 @@ internal sealed class SwyxClient : IDisposable
|
|||||||
return index + 1;
|
return index + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Obergrenze fuer eine Kontaktantwort. Die Suche des Clients kennt keine
|
||||||
|
/// eigene Grenze; ohne Deckel koennte eine breite Anfrage ("e") tausende
|
||||||
|
/// Eintraege in eine WebSocket-Nachricht giessen.
|
||||||
|
/// </summary>
|
||||||
|
private const int MaxContactResults = 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liest die Adressdaten (globales und persoenliches Telefonbuch) neu aus
|
||||||
|
/// dem Swyx-Client und aktualisiert den Cache. Wirft nicht: bei einem
|
||||||
|
/// Lesefehler bleibt der letzte erfolgreiche Stand erhalten, gemeldet wird
|
||||||
|
/// der Fehler im Protokoll. Nach jedem erfolgreichen Lesen feuert
|
||||||
|
/// <see cref="AddressesChanged"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void RefreshAddresses()
|
||||||
|
{
|
||||||
|
var disp = _disp;
|
||||||
|
if (disp is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SwyxContact> addresses;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
addresses = ReadAddresses(disp);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is COMException or InvalidCastException
|
||||||
|
or Microsoft.CSharp.RuntimeBinder.RuntimeBinderException)
|
||||||
|
{
|
||||||
|
Log.Error("Adressdaten konnten nicht gelesen werden; der Cache behaelt " +
|
||||||
|
$"seinen letzten Stand ({Addresses.Count} Eintraege).", ex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var changed = !addresses.SequenceEqual(Addresses);
|
||||||
|
Addresses = addresses;
|
||||||
|
Log.Info($"Adressdaten gelesen: {addresses.Count} Eintraege" +
|
||||||
|
(changed ? "." : " (unveraendert)."));
|
||||||
|
AddressesChanged?.Invoke(Addresses);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<SwyxContact> ReadAddresses(IClientLineMgrDisp disp)
|
||||||
|
{
|
||||||
|
var addresses = new List<SwyxContact>();
|
||||||
|
|
||||||
|
if (disp.DispClientConfig is not IClientConfig config)
|
||||||
|
{
|
||||||
|
Log.Debug("DispClientConfig liefert keine IClientConfig — keine Adressdaten.");
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AppendPhoneBook(config.PbxPhoneBookEnumerator, addresses, "Globales Telefonbuch", hasDescription: true);
|
||||||
|
AppendPhoneBook(config.UserPhoneBookEnumerator, addresses, "Persoenliches Telefonbuch", hasDescription: false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FinalReleaseComObject(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stabile Reihenfolge: der Vergleich mit dem Cache und die Anzeige
|
||||||
|
// sollen nicht davon abhaengen, in welcher Folge CLMgr liefert.
|
||||||
|
addresses.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sammelt die Eintraege eines Telefonbuchs ein. Versteckte Eintraege
|
||||||
|
/// (<c>Hide</c>) bleiben aussen vor; sie zeigt auch SwyxIt! nicht an.
|
||||||
|
///
|
||||||
|
/// CLMgr liefert die Telefonbuecher als reine IDispatch-Objekte: weder die
|
||||||
|
/// Sammlung noch ihre Eintraege implementieren die typisierten
|
||||||
|
/// Interop-Schnittstellen (<c>IDispCollection</c>,
|
||||||
|
/// <c>IPbxPhoneBookEntryDisp</c>) — ein Cast darauf scheitert still und
|
||||||
|
/// ergibt 0 Eintraege. Aufzaehlbar ist die Sammlung aber ueber
|
||||||
|
/// <c>DISPID_NEWENUM</c> (<see cref="IEnumerable"/>), und die Eigenschaften
|
||||||
|
/// der Eintraege sind per Late-Binding erreichbar. Gegen SwyxIt! 14.21
|
||||||
|
/// geprueft: 404 Eintraege im globalen Telefonbuch.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="source">Quelle fuer <see cref="SwyxContact.Description"/>,
|
||||||
|
/// falls der Eintrag keine eigene Beschreibung hat.</param>
|
||||||
|
/// <param name="hasDescription">Nur das globale Telefonbuch fuehrt eine
|
||||||
|
/// <c>Description</c>; beim persoenlichen wuerde der Zugriff scheitern.</param>
|
||||||
|
private static void AppendPhoneBook(object? raw, List<SwyxContact> addresses, string source, bool hasDescription)
|
||||||
|
{
|
||||||
|
if (raw is null)
|
||||||
|
{
|
||||||
|
Log.Debug($"{source}: CLMgr liefert keine Sammlung.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw is not IEnumerable collection)
|
||||||
|
{
|
||||||
|
Log.Error($"{source}: Sammlung ist nicht aufzaehlbar ({raw.GetType().Name}).");
|
||||||
|
ReleaseIfCom(raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var item in collection)
|
||||||
|
{
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dynamic entry = item;
|
||||||
|
if ((int)entry.Hide != 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string name = entry.Name as string ?? string.Empty;
|
||||||
|
string number = entry.Number as string ?? string.Empty;
|
||||||
|
string description = hasDescription ? (entry.Description as string ?? string.Empty) : string.Empty;
|
||||||
|
|
||||||
|
addresses.Add(new SwyxContact(
|
||||||
|
Name: name,
|
||||||
|
Number: number,
|
||||||
|
Description: string.IsNullOrWhiteSpace(description) ? source : description));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ReleaseIfCom(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ReleaseIfCom(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReleaseIfCom(object obj)
|
||||||
|
{
|
||||||
|
if (Marshal.IsComObject(obj))
|
||||||
|
{
|
||||||
|
Marshal.FinalReleaseComObject(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Durchsucht die Adressdaten ueber den Swyx-Client: globales und
|
||||||
|
/// persoenliches Telefonbuch sowie die im Client eingerichteten
|
||||||
|
/// Kontakt-Plugins, jeweils auch nach Rufnummern. Die Suche selbst macht
|
||||||
|
/// der Client — hier wird nur das Ergebnis eingesammelt.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<SwyxContact> SearchContacts(string? query)
|
||||||
|
{
|
||||||
|
var disp = RequireConnection();
|
||||||
|
|
||||||
|
var term = (query ?? string.Empty).Trim();
|
||||||
|
if (term.Length is 0 or > 128)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen).");
|
||||||
|
}
|
||||||
|
|
||||||
|
disp.FulltextSearchInContactsEx(term,
|
||||||
|
bSearchInPhonebook: 1, bSearchInPlugins: 1, bSearchInNumbers: 1,
|
||||||
|
out var raw);
|
||||||
|
|
||||||
|
if (raw is not INameNumberSearchResultCollection collection)
|
||||||
|
{
|
||||||
|
return Array.Empty<SwyxContact>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var contacts = new List<SwyxContact>(Math.Min(collection.Count, MaxContactResults));
|
||||||
|
|
||||||
|
foreach (var item in collection)
|
||||||
|
{
|
||||||
|
if (item is not INameNumberSearchResult result)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (contacts.Count >= MaxContactResults)
|
||||||
|
{
|
||||||
|
Log.Info($"Kontaktsuche '{term}': mehr als {MaxContactResults} Treffer, " +
|
||||||
|
"Antwort wird abgeschnitten.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
contacts.Add(new SwyxContact(
|
||||||
|
Name: result.Name ?? string.Empty,
|
||||||
|
Number: result.Number ?? string.Empty,
|
||||||
|
Description: result.Description ?? string.Empty));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FinalReleaseComObject(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Debug($"Kontaktsuche '{term}': {contacts.Count} Treffer.");
|
||||||
|
return contacts;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FinalReleaseComObject(collection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loest eine Rufnummer ueber die Adressdaten des Clients in einen Namen
|
||||||
|
/// auf — derselbe Weg, ueber den auch SwyxIt! Anrufer beschriftet.
|
||||||
|
/// Liefert eine leere Zeichenkette, wenn der Client nichts findet.
|
||||||
|
/// </summary>
|
||||||
|
public string ResolveNumber(string? number)
|
||||||
|
{
|
||||||
|
var disp = RequireConnection();
|
||||||
|
var dialstring = SanitizeNumber(number);
|
||||||
|
var resolved = disp.DispResolveNumber(dialstring) ?? string.Empty;
|
||||||
|
|
||||||
|
// Kennt der Client die Nummer nicht, gibt er sie unveraendert zurueck —
|
||||||
|
// das ist kein Name und wird deshalb als "nichts gefunden" gemeldet.
|
||||||
|
return resolved == dialstring ? string.Empty : resolved;
|
||||||
|
}
|
||||||
|
|
||||||
private IClientLineMgrDisp RequireConnection()
|
private IClientLineMgrDisp RequireConnection()
|
||||||
=> _disp ?? throw new InvalidOperationException("Keine Verbindung zu SwyxIt!.");
|
=> _disp ?? throw new InvalidOperationException("Keine Verbindung zu SwyxIt!.");
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ public sealed record SwyxLineInfo(
|
|||||||
public bool IsBusy => !State.IsIdle();
|
public bool IsBusy => !State.IsIdle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ein Adressdatensatz aus der Kontaktsuche des Clients. <c>Description</c>
|
||||||
|
/// nennt die Quelle bzw. Zusatzangaben, so wie der Client sie liefert
|
||||||
|
/// (z. B. Telefonbuch- oder Plugin-Herkunft).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SwyxContact(string Name, string Number, string Description);
|
||||||
|
|
||||||
/// <summary>Momentaufnahme des gesamten Clients.</summary>
|
/// <summary>Momentaufnahme des gesamten Clients.</summary>
|
||||||
public sealed record SwyxSnapshot(
|
public sealed record SwyxSnapshot(
|
||||||
bool IsConnected,
|
bool IsConnected,
|
||||||
|
|||||||
@@ -23,21 +23,22 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly TimeSpan PollingOnlyInterval = TimeSpan.FromSeconds(1);
|
private static readonly TimeSpan PollingOnlyInterval = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zyklus fuer das Neueinlesen der Adressdaten aus dem Swyx-Client. Der
|
||||||
|
/// erste Abruf laeuft direkt beim Verbindungsaufbau (siehe
|
||||||
|
/// <see cref="SwyxClient"/>); dieser Zyklus haelt den Cache aktuell.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan AddressRefreshInterval = TimeSpan.FromMinutes(60);
|
||||||
|
|
||||||
private readonly NotifyIcon _notifyIcon;
|
private readonly NotifyIcon _notifyIcon;
|
||||||
private readonly SwyxClient _client;
|
private readonly SwyxClient _client;
|
||||||
private readonly System.Windows.Forms.Timer _safetyTimer;
|
private readonly System.Windows.Forms.Timer _safetyTimer;
|
||||||
|
private readonly System.Windows.Forms.Timer _addressTimer;
|
||||||
private readonly ToolStripMenuItem _statusItem;
|
private readonly ToolStripMenuItem _statusItem;
|
||||||
private readonly ToolStripMenuItem _linesItem;
|
private readonly ToolStripMenuItem _linesItem;
|
||||||
private readonly ToolStripSeparator _linesSeparator;
|
private readonly ToolStripSeparator _linesSeparator;
|
||||||
private readonly ToolStripMenuItem _webSocketItem;
|
private readonly ToolStripMenuItem _webSocketItem;
|
||||||
private readonly ToolStripMenuItem _copyEndpointItem;
|
private readonly ToolStripMenuItem _copyEndpointItem;
|
||||||
private readonly ToolStripMenuItem _readTabsItem;
|
|
||||||
private readonly ToolStripMenuItem _openTabItem;
|
|
||||||
private readonly ToolStripMenuItem _closeTabItem;
|
|
||||||
|
|
||||||
/// <summary>Ziel der Menuepunkte "Tab oeffnen" und "Tab schliessen".</summary>
|
|
||||||
private const string AssecutorUrl = "https://www.assecutor.de";
|
|
||||||
private const string AssecutorTitle = "Assecutor Data Service GmbH";
|
|
||||||
|
|
||||||
private LocalWebSocketServer? _server;
|
private LocalWebSocketServer? _server;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -58,13 +59,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||||||
(_, _) => OpenWithShell(WebSocketConfig.FilePath))
|
(_, _) => OpenWithShell(WebSocketConfig.FilePath))
|
||||||
});
|
});
|
||||||
|
|
||||||
_readTabsItem = new ToolStripMenuItem(
|
|
||||||
"Lese Tabs", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, ReadTabsAsync)) { Enabled = false };
|
|
||||||
_openTabItem = new ToolStripMenuItem(
|
|
||||||
"Tab oeffnen", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, OpenAssecutorTabAsync)) { Enabled = false };
|
|
||||||
_closeTabItem = new ToolStripMenuItem(
|
|
||||||
"Tab schliessen", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, CloseAssecutorTabAsync)) { Enabled = false };
|
|
||||||
|
|
||||||
var menu = new ContextMenuStrip();
|
var menu = new ContextMenuStrip();
|
||||||
menu.Items.AddRange(new ToolStripItem[]
|
menu.Items.AddRange(new ToolStripItem[]
|
||||||
{
|
{
|
||||||
@@ -72,9 +66,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||||||
_linesItem,
|
_linesItem,
|
||||||
_linesSeparator,
|
_linesSeparator,
|
||||||
_webSocketItem,
|
_webSocketItem,
|
||||||
_readTabsItem,
|
|
||||||
_openTabItem,
|
|
||||||
_closeTabItem,
|
|
||||||
new ToolStripSeparator(),
|
new ToolStripSeparator(),
|
||||||
new ToolStripMenuItem("Neu verbinden", null, (_, _) => Reconnect()),
|
new ToolStripMenuItem("Neu verbinden", null, (_, _) => Reconnect()),
|
||||||
new ToolStripMenuItem("Protokoll oeffnen", null, (_, _) => OpenLog()),
|
new ToolStripMenuItem("Protokoll oeffnen", null, (_, _) => OpenLog()),
|
||||||
@@ -93,6 +84,14 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||||||
|
|
||||||
_client = new SwyxClient();
|
_client = new SwyxClient();
|
||||||
_client.SnapshotChanged += OnSnapshotChanged;
|
_client.SnapshotChanged += OnSnapshotChanged;
|
||||||
|
_client.AddressesChanged += OnAddressesChanged;
|
||||||
|
|
||||||
|
_addressTimer = new System.Windows.Forms.Timer
|
||||||
|
{
|
||||||
|
Interval = (int)AddressRefreshInterval.TotalMilliseconds
|
||||||
|
};
|
||||||
|
_addressTimer.Tick += (_, _) => _client.RefreshAddresses();
|
||||||
|
_addressTimer.Start();
|
||||||
|
|
||||||
_safetyTimer = new System.Windows.Forms.Timer { Interval = (int)SafetyInterval.TotalMilliseconds };
|
_safetyTimer = new System.Windows.Forms.Timer { Interval = (int)SafetyInterval.TotalMilliseconds };
|
||||||
_safetyTimer.Tick += (_, _) =>
|
_safetyTimer.Tick += (_, _) =>
|
||||||
@@ -168,157 +167,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||||||
: "WebSocket: aus (siehe Protokoll)";
|
: "WebSocket: aus (siehe Protokoll)";
|
||||||
|
|
||||||
_copyEndpointItem.Enabled = _server is { IsRunning: true };
|
_copyEndpointItem.Enabled = _server is { IsRunning: true };
|
||||||
_readTabsItem.Enabled = _openTabItem.Enabled = _closeTabItem.Enabled =
|
|
||||||
_server is { IsRunning: true, PluginPort: > 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gemeinsamer Rahmen der Tab-Menuepunkte: waehrend der Auftrag beim
|
|
||||||
/// Firefox-Plugin laeuft, ist der Punkt gesperrt — sonst stiesse ein
|
|
||||||
/// zweiter Klick eine zweite Anfrage an. async void ist hier richtig:
|
|
||||||
/// ein Menue-Handler hat keinen Aufrufer, der auf ein Task warten koennte.
|
|
||||||
/// </summary>
|
|
||||||
private async void RunTabAction(ToolStripMenuItem item, Func<LocalWebSocketServer, Task> action)
|
|
||||||
{
|
|
||||||
if (_server is not { IsRunning: true } server)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
item.Enabled = false;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await action(server);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (!_disposed)
|
|
||||||
{
|
|
||||||
item.Enabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Holt die offenen Tabs vom Firefox-Plugin (ueber den Plugin-Port) und
|
|
||||||
/// zeigt ihre Titel als Sprechblase; die vollstaendige Liste samt Adressen
|
|
||||||
/// steht im Protokoll.
|
|
||||||
/// </summary>
|
|
||||||
private async Task ReadTabsAsync(LocalWebSocketServer server)
|
|
||||||
{
|
|
||||||
var tabs = await server.TryListTabsAsync(CancellationToken.None);
|
|
||||||
|
|
||||||
if (_disposed)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tabs is null)
|
|
||||||
{
|
|
||||||
_notifyIcon.ShowBalloonTip(5_000, "Lese Tabs",
|
|
||||||
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tabs.Count == 0)
|
|
||||||
{
|
|
||||||
_notifyIcon.ShowBalloonTip(5_000, "Lese Tabs", "Keine Tabs offen.", ToolTipIcon.Info);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Info($"Firefox meldet {tabs.Count} offene(n) Tab(s): " + string.Join(" | ",
|
|
||||||
tabs.Select(t => $"{(t.Active == true ? "*" : "")}{t.Title} <{t.Url}>")));
|
|
||||||
|
|
||||||
// Eine Sprechblase fasst hoechstens 255 Zeichen — die ersten Titel
|
|
||||||
// genuegen, der Rest steht im Protokoll.
|
|
||||||
const int maxShown = 5;
|
|
||||||
var lines = tabs.Take(maxShown)
|
|
||||||
.Select(t => Truncate((t.Active == true ? "▶ " : "• ") + (t.Title ?? "(ohne Titel)"), 40))
|
|
||||||
.ToList();
|
|
||||||
if (tabs.Count > maxShown)
|
|
||||||
{
|
|
||||||
lines.Add($"… und {tabs.Count - maxShown} weitere (siehe Protokoll)");
|
|
||||||
}
|
|
||||||
|
|
||||||
_notifyIcon.ShowBalloonTip(10_000, $"{tabs.Count} offene(r) Firefox-Tab(s)",
|
|
||||||
Truncate(string.Join(Environment.NewLine, lines), 255), ToolTipIcon.Info);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Oeffnet ueber das Plugin einen Tab mit der Assecutor-Seite.</summary>
|
|
||||||
private async Task OpenAssecutorTabAsync(LocalWebSocketServer server)
|
|
||||||
{
|
|
||||||
var tabId = await server.TryOpenTabAsync(AssecutorUrl, CancellationToken.None);
|
|
||||||
|
|
||||||
if (_disposed)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tabId is null)
|
|
||||||
{
|
|
||||||
_notifyIcon.ShowBalloonTip(5_000, "Tab oeffnen",
|
|
||||||
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Info($"Firefox-Tab {tabId} mit {AssecutorUrl} geoeffnet.");
|
|
||||||
_notifyIcon.ShowBalloonTip(5_000, "Tab oeffnen", $"{AssecutorUrl} geoeffnet.", ToolTipIcon.Info);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Schliesst die Tabs mit dem Assecutor-Titel — wie beim focus-Kommando
|
|
||||||
/// als Komposition aus den Grundauftraegen des Kanals: erst <c>list</c>,
|
|
||||||
/// dann je Treffer ein <c>close</c> mit der Tab-Id. So laesst sich auch
|
|
||||||
/// unterscheiden, ob das Plugin fehlt oder nur kein Tab passt.
|
|
||||||
/// </summary>
|
|
||||||
private async Task CloseAssecutorTabAsync(LocalWebSocketServer server)
|
|
||||||
{
|
|
||||||
var tabs = await server.TryListTabsAsync(CancellationToken.None);
|
|
||||||
|
|
||||||
if (_disposed)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tabs is null)
|
|
||||||
{
|
|
||||||
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
|
|
||||||
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var matching = tabs
|
|
||||||
.Where(t => t.Id is not null
|
|
||||||
&& t.Title?.Contains(AssecutorTitle, StringComparison.OrdinalIgnoreCase) == true)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (matching.Count == 0)
|
|
||||||
{
|
|
||||||
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
|
|
||||||
$"Kein Tab \"{AssecutorTitle}\" offen.", ToolTipIcon.Warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var closed = 0;
|
|
||||||
foreach (var tab in matching)
|
|
||||||
{
|
|
||||||
if (await server.TryCloseTabAsync(tab.Id!.Value, CancellationToken.None))
|
|
||||||
{
|
|
||||||
closed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_disposed)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Info($"Firefox-Tab(s) \"{AssecutorTitle}\": {closed} von {matching.Count} geschlossen.");
|
|
||||||
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
|
|
||||||
closed == matching.Count
|
|
||||||
? $"Tab \"{AssecutorTitle}\" geschlossen."
|
|
||||||
: $"Nur {closed} von {matching.Count} Tabs geschlossen (siehe Protokoll).",
|
|
||||||
closed == matching.Count ? ToolTipIcon.Info : ToolTipIcon.Warning);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -370,6 +218,9 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||||||
_server?.Broadcast(previous, current);
|
_server?.Broadcast(previous, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnAddressesChanged(IReadOnlyList<SwyxContact> addresses)
|
||||||
|
=> _server?.BroadcastAddresses(addresses);
|
||||||
|
|
||||||
private void UpdateIcon(SwyxSnapshot s)
|
private void UpdateIcon(SwyxSnapshot s)
|
||||||
{
|
{
|
||||||
_notifyIcon.Icon = TrayIcons.For(s.Overall);
|
_notifyIcon.Icon = TrayIcons.For(s.Overall);
|
||||||
@@ -490,8 +341,11 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
|||||||
_disposed = true;
|
_disposed = true;
|
||||||
_safetyTimer.Stop();
|
_safetyTimer.Stop();
|
||||||
_safetyTimer.Dispose();
|
_safetyTimer.Dispose();
|
||||||
|
_addressTimer.Stop();
|
||||||
|
_addressTimer.Dispose();
|
||||||
_server?.Dispose();
|
_server?.Dispose();
|
||||||
_client.SnapshotChanged -= OnSnapshotChanged;
|
_client.SnapshotChanged -= OnSnapshotChanged;
|
||||||
|
_client.AddressesChanged -= OnAddressesChanged;
|
||||||
_client.Dispose();
|
_client.Dispose();
|
||||||
_notifyIcon.Visible = false;
|
_notifyIcon.Visible = false;
|
||||||
_notifyIcon.ContextMenuStrip?.Dispose();
|
_notifyIcon.ContextMenuStrip?.Dispose();
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ internal sealed class CommandExecutor
|
|||||||
public Task<SwyxSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
|
public Task<SwyxSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
|
||||||
=> RunAsync(() => _client.Current, cancellationToken);
|
=> RunAsync(() => _client.Current, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Die zwischengespeicherten Adressdaten aus dem Swyx-Client.</summary>
|
||||||
|
public Task<IReadOnlyList<SwyxContact>> GetAddressesAsync(CancellationToken cancellationToken)
|
||||||
|
=> RunAsync(() => _client.Addresses, cancellationToken);
|
||||||
|
|
||||||
public async Task<ResultMessage> ExecuteAsync(ClientCommand command, CancellationToken cancellationToken)
|
public async Task<ResultMessage> ExecuteAsync(ClientCommand command, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -59,6 +63,22 @@ internal sealed class CommandExecutor
|
|||||||
Line: await RunAsync(() => _client.Hold(command.Line), cancellationToken)
|
Line: await RunAsync(() => _client.Hold(command.Line), cancellationToken)
|
||||||
.ConfigureAwait(false));
|
.ConfigureAwait(false));
|
||||||
|
|
||||||
|
case "addresses":
|
||||||
|
return new ResultMessage(command.Id, true,
|
||||||
|
Addresses: (await GetAddressesAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
.Select(ContactMessage.From).ToList());
|
||||||
|
|
||||||
|
case "contacts":
|
||||||
|
return new ResultMessage(command.Id, true,
|
||||||
|
Contacts: (await RunAsync(() => _client.SearchContacts(command.Query), cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
.Select(ContactMessage.From).ToList());
|
||||||
|
|
||||||
|
case "resolve":
|
||||||
|
return new ResultMessage(command.Id, true,
|
||||||
|
Name: await RunAsync(() => _client.ResolveNumber(command.Number), cancellationToken)
|
||||||
|
.ConfigureAwait(false));
|
||||||
|
|
||||||
case "focus":
|
case "focus":
|
||||||
return new ResultMessage(command.Id, true,
|
return new ResultMessage(command.Id, true,
|
||||||
Focused: await FocusAsync(command, cancellationToken).ConfigureAwait(false));
|
Focused: await FocusAsync(command, cancellationToken).ConfigureAwait(false));
|
||||||
|
|||||||
@@ -59,27 +59,6 @@ internal sealed class LocalWebSocketServer : IDisposable
|
|||||||
|
|
||||||
public bool IsRemote => _config.AllowRemoteAccess;
|
public bool IsRemote => _config.AllowRemoteAccess;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fragt die offenen Tabs beim Firefox-Plugin ab. <c>null</c>, wenn kein
|
|
||||||
/// Plugin verbunden ist, keine Antwort kommt oder es den Auftrag ablehnt.
|
|
||||||
/// </summary>
|
|
||||||
public Task<IReadOnlyList<TabInfo>?> TryListTabsAsync(CancellationToken cancellationToken)
|
|
||||||
=> _tabChannel.TryListTabsAsync(cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Oeffnet ueber das Firefox-Plugin einen neuen Tab mit der URL.
|
|
||||||
/// <c>null</c> bei fehlendem Plugin, Zeitablauf oder Ablehnung.
|
|
||||||
/// </summary>
|
|
||||||
public Task<int?> TryOpenTabAsync(string url, CancellationToken cancellationToken)
|
|
||||||
=> _tabChannel.TryOpenTabAsync(url, cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Schliesst ueber das Firefox-Plugin den Tab mit dieser Id.
|
|
||||||
/// <c>true</c> nur bei bestaetigtem Erfolg.
|
|
||||||
/// </summary>
|
|
||||||
public Task<bool> TryCloseTabAsync(int tabId, CancellationToken cancellationToken)
|
|
||||||
=> _tabChannel.TryCloseTabAsync(tabId, cancellationToken);
|
|
||||||
|
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
if (!_config.Enabled)
|
if (!_config.Enabled)
|
||||||
@@ -336,6 +315,31 @@ internal sealed class LocalWebSocketServer : IDisposable
|
|||||||
/// <summary>Verteilt nur den Zustand, ohne Anruf-Ereignisse abzuleiten.</summary>
|
/// <summary>Verteilt nur den Zustand, ohne Anruf-Ereignisse abzuleiten.</summary>
|
||||||
public void Broadcast(SwyxSnapshot snapshot) => Broadcast(snapshot, snapshot);
|
public void Broadcast(SwyxSnapshot snapshot) => Broadcast(snapshot, snapshot);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verteilt die neu eingelesenen Adressdaten an alle Verbundenen — nur an
|
||||||
|
/// den Hauptport, wie die Zustandsmeldungen. Neue Verbindungen bekommen
|
||||||
|
/// den Cache ohnehin direkt nach dem snapshot zugestellt.
|
||||||
|
/// </summary>
|
||||||
|
public void BroadcastAddresses(IReadOnlyList<SwyxContact> addresses)
|
||||||
|
{
|
||||||
|
if (_sessions.IsEmpty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = Protocol.Serialize(AddressesMessage.From(addresses));
|
||||||
|
|
||||||
|
foreach (var session in _sessions.Values)
|
||||||
|
{
|
||||||
|
if (session.IsPlugin)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = session.TrySendManyAsync([message], _shutdown.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verteilt einen Zustand samt der daraus abgeleiteten Anruf-Ereignisse an
|
/// Verteilt einen Zustand samt der daraus abgeleiteten Anruf-Ereignisse an
|
||||||
/// alle Verbundenen — erst der snapshot, dann die Ereignisse, damit der
|
/// alle Verbundenen — erst der snapshot, dann die Ereignisse, damit der
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ internal sealed class ClientCommand
|
|||||||
|
|
||||||
public string? Cmd { get; set; }
|
public string? Cmd { get; set; }
|
||||||
|
|
||||||
/// <summary>Nur bei <c>call</c>.</summary>
|
/// <summary>Bei <c>call</c> und <c>resolve</c>.</summary>
|
||||||
public string? Number { get; set; }
|
public string? Number { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Nur bei <c>contacts</c>: der Suchbegriff (Name oder Nummer).</summary>
|
||||||
|
public string? Query { get; set; }
|
||||||
|
|
||||||
/// <summary>1-basierte Leitung; fehlt sie, waehlt die App selbst.</summary>
|
/// <summary>1-basierte Leitung; fehlt sie, waehlt die App selbst.</summary>
|
||||||
public int? Line { get; set; }
|
public int? Line { get; set; }
|
||||||
|
|
||||||
@@ -71,15 +74,42 @@ internal sealed record HelloMessage(string App, string Version, int Protocol, in
|
|||||||
public string Type => "hello";
|
public string Type => "hello";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein Adressdatensatz in der Antwort auf <c>contacts</c> und in
|
||||||
|
/// der <c>addresses</c>-Nachricht.</summary>
|
||||||
|
internal sealed record ContactMessage(string Name, string Number, string Description)
|
||||||
|
{
|
||||||
|
public static ContactMessage From(SwyxContact contact)
|
||||||
|
=> new(contact.Name, contact.Number, contact.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Die zwischengespeicherten Adressdaten aus dem Swyx-Client (globales und
|
||||||
|
/// persoenliches Telefonbuch). Wird nach dem <c>snapshot</c> beim Verbinden,
|
||||||
|
/// auf <c>status</c> und nach jedem Neueinlesen mit Aenderung gesendet — die
|
||||||
|
/// Seite muss die Daten also nie selbst anfordern.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record AddressesMessage(IReadOnlyList<ContactMessage> Addresses)
|
||||||
|
{
|
||||||
|
public string Type => "addresses";
|
||||||
|
|
||||||
|
public static AddressesMessage From(IEnumerable<SwyxContact> addresses)
|
||||||
|
=> new(addresses.Select(ContactMessage.From).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Antwort auf genau ein Kommando. <c>Focused</c> wird nur bei <c>focus</c>
|
/// Antwort auf genau ein Kommando. <c>Focused</c> wird nur bei <c>focus</c>
|
||||||
/// gesetzt: <c>true</c> = Fenster in den Vordergrund geholt, <c>false</c> =
|
/// gesetzt: <c>true</c> = Fenster in den Vordergrund geholt, <c>false</c> =
|
||||||
/// stattdessen den Browser mit der URL gestartet. <c>Tabs</c> steht nur in
|
/// stattdessen den Browser mit der URL gestartet. <c>Tabs</c> steht nur in
|
||||||
/// der Antwort auf <c>tabs</c>, <c>TabId</c> nur in der auf <c>opentab</c>.
|
/// der Antwort auf <c>tabs</c>, <c>TabId</c> nur in der auf <c>opentab</c>,
|
||||||
|
/// <c>Contacts</c> nur in der auf <c>contacts</c>, <c>Name</c> nur in der
|
||||||
|
/// auf <c>resolve</c> (leer, wenn der Client die Nummer nicht kennt),
|
||||||
|
/// <c>Addresses</c> nur in der auf <c>addresses</c> (der komplette Cache).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed record ResultMessage(
|
internal sealed record ResultMessage(
|
||||||
int? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null,
|
int? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null,
|
||||||
int? TabId = null, IReadOnlyList<TabInfo>? Tabs = null)
|
int? TabId = null, IReadOnlyList<TabInfo>? Tabs = null,
|
||||||
|
IReadOnlyList<ContactMessage>? Contacts = null, string? Name = null,
|
||||||
|
IReadOnlyList<ContactMessage>? Addresses = null)
|
||||||
{
|
{
|
||||||
public string Type => "result";
|
public string Type => "result";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,16 @@ internal sealed class WebSocketConfig
|
|||||||
/// <c>open</c> und <c>close</c> aufgeteilt.
|
/// <c>open</c> und <c>close</c> aufgeteilt.
|
||||||
/// Version 6: die Tab-Verwaltung steht als <c>tabs</c>, <c>opentab</c>
|
/// Version 6: die Tab-Verwaltung steht als <c>tabs</c>, <c>opentab</c>
|
||||||
/// und <c>closetab</c> auch Webseiten auf dem Hauptport offen.
|
/// und <c>closetab</c> auch Webseiten auf dem Hauptport offen.
|
||||||
|
/// Version 7: Adressdaten aus dem Swyx-Client — <c>contacts</c>
|
||||||
|
/// (Kontaktsuche in Telefonbuechern und Kontakt-Plugins) und
|
||||||
|
/// <c>resolve</c> (Rufnummer zu Name).
|
||||||
|
/// Version 8: <c>addresses</c>-Nachricht mit den zwischengespeicherten
|
||||||
|
/// Telefonbuecher-Eintraegen — beim Verbinden, auf <c>status</c> und nach
|
||||||
|
/// jedem Neueinlesen (Start, Neuverbindung, alle 60 Minuten). Dazu das
|
||||||
|
/// Kommando <c>addresses</c>, mit dem eine Seite den Cache jederzeit
|
||||||
|
/// selbst abrufen kann.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int ProtocolVersion = 6;
|
public const int ProtocolVersion = 8;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen —
|
/// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen —
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ internal sealed class WebSocketSession : IDisposable
|
|||||||
if (!IsPlugin)
|
if (!IsPlugin)
|
||||||
{
|
{
|
||||||
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await SendAddressesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var buffer = new byte[8 * 1024];
|
var buffer = new byte[8 * 1024];
|
||||||
@@ -128,11 +129,13 @@ internal sealed class WebSocketSession : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'status' beantwortet der Server mit dem vollen Zustand; die Quittung
|
// 'status' beantwortet der Server mit dem vollen Zustand samt der
|
||||||
// danach haelt den Ablauf fuer den Client einheitlich.
|
// zwischengespeicherten Adressdaten; die Quittung danach haelt den
|
||||||
|
// Ablauf fuer den Client einheitlich.
|
||||||
if (command.Cmd.Equals("status", StringComparison.OrdinalIgnoreCase))
|
if (command.Cmd.Equals("status", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await SendAddressesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken)
|
await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
return;
|
return;
|
||||||
@@ -181,6 +184,14 @@ internal sealed class WebSocketSession : IDisposable
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Stellt der Seite die zwischengespeicherten Adressdaten zu.</summary>
|
||||||
|
private async Task SendAddressesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var addresses = await _executor.GetAddressesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await SendAsync(Protocol.Serialize(AddressesMessage.From(addresses)), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Auf einem WebSocket darf immer nur ein Sendevorgang gleichzeitig laufen.
|
/// Auf einem WebSocket darf immer nur ein Sendevorgang gleichzeitig laufen.
|
||||||
/// Da Zustandsmeldungen aus dem UI-Thread und Antworten aus dem
|
/// Da Zustandsmeldungen aus dem UI-Thread und Antworten aus dem
|
||||||
|
|||||||
Reference in New Issue
Block a user