diff --git a/Anrufmonitor.slnx b/Anrufmonitor.slnx new file mode 100644 index 0000000..912db0a --- /dev/null +++ b/Anrufmonitor.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/README.md b/README.md index 408d5d1..9849a56 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,61 @@ # Anrufmonitor -SIP-Client (Windows / Java, ggf. Mac), der eingehende Anrufe registriert und automatisch den passenden Kontakt anzeigt. +SIP-Client (Windows, .NET/C# Tray-App), der eingehende Anrufe passiv mitliest und automatisch +den passenden Kontakt aus Office 365, Google Contacts oder einem öffentlichen Verzeichnis anzeigt. -## Ziel +## Funktionsweise -- SIP-Registrierung, eingehende Anrufe erkennen (Rufnummer-Anzeige) -- Kontaktabgleich gegen: - - Office 365 (Microsoft Graph API) - - Google Contacts (Google People API) - - Öffentliche Verzeichnisse (z. B. Telefonbuch-APIs) -- Caching der Kontakte für schnelles Auffinden bei eingehendem Anruf -- Popup/Notification mit Name, Firma, Foto (falls vorhanden) bei Anruf +- **SIP:** registriert sich bei der Telefonanlage/dem Provider und liest eingehende `INVITE`-Requests + passiv mit (Anrufer-Rufnummer). Es wird **nie** `Answer()` aufgerufen – der Client nimmt den Anruf + nicht an, sondern zeigt nur an, wer anruft. Voraussetzung: die Telefonanlage forkt eingehende Anrufe + an mehrere registrierte Geräte/Kontakte derselben Nebenstelle. +- **Kontakte:** werden periodisch im Hintergrund aus Office 365 (Microsoft Graph) und Google Contacts + (People API) in einen lokalen SQLite-Cache synchronisiert. Beim eingehenden Anruf wird **nur der + Cache** abgefragt (schnell, keine Live-API-Calls während des Anrufs). Ist die Nummer unbekannt, kann + optional ein öffentliches Verzeichnis per On-Demand-Reverse-Lookup gefragt werden (Ergebnis wird + ebenfalls gecacht). +- **UI:** Tray-Icon, Popup unten rechts mit Name/Firma/Nummer bei eingehendem Anruf. -## Status +## Projektstruktur -Projekt-Setup. Architektur und Plattform (Windows-Programm, Java/JVM plattformübergreifend, ggf. macOS) noch offen. +``` +src/ + Anrufmonitor.App/ WPF-Tray-App (Einstiegspunkt, appsettings.json) + Anrufmonitor.Sip/ Passiver SIP-Monitor (SIPSorcery) + Anrufmonitor.Contacts/ Kontakt-Provider, SQLite-Cache, Lookup-Service +tests/ + Anrufmonitor.Tests/ xUnit-Tests +``` -## Mögliche Architektur (Vorschlag, nicht final) +## Bauen & Starten -- SIP-Stack: z. B. PJSIP / JsSIP (nur Signaling, kein Media nötig, da nur Anruferkennung) -- Kontakt-Cache: lokale SQLite-DB, TTL-basiertes Refresh -- Auth: OAuth2 gegen Microsoft Graph und Google People API -- UI: Tray-Icon + Popup-Notification +```powershell +dotnet build Anrufmonitor.slnx +dotnet test tests\Anrufmonitor.Tests\Anrufmonitor.Tests.csproj +dotnet run --project src\Anrufmonitor.App\Anrufmonitor.App.csproj +``` + +## Konfiguration + +`src\Anrufmonitor.App\appsettings.json` enthält nur die Struktur (keine Secrets, wird committet). +Für echte Werte `appsettings.Local.json.example` nach `appsettings.Local.json` kopieren +(liegt in `.gitignore`, wird beim Build automatisch mitkopiert falls vorhanden) und ausfüllen: + +- **Sip:** Server, Username, Password der Telefonanlage. +- **Contacts:Office365:** App-Registration in Azure/Entra (Typ *Public client*, Delegated Permission + `Contacts.Read`), `ClientId` eintragen. Anmeldung erfolgt beim ersten Sync per Device-Code-Flow + (Code wird geloggt, im Browser unter microsoft.com/devicelogin eingeben). +- **Contacts:Google:** OAuth-Client (Typ *Desktop app*) in der Google Cloud Console, People API + aktivieren, `ClientId`/`ClientSecret` eintragen. Anmeldung per Browser-Login beim ersten Sync, + Token wird danach lokal zwischengespeichert. +- **Contacts:PublicDirectory:** optional, generisches URL-Template (`{number}` wird ersetzt) für + ein öffentliches Verzeichnis – muss je nach Anbieter angepasst werden + (`PublicDirectoryLookupProvider.ExtractDisplayName`). ## Sicherheit **Public Repo** – niemals Zugangsdaten, Tokens, Client-Secrets oder SIP-Passwörter committen. -Konfiguration ausschließlich über lokale, per `.gitignore` ausgeschlossene Dateien (siehe `.gitignore`) oder Umgebungsvariablen. +Konfiguration ausschließlich über `appsettings.Local.json` (gitignored) oder Umgebungsvariablen. ## Lizenz diff --git a/src/Anrufmonitor.App/Anrufmonitor.App.csproj b/src/Anrufmonitor.App/Anrufmonitor.App.csproj new file mode 100644 index 0000000..983f6a3 --- /dev/null +++ b/src/Anrufmonitor.App/Anrufmonitor.App.csproj @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + + WinExe + net10.0-windows + enable + enable + true + true + + + diff --git a/src/Anrufmonitor.App/App.xaml b/src/Anrufmonitor.App/App.xaml new file mode 100644 index 0000000..a008662 --- /dev/null +++ b/src/Anrufmonitor.App/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/src/Anrufmonitor.App/App.xaml.cs b/src/Anrufmonitor.App/App.xaml.cs new file mode 100644 index 0000000..8cc7917 --- /dev/null +++ b/src/Anrufmonitor.App/App.xaml.cs @@ -0,0 +1,112 @@ +using Anrufmonitor.Contacts; +using Anrufmonitor.Sip; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Anrufmonitor.App; + +/// +/// Startpunkt der Anwendung. Baut den generischen Host (DI/Config/Logging), startet +/// SIP-Monitor und Kontakt-Sync im Hintergrund und zeigt bei eingehenden Anrufen +/// ein Popup an. Die App laeuft ausschliesslich im System-Tray, es gibt kein +/// klassisches Hauptfenster ( = OnExplicitShutdown). +/// +public partial class App : System.Windows.Application +{ + private IHost? _host; + private TrayIconManager? _trayIconManager; + + protected override async void OnStartup(System.Windows.StartupEventArgs e) + { + base.OnStartup(e); + + _host = Host.CreateDefaultBuilder() + .ConfigureAppConfiguration(builder => + { + builder.SetBasePath(AppContext.BaseDirectory); + builder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false); + builder.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false); + }) + .ConfigureServices((context, services) => + { + services.Configure(context.Configuration.GetSection(ContactsOptions.SectionName)); + services.Configure(context.Configuration.GetSection(SipOptions.SectionName)); + + services.AddHttpClient(); + + services.AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + return new ContactCache(options.ResolveCacheDatabasePath(), sp.GetRequiredService>()); + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + }) + .Build(); + + await _host.StartAsync(); + + var sipMonitor = _host.Services.GetRequiredService(); + var lookupService = _host.Services.GetRequiredService(); + var logger = _host.Services.GetRequiredService>(); + + _trayIconManager = new TrayIconManager( + onSyncNow: async () => + { + var syncService = _host.Services.GetRequiredService(); + await syncService.SyncOnceAsync(CancellationToken.None); + }, + onExit: () => System.Windows.Application.Current.Shutdown()); + _trayIconManager.Show(); + + sipMonitor.IncomingCall += async (_, args) => + { + try + { + var contact = await lookupService.ResolveAsync(args.CallerNumber, CancellationToken.None); + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + var popup = new CallPopupWindow(contact, args.CallId); + popup.Show(); + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Fehler beim Anzeigen des Anruf-Popups"); + } + }; + + sipMonitor.CallEnded += (_, args) => + { + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + CallPopupWindow.CloseByCallId(args.CallId); + }); + }; + } + + protected override async void OnExit(System.Windows.ExitEventArgs e) + { + _trayIconManager?.Dispose(); + + if (_host is not null) + { + await _host.StopAsync(TimeSpan.FromSeconds(3)); + _host.Dispose(); + } + + base.OnExit(e); + } +} diff --git a/src/Anrufmonitor.App/AssemblyInfo.cs b/src/Anrufmonitor.App/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/src/Anrufmonitor.App/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/src/Anrufmonitor.App/CallPopupWindow.xaml b/src/Anrufmonitor.App/CallPopupWindow.xaml new file mode 100644 index 0000000..2907b0c --- /dev/null +++ b/src/Anrufmonitor.App/CallPopupWindow.xaml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + +