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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Anrufmonitor.App/CallPopupWindow.xaml.cs b/src/Anrufmonitor.App/CallPopupWindow.xaml.cs
new file mode 100644
index 0000000..f28aab7
--- /dev/null
+++ b/src/Anrufmonitor.App/CallPopupWindow.xaml.cs
@@ -0,0 +1,65 @@
+using System.Windows;
+using System.Windows.Threading;
+using Anrufmonitor.Contacts;
+
+namespace Anrufmonitor.App;
+
+///
+/// Toast-artiges Popup unten rechts, zeigt den (per
+/// aufgeloesten) Anrufer an. Schliesst sich automatisch nach
+/// oder wenn der Anruf beendet wird (, ausgeloest durch
+/// SIP CANCEL/Ring-Timeout).
+///
+public partial class CallPopupWindow : Window
+{
+ private static readonly TimeSpan AutoCloseAfter = TimeSpan.FromSeconds(20);
+ private static readonly Dictionary OpenPopups = new();
+
+ private readonly string _callId;
+ private readonly DispatcherTimer _autoCloseTimer;
+
+ public CallPopupWindow(ContactInfo contact, string callId)
+ {
+ InitializeComponent();
+
+ _callId = callId;
+ OpenPopups[callId] = this;
+
+ NameText.Text = contact.DisplayName;
+ CompanyText.Text = contact.Company ?? string.Empty;
+ CompanyText.Visibility = string.IsNullOrWhiteSpace(contact.Company) ? Visibility.Collapsed : Visibility.Visible;
+ NumberText.Text = contact.PhoneNumbers.Count > 0 ? contact.PhoneNumbers[0] : string.Empty;
+
+ PositionBottomRight();
+
+ _autoCloseTimer = new DispatcherTimer { Interval = AutoCloseAfter };
+ _autoCloseTimer.Tick += (_, _) => Close();
+ _autoCloseTimer.Start();
+
+ Closed += (_, _) =>
+ {
+ _autoCloseTimer.Stop();
+ if (OpenPopups.TryGetValue(_callId, out var self) && ReferenceEquals(self, this))
+ {
+ OpenPopups.Remove(_callId);
+ }
+ };
+ }
+
+ public static void CloseByCallId(string callId)
+ {
+ if (OpenPopups.TryGetValue(callId, out var popup))
+ {
+ popup.Close();
+ }
+ }
+
+ private void PositionBottomRight()
+ {
+ var workArea = SystemParameters.WorkArea;
+ Left = workArea.Right - Width - 16;
+ Top = workArea.Bottom - Height - 16;
+ }
+
+ private void OnCloseClicked(object sender, RoutedEventArgs e) => Close();
+}
diff --git a/src/Anrufmonitor.App/TrayIconManager.cs b/src/Anrufmonitor.App/TrayIconManager.cs
new file mode 100644
index 0000000..769a33b
--- /dev/null
+++ b/src/Anrufmonitor.App/TrayIconManager.cs
@@ -0,0 +1,43 @@
+using System.Windows.Forms;
+
+namespace Anrufmonitor.App;
+
+///
+/// Verwaltet das Tray-Icon (System.Windows.Forms.NotifyIcon, da WPF selbst kein
+/// natives Tray-Icon anbietet) mit Kontextmenue.
+///
+public sealed class TrayIconManager : IDisposable
+{
+ private readonly NotifyIcon _notifyIcon;
+
+ public TrayIconManager(Func onSyncNow, System.Action onExit)
+ {
+ var menu = new ContextMenuStrip();
+
+ var syncItem = new ToolStripMenuItem("Kontakte jetzt synchronisieren");
+ syncItem.Click += async (_, _) => await onSyncNow();
+ menu.Items.Add(syncItem);
+
+ menu.Items.Add(new ToolStripSeparator());
+
+ var exitItem = new ToolStripMenuItem("Beenden");
+ exitItem.Click += (_, _) => onExit();
+ menu.Items.Add(exitItem);
+
+ _notifyIcon = new NotifyIcon
+ {
+ Icon = System.Drawing.SystemIcons.Application,
+ Text = "Anrufmonitor",
+ ContextMenuStrip = menu,
+ Visible = false,
+ };
+ }
+
+ public void Show() => _notifyIcon.Visible = true;
+
+ public void Dispose()
+ {
+ _notifyIcon.Visible = false;
+ _notifyIcon.Dispose();
+ }
+}
diff --git a/src/Anrufmonitor.App/appsettings.Local.json.example b/src/Anrufmonitor.App/appsettings.Local.json.example
new file mode 100644
index 0000000..8eb4e71
--- /dev/null
+++ b/src/Anrufmonitor.App/appsettings.Local.json.example
@@ -0,0 +1,25 @@
+{
+ "_comment": "Kopie als appsettings.Local.json ablegen (gitignored) und mit echten Werten fuellen. Diese Datei wird NICHT eingelesen, nur appsettings.Local.json.",
+ "Sip": {
+ "Enabled": true,
+ "Server": "pbx.example.com",
+ "Username": "1234",
+ "Password": "geheim"
+ },
+ "Contacts": {
+ "Office365": {
+ "Enabled": true,
+ "ClientId": "00000000-0000-0000-0000-000000000000"
+ },
+ "Google": {
+ "Enabled": true,
+ "ClientId": "xxxxxxxx.apps.googleusercontent.com",
+ "ClientSecret": "GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ },
+ "PublicDirectory": {
+ "Enabled": false,
+ "RequestUrlTemplate": "https://example-directory.local/api/lookup?number={number}",
+ "ApiKey": ""
+ }
+ }
+}
diff --git a/src/Anrufmonitor.App/appsettings.json b/src/Anrufmonitor.App/appsettings.json
new file mode 100644
index 0000000..e76d0b2
--- /dev/null
+++ b/src/Anrufmonitor.App/appsettings.json
@@ -0,0 +1,40 @@
+{
+ "Sip": {
+ "Enabled": false,
+ "Server": "",
+ "Port": 5060,
+ "Username": "",
+ "Password": "",
+ "DisplayName": "",
+ "RegisterExpirySeconds": 300,
+ "SendRingingProgress": true
+ },
+ "Contacts": {
+ "CacheDatabasePath": "%LocalAppData%\\Anrufmonitor\\cache.db",
+ "SyncInterval": "00:30:00",
+ "ReverseLookupCacheTtl": "7.00:00:00",
+ "Office365": {
+ "Enabled": false,
+ "TenantId": "common",
+ "ClientId": ""
+ },
+ "Google": {
+ "Enabled": false,
+ "ClientId": "",
+ "ClientSecret": "",
+ "TokenStorePath": "%LocalAppData%\\Anrufmonitor\\google-token"
+ },
+ "PublicDirectory": {
+ "Enabled": false,
+ "RequestUrlTemplate": "",
+ "ApiKey": "",
+ "TimeoutMilliseconds": 2000
+ }
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning"
+ }
+ }
+}
diff --git a/src/Anrufmonitor.Contacts/Anrufmonitor.Contacts.csproj b/src/Anrufmonitor.Contacts/Anrufmonitor.Contacts.csproj
new file mode 100644
index 0000000..8684467
--- /dev/null
+++ b/src/Anrufmonitor.Contacts/Anrufmonitor.Contacts.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Anrufmonitor.Contacts/ContactCache.cs b/src/Anrufmonitor.Contacts/ContactCache.cs
new file mode 100644
index 0000000..2c0b711
--- /dev/null
+++ b/src/Anrufmonitor.Contacts/ContactCache.cs
@@ -0,0 +1,242 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging;
+
+namespace Anrufmonitor.Contacts;
+
+///
+/// Lokaler SQLite-Cache: eine Zeile pro (normalisierter Rufnummer, Quelle).
+/// Bewusst denormalisiert (kein Join noetig), damit die Lookup-Anfrage beim
+/// eingehenden Anruf so schnell wie moeglich ist.
+///
+public sealed class ContactCache
+{
+ private readonly string _connectionString;
+ private readonly ILogger _logger;
+
+ public ContactCache(string databasePath, ILogger logger)
+ {
+ _logger = logger;
+ var dir = Path.GetDirectoryName(databasePath);
+ if (!string.IsNullOrEmpty(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+
+ _connectionString = new SqliteConnectionStringBuilder
+ {
+ DataSource = databasePath,
+ Cache = SqliteCacheMode.Shared,
+ }.ToString();
+ }
+
+ public async Task EnsureCreatedAsync(CancellationToken cancellationToken)
+ {
+ await using var connection = await OpenAsync(cancellationToken);
+ await using var transaction = connection.BeginTransaction();
+
+ await ExecuteAsync(connection, transaction, """
+ CREATE TABLE IF NOT EXISTS ContactNumbers (
+ NormalizedNumber TEXT NOT NULL,
+ Source TEXT NOT NULL,
+ ContactId TEXT NOT NULL,
+ DisplayName TEXT NOT NULL,
+ Company TEXT NULL,
+ PhotoUrl TEXT NULL,
+ SyncedAtUtc TEXT NOT NULL,
+ PRIMARY KEY (NormalizedNumber, Source)
+ );
+ """, cancellationToken);
+
+ await ExecuteAsync(connection, transaction, """
+ CREATE TABLE IF NOT EXISTS ReverseLookupCache (
+ NormalizedNumber TEXT PRIMARY KEY,
+ DisplayName TEXT NOT NULL,
+ Company TEXT NULL,
+ PhotoUrl TEXT NULL,
+ CachedAtUtc TEXT NOT NULL
+ );
+ """, cancellationToken);
+
+ transaction.Commit();
+ }
+
+ ///
+ /// Ersetzt den kompletten Bestand einer Quelle in einer einzigen Transaktion
+ /// (kein Zwischenzustand mit teilweise geloeschten/eingefuegten Daten).
+ ///
+ public async Task ReplaceContactsForSourceAsync(
+ ContactSource source,
+ IReadOnlyList contacts,
+ CancellationToken cancellationToken)
+ {
+ await using var connection = await OpenAsync(cancellationToken);
+ await using var transaction = connection.BeginTransaction();
+
+ await using (var deleteCmd = connection.CreateCommand())
+ {
+ deleteCmd.Transaction = transaction;
+ deleteCmd.CommandText = "DELETE FROM ContactNumbers WHERE Source = $source;";
+ deleteCmd.Parameters.AddWithValue("$source", source.ToString());
+ await deleteCmd.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ var syncedAt = DateTime.UtcNow.ToString("O");
+
+ await using (var insertCmd = connection.CreateCommand())
+ {
+ insertCmd.Transaction = transaction;
+ insertCmd.CommandText = """
+ INSERT INTO ContactNumbers
+ (NormalizedNumber, Source, ContactId, DisplayName, Company, PhotoUrl, SyncedAtUtc)
+ VALUES
+ ($number, $source, $contactId, $displayName, $company, $photoUrl, $syncedAt)
+ ON CONFLICT(NormalizedNumber, Source) DO UPDATE SET
+ ContactId = excluded.ContactId,
+ DisplayName = excluded.DisplayName,
+ Company = excluded.Company,
+ PhotoUrl = excluded.PhotoUrl,
+ SyncedAtUtc = excluded.SyncedAtUtc;
+ """;
+
+ var pNumber = insertCmd.CreateParameter(); pNumber.ParameterName = "$number"; insertCmd.Parameters.Add(pNumber);
+ var pSource = insertCmd.CreateParameter(); pSource.ParameterName = "$source"; pSource.Value = source.ToString(); insertCmd.Parameters.Add(pSource);
+ var pContactId = insertCmd.CreateParameter(); pContactId.ParameterName = "$contactId"; insertCmd.Parameters.Add(pContactId);
+ var pDisplayName = insertCmd.CreateParameter(); pDisplayName.ParameterName = "$displayName"; insertCmd.Parameters.Add(pDisplayName);
+ var pCompany = insertCmd.CreateParameter(); pCompany.ParameterName = "$company"; insertCmd.Parameters.Add(pCompany);
+ var pPhotoUrl = insertCmd.CreateParameter(); pPhotoUrl.ParameterName = "$photoUrl"; insertCmd.Parameters.Add(pPhotoUrl);
+ var pSyncedAt = insertCmd.CreateParameter(); pSyncedAt.ParameterName = "$syncedAt"; pSyncedAt.Value = syncedAt; insertCmd.Parameters.Add(pSyncedAt);
+
+ foreach (var contact in contacts)
+ {
+ foreach (var rawNumber in contact.PhoneNumbers)
+ {
+ var normalized = PhoneNumberNormalizer.Normalize(rawNumber);
+ if (normalized.Length == 0)
+ {
+ continue;
+ }
+
+ pNumber.Value = normalized;
+ pContactId.Value = contact.Id;
+ pDisplayName.Value = contact.DisplayName;
+ pCompany.Value = (object?)contact.Company ?? DBNull.Value;
+ pPhotoUrl.Value = (object?)contact.PhotoUrl ?? DBNull.Value;
+
+ await insertCmd.ExecuteNonQueryAsync(cancellationToken);
+ }
+ }
+ }
+
+ transaction.Commit();
+ _logger.LogInformation("Cache fuer {Source} aktualisiert: {Count} Kontakte", source, contacts.Count);
+ }
+
+ public async Task TryFindByNumberAsync(string normalizedNumber, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(normalizedNumber))
+ {
+ return null;
+ }
+
+ await using var connection = await OpenAsync(cancellationToken);
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = """
+ SELECT ContactId, Source, DisplayName, Company, PhotoUrl
+ FROM ContactNumbers
+ WHERE NormalizedNumber = $number
+ LIMIT 1;
+ """;
+ cmd.Parameters.AddWithValue("$number", normalizedNumber);
+
+ await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
+ if (!await reader.ReadAsync(cancellationToken))
+ {
+ return null;
+ }
+
+ return new ContactInfo(
+ Id: reader.GetString(0),
+ Source: Enum.Parse(reader.GetString(1)),
+ DisplayName: reader.GetString(2),
+ Company: reader.IsDBNull(3) ? null : reader.GetString(3),
+ PhoneNumbers: [normalizedNumber],
+ PhotoUrl: reader.IsDBNull(4) ? null : reader.GetString(4));
+ }
+
+ public async Task TryGetReverseLookupCacheAsync(
+ string normalizedNumber, TimeSpan maxAge, CancellationToken cancellationToken)
+ {
+ await using var connection = await OpenAsync(cancellationToken);
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = """
+ SELECT DisplayName, Company, PhotoUrl, CachedAtUtc
+ FROM ReverseLookupCache
+ WHERE NormalizedNumber = $number
+ LIMIT 1;
+ """;
+ cmd.Parameters.AddWithValue("$number", normalizedNumber);
+
+ await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
+ if (!await reader.ReadAsync(cancellationToken))
+ {
+ return null;
+ }
+
+ var cachedAt = DateTime.Parse(reader.GetString(3)).ToUniversalTime();
+ if (DateTime.UtcNow - cachedAt > maxAge)
+ {
+ return null;
+ }
+
+ return new ContactInfo(
+ Id: normalizedNumber,
+ Source: ContactSource.PublicDirectory,
+ DisplayName: reader.GetString(0),
+ Company: reader.IsDBNull(1) ? null : reader.GetString(1),
+ PhoneNumbers: [normalizedNumber],
+ PhotoUrl: reader.IsDBNull(2) ? null : reader.GetString(2));
+ }
+
+ public async Task UpsertReverseLookupCacheAsync(ContactInfo contact, CancellationToken cancellationToken)
+ {
+ var normalized = PhoneNumberNormalizer.Normalize(contact.Id);
+
+ await using var connection = await OpenAsync(cancellationToken);
+ await using var transaction = connection.BeginTransaction();
+ await using var cmd = connection.CreateCommand();
+ cmd.Transaction = transaction;
+ cmd.CommandText = """
+ INSERT INTO ReverseLookupCache (NormalizedNumber, DisplayName, Company, PhotoUrl, CachedAtUtc)
+ VALUES ($number, $displayName, $company, $photoUrl, $cachedAt)
+ ON CONFLICT(NormalizedNumber) DO UPDATE SET
+ DisplayName = excluded.DisplayName,
+ Company = excluded.Company,
+ PhotoUrl = excluded.PhotoUrl,
+ CachedAtUtc = excluded.CachedAtUtc;
+ """;
+ cmd.Parameters.AddWithValue("$number", normalized);
+ cmd.Parameters.AddWithValue("$displayName", contact.DisplayName);
+ cmd.Parameters.AddWithValue("$company", (object?)contact.Company ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("$photoUrl", (object?)contact.PhotoUrl ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("$cachedAt", DateTime.UtcNow.ToString("O"));
+
+ await cmd.ExecuteNonQueryAsync(cancellationToken);
+ transaction.Commit();
+ }
+
+ private async Task OpenAsync(CancellationToken cancellationToken)
+ {
+ var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync(cancellationToken);
+ return connection;
+ }
+
+ private static async Task ExecuteAsync(
+ SqliteConnection connection, SqliteTransaction transaction, string sql, CancellationToken cancellationToken)
+ {
+ await using var cmd = connection.CreateCommand();
+ cmd.Transaction = transaction;
+ cmd.CommandText = sql;
+ await cmd.ExecuteNonQueryAsync(cancellationToken);
+ }
+}
diff --git a/src/Anrufmonitor.Contacts/ContactInfo.cs b/src/Anrufmonitor.Contacts/ContactInfo.cs
new file mode 100644
index 0000000..1016bef
--- /dev/null
+++ b/src/Anrufmonitor.Contacts/ContactInfo.cs
@@ -0,0 +1,37 @@
+namespace Anrufmonitor.Contacts;
+
+///
+/// Quelle eines Kontakts.
+///
+public enum ContactSource
+{
+ Office365,
+ Google,
+ PublicDirectory,
+}
+
+///
+/// Normalisierter Kontakt, wie er im Cache abgelegt und in der UI angezeigt wird.
+///
+/// Eindeutige Id innerhalb der Quelle (z. B. Graph-/People-Api-Id).
+/// Herkunft des Kontakts.
+/// Anzeigename.
+/// Firma, falls vorhanden.
+/// Alle bekannten Rufnummern, bereits normalisiert (E.164-ähnlich).
+/// Optionale URL/Pfad zu einem Kontaktfoto.
+public sealed record ContactInfo(
+ string Id,
+ ContactSource Source,
+ string DisplayName,
+ string? Company,
+ IReadOnlyList PhoneNumbers,
+ string? PhotoUrl)
+{
+ public static ContactInfo Unknown(string phoneNumber) => new(
+ Id: phoneNumber,
+ Source: ContactSource.PublicDirectory,
+ DisplayName: phoneNumber,
+ Company: null,
+ PhoneNumbers: [phoneNumber],
+ PhotoUrl: null);
+}
diff --git a/src/Anrufmonitor.Contacts/ContactLookupService.cs b/src/Anrufmonitor.Contacts/ContactLookupService.cs
new file mode 100644
index 0000000..d9b86cc
--- /dev/null
+++ b/src/Anrufmonitor.Contacts/ContactLookupService.cs
@@ -0,0 +1,60 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Anrufmonitor.Contacts;
+
+///
+/// Wird beim eingehenden Anruf aufgerufen. Reihenfolge:
+/// 1. Cache (schnell, synchronisiert durch )
+/// 2. On-Demand-Reverse-Lookup-Provider (z. B. oeffentliches Verzeichnis), Ergebnis wird gecacht
+/// 3. als Fallback, damit die UI immer etwas anzuzeigen hat
+///
+public sealed class ContactLookupService(
+ ContactCache cache,
+ IEnumerable reverseLookupProviders,
+ IOptions options,
+ ILogger logger)
+{
+ private readonly ContactsOptions _options = options.Value;
+
+ public async Task ResolveAsync(string rawIncomingNumber, CancellationToken cancellationToken)
+ {
+ var normalized = PhoneNumberNormalizer.Normalize(rawIncomingNumber);
+ if (normalized.Length == 0)
+ {
+ return ContactInfo.Unknown(rawIncomingNumber);
+ }
+
+ var cached = await cache.TryFindByNumberAsync(normalized, cancellationToken);
+ if (cached is not null)
+ {
+ return cached;
+ }
+
+ var reverseCached = await cache.TryGetReverseLookupCacheAsync(
+ normalized, _options.ReverseLookupCacheTtl, cancellationToken);
+ if (reverseCached is not null)
+ {
+ return reverseCached;
+ }
+
+ foreach (var provider in reverseLookupProviders.Where(p => p.IsEnabled))
+ {
+ try
+ {
+ var result = await provider.LookupAsync(normalized, cancellationToken);
+ if (result is not null)
+ {
+ await cache.UpsertReverseLookupCacheAsync(result, cancellationToken);
+ return result;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Reverse-Lookup ({Source}) fehlgeschlagen fuer {Number}", provider.Source, normalized);
+ }
+ }
+
+ return ContactInfo.Unknown(rawIncomingNumber);
+ }
+}
diff --git a/src/Anrufmonitor.Contacts/ContactSyncService.cs b/src/Anrufmonitor.Contacts/ContactSyncService.cs
new file mode 100644
index 0000000..854d9fd
--- /dev/null
+++ b/src/Anrufmonitor.Contacts/ContactSyncService.cs
@@ -0,0 +1,57 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Anrufmonitor.Contacts;
+
+///
+/// Laeuft im Hintergrund und synchronisiert periodisch alle aktivierten
+/// in den lokalen .
+/// Der eigentliche Anruf-Lookup () liest danach
+/// nur noch aus dem Cache und ruft nie live gegen Office365/Google waehrend eines
+/// eingehenden Anrufs.
+///
+public sealed class ContactSyncService(
+ ContactCache cache,
+ IEnumerable providers,
+ IOptions options,
+ ILogger logger)
+ : BackgroundService
+{
+ private readonly ContactsOptions _options = options.Value;
+
+ public event EventHandler? SyncCompleted;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ await cache.EnsureCreatedAsync(stoppingToken);
+
+ using var timer = new PeriodicTimer(_options.SyncInterval);
+
+ // Erster Sync sofort beim Start, danach im konfigurierten Intervall.
+ do
+ {
+ await SyncOnceAsync(stoppingToken);
+ }
+ while (!stoppingToken.IsCancellationRequested
+ && await timer.WaitForNextTickAsync(stoppingToken));
+ }
+
+ public async Task SyncOnceAsync(CancellationToken cancellationToken)
+ {
+ foreach (var provider in providers.Where(p => p.IsEnabled))
+ {
+ try
+ {
+ var contacts = await provider.GetAllContactsAsync(cancellationToken);
+ await cache.ReplaceContactsForSourceAsync(provider.Source, contacts, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Sync fuer {Source} fehlgeschlagen", provider.Source);
+ }
+ }
+
+ SyncCompleted?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/src/Anrufmonitor.Contacts/ContactsOptions.cs b/src/Anrufmonitor.Contacts/ContactsOptions.cs
new file mode 100644
index 0000000..bcd8a06
--- /dev/null
+++ b/src/Anrufmonitor.Contacts/ContactsOptions.cs
@@ -0,0 +1,71 @@
+namespace Anrufmonitor.Contacts;
+
+/// Bindung fuer den Konfigurationsabschnitt "Contacts" (appsettings.json / appsettings.Local.json).
+public sealed class ContactsOptions
+{
+ public const string SectionName = "Contacts";
+
+ /// Pfad zur SQLite-Cache-Datei. "%LocalAppData%" wird aufgeloest.
+ public string CacheDatabasePath { get; set; } = "%LocalAppData%\\Anrufmonitor\\cache.db";
+
+ /// Intervall fuer den periodischen Kontakt-Sync (Office365/Google -> Cache).
+ public TimeSpan SyncInterval { get; set; } = TimeSpan.FromMinutes(30);
+
+ /// Wie lange ein On-Demand-Reverse-Lookup-Ergebnis (oeffentliches Verzeichnis) im Cache bleibt.
+ public TimeSpan ReverseLookupCacheTtl { get; set; } = TimeSpan.FromDays(7);
+
+ public Office365Options Office365 { get; set; } = new();
+
+ public GoogleOptions Google { get; set; } = new();
+
+ public PublicDirectoryOptions PublicDirectory { get; set; } = new();
+
+ public string ResolveCacheDatabasePath() =>
+ Environment.ExpandEnvironmentVariables(CacheDatabasePath);
+}
+
+///
+/// App-Registration in Azure AD (Entra ID) mit Delegated Permission "Contacts.Read".
+/// Anmeldung per Device-Code-Flow (kein Client-Secret noetig, daher unbedenklich in appsettings.json,
+/// solange es sich um eine "Public Client"-Registrierung handelt).
+///
+public sealed class Office365Options
+{
+ public bool Enabled { get; set; }
+
+ public string TenantId { get; set; } = "common";
+
+ public string ClientId { get; set; } = string.Empty;
+}
+
+///
+/// OAuth-Client fuer die Google People API (Installed-App-Flow).
+/// ClientSecret gehoert NICHT in appsettings.json, sondern in appsettings.Local.json (gitignored).
+///
+public sealed class GoogleOptions
+{
+ public bool Enabled { get; set; }
+
+ public string ClientId { get; set; } = string.Empty;
+
+ public string ClientSecret { get; set; } = string.Empty;
+
+ /// Wo der OAuth-Token nach dem ersten Login zwischengespeichert wird.
+ public string TokenStorePath { get; set; } = "%LocalAppData%\\Anrufmonitor\\google-token";
+}
+
+///
+/// Oeffentliches Telefonverzeichnis fuer On-Demand-Reverse-Lookup unbekannter Nummern.
+/// Bewusst generisch gehalten (URL-Template + JSON-Pfade), da Anbieter/API je nach Land/Vertrag variieren.
+///
+public sealed class PublicDirectoryOptions
+{
+ public bool Enabled { get; set; }
+
+ /// URL-Template, "{number}" wird durch die normalisierte Rufnummer ersetzt.
+ public string RequestUrlTemplate { get; set; } = string.Empty;
+
+ public string? ApiKey { get; set; }
+
+ public int TimeoutMilliseconds { get; set; } = 2000;
+}
diff --git a/src/Anrufmonitor.Contacts/GoogleContactProvider.cs b/src/Anrufmonitor.Contacts/GoogleContactProvider.cs
new file mode 100644
index 0000000..9c88569
--- /dev/null
+++ b/src/Anrufmonitor.Contacts/GoogleContactProvider.cs
@@ -0,0 +1,108 @@
+using Google.Apis.Auth.OAuth2;
+using Google.Apis.PeopleService.v1;
+using Google.Apis.PeopleService.v1.Data;
+using Google.Apis.Services;
+using Google.Apis.Util.Store;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Anrufmonitor.Contacts;
+
+///
+/// Liest die Google-Kontakte des angemeldeten Benutzers ueber die People API.
+/// Anmeldung per Installed-App-OAuth-Flow (Browser oeffnet sich einmalig), Token wird
+/// lokal unter zwischengespeichert.
+/// Benoetigt ein OAuth-Client (Typ "Desktop app") in der Google Cloud Console mit
+/// aktivierter People API und Scope "contacts.readonly".
+///
+public sealed class GoogleContactProvider : IContactProvider
+{
+ private readonly ContactsOptions _options;
+ private readonly ILogger _logger;
+
+ public GoogleContactProvider(IOptions options, ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ public ContactSource Source => ContactSource.Google;
+
+ public bool IsEnabled =>
+ _options.Google.Enabled
+ && !string.IsNullOrWhiteSpace(_options.Google.ClientId)
+ && !string.IsNullOrWhiteSpace(_options.Google.ClientSecret);
+
+ public async Task> GetAllContactsAsync(CancellationToken cancellationToken)
+ {
+ if (!IsEnabled)
+ {
+ return [];
+ }
+
+ var tokenPath = Environment.ExpandEnvironmentVariables(_options.Google.TokenStorePath);
+ Directory.CreateDirectory(tokenPath);
+
+ var credential = await GoogleWebAuthorizationBroker.AuthorizeAsync(
+ new ClientSecrets
+ {
+ ClientId = _options.Google.ClientId,
+ ClientSecret = _options.Google.ClientSecret,
+ },
+ [PeopleServiceService.Scope.ContactsReadonly],
+ "anrufmonitor-user",
+ cancellationToken,
+ new FileDataStore(tokenPath, true));
+
+ using var service = new PeopleServiceService(new BaseClientService.Initializer
+ {
+ HttpClientInitializer = credential,
+ ApplicationName = "Anrufmonitor",
+ });
+
+ var result = new List();
+ string? pageToken = null;
+
+ do
+ {
+ var request = service.People.Connections.List("people/me");
+ request.PersonFields = "names,phoneNumbers,organizations,photos";
+ request.PageSize = 1000;
+ request.PageToken = pageToken;
+
+ var response = await request.ExecuteAsync(cancellationToken);
+
+ if (response.Connections is { } connections)
+ {
+ result.AddRange(connections.Select(MapPerson).Where(c => c.PhoneNumbers.Count > 0));
+ }
+
+ pageToken = response.NextPageToken;
+ }
+ while (!string.IsNullOrEmpty(pageToken));
+
+ _logger.LogInformation("Google: {Count} Kontakte mit Rufnummer gelesen", result.Count);
+ return result;
+ }
+
+ private static ContactInfo MapPerson(Person person)
+ {
+ var displayName = person.Names?.FirstOrDefault()?.DisplayName ?? "(ohne Namen)";
+ var company = person.Organizations?.FirstOrDefault()?.Name;
+ var photoUrl = person.Photos?.FirstOrDefault()?.Url;
+
+ var numbers = person.PhoneNumbers?
+ .Select(p => p.Value)
+ .Where(v => !string.IsNullOrWhiteSpace(v))
+ .Select(v => v!)
+ .ToList() ?? [];
+
+ return new ContactInfo(
+ Id: person.ResourceName ?? Guid.NewGuid().ToString(),
+ Source: ContactSource.Google,
+ DisplayName: displayName,
+ Company: company,
+ PhoneNumbers: numbers,
+ PhotoUrl: photoUrl);
+ }
+}
diff --git a/src/Anrufmonitor.Contacts/IContactProvider.cs b/src/Anrufmonitor.Contacts/IContactProvider.cs
new file mode 100644
index 0000000..4ff2bf8
--- /dev/null
+++ b/src/Anrufmonitor.Contacts/IContactProvider.cs
@@ -0,0 +1,30 @@
+namespace Anrufmonitor.Contacts;
+
+///
+/// Liefert den kompletten Kontaktbestand einer Quelle fuer den periodischen Cache-Sync.
+/// Wird NICHT waehrend eines eingehenden Anrufs aufgerufen (zu langsam) - siehe
+/// und .
+///
+public interface IContactProvider
+{
+ ContactSource Source { get; }
+
+ /// Ob der Provider konfiguriert/aktiviert ist (z. B. Zugangsdaten vorhanden).
+ bool IsEnabled { get; }
+
+ Task> GetAllContactsAsync(CancellationToken cancellationToken);
+}
+
+///
+/// Optionaler On-Demand-Reverse-Lookup fuer Nummern, die nicht im Cache gefunden wurden
+/// (z. B. oeffentliches Verzeichnis). Wird direkt beim eingehenden Anruf aufgerufen,
+/// muss also schnell sein / einen kurzen Timeout haben.
+///
+public interface IReverseLookupProvider
+{
+ ContactSource Source { get; }
+
+ bool IsEnabled { get; }
+
+ Task LookupAsync(string normalizedNumber, CancellationToken cancellationToken);
+}
diff --git a/src/Anrufmonitor.Contacts/Office365ContactProvider.cs b/src/Anrufmonitor.Contacts/Office365ContactProvider.cs
new file mode 100644
index 0000000..2a71b4d
--- /dev/null
+++ b/src/Anrufmonitor.Contacts/Office365ContactProvider.cs
@@ -0,0 +1,103 @@
+using Azure.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.Graph;
+using Microsoft.Graph.Models;
+
+namespace Anrufmonitor.Contacts;
+
+///
+/// Liest die Outlook/Office365-Kontakte des angemeldeten Benutzers ueber Microsoft Graph.
+/// Anmeldung per Device-Code-Flow (interaktiv, einmalig, Token wird vom MSAL-Cache verwaltet).
+/// Benoetigte App-Registration (Azure/Entra Portal): Public client, Delegated Permission "Contacts.Read".
+///
+public sealed class Office365ContactProvider : IContactProvider
+{
+ private readonly ContactsOptions _options;
+ private readonly ILogger _logger;
+
+ public Office365ContactProvider(IOptions options, ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ public ContactSource Source => ContactSource.Office365;
+
+ public bool IsEnabled => _options.Office365.Enabled && !string.IsNullOrWhiteSpace(_options.Office365.ClientId);
+
+ public async Task> GetAllContactsAsync(CancellationToken cancellationToken)
+ {
+ if (!IsEnabled)
+ {
+ return [];
+ }
+
+ var credential = new DeviceCodeCredential(new DeviceCodeCredentialOptions
+ {
+ ClientId = _options.Office365.ClientId,
+ TenantId = _options.Office365.TenantId,
+ DeviceCodeCallback = (info, ct) =>
+ {
+ _logger.LogWarning(
+ "Office365-Anmeldung erforderlich: {Message}",
+ info.Message);
+ return Task.CompletedTask;
+ },
+ });
+
+ var graphClient = new GraphServiceClient(credential, ["Contacts.Read"]);
+
+ var result = new List();
+
+ var page = await graphClient.Me.Contacts.GetAsync(request =>
+ {
+ request.QueryParameters.Top = 999;
+ request.QueryParameters.Select =
+ [
+ "id", "displayName", "companyName", "businessPhones", "homePhones", "mobilePhone",
+ ];
+ }, cancellationToken);
+
+ var iterator = PageIterator.CreatePageIterator(
+ graphClient,
+ page!,
+ contact =>
+ {
+ result.Add(MapContact(contact));
+ return true;
+ });
+
+ await iterator.IterateAsync(cancellationToken);
+
+ _logger.LogInformation("Office365: {Count} Kontakte gelesen", result.Count);
+ return result;
+ }
+
+ private static ContactInfo MapContact(Contact contact)
+ {
+ var numbers = new List();
+ if (contact.BusinessPhones is { } business)
+ {
+ numbers.AddRange(business.Where(n => !string.IsNullOrWhiteSpace(n)));
+ }
+
+ if (contact.HomePhones is { } home)
+ {
+ numbers.AddRange(home.Where(n => !string.IsNullOrWhiteSpace(n)));
+ }
+
+ if (!string.IsNullOrWhiteSpace(contact.MobilePhone))
+ {
+ numbers.Add(contact.MobilePhone);
+ }
+
+ return new ContactInfo(
+ Id: contact.Id ?? Guid.NewGuid().ToString(),
+ Source: ContactSource.Office365,
+ DisplayName: contact.DisplayName ?? "(ohne Namen)",
+ Company: contact.CompanyName,
+ PhoneNumbers: numbers,
+ PhotoUrl: null);
+ }
+}
diff --git a/src/Anrufmonitor.Contacts/PhoneNumberNormalizer.cs b/src/Anrufmonitor.Contacts/PhoneNumberNormalizer.cs
new file mode 100644
index 0000000..b3ecfd5
--- /dev/null
+++ b/src/Anrufmonitor.Contacts/PhoneNumberNormalizer.cs
@@ -0,0 +1,80 @@
+using System.Text;
+
+namespace Anrufmonitor.Contacts;
+
+///
+/// Normalisiert Rufnummern auf ein vergleichbares Format (E.164-aehnlich, ohne "+"),
+/// damit SIP-Caller-ID und Kontakt-Telefonnummern aus unterschiedlichen Quellen
+/// zuverlaessig gematcht werden koennen.
+/// Beispiel: "0175 1234567" und "+491751234567" werden beide zu "491751234567".
+///
+public static class PhoneNumberNormalizer
+{
+ ///
+ /// Landesvorwahl ohne "+", die fuer national geschriebene Nummern (fuehrende "0") angenommen wird.
+ ///
+ public const string DefaultCountryCallingCode = "49";
+
+ public static string Normalize(string? rawNumber, string countryCallingCode = DefaultCountryCallingCode)
+ {
+ if (string.IsNullOrWhiteSpace(rawNumber))
+ {
+ return string.Empty;
+ }
+
+ var sb = new StringBuilder(rawNumber.Length);
+ foreach (var c in rawNumber)
+ {
+ if (char.IsDigit(c) || c == '+')
+ {
+ sb.Append(c);
+ }
+ }
+
+ var digitsOnly = sb.ToString();
+
+ // SIP-URIs liefern manchmal "sip:004917512345678@..." o.ae. - wurde oben bereits auf Ziffern/+ reduziert.
+ if (digitsOnly.StartsWith("+", StringComparison.Ordinal))
+ {
+ return digitsOnly[1..];
+ }
+
+ if (digitsOnly.StartsWith("00", StringComparison.Ordinal))
+ {
+ return digitsOnly[2..];
+ }
+
+ if (digitsOnly.StartsWith("0", StringComparison.Ordinal))
+ {
+ return countryCallingCode + digitsOnly[1..];
+ }
+
+ return digitsOnly;
+ }
+
+ ///
+ /// Extrahiert die Rufnummer aus einem SIP-From/To-Header-Wert wie
+ /// "\"Max Mustermann\" <sip:+491751234567@pbx.example.com>" oder "sip:01751234567@pbx".
+ ///
+ public static string? ExtractFromSipUri(string? sipHeaderValue)
+ {
+ if (string.IsNullOrWhiteSpace(sipHeaderValue))
+ {
+ return null;
+ }
+
+ var start = sipHeaderValue.IndexOf("sip:", StringComparison.OrdinalIgnoreCase);
+ if (start < 0)
+ {
+ return null;
+ }
+
+ start += "sip:".Length;
+ var end = sipHeaderValue.IndexOfAny(['@', '>', ';'], start);
+ var user = end < 0
+ ? sipHeaderValue[start..]
+ : sipHeaderValue[start..end];
+
+ return string.IsNullOrWhiteSpace(user) ? null : user;
+ }
+}
diff --git a/src/Anrufmonitor.Contacts/PublicDirectoryLookupProvider.cs b/src/Anrufmonitor.Contacts/PublicDirectoryLookupProvider.cs
new file mode 100644
index 0000000..89451d7
--- /dev/null
+++ b/src/Anrufmonitor.Contacts/PublicDirectoryLookupProvider.cs
@@ -0,0 +1,97 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Anrufmonitor.Contacts;
+
+///
+/// On-Demand-Reverse-Lookup gegen ein oeffentliches Telefonverzeichnis.
+/// Bewusst generisch (URL-Template statt fest verdrahtetem Anbieter), da es kein
+/// einheitliches kostenloses API fuer sowas gibt und der Anbieter je nach Land/Vertrag
+/// unterschiedlich ist. Erwartet eine simple JSON-Antwort mit einem "name"-Feld;
+/// bei Bedarf an den tatsaechlichen Anbieter anpassen.
+///
+public sealed class PublicDirectoryLookupProvider : IReverseLookupProvider
+{
+ private readonly ContactsOptions _options;
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ public PublicDirectoryLookupProvider(
+ IOptions options, HttpClient httpClient, ILogger logger)
+ {
+ _options = options.Value;
+ _httpClient = httpClient;
+ _logger = logger;
+ }
+
+ public ContactSource Source => ContactSource.PublicDirectory;
+
+ public bool IsEnabled =>
+ _options.PublicDirectory.Enabled && !string.IsNullOrWhiteSpace(_options.PublicDirectory.RequestUrlTemplate);
+
+ public async Task LookupAsync(string normalizedNumber, CancellationToken cancellationToken)
+ {
+ if (!IsEnabled)
+ {
+ return null;
+ }
+
+ var url = _options.PublicDirectory.RequestUrlTemplate.Replace("{number}", normalizedNumber);
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(_options.PublicDirectory.TimeoutMilliseconds));
+
+ try
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ if (!string.IsNullOrWhiteSpace(_options.PublicDirectory.ApiKey))
+ {
+ request.Headers.Add("Authorization", $"Bearer {_options.PublicDirectory.ApiKey}");
+ }
+
+ using var response = await _httpClient.SendAsync(request, timeoutCts.Token);
+ if (!response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+
+ await using var stream = await response.Content.ReadAsStreamAsync(timeoutCts.Token);
+ using var document = await JsonDocument.ParseAsync(stream, cancellationToken: timeoutCts.Token);
+
+ var name = ExtractDisplayName(document.RootElement);
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ return null;
+ }
+
+ return new ContactInfo(
+ Id: normalizedNumber,
+ Source: ContactSource.PublicDirectory,
+ DisplayName: name,
+ Company: null,
+ PhoneNumbers: [normalizedNumber],
+ PhotoUrl: null);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogDebug("Reverse-Lookup fuer {Number} hat Timeout ueberschritten", normalizedNumber);
+ return null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Reverse-Lookup fuer {Number} fehlgeschlagen", normalizedNumber);
+ return null;
+ }
+ }
+
+ ///
+ /// TODO: an das tatsaechlich verwendete oeffentliche Verzeichnis anpassen.
+ /// Aktuell wird ein einfaches Top-Level-Feld "name" erwartet.
+ ///
+ private static string? ExtractDisplayName(JsonElement root)
+ {
+ return root.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null;
+ }
+}
diff --git a/src/Anrufmonitor.Sip/Anrufmonitor.Sip.csproj b/src/Anrufmonitor.Sip/Anrufmonitor.Sip.csproj
new file mode 100644
index 0000000..661a2da
--- /dev/null
+++ b/src/Anrufmonitor.Sip/Anrufmonitor.Sip.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Anrufmonitor.Sip/IncomingCallEventArgs.cs b/src/Anrufmonitor.Sip/IncomingCallEventArgs.cs
new file mode 100644
index 0000000..6ade040
--- /dev/null
+++ b/src/Anrufmonitor.Sip/IncomingCallEventArgs.cs
@@ -0,0 +1,17 @@
+namespace Anrufmonitor.Sip;
+
+public sealed class IncomingCallEventArgs(string callId, string callerNumber, string? callerDisplayName) : EventArgs
+{
+ /// SIP Call-ID, um zusammengehoerige Events (Ringing -> Ended) zu korrelieren.
+ public string CallId { get; } = callId;
+
+ /// Rohe Rufnummer aus dem From-Header, noch nicht normalisiert.
+ public string CallerNumber { get; } = callerNumber;
+
+ public string? CallerDisplayName { get; } = callerDisplayName;
+}
+
+public sealed class CallEndedEventArgs(string callId) : EventArgs
+{
+ public string CallId { get; } = callId;
+}
diff --git a/src/Anrufmonitor.Sip/SipCallMonitor.cs b/src/Anrufmonitor.Sip/SipCallMonitor.cs
new file mode 100644
index 0000000..3f7920d
--- /dev/null
+++ b/src/Anrufmonitor.Sip/SipCallMonitor.cs
@@ -0,0 +1,125 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using SIPSorcery.SIP;
+using SIPSorcery.SIP.App;
+
+namespace Anrufmonitor.Sip;
+
+///
+/// Registriert sich passiv als SIP-Endpunkt und meldet eingehende Anrufe (INVITE),
+/// OHNE sie jemals anzunehmen. Setzt voraus, dass die Telefonanlage/der Provider
+/// eingehende Anrufe an mehrere registrierte Kontakte forkt (Nebenstelle/Konto teilen
+/// sich mehrere Geraete) - der eigentliche Anruf wird weiterhin vom echten Telefon
+/// angenommen, dieser Client dient nur der Anzeige des Anrufers.
+///
+public sealed class SipCallMonitor : BackgroundService
+{
+ private readonly SipOptions _options;
+ private readonly ILogger _logger;
+
+ private SIPTransport? _transport;
+ private SIPUserAgent? _userAgent;
+ private SIPRegistrationUserAgent? _registrationAgent;
+
+ public SipCallMonitor(IOptions options, ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ public event EventHandler? IncomingCall;
+
+ public event EventHandler? CallEnded;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ if (!_options.Enabled)
+ {
+ _logger.LogInformation("SIP-Monitor deaktiviert (Sip:Enabled = false)");
+ return;
+ }
+
+ _transport = new SIPTransport();
+ _transport.AddSIPChannel(new SIPUDPChannel(new System.Net.IPEndPoint(System.Net.IPAddress.Any, 0)));
+
+ _userAgent = new SIPUserAgent(_transport, null);
+ _userAgent.OnIncomingCall += OnIncomingCall;
+
+ _userAgent.ServerCallCancelled += (uas, cancelRequest) =>
+ {
+ var callId = uas.CallRequest?.Header.CallId ?? cancelRequest.Header.CallId;
+ _logger.LogDebug("Anruf abgebrochen (Cancel), Call-Id {CallId}", callId);
+ CallEnded?.Invoke(this, new CallEndedEventArgs(callId));
+ };
+
+ _userAgent.ServerCallRingTimeout += (uas) =>
+ {
+ var callId = uas.CallRequest?.Header.CallId ?? string.Empty;
+ _logger.LogDebug("Ring-Timeout, Call-Id {CallId}", callId);
+ CallEnded?.Invoke(this, new CallEndedEventArgs(callId));
+ };
+
+ _registrationAgent = new SIPRegistrationUserAgent(
+ _transport,
+ _options.Username,
+ _options.Password,
+ _options.Server,
+ _options.RegisterExpirySeconds);
+
+ _registrationAgent.RegistrationSuccessful += (uri, resp) =>
+ _logger.LogInformation("SIP-Registrierung erfolgreich: {Uri}", uri);
+ _registrationAgent.RegistrationFailed += (uri, resp, err) =>
+ _logger.LogError("SIP-Registrierung fehlgeschlagen ({Uri}): {Error}", uri, err);
+ _registrationAgent.RegistrationTemporaryFailure += (uri, resp, msg) =>
+ _logger.LogWarning("SIP-Registrierung temporaer fehlgeschlagen ({Uri}): {Message}", uri, msg);
+ _registrationAgent.RegistrationRemoved += (uri, resp) =>
+ _logger.LogWarning("SIP-Registrierung entfernt: {Uri}", uri);
+
+ _registrationAgent.Start();
+
+ try
+ {
+ await Task.Delay(Timeout.Infinite, stoppingToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // erwartetes Verhalten beim Beenden
+ }
+ }
+
+ private void OnIncomingCall(SIPUserAgent userAgent, SIPRequest inviteRequest)
+ {
+ try
+ {
+ var uas = userAgent.AcceptCall(inviteRequest);
+
+ var fromHeader = inviteRequest.Header.From;
+ var callerNumber = fromHeader?.FromURI?.User ?? "unbekannt";
+ var callerDisplayName = fromHeader?.FromName;
+ var callId = inviteRequest.Header.CallId;
+
+ _logger.LogInformation(
+ "Eingehender Anruf von {Number} ({DisplayName}), Call-Id {CallId}",
+ callerNumber, callerDisplayName, callId);
+
+ IncomingCall?.Invoke(this, new IncomingCallEventArgs(callId, callerNumber, callerDisplayName));
+
+ // Bewusst KEIN uas.Answer(...) - der Client bleibt rein passiv.
+ // uas selbst wird hier nicht weiter benoetigt, das Cancel-Handling laeuft
+ // zentral ueber userAgent.ServerCallCancelled.
+ _ = uas;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Fehler bei der Verarbeitung eines eingehenden Anrufs");
+ }
+ }
+
+ public override void Dispose()
+ {
+ _registrationAgent?.Stop();
+ _transport?.Shutdown();
+ base.Dispose();
+ }
+}
diff --git a/src/Anrufmonitor.Sip/SipOptions.cs b/src/Anrufmonitor.Sip/SipOptions.cs
new file mode 100644
index 0000000..7b84460
--- /dev/null
+++ b/src/Anrufmonitor.Sip/SipOptions.cs
@@ -0,0 +1,29 @@
+namespace Anrufmonitor.Sip;
+
+/// Bindung fuer den Konfigurationsabschnitt "Sip" (appsettings.json / appsettings.Local.json).
+public sealed class SipOptions
+{
+ public const string SectionName = "Sip";
+
+ public bool Enabled { get; set; }
+
+ /// SIP-Registrar/PBX, z. B. "pbx.example.com" oder "192.168.1.10".
+ public string Server { get; set; } = string.Empty;
+
+ public int Port { get; set; } = 5060;
+
+ public string Username { get; set; } = string.Empty;
+
+ /// Gehoert in appsettings.Local.json (gitignored), nicht in appsettings.json.
+ public string Password { get; set; } = string.Empty;
+
+ public string? DisplayName { get; set; }
+
+ public int RegisterExpirySeconds { get; set; } = 300;
+
+ ///
+ /// Passiv = nur mitlesen. Es wird nie geantwortet (200 OK), der Anruf wird also nie
+ /// von diesem Client "gestohlen" - er dient ausschliesslich der Anzeige des Anrufers.
+ ///
+ public bool SendRingingProgress { get; set; } = true;
+}
diff --git a/tests/Anrufmonitor.Tests/Anrufmonitor.Tests.csproj b/tests/Anrufmonitor.Tests/Anrufmonitor.Tests.csproj
new file mode 100644
index 0000000..2b6934d
--- /dev/null
+++ b/tests/Anrufmonitor.Tests/Anrufmonitor.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Anrufmonitor.Tests/PhoneNumberNormalizerTests.cs b/tests/Anrufmonitor.Tests/PhoneNumberNormalizerTests.cs
new file mode 100644
index 0000000..0515897
--- /dev/null
+++ b/tests/Anrufmonitor.Tests/PhoneNumberNormalizerTests.cs
@@ -0,0 +1,35 @@
+using Anrufmonitor.Contacts;
+using Xunit;
+
+namespace Anrufmonitor.Tests;
+
+public class PhoneNumberNormalizerTests
+{
+ [Theory]
+ [InlineData("0175 1234567", "491751234567")]
+ [InlineData("0175-1234567", "491751234567")]
+ [InlineData("+49 175 1234567", "491751234567")]
+ [InlineData("0049 175 1234567", "491751234567")]
+ [InlineData("491751234567", "491751234567")]
+ public void Normalize_ProducesSameResult_ForEquivalentNumbers(string input, string expected)
+ {
+ Assert.Equal(expected, PhoneNumberNormalizer.Normalize(input));
+ }
+
+ [Fact]
+ public void Normalize_EmptyInput_ReturnsEmptyString()
+ {
+ Assert.Equal(string.Empty, PhoneNumberNormalizer.Normalize(null));
+ Assert.Equal(string.Empty, PhoneNumberNormalizer.Normalize(""));
+ }
+
+ [Theory]
+ [InlineData("\"Max Mustermann\" ", "+491751234567")]
+ [InlineData("sip:01751234567@pbx.example.com", "01751234567")]
+ [InlineData("sip:1234;user=phone@pbx.example.com", "1234")]
+ [InlineData("kein-sip-header", null)]
+ public void ExtractFromSipUri_ParsesUserPart(string input, string? expected)
+ {
+ Assert.Equal(expected, PhoneNumberNormalizer.ExtractFromSipUri(input));
+ }
+}