Anrufliste (SQLite CallLog, Tray-Fenster), angerufene Nummer aus P-Called-Party-ID im Popup
This commit is contained in:
parent
c4b0148121
commit
4376619459
10 changed files with 180 additions and 9 deletions
|
|
@ -77,6 +77,7 @@ public partial class App : System.Windows.Application
|
||||||
var sipMonitor = _host.Services.GetRequiredService<SipCallMonitor>();
|
var sipMonitor = _host.Services.GetRequiredService<SipCallMonitor>();
|
||||||
var lookupService = _host.Services.GetRequiredService<ContactLookupService>();
|
var lookupService = _host.Services.GetRequiredService<ContactLookupService>();
|
||||||
var logger = _host.Services.GetRequiredService<ILogger<App>>();
|
var logger = _host.Services.GetRequiredService<ILogger<App>>();
|
||||||
|
var cache = _host.Services.GetRequiredService<ContactCache>();
|
||||||
|
|
||||||
_trayIconManager = new TrayIconManager(
|
_trayIconManager = new TrayIconManager(
|
||||||
onSyncNow: async () =>
|
onSyncNow: async () =>
|
||||||
|
|
@ -84,6 +85,7 @@ public partial class App : System.Windows.Application
|
||||||
var syncService = _host.Services.GetRequiredService<ContactSyncService>();
|
var syncService = _host.Services.GetRequiredService<ContactSyncService>();
|
||||||
await syncService.SyncOnceAsync(CancellationToken.None);
|
await syncService.SyncOnceAsync(CancellationToken.None);
|
||||||
},
|
},
|
||||||
|
onShowHistory: () => CallHistoryWindow.ShowAsync(cache),
|
||||||
onExit: () => System.Windows.Application.Current.Shutdown());
|
onExit: () => System.Windows.Application.Current.Shutdown());
|
||||||
_trayIconManager.Show();
|
_trayIconManager.Show();
|
||||||
|
|
||||||
|
|
@ -103,9 +105,14 @@ public partial class App : System.Windows.Application
|
||||||
{
|
{
|
||||||
var contact = await lookupService.ResolveAsync(args.CallerNumber, CancellationToken.None);
|
var contact = await lookupService.ResolveAsync(args.CallerNumber, CancellationToken.None);
|
||||||
logger.LogInformation("App: Kontakt aufgeloest zu {Name}", contact.DisplayName);
|
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(() =>
|
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||||
{
|
{
|
||||||
var popup = new CallPopupWindow(contact, args.CallId);
|
var popup = new CallPopupWindow(contact, args.CallId, args.CalledNumber);
|
||||||
popup.Show();
|
popup.Show();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
src/Anrufmonitor.App/CallHistoryWindow.xaml
Normal file
18
src/Anrufmonitor.App/CallHistoryWindow.xaml
Normal 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>
|
||||||
34
src/Anrufmonitor.App/CallHistoryWindow.xaml.cs
Normal file
34
src/Anrufmonitor.App/CallHistoryWindow.xaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Anrufmonitor"
|
Title="Anrufmonitor"
|
||||||
Width="360" Height="140"
|
Width="360" Height="160"
|
||||||
WindowStyle="None"
|
WindowStyle="None"
|
||||||
AllowsTransparency="True"
|
AllowsTransparency="True"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Eingehender Anruf" Foreground="#FF8CA0C4" FontSize="12" FontWeight="SemiBold" />
|
<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" />
|
<TextBlock x:Name="NumberText" Foreground="#FF7C879C" FontSize="14" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" x:Name="CalledText" Foreground="#FF8CA0C4" FontSize="12" Margin="0,6,0,0" />
|
||||||
|
|
||||||
<Button Content="✕" HorizontalAlignment="Right" VerticalAlignment="Top"
|
<Button Content="✕" HorizontalAlignment="Right" VerticalAlignment="Top"
|
||||||
Width="24" Height="24" Padding="0" Margin="0,-16,-16,0"
|
Width="24" Height="24" Padding="0" Margin="0,-16,-16,0"
|
||||||
Background="Transparent" BorderThickness="0" Foreground="#FF7C879C"
|
Background="Transparent" BorderThickness="0" Foreground="#FF7C879C"
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,16 @@ public partial class CallPopupWindow : Window
|
||||||
private readonly string _callId;
|
private readonly string _callId;
|
||||||
private readonly DispatcherTimer _autoCloseTimer;
|
private readonly DispatcherTimer _autoCloseTimer;
|
||||||
|
|
||||||
public CallPopupWindow(ContactInfo contact, string callId)
|
public CallPopupWindow(ContactInfo contact, string callId, string? calledNumber = null)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
_callId = callId;
|
_callId = callId;
|
||||||
OpenPopups[callId] = this;
|
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;
|
NameText.Text = contact.DisplayName;
|
||||||
CompanyText.Text = contact.Company ?? string.Empty;
|
CompanyText.Text = contact.Company ?? string.Empty;
|
||||||
CompanyText.Visibility = string.IsNullOrWhiteSpace(contact.Company) ? Visibility.Collapsed : Visibility.Visible;
|
CompanyText.Visibility = string.IsNullOrWhiteSpace(contact.Company) ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,14 @@ public sealed class TrayIconManager : IDisposable
|
||||||
private readonly NotifyIcon _notifyIcon;
|
private readonly NotifyIcon _notifyIcon;
|
||||||
private Icon? _currentIcon;
|
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 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");
|
var syncItem = new ToolStripMenuItem("Kontakte jetzt synchronisieren");
|
||||||
syncItem.Click += async (_, _) => await onSyncNow();
|
syncItem.Click += async (_, _) => await onSyncNow();
|
||||||
menu.Items.Add(syncItem);
|
menu.Items.Add(syncItem);
|
||||||
|
|
@ -36,6 +40,7 @@ public sealed class TrayIconManager : IDisposable
|
||||||
ContextMenuStrip = menu,
|
ContextMenuStrip = menu,
|
||||||
Visible = false,
|
Visible = false,
|
||||||
};
|
};
|
||||||
|
_notifyIcon.DoubleClick += async (_, _) => await onShowHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Show() => _notifyIcon.Visible = true;
|
public void Show() => _notifyIcon.Visible = true;
|
||||||
|
|
|
||||||
10
src/Anrufmonitor.Contacts/CallLogEntry.cs
Normal file
10
src/Anrufmonitor.Contacts/CallLogEntry.cs
Normal 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);
|
||||||
|
|
@ -60,9 +60,67 @@ public sealed class ContactCache
|
||||||
);
|
);
|
||||||
""", cancellationToken);
|
""", 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();
|
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>
|
/// <summary>
|
||||||
/// Ersetzt den kompletten Bestand einer Provider-Instanz (z. B. eines einzelnen
|
/// Ersetzt den kompletten Bestand einer Provider-Instanz (z. B. eines einzelnen
|
||||||
/// Office365-Accounts) in einer einzigen Transaktion (kein Zwischenzustand mit
|
/// Office365-Accounts) in einer einzigen Transaktion (kein Zwischenzustand mit
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
namespace Anrufmonitor.Sip;
|
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>
|
/// <summary>SIP Call-ID, um zusammengehoerige Events (Ringing -> Ended) zu korrelieren.</summary>
|
||||||
public string CallId { get; } = callId;
|
public string CallId { get; } = callId;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,24 @@ public sealed class SipCallMonitor : BackgroundService
|
||||||
private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(15);
|
private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(15);
|
||||||
private static readonly TimeSpan WatchdogInterval = TimeSpan.FromSeconds(60);
|
private static readonly TimeSpan WatchdogInterval = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liest die angerufene Nummer aus dem Header "P-Called-Party-ID: <sip:NUMMER@fritz.box>".
|
||||||
|
/// 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 SipOptions _options;
|
||||||
private readonly ILogger<SipCallMonitor> _logger;
|
private readonly ILogger<SipCallMonitor> _logger;
|
||||||
|
|
||||||
|
|
@ -211,11 +229,22 @@ public sealed class SipCallMonitor : BackgroundService
|
||||||
var callerDisplayName = fromHeader?.FromName;
|
var callerDisplayName = fromHeader?.FromName;
|
||||||
var callId = inviteRequest.Header.CallId;
|
var callId = inviteRequest.Header.CallId;
|
||||||
|
|
||||||
_logger.LogInformation(
|
// Angerufene Nummer: Die Fritz!Box liefert sie im Header P-Called-Party-ID
|
||||||
"Eingehender Anruf von {Number} ({DisplayName}), Call-Id {CallId}",
|
// (To/Request-URI enthalten nur die Geraeteadresse). Fallback: To-/Request-URI-User.
|
||||||
callerNumber, callerDisplayName, callId);
|
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.
|
// Bewusst KEIN uas.Answer(...) - der Client bleibt rein passiv.
|
||||||
// uas selbst wird hier nicht weiter benoetigt, das Cancel-Handling laeuft
|
// uas selbst wird hier nicht weiter benoetigt, das Cancel-Handling laeuft
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue