Anrufliste (SQLite CallLog, Tray-Fenster), angerufene Nummer aus P-Called-Party-ID im Popup

This commit is contained in:
dimedtec 2026-10-07 16:43:20 +02:00
parent c4b0148121
commit 4376619459
10 changed files with 180 additions and 9 deletions

View file

@ -77,6 +77,7 @@ public partial class App : System.Windows.Application
var sipMonitor = _host.Services.GetRequiredService<SipCallMonitor>();
var lookupService = _host.Services.GetRequiredService<ContactLookupService>();
var logger = _host.Services.GetRequiredService<ILogger<App>>();
var cache = _host.Services.GetRequiredService<ContactCache>();
_trayIconManager = new TrayIconManager(
onSyncNow: async () =>
@ -84,6 +85,7 @@ public partial class App : System.Windows.Application
var syncService = _host.Services.GetRequiredService<ContactSyncService>();
await syncService.SyncOnceAsync(CancellationToken.None);
},
onShowHistory: () => CallHistoryWindow.ShowAsync(cache),
onExit: () => System.Windows.Application.Current.Shutdown());
_trayIconManager.Show();
@ -103,9 +105,14 @@ public partial class App : System.Windows.Application
{
var contact = await lookupService.ResolveAsync(args.CallerNumber, CancellationToken.None);
logger.LogInformation("App: Kontakt aufgeloest zu {Name}", contact.DisplayName);
await cache.AddCallAsync(new CallLogEntry(
args.CallId, DateTime.UtcNow, args.CallerNumber, contact.DisplayName,
contact.Company, args.CalledNumber), CancellationToken.None);
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var popup = new CallPopupWindow(contact, args.CallId);
var popup = new CallPopupWindow(contact, args.CallId, args.CalledNumber);
popup.Show();
});
}

View file

@ -0,0 +1,18 @@
<Window x:Class="Anrufmonitor.App.CallHistoryWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Anrufmonitor - Letzte Anrufe"
Width="760" Height="460"
WindowStartupLocation="CenterScreen">
<DataGrid x:Name="Grid" AutoGenerateColumns="False" IsReadOnly="True"
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
CanUserAddRows="False" SelectionMode="Single">
<DataGrid.Columns>
<DataGridTextColumn Header="Zeit" Binding="{Binding Time}" Width="140" />
<DataGridTextColumn Header="Name" Binding="{Binding DisplayName}" Width="*" />
<DataGridTextColumn Header="Firma" Binding="{Binding Company}" Width="160" />
<DataGridTextColumn Header="Nummer" Binding="{Binding Number}" Width="130" />
<DataGridTextColumn Header="Angerufen" Binding="{Binding CalledNumber}" Width="110" />
</DataGrid.Columns>
</DataGrid>
</Window>

View file

@ -0,0 +1,34 @@
using System.Windows;
using Anrufmonitor.Contacts;
namespace Anrufmonitor.App;
/// <summary>Liste der letzten eingehenden Anrufe (aus der SQLite-Tabelle CallLog). Einzelinstanz.</summary>
public partial class CallHistoryWindow : Window
{
private static CallHistoryWindow? _instance;
private CallHistoryWindow()
{
InitializeComponent();
Closed += (_, _) => _instance = null;
}
public static async Task ShowAsync(ContactCache cache)
{
var entries = await cache.GetRecentCallsAsync(200, CancellationToken.None);
_instance ??= new CallHistoryWindow();
_instance.Grid.ItemsSource = entries.Select(e => new
{
Time = e.TimestampUtc.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss"),
e.DisplayName,
e.Company,
e.Number,
e.CalledNumber,
}).ToList();
_instance.Show();
_instance.Activate();
}
}

View file

@ -2,7 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Anrufmonitor"
Width="360" Height="140"
Width="360" Height="160"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
@ -16,6 +16,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Eingehender Anruf" Foreground="#FF8CA0C4" FontSize="12" FontWeight="SemiBold" />
@ -28,6 +29,8 @@
<TextBlock x:Name="NumberText" Foreground="#FF7C879C" FontSize="14" />
</StackPanel>
<TextBlock Grid.Row="3" x:Name="CalledText" Foreground="#FF8CA0C4" FontSize="12" Margin="0,6,0,0" />
<Button Content="✕" HorizontalAlignment="Right" VerticalAlignment="Top"
Width="24" Height="24" Padding="0" Margin="0,-16,-16,0"
Background="Transparent" BorderThickness="0" Foreground="#FF7C879C"

View file

@ -18,13 +18,16 @@ public partial class CallPopupWindow : Window
private readonly string _callId;
private readonly DispatcherTimer _autoCloseTimer;
public CallPopupWindow(ContactInfo contact, string callId)
public CallPopupWindow(ContactInfo contact, string callId, string? calledNumber = null)
{
InitializeComponent();
_callId = callId;
OpenPopups[callId] = this;
CalledText.Text = string.IsNullOrWhiteSpace(calledNumber) ? string.Empty : $"angerufen: {calledNumber}";
CalledText.Visibility = string.IsNullOrWhiteSpace(calledNumber) ? Visibility.Collapsed : Visibility.Visible;
NameText.Text = contact.DisplayName;
CompanyText.Text = contact.Company ?? string.Empty;
CompanyText.Visibility = string.IsNullOrWhiteSpace(contact.Company) ? Visibility.Collapsed : Visibility.Visible;

View file

@ -13,10 +13,14 @@ public sealed class TrayIconManager : IDisposable
private readonly NotifyIcon _notifyIcon;
private Icon? _currentIcon;
public TrayIconManager(Func<Task> onSyncNow, System.Action onExit)
public TrayIconManager(Func<Task> onSyncNow, Func<Task> onShowHistory, System.Action onExit)
{
var menu = new ContextMenuStrip();
var historyItem = new ToolStripMenuItem("Letzte Anrufe");
historyItem.Click += async (_, _) => await onShowHistory();
menu.Items.Add(historyItem);
var syncItem = new ToolStripMenuItem("Kontakte jetzt synchronisieren");
syncItem.Click += async (_, _) => await onSyncNow();
menu.Items.Add(syncItem);
@ -36,6 +40,7 @@ public sealed class TrayIconManager : IDisposable
ContextMenuStrip = menu,
Visible = false,
};
_notifyIcon.DoubleClick += async (_, _) => await onShowHistory();
}
public void Show() => _notifyIcon.Visible = true;

View file

@ -0,0 +1,10 @@
namespace Anrufmonitor.Contacts;
/// <summary>Ein Eintrag der Anrufliste (eingehender Anruf).</summary>
public sealed record CallLogEntry(
string CallId,
DateTime TimestampUtc,
string Number,
string DisplayName,
string? Company,
string? CalledNumber);

View file

@ -60,9 +60,67 @@ public sealed class ContactCache
);
""", cancellationToken);
await ExecuteAsync(connection, transaction, """
CREATE TABLE IF NOT EXISTS CallLog (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
CallId TEXT NOT NULL,
CalledAtUtc TEXT NOT NULL,
Number TEXT NOT NULL,
DisplayName TEXT NOT NULL,
Company TEXT NULL,
CalledNumber TEXT NULL
);
""", cancellationToken);
transaction.Commit();
}
public async Task AddCallAsync(CallLogEntry entry, CancellationToken cancellationToken)
{
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 CallLog (CallId, CalledAtUtc, Number, DisplayName, Company, CalledNumber)
VALUES ($callId, $at, $number, $displayName, $company, $calledNumber);
""";
cmd.Parameters.AddWithValue("$callId", entry.CallId);
cmd.Parameters.AddWithValue("$at", entry.TimestampUtc.ToString("O"));
cmd.Parameters.AddWithValue("$number", entry.Number);
cmd.Parameters.AddWithValue("$displayName", entry.DisplayName);
cmd.Parameters.AddWithValue("$company", (object?)entry.Company ?? DBNull.Value);
cmd.Parameters.AddWithValue("$calledNumber", (object?)entry.CalledNumber ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(cancellationToken);
transaction.Commit();
}
public async Task<IReadOnlyList<CallLogEntry>> GetRecentCallsAsync(int limit, CancellationToken cancellationToken)
{
await using var connection = await OpenAsync(cancellationToken);
await using var cmd = connection.CreateCommand();
cmd.CommandText = """
SELECT CallId, CalledAtUtc, Number, DisplayName, Company, CalledNumber
FROM CallLog ORDER BY Id DESC LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$limit", limit);
var result = new List<CallLogEntry>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
result.Add(new CallLogEntry(
CallId: reader.GetString(0),
TimestampUtc: DateTime.Parse(reader.GetString(1), null, System.Globalization.DateTimeStyles.RoundtripKind),
Number: reader.GetString(2),
DisplayName: reader.GetString(3),
Company: reader.IsDBNull(4) ? null : reader.GetString(4),
CalledNumber: reader.IsDBNull(5) ? null : reader.GetString(5)));
}
return result;
}
/// <summary>
/// Ersetzt den kompletten Bestand einer Provider-Instanz (z. B. eines einzelnen
/// Office365-Accounts) in einer einzigen Transaktion (kein Zwischenzustand mit

View file

@ -1,7 +1,11 @@
namespace Anrufmonitor.Sip;
public sealed class IncomingCallEventArgs(string callId, string callerNumber, string? callerDisplayName) : EventArgs
public sealed class IncomingCallEventArgs(
string callId, string callerNumber, string? callerDisplayName, string? calledNumber = null) : EventArgs
{
/// <summary>Angerufene Nummer (aus To-Header bzw. Request-URI), falls der SIP-Server sie liefert.</summary>
public string? CalledNumber { get; } = calledNumber;
/// <summary>SIP Call-ID, um zusammengehoerige Events (Ringing -> Ended) zu korrelieren.</summary>
public string CallId { get; } = callId;

View file

@ -25,6 +25,24 @@ public sealed class SipCallMonitor : BackgroundService
private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(15);
private static readonly TimeSpan WatchdogInterval = TimeSpan.FromSeconds(60);
/// <summary>
/// Liest die angerufene Nummer aus dem Header "P-Called-Party-ID: &lt;sip:NUMMER@fritz.box&gt;".
/// SIPSorcery kennt den Header nicht typisiert, er liegt in UnknownHeaders.
/// </summary>
private static string? ExtractCalledNumber(SIPRequest request)
{
const string headerName = "P-Called-Party-ID";
var raw = request.Header.UnknownHeaders
.FirstOrDefault(h => h.StartsWith(headerName, StringComparison.OrdinalIgnoreCase));
if (raw is null)
{
return null;
}
var match = System.Text.RegularExpressions.Regex.Match(raw, @"sips?:(?<user>[^@>;]+)@");
return match.Success ? match.Groups["user"].Value : null;
}
private readonly SipOptions _options;
private readonly ILogger<SipCallMonitor> _logger;
@ -211,11 +229,22 @@ public sealed class SipCallMonitor : BackgroundService
var callerDisplayName = fromHeader?.FromName;
var callId = inviteRequest.Header.CallId;
_logger.LogInformation(
"Eingehender Anruf von {Number} ({DisplayName}), Call-Id {CallId}",
callerNumber, callerDisplayName, callId);
// Angerufene Nummer: Die Fritz!Box liefert sie im Header P-Called-Party-ID
// (To/Request-URI enthalten nur die Geraeteadresse). Fallback: To-/Request-URI-User.
var calledNumber = ExtractCalledNumber(inviteRequest)
?? inviteRequest.Header.To?.ToURI?.User
?? inviteRequest.URI?.User;
var toNumber = inviteRequest.Header.To?.ToURI?.User;
var requestUriUser = inviteRequest.URI?.User;
IncomingCall?.Invoke(this, new IncomingCallEventArgs(callId, callerNumber, callerDisplayName));
_logger.LogInformation(
"Eingehender Anruf von {Number} ({DisplayName}) an {Called} [To={To}, Request-URI={Uri}], Call-Id {CallId}",
callerNumber, callerDisplayName, calledNumber, toNumber, requestUriUser, callId);
// Alle Header einmal roh ins Log (Debug), um z. B. P-Called-Party-ID/Diversion/History-Info zu pruefen.
_logger.LogDebug("INVITE-Header:{NewLine}{Headers}", Environment.NewLine, inviteRequest.Header.ToString());
IncomingCall?.Invoke(this, new IncomingCallEventArgs(callId, callerNumber, callerDisplayName, calledNumber));
// Bewusst KEIN uas.Answer(...) - der Client bleibt rein passiv.
// uas selbst wird hier nicht weiter benoetigt, das Cancel-Handling laeuft