Add meeting assistant speech and summary automation

This commit is contained in:
2026-05-21 09:55:39 +02:00
parent 1e97d2b9ff
commit 48d98d9cbb
63 changed files with 5143 additions and 151 deletions
@@ -1,8 +1,10 @@
using System.Runtime.InteropServices;
using System.Diagnostics;
using System.Collections;
namespace MeetingAssistant.MeetingNotes;
public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider
public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider, IMeetingMetadataDiagnosticProvider
{
private const int OutlookCalendarFolder = 9;
private readonly ILogger<OutlookClassicMeetingMetadataProvider> logger;
@@ -19,7 +21,7 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
cancellationToken.ThrowIfCancellationRequested();
try
{
return Task.FromResult(GetCurrentMeeting(startedAt));
return RunStaAsync(() => GetCurrentMeeting(startedAt), cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
@@ -28,13 +30,66 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
}
}
public async Task<MeetingMetadataDiagnosticResult> DiagnoseCurrentMeetingAsync(
DateTimeOffset startedAt,
TimeSpan timeout,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
var lookup = RunStaAsync(() => GetCurrentMeeting(startedAt), cancellationToken);
var metadata = timeout > TimeSpan.Zero
? await lookup.WaitAsync(timeout, cancellationToken)
: await lookup.WaitAsync(cancellationToken);
stopwatch.Stop();
return new MeetingMetadataDiagnosticResult(
nameof(OutlookClassicMeetingMetadataProvider),
startedAt,
metadata is not null,
false,
stopwatch.ElapsedMilliseconds,
metadata,
metadata is null ? "No matching Teams appointment was found." : null);
}
catch (TimeoutException exception)
{
stopwatch.Stop();
return new MeetingMetadataDiagnosticResult(
nameof(OutlookClassicMeetingMetadataProvider),
startedAt,
false,
true,
stopwatch.ElapsedMilliseconds,
null,
exception.Message);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
stopwatch.Stop();
return new MeetingMetadataDiagnosticResult(
nameof(OutlookClassicMeetingMetadataProvider),
startedAt,
false,
false,
stopwatch.ElapsedMilliseconds,
null,
exception.Message);
}
}
private MeetingMetadata? GetCurrentMeeting(DateTimeOffset startedAt)
{
EnsureOutlookRunning();
object? application = null;
object? session = null;
object? calendar = null;
object? items = null;
object? restrictedItems = null;
try
{
@@ -53,31 +108,50 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
session = GetProperty(application, "Session");
calendar = Invoke(session!, "GetDefaultFolder", OutlookCalendarFolder);
items = GetProperty(calendar!, "Items");
SetProperty(items!, "IncludeRecurrences", true);
Invoke(items!, "Sort", "[Start]");
SetProperty(items!, "IncludeRecurrences", true);
var startedLocal = startedAt.LocalDateTime;
var windowStart = startedLocal.AddMinutes(-1);
var windowStart = startedLocal.AddHours(-4);
var windowEnd = startedLocal.AddMinutes(5);
var filter =
$"[Start] <= '{windowEnd:g}' AND [End] >= '{windowStart:g}'";
restrictedItems = Invoke(items!, "Restrict", filter);
var candidates = new List<OutlookAppointmentCandidate>();
var count = Convert.ToInt32(GetProperty(restrictedItems!, "Count"));
for (var index = 1; index <= count; index++)
foreach (var item in EnumerateItems(items!))
{
var appointment = Invoke(restrictedItems!, "Item", index);
if (appointment is not null && IsTeamsAppointment(appointment))
if (item is null)
{
candidates.Add(new OutlookAppointmentCandidate(
appointment,
Convert.ToDateTime(GetProperty(appointment, "Start")),
Convert.ToDateTime(GetProperty(appointment, "End"))));
continue;
}
else
var keepAppointment = false;
try
{
ReleaseComObject(appointment);
if (!IsAppointment(item))
{
continue;
}
var start = Convert.ToDateTime(GetProperty(item, "Start"));
if (start > windowEnd)
{
break;
}
var end = Convert.ToDateTime(GetProperty(item, "End"));
if (end < windowStart || !IsTeamsAppointment(item))
{
continue;
}
candidates.Add(new OutlookAppointmentCandidate(item, start, end));
keepAppointment = true;
}
finally
{
if (!keepAppointment)
{
ReleaseComObject(item);
}
}
}
@@ -118,7 +192,6 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
}
finally
{
ReleaseComObject(restrictedItems);
ReleaseComObject(items);
ReleaseComObject(calendar);
ReleaseComObject(session);
@@ -126,6 +199,83 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
}
}
private static IEnumerable<object?> EnumerateItems(object items)
{
if (items is IEnumerable enumerable)
{
foreach (var item in enumerable)
{
yield return item;
}
}
}
private static bool IsAppointment(object item)
{
try
{
return Convert.ToInt32(GetProperty(item, "Class")) == 26;
}
catch
{
return false;
}
}
private void EnsureOutlookRunning()
{
if (Process.GetProcessesByName("OUTLOOK").Length > 0)
{
return;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = "outlook.exe",
UseShellExecute = true
});
Thread.Sleep(TimeSpan.FromSeconds(5));
}
catch (Exception exception)
{
logger.LogDebug(exception, "Could not start Outlook Classic before metadata lookup");
}
}
private static Task<T> RunStaAsync<T>(
Func<T> action,
CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
try
{
completion.TrySetResult(action());
}
catch (Exception exception)
{
completion.TrySetException(exception);
}
})
{
IsBackground = true,
Name = "Meeting Assistant Outlook COM Lookup"
};
thread.SetApartmentState(ApartmentState.STA);
var registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
_ = completion.Task.ContinueWith(
_ => registration.Dispose(),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
thread.Start();
return completion.Task;
}
internal static string ExtractAgenda(string body)
{
if (string.IsNullOrWhiteSpace(body))
@@ -215,10 +365,7 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
ReleaseComObject(recipients);
}
return attendees
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return NormalizeAttendees(attendees);
}
private static DateTimeOffset ToLocalOffset(DateTime value)
@@ -226,6 +373,58 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local));
}
internal static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
{
return attendees
.Select(NormalizeAttendee)
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.GroupBy(GetAttendeeDeduplicationKey, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(attendee => attendee.Contains('<', StringComparison.Ordinal))
.ThenBy(attendee => attendee.Length)
.First())
.ToList();
}
private static string NormalizeAttendee(string attendee)
{
var normalized = attendee.Trim();
var mailtoPrefix = "mailto:";
normalized = normalized.Replace(mailtoPrefix, "", StringComparison.OrdinalIgnoreCase);
while (normalized.Contains(" ", StringComparison.Ordinal))
{
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
}
var emailStart = normalized.IndexOf('<', StringComparison.Ordinal);
var emailEnd = normalized.LastIndexOf('>');
if (emailStart > 0 && emailEnd > emailStart)
{
var name = normalized[..emailStart].Trim();
var email = normalized[(emailStart + 1)..emailEnd].Trim();
if (name.Equals(email, StringComparison.OrdinalIgnoreCase))
{
return email;
}
return $"{name} <{email}>";
}
return normalized;
}
private static string GetAttendeeDeduplicationKey(string attendee)
{
var emailStart = attendee.IndexOf('<', StringComparison.Ordinal);
var emailEnd = attendee.LastIndexOf('>');
if (emailStart > 0 && emailEnd > emailStart)
{
return attendee[(emailStart + 1)..emailEnd].Trim();
}
return attendee.Trim();
}
private static string FormatRecipient(object recipient)
{
var name = Convert.ToString(GetProperty(recipient, "Name"))?.Trim() ?? "";