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
|
# 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)
|
- **SIP:** registriert sich bei der Telefonanlage/dem Provider und liest eingehende `INVITE`-Requests
|
||||||
- Kontaktabgleich gegen:
|
passiv mit (Anrufer-Rufnummer). Es wird **nie** `Answer()` aufgerufen – der Client nimmt den Anruf
|
||||||
- Office 365 (Microsoft Graph API)
|
nicht an, sondern zeigt nur an, wer anruft. Voraussetzung: die Telefonanlage forkt eingehende Anrufe
|
||||||
- Google Contacts (Google People API)
|
an mehrere registrierte Geräte/Kontakte derselben Nebenstelle.
|
||||||
- Öffentliche Verzeichnisse (z. B. Telefonbuch-APIs)
|
- **Kontakte:** werden periodisch im Hintergrund aus Office 365 (Microsoft Graph) und Google Contacts
|
||||||
- Caching der Kontakte für schnelles Auffinden bei eingehendem Anruf
|
(People API) in einen lokalen SQLite-Cache synchronisiert. Beim eingehenden Anruf wird **nur der
|
||||||
- Popup/Notification mit Name, Firma, Foto (falls vorhanden) bei Anruf
|
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)
|
```powershell
|
||||||
- Kontakt-Cache: lokale SQLite-DB, TTL-basiertes Refresh
|
dotnet build Anrufmonitor.slnx
|
||||||
- Auth: OAuth2 gegen Microsoft Graph und Google People API
|
dotnet test tests\Anrufmonitor.Tests\Anrufmonitor.Tests.csproj
|
||||||
- UI: Tray-Icon + Popup-Notification
|
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
|
## Sicherheit
|
||||||
|
|
||||||
**Public Repo** – niemals Zugangsdaten, Tokens, Client-Secrets oder SIP-Passwörter committen.
|
**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
|
## 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