Public Access
Add meeting assistant speech and summary automation
This commit is contained in:
@@ -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() ?? "";
|
||||
|
||||
Reference in New Issue
Block a user