Anrufmonitor Grundgeruest: SIP Monitor, Contacts Cache, WPF Tray App
This commit is contained in:
parent
f78831a375
commit
d7869b977f
28 changed files with 1581 additions and 17 deletions
10
Anrufmonitor.slnx
Normal file
10
Anrufmonitor.slnx
Normal 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>
|
||||
63
README.md
63
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
|
||||
|
||||
|
|
|
|||
30
src/Anrufmonitor.App/Anrufmonitor.App.csproj
Normal file
30
src/Anrufmonitor.App/Anrufmonitor.App.csproj
Normal 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>
|
||||
9
src/Anrufmonitor.App/App.xaml
Normal file
9
src/Anrufmonitor.App/App.xaml
Normal 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>
|
||||
112
src/Anrufmonitor.App/App.xaml.cs
Normal file
112
src/Anrufmonitor.App/App.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
10
src/Anrufmonitor.App/AssemblyInfo.cs
Normal file
10
src/Anrufmonitor.App/AssemblyInfo.cs
Normal 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)
|
||||
)]
|
||||
37
src/Anrufmonitor.App/CallPopupWindow.xaml
Normal file
37
src/Anrufmonitor.App/CallPopupWindow.xaml
Normal 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>
|
||||
65
src/Anrufmonitor.App/CallPopupWindow.xaml.cs
Normal file
65
src/Anrufmonitor.App/CallPopupWindow.xaml.cs
Normal 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();
|
||||
}
|
||||
43
src/Anrufmonitor.App/TrayIconManager.cs
Normal file
43
src/Anrufmonitor.App/TrayIconManager.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
25
src/Anrufmonitor.App/appsettings.Local.json.example
Normal file
25
src/Anrufmonitor.App/appsettings.Local.json.example
Normal 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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/Anrufmonitor.App/appsettings.json
Normal file
40
src/Anrufmonitor.App/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/Anrufmonitor.Contacts/Anrufmonitor.Contacts.csproj
Normal file
21
src/Anrufmonitor.Contacts/Anrufmonitor.Contacts.csproj
Normal 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>
|
||||
242
src/Anrufmonitor.Contacts/ContactCache.cs
Normal file
242
src/Anrufmonitor.Contacts/ContactCache.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
37
src/Anrufmonitor.Contacts/ContactInfo.cs
Normal file
37
src/Anrufmonitor.Contacts/ContactInfo.cs
Normal 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);
|
||||
}
|
||||
60
src/Anrufmonitor.Contacts/ContactLookupService.cs
Normal file
60
src/Anrufmonitor.Contacts/ContactLookupService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
57
src/Anrufmonitor.Contacts/ContactSyncService.cs
Normal file
57
src/Anrufmonitor.Contacts/ContactSyncService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
71
src/Anrufmonitor.Contacts/ContactsOptions.cs
Normal file
71
src/Anrufmonitor.Contacts/ContactsOptions.cs
Normal 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;
|
||||
}
|
||||
108
src/Anrufmonitor.Contacts/GoogleContactProvider.cs
Normal file
108
src/Anrufmonitor.Contacts/GoogleContactProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/Anrufmonitor.Contacts/IContactProvider.cs
Normal file
30
src/Anrufmonitor.Contacts/IContactProvider.cs
Normal 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);
|
||||
}
|
||||
103
src/Anrufmonitor.Contacts/Office365ContactProvider.cs
Normal file
103
src/Anrufmonitor.Contacts/Office365ContactProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
80
src/Anrufmonitor.Contacts/PhoneNumberNormalizer.cs
Normal file
80
src/Anrufmonitor.Contacts/PhoneNumberNormalizer.cs
Normal 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\" <sip:+491751234567@pbx.example.com>" 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;
|
||||
}
|
||||
}
|
||||
97
src/Anrufmonitor.Contacts/PublicDirectoryLookupProvider.cs
Normal file
97
src/Anrufmonitor.Contacts/PublicDirectoryLookupProvider.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
16
src/Anrufmonitor.Sip/Anrufmonitor.Sip.csproj
Normal file
16
src/Anrufmonitor.Sip/Anrufmonitor.Sip.csproj
Normal 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>
|
||||
17
src/Anrufmonitor.Sip/IncomingCallEventArgs.cs
Normal file
17
src/Anrufmonitor.Sip/IncomingCallEventArgs.cs
Normal 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;
|
||||
}
|
||||
125
src/Anrufmonitor.Sip/SipCallMonitor.cs
Normal file
125
src/Anrufmonitor.Sip/SipCallMonitor.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
29
src/Anrufmonitor.Sip/SipOptions.cs
Normal file
29
src/Anrufmonitor.Sip/SipOptions.cs
Normal 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;
|
||||
}
|
||||
26
tests/Anrufmonitor.Tests/Anrufmonitor.Tests.csproj
Normal file
26
tests/Anrufmonitor.Tests/Anrufmonitor.Tests.csproj
Normal 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>
|
||||
35
tests/Anrufmonitor.Tests/PhoneNumberNormalizerTests.cs
Normal file
35
tests/Anrufmonitor.Tests/PhoneNumberNormalizerTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue