Add meeting inactivity safeguards
PR and Push Build/Test / build-and-test (push) Failing after 14m53s

This commit is contained in:
2026-06-03 08:30:15 +02:00
parent efe7c4ba0a
commit edade8ab62
21 changed files with 532 additions and 275 deletions
@@ -0,0 +1,59 @@
using MeetingAssistant.MeetingNotes;
namespace MeetingAssistant.Recording;
internal static class MeetingArtifactContentDetector
{
private static readonly string[] DefaultAssistantContextLines =
[
"# Assistant Context",
"## Live Context"
];
public static async Task<bool> IsDefaultOnlyAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
if (!File.Exists(artifacts.MeetingNotePath) ||
!File.Exists(artifacts.TranscriptPath) ||
!File.Exists(artifacts.AssistantContextPath))
{
return false;
}
var meetingNote = await File.ReadAllTextAsync(artifacts.MeetingNotePath, cancellationToken);
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath, cancellationToken);
var assistantContext = await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken);
return IsDefaultOnlyMeetingNote(meetingNote) &&
IsDefaultOnlyTranscript(transcript) &&
IsDefaultOnlyAssistantContext(assistantContext);
}
private static bool IsDefaultOnlyMeetingNote(string content)
{
return string.IsNullOrWhiteSpace(MarkdownDocumentParser.SplitOptional(content).Body);
}
private static bool IsDefaultOnlyTranscript(string content)
{
var body = MarkdownDocumentParser.SplitOptional(content).Body;
return GetMeaningfulLines(body)
.All(line => string.Equals(line, "# Meeting Transcript", StringComparison.OrdinalIgnoreCase));
}
private static bool IsDefaultOnlyAssistantContext(string content)
{
var body = MarkdownDocumentParser.SplitOptional(content).Body;
return GetMeaningfulLines(body)
.All(line => DefaultAssistantContextLines.Contains(line, StringComparer.OrdinalIgnoreCase));
}
private static IEnumerable<string> GetMeaningfulLines(string body)
{
return body
.Split(["\r\n", "\n"], StringSplitOptions.None)
.Select(line => line.Trim())
.Where(line => !string.IsNullOrWhiteSpace(line));
}
}
@@ -502,6 +502,14 @@ public sealed class MeetingRecordingCoordinator
run.TranscriptionCancellation);
}
if (await MeetingArtifactContentDetector.IsDefaultOnlyAsync(artifacts, run.TranscriptionCancellation))
{
logger.LogInformation(
"Deleting stopped meeting artifacts instead of summarizing because note, transcript, and assistant context contain only generated defaults");
await artifactCleaner.DeleteRunArtifactsAsync(artifacts, run.Options, run.TranscriptionCancellation);
return;
}
var summaryState = await RunSummaryAsync(run, run.TranscriptionCancellation);
await CompletePendingFinalSpeakerIdentificationAsync(run, run.TranscriptionCancellation);
await TransitionMeetingAsync(run, summaryState, CancellationToken.None);
@@ -0,0 +1,29 @@
namespace MeetingAssistant.Recording;
internal static class NotificationActivationArguments
{
public static IReadOnlyDictionary<string, string> Parse(string argumentText)
{
if (string.IsNullOrWhiteSpace(argumentText))
{
return new Dictionary<string, string>();
}
var arguments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in argumentText.Split(['&', ';'], StringSplitOptions.RemoveEmptyEntries))
{
var separatorIndex = pair.IndexOf('=');
if (separatorIndex < 0)
{
arguments[Uri.UnescapeDataString(pair)] = "";
continue;
}
var key = Uri.UnescapeDataString(pair[..separatorIndex]);
var value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]);
arguments[key] = value;
}
return arguments;
}
}
@@ -4,7 +4,7 @@ using CommunityToolkit.WinUI.Notifications;
namespace MeetingAssistant.Recording;
public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPromptService, IDisposable
public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPromptService, IHostedService, IDisposable
{
private const string NotificationSource = "meeting-inactivity-safeguard";
private const string NotificationGroup = "meeting-inactivity";
@@ -39,6 +39,10 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
EnsureRegistered();
var promptId = Guid.NewGuid().ToString("N");
pendingPrompts[promptId] = new PendingPrompt(handleResponseAsync);
logger.LogInformation(
"Registered native Windows inactivity notification {PromptId} response callback at threshold {Threshold}",
promptId,
request.Threshold);
var notification = BuildNotification(promptId, request);
notification.Show(toast =>
{
@@ -59,6 +63,23 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
return Task.CompletedTask;
}
public Task StartAsync(CancellationToken cancellationToken)
{
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763))
{
logger.LogWarning("Windows app notifications require Windows 10 version 1809 or later");
return Task.CompletedTask;
}
EnsureRegistered();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public void Dispose()
{
if (!registered)
@@ -69,11 +90,10 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
try
{
ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked;
ToastNotificationManagerCompat.Uninstall();
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to unregister native Windows toast notifications");
logger.LogWarning(exception, "Failed to detach native Windows toast notification activation handler");
}
}
@@ -126,12 +146,31 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args)
{
var arguments = ParseArguments(args.Argument);
logger.LogInformation(
"Native Windows inactivity notification activation received with arguments {Arguments}",
args.Argument);
var arguments = NotificationActivationArguments.Parse(args.Argument);
if (!TryGetArgument(arguments, "source", out var source) ||
!string.Equals(source, NotificationSource, StringComparison.OrdinalIgnoreCase) ||
!TryGetArgument(arguments, "promptId", out var promptId) ||
!pendingPrompts.TryRemove(promptId, out var pendingPrompt))
!string.Equals(source, NotificationSource, StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug(
"Ignoring native Windows notification activation for unrelated source {Source}",
source);
return;
}
if (!TryGetArgument(arguments, "promptId", out var promptId))
{
logger.LogWarning("Ignoring native Windows inactivity notification activation without prompt id");
return;
}
if (!pendingPrompts.TryRemove(promptId, out var pendingPrompt))
{
logger.LogWarning(
"Ignoring native Windows inactivity notification {PromptId} activation because no pending prompt callback exists",
promptId);
return;
}
@@ -159,31 +198,6 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
});
}
private static IReadOnlyDictionary<string, string> ParseArguments(string argumentText)
{
if (string.IsNullOrWhiteSpace(argumentText))
{
return new Dictionary<string, string>();
}
var arguments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in argumentText.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var separatorIndex = pair.IndexOf('=');
if (separatorIndex < 0)
{
arguments[Uri.UnescapeDataString(pair)] = "";
continue;
}
var key = Uri.UnescapeDataString(pair[..separatorIndex]);
var value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]);
arguments[key] = value;
}
return arguments;
}
private static bool TryGetArgument(
IReadOnlyDictionary<string, string> arguments,
string key,