Anrufmonitor Grundgeruest: SIP Monitor, Contacts Cache, WPF Tray App

This commit is contained in:
dimedtec 2026-08-28 15:51:56 +02:00
parent f78831a375
commit d7869b977f
28 changed files with 1581 additions and 17 deletions

10
Anrufmonitor.slnx Normal file
View file

@ -0,0 +1,10 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Anrufmonitor.App/Anrufmonitor.App.csproj" />
<Project Path="src/Anrufmonitor.Contacts/Anrufmonitor.Contacts.csproj" />
<Project Path="src/Anrufmonitor.Sip/Anrufmonitor.Sip.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Anrufmonitor.Tests/Anrufmonitor.Tests.csproj" />
</Folder>
</Solution>

View file

@ -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

View file

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Anrufmonitor.Contacts\Anrufmonitor.Contacts.csproj" />
<ProjectReference Include="..\Anrufmonitor.Sip\Anrufmonitor.Sip.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.11" />
</ItemGroup>
<ItemGroup>
<None Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="appsettings.Local.json" Condition="Exists('appsettings.Local.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,9 @@
<Application x:Class="Anrufmonitor.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Anrufmonitor.App"
ShutdownMode="OnExplicitShutdown">
<Application.Resources>
</Application.Resources>
</Application>

View file

@ -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;
/// <summary>
/// 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 (<see cref="System.Windows.Application.ShutdownMode"/> = OnExplicitShutdown).
/// </summary>
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<ContactsOptions>(context.Configuration.GetSection(ContactsOptions.SectionName));
services.Configure<SipOptions>(context.Configuration.GetSection(SipOptions.SectionName));
services.AddHttpClient<PublicDirectoryLookupProvider>();
services.AddSingleton(sp =>
{
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<ContactsOptions>>().Value;
return new ContactCache(options.ResolveCacheDatabasePath(), sp.GetRequiredService<ILogger<ContactCache>>());
});
services.AddSingleton<IContactProvider, Office365ContactProvider>();
services.AddSingleton<IContactProvider, GoogleContactProvider>();
services.AddSingleton<IReverseLookupProvider, PublicDirectoryLookupProvider>();
services.AddSingleton<ContactLookupService>();
services.AddSingleton<ContactSyncService>();
services.AddHostedService(sp => sp.GetRequiredService<ContactSyncService>());
services.AddSingleton<SipCallMonitor>();
services.AddHostedService(sp => sp.GetRequiredService<SipCallMonitor>());
})
.Build();
await _host.StartAsync();
var sipMonitor = _host.Services.GetRequiredService<SipCallMonitor>();
var lookupService = _host.Services.GetRequiredService<ContactLookupService>();
var logger = _host.Services.GetRequiredService<ILogger<App>>();
_trayIconManager = new TrayIconManager(
onSyncNow: async () =>
{
var syncService = _host.Services.GetRequiredService<ContactSyncService>();
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);
}
}

View file

@ -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)
)]

View file

@ -0,0 +1,37 @@
<Window x:Class="Anrufmonitor.App.CallPopupWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Anrufmonitor"
Width="360" Height="140"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ShowInTaskbar="False"
Topmost="True"
ResizeMode="NoResize"
SizeToContent="Manual">
<Border Background="#FF1F2430" CornerRadius="10" BorderBrush="#FF3A4152" BorderThickness="1">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Eingehender Anruf" Foreground="#FF8CA0C4" FontSize="12" FontWeight="SemiBold" />
<TextBlock Grid.Row="1" x:Name="NameText" Text="Unbekannt" Foreground="White" FontSize="22" FontWeight="Bold"
Margin="0,4,0,0" TextTrimming="CharacterEllipsis" />
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,4,0,0">
<TextBlock x:Name="CompanyText" Foreground="#FFB6C0D6" FontSize="14" Margin="0,0,8,0" />
<TextBlock x:Name="NumberText" Foreground="#FF7C879C" FontSize="14" />
</StackPanel>
<Button Content="✕" HorizontalAlignment="Right" VerticalAlignment="Top"
Width="24" Height="24" Padding="0" Margin="0,-16,-16,0"
Background="Transparent" BorderThickness="0" Foreground="#FF7C879C"
Click="OnCloseClicked" />
</Grid>
</Border>
</Window>

View file

@ -0,0 +1,65 @@
using System.Windows;
using System.Windows.Threading;
using Anrufmonitor.Contacts;
namespace Anrufmonitor.App;
/// <summary>
/// Toast-artiges Popup unten rechts, zeigt den (per <see cref="ContactLookupService"/>
/// aufgeloesten) Anrufer an. Schliesst sich automatisch nach <see cref="AutoCloseAfter"/>
/// oder wenn der Anruf beendet wird (<see cref="CloseByCallId"/>, ausgeloest durch
/// SIP CANCEL/Ring-Timeout).
/// </summary>
public partial class CallPopupWindow : Window
{
private static readonly TimeSpan AutoCloseAfter = TimeSpan.FromSeconds(20);
private static readonly Dictionary<string, CallPopupWindow> 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();
}

View file

@ -0,0 +1,43 @@
using System.Windows.Forms;
namespace Anrufmonitor.App;
/// <summary>
/// Verwaltet das Tray-Icon (System.Windows.Forms.NotifyIcon, da WPF selbst kein
/// natives Tray-Icon anbietet) mit Kontextmenue.
/// </summary>
public sealed class TrayIconManager : IDisposable
{
private readonly NotifyIcon _notifyIcon;
public TrayIconManager(Func<Task> 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();
}
}

View file

@ -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": ""
}
}
}

View file

@ -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"
}
}
}

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Google.Apis.Auth" Version="1.76.0" />
<PackageReference Include="Google.Apis.PeopleService.v1" Version="1.74.0.3973" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.11" />
<PackageReference Include="Microsoft.Graph" Version="6.5.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,242 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
namespace Anrufmonitor.Contacts;
/// <summary>
/// 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.
/// </summary>
public sealed class ContactCache
{
private readonly string _connectionString;
private readonly ILogger<ContactCache> _logger;
public ContactCache(string databasePath, ILogger<ContactCache> 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();
}
/// <summary>
/// Ersetzt den kompletten Bestand einer Quelle in einer einzigen Transaktion
/// (kein Zwischenzustand mit teilweise geloeschten/eingefuegten Daten).
/// </summary>
public async Task ReplaceContactsForSourceAsync(
ContactSource source,
IReadOnlyList<ContactInfo> 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<ContactInfo?> 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<ContactSource>(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<ContactInfo?> 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<SqliteConnection> 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);
}
}

View file

@ -0,0 +1,37 @@
namespace Anrufmonitor.Contacts;
/// <summary>
/// Quelle eines Kontakts.
/// </summary>
public enum ContactSource
{
Office365,
Google,
PublicDirectory,
}
/// <summary>
/// Normalisierter Kontakt, wie er im Cache abgelegt und in der UI angezeigt wird.
/// </summary>
/// <param name="Id">Eindeutige Id innerhalb der Quelle (z. B. Graph-/People-Api-Id).</param>
/// <param name="Source">Herkunft des Kontakts.</param>
/// <param name="DisplayName">Anzeigename.</param>
/// <param name="Company">Firma, falls vorhanden.</param>
/// <param name="PhoneNumbers">Alle bekannten Rufnummern, bereits normalisiert (E.164-ähnlich).</param>
/// <param name="PhotoUrl">Optionale URL/Pfad zu einem Kontaktfoto.</param>
public sealed record ContactInfo(
string Id,
ContactSource Source,
string DisplayName,
string? Company,
IReadOnlyList<string> PhoneNumbers,
string? PhotoUrl)
{
public static ContactInfo Unknown(string phoneNumber) => new(
Id: phoneNumber,
Source: ContactSource.PublicDirectory,
DisplayName: phoneNumber,
Company: null,
PhoneNumbers: [phoneNumber],
PhotoUrl: null);
}

View file

@ -0,0 +1,60 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Anrufmonitor.Contacts;
/// <summary>
/// Wird beim eingehenden Anruf aufgerufen. Reihenfolge:
/// 1. Cache (schnell, synchronisiert durch <see cref="ContactSyncService"/>)
/// 2. On-Demand-Reverse-Lookup-Provider (z. B. oeffentliches Verzeichnis), Ergebnis wird gecacht
/// 3. <see cref="ContactInfo.Unknown"/> als Fallback, damit die UI immer etwas anzuzeigen hat
/// </summary>
public sealed class ContactLookupService(
ContactCache cache,
IEnumerable<IReverseLookupProvider> reverseLookupProviders,
IOptions<ContactsOptions> options,
ILogger<ContactLookupService> logger)
{
private readonly ContactsOptions _options = options.Value;
public async Task<ContactInfo> 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);
}
}

View file

@ -0,0 +1,57 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Anrufmonitor.Contacts;
/// <summary>
/// Laeuft im Hintergrund und synchronisiert periodisch alle aktivierten
/// <see cref="IContactProvider"/> in den lokalen <see cref="ContactCache"/>.
/// Der eigentliche Anruf-Lookup (<see cref="ContactLookupService"/>) liest danach
/// nur noch aus dem Cache und ruft nie live gegen Office365/Google waehrend eines
/// eingehenden Anrufs.
/// </summary>
public sealed class ContactSyncService(
ContactCache cache,
IEnumerable<IContactProvider> providers,
IOptions<ContactsOptions> options,
ILogger<ContactSyncService> 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);
}
}

View file

@ -0,0 +1,71 @@
namespace Anrufmonitor.Contacts;
/// <summary>Bindung fuer den Konfigurationsabschnitt "Contacts" (appsettings.json / appsettings.Local.json).</summary>
public sealed class ContactsOptions
{
public const string SectionName = "Contacts";
/// <summary>Pfad zur SQLite-Cache-Datei. "%LocalAppData%" wird aufgeloest.</summary>
public string CacheDatabasePath { get; set; } = "%LocalAppData%\\Anrufmonitor\\cache.db";
/// <summary>Intervall fuer den periodischen Kontakt-Sync (Office365/Google -> Cache).</summary>
public TimeSpan SyncInterval { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>Wie lange ein On-Demand-Reverse-Lookup-Ergebnis (oeffentliches Verzeichnis) im Cache bleibt.</summary>
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);
}
/// <summary>
/// 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).
/// </summary>
public sealed class Office365Options
{
public bool Enabled { get; set; }
public string TenantId { get; set; } = "common";
public string ClientId { get; set; } = string.Empty;
}
/// <summary>
/// OAuth-Client fuer die Google People API (Installed-App-Flow).
/// ClientSecret gehoert NICHT in appsettings.json, sondern in appsettings.Local.json (gitignored).
/// </summary>
public sealed class GoogleOptions
{
public bool Enabled { get; set; }
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
/// <summary>Wo der OAuth-Token nach dem ersten Login zwischengespeichert wird.</summary>
public string TokenStorePath { get; set; } = "%LocalAppData%\\Anrufmonitor\\google-token";
}
/// <summary>
/// Oeffentliches Telefonverzeichnis fuer On-Demand-Reverse-Lookup unbekannter Nummern.
/// Bewusst generisch gehalten (URL-Template + JSON-Pfade), da Anbieter/API je nach Land/Vertrag variieren.
/// </summary>
public sealed class PublicDirectoryOptions
{
public bool Enabled { get; set; }
/// <summary>URL-Template, "{number}" wird durch die normalisierte Rufnummer ersetzt.</summary>
public string RequestUrlTemplate { get; set; } = string.Empty;
public string? ApiKey { get; set; }
public int TimeoutMilliseconds { get; set; } = 2000;
}

View file

@ -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;
/// <summary>
/// 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 <see cref="GoogleOptions.TokenStorePath"/> zwischengespeichert.
/// Benoetigt ein OAuth-Client (Typ "Desktop app") in der Google Cloud Console mit
/// aktivierter People API und Scope "contacts.readonly".
/// </summary>
public sealed class GoogleContactProvider : IContactProvider
{
private readonly ContactsOptions _options;
private readonly ILogger<GoogleContactProvider> _logger;
public GoogleContactProvider(IOptions<ContactsOptions> options, ILogger<GoogleContactProvider> 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<IReadOnlyList<ContactInfo>> 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<ContactInfo>();
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);
}
}

View file

@ -0,0 +1,30 @@
namespace Anrufmonitor.Contacts;
/// <summary>
/// Liefert den kompletten Kontaktbestand einer Quelle fuer den periodischen Cache-Sync.
/// Wird NICHT waehrend eines eingehenden Anrufs aufgerufen (zu langsam) - siehe
/// <see cref="ContactSyncService"/> und <see cref="ContactLookupService"/>.
/// </summary>
public interface IContactProvider
{
ContactSource Source { get; }
/// <summary>Ob der Provider konfiguriert/aktiviert ist (z. B. Zugangsdaten vorhanden).</summary>
bool IsEnabled { get; }
Task<IReadOnlyList<ContactInfo>> GetAllContactsAsync(CancellationToken cancellationToken);
}
/// <summary>
/// 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.
/// </summary>
public interface IReverseLookupProvider
{
ContactSource Source { get; }
bool IsEnabled { get; }
Task<ContactInfo?> LookupAsync(string normalizedNumber, CancellationToken cancellationToken);
}

View file

@ -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;
/// <summary>
/// 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".
/// </summary>
public sealed class Office365ContactProvider : IContactProvider
{
private readonly ContactsOptions _options;
private readonly ILogger<Office365ContactProvider> _logger;
public Office365ContactProvider(IOptions<ContactsOptions> options, ILogger<Office365ContactProvider> 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<IReadOnlyList<ContactInfo>> 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<ContactInfo>();
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<Contact, ContactCollectionResponse>.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<string>();
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);
}
}

View file

@ -0,0 +1,80 @@
using System.Text;
namespace Anrufmonitor.Contacts;
/// <summary>
/// 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".
/// </summary>
public static class PhoneNumberNormalizer
{
/// <summary>
/// Landesvorwahl ohne "+", die fuer national geschriebene Nummern (fuehrende "0") angenommen wird.
/// </summary>
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;
}
/// <summary>
/// Extrahiert die Rufnummer aus einem SIP-From/To-Header-Wert wie
/// "\"Max Mustermann\" &lt;sip:+491751234567@pbx.example.com&gt;" oder "sip:01751234567@pbx".
/// </summary>
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;
}
}

View file

@ -0,0 +1,97 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Anrufmonitor.Contacts;
/// <summary>
/// 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 <see cref="ExtractDisplayName"/> an den tatsaechlichen Anbieter anpassen.
/// </summary>
public sealed class PublicDirectoryLookupProvider : IReverseLookupProvider
{
private readonly ContactsOptions _options;
private readonly HttpClient _httpClient;
private readonly ILogger<PublicDirectoryLookupProvider> _logger;
public PublicDirectoryLookupProvider(
IOptions<ContactsOptions> options, HttpClient httpClient, ILogger<PublicDirectoryLookupProvider> 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<ContactInfo?> 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;
}
}
/// <summary>
/// TODO: an das tatsaechlich verwendete oeffentliche Verzeichnis anpassen.
/// Aktuell wird ein einfaches Top-Level-Feld "name" erwartet.
/// </summary>
private static string? ExtractDisplayName(JsonElement root)
{
return root.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null;
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.11" />
<PackageReference Include="SIPSorcery" Version="10.0.16" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,17 @@
namespace Anrufmonitor.Sip;
public sealed class IncomingCallEventArgs(string callId, string callerNumber, string? callerDisplayName) : EventArgs
{
/// <summary>SIP Call-ID, um zusammengehoerige Events (Ringing -> Ended) zu korrelieren.</summary>
public string CallId { get; } = callId;
/// <summary>Rohe Rufnummer aus dem From-Header, noch nicht normalisiert.</summary>
public string CallerNumber { get; } = callerNumber;
public string? CallerDisplayName { get; } = callerDisplayName;
}
public sealed class CallEndedEventArgs(string callId) : EventArgs
{
public string CallId { get; } = callId;
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class SipCallMonitor : BackgroundService
{
private readonly SipOptions _options;
private readonly ILogger<SipCallMonitor> _logger;
private SIPTransport? _transport;
private SIPUserAgent? _userAgent;
private SIPRegistrationUserAgent? _registrationAgent;
public SipCallMonitor(IOptions<SipOptions> options, ILogger<SipCallMonitor> logger)
{
_options = options.Value;
_logger = logger;
}
public event EventHandler<IncomingCallEventArgs>? IncomingCall;
public event EventHandler<CallEndedEventArgs>? 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();
}
}

View file

@ -0,0 +1,29 @@
namespace Anrufmonitor.Sip;
/// <summary>Bindung fuer den Konfigurationsabschnitt "Sip" (appsettings.json / appsettings.Local.json).</summary>
public sealed class SipOptions
{
public const string SectionName = "Sip";
public bool Enabled { get; set; }
/// <summary>SIP-Registrar/PBX, z. B. "pbx.example.com" oder "192.168.1.10".</summary>
public string Server { get; set; } = string.Empty;
public int Port { get; set; } = 5060;
public string Username { get; set; } = string.Empty;
/// <summary>Gehoert in appsettings.Local.json (gitignored), nicht in appsettings.json.</summary>
public string Password { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public int RegisterExpirySeconds { get; set; } = 300;
/// <summary>
/// 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.
/// </summary>
public bool SendRingingProgress { get; set; } = true;
}

View file

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Anrufmonitor.Contacts\Anrufmonitor.Contacts.csproj" />
<ProjectReference Include="..\..\src\Anrufmonitor.Sip\Anrufmonitor.Sip.csproj" />
</ItemGroup>
</Project>

View file

@ -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\" <sip:+491751234567@pbx.example.com>", "+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));
}
}