Add Outlook Teams recording prompts

This commit is contained in:
2026-06-03 10:45:20 +02:00
parent edade8ab62
commit 50daa88389
17 changed files with 1435 additions and 234 deletions
@@ -0,0 +1,217 @@
#if WINDOWS
using System.Collections;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace MeetingAssistant.MeetingNotes;
internal static class OutlookClassicCom
{
private const int OutlookCalendarFolder = 9;
public static OutlookCalendarSession? OpenDefaultCalendar(
ILogger logger,
string logContext)
{
EnsureOutlookRunning(logger, logContext);
object? application = null;
object? session = null;
object? calendar = null;
object? items = null;
try
{
var applicationType = Type.GetTypeFromProgID("Outlook.Application");
if (applicationType is null)
{
return null;
}
application = Activator.CreateInstance(applicationType);
if (application is null)
{
return null;
}
session = GetProperty(application, "Session");
calendar = Invoke(session!, "GetDefaultFolder", OutlookCalendarFolder);
items = GetProperty(calendar!, "Items");
Invoke(items!, "Sort", "[Start]");
SetProperty(items!, "IncludeRecurrences", true);
return new OutlookCalendarSession(application, session, calendar, items!);
}
catch
{
ReleaseComObject(items);
ReleaseComObject(calendar);
ReleaseComObject(session);
ReleaseComObject(application);
throw;
}
}
public static Task<T> RunStaAsync<T>(
Func<T> action,
CancellationToken cancellationToken,
string threadName)
{
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
try
{
completion.TrySetResult(action());
}
catch (Exception exception)
{
completion.TrySetException(exception);
}
})
{
IsBackground = true,
Name = threadName
};
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;
}
public static IEnumerable<object?> EnumerateItems(object items)
{
if (items is IEnumerable enumerable)
{
foreach (var item in enumerable)
{
yield return item;
}
}
}
public static bool IsAppointment(object item)
{
try
{
return Convert.ToInt32(GetProperty(item, "Class")) == 26;
}
catch
{
return false;
}
}
public static DateTimeOffset ToLocalOffset(DateTime value)
{
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local));
}
public static object? TryGetProperty(object target, string name)
{
try
{
return GetProperty(target, name);
}
catch
{
return null;
}
}
public static object? GetProperty(object target, string name)
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.GetProperty,
null,
target,
null);
}
public static void SetProperty(object target, string name, object value)
{
target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.SetProperty,
null,
target,
[value]);
}
public static object? Invoke(object target, string name, params object[] arguments)
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.InvokeMethod,
null,
target,
arguments);
}
public static void ReleaseComObject(object? value)
{
if (value is not null && Marshal.IsComObject(value))
{
Marshal.FinalReleaseComObject(value);
}
}
private static void EnsureOutlookRunning(ILogger logger, string logContext)
{
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 {OutlookContext}", logContext);
}
}
}
internal sealed class OutlookCalendarSession : IDisposable
{
private readonly object? application;
private readonly object? session;
private readonly object? calendar;
public OutlookCalendarSession(
object? application,
object? session,
object? calendar,
object items)
{
this.application = application;
this.session = session;
this.calendar = calendar;
Items = items;
}
public object Items { get; }
public void Dispose()
{
OutlookClassicCom.ReleaseComObject(Items);
OutlookClassicCom.ReleaseComObject(calendar);
OutlookClassicCom.ReleaseComObject(session);
OutlookClassicCom.ReleaseComObject(application);
}
}
#endif
@@ -1,12 +1,10 @@
using System.Runtime.InteropServices;
using System.Diagnostics;
using System.Collections;
using MeetingAssistant.Calendar;
namespace MeetingAssistant.MeetingNotes;
public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider, IMeetingMetadataDiagnosticProvider
{
private const int OutlookCalendarFolder = 9;
private readonly ILogger<OutlookClassicMeetingMetadataProvider> logger;
public OutlookClassicMeetingMetadataProvider(ILogger<OutlookClassicMeetingMetadataProvider> logger)
@@ -21,7 +19,10 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
cancellationToken.ThrowIfCancellationRequested();
try
{
return RunStaAsync(() => GetCurrentMeeting(startedAt), cancellationToken);
return OutlookClassicCom.RunStaAsync(
() => GetCurrentMeeting(startedAt),
cancellationToken,
"Meeting Assistant Outlook COM Lookup");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
@@ -38,7 +39,10 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
var stopwatch = Stopwatch.StartNew();
try
{
var lookup = RunStaAsync(() => GetCurrentMeeting(startedAt), cancellationToken);
var lookup = OutlookClassicCom.RunStaAsync(
() => GetCurrentMeeting(startedAt),
cancellationToken,
"Meeting Assistant Outlook COM Lookup");
var metadata = timeout > TimeSpan.Zero
? await lookup.WaitAsync(timeout, cancellationToken)
: await lookup.WaitAsync(cancellationToken);
@@ -84,77 +88,58 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
private MeetingMetadata? GetCurrentMeeting(DateTimeOffset startedAt)
{
EnsureOutlookRunning();
object? application = null;
object? session = null;
object? calendar = null;
object? items = null;
try
using var calendar = OutlookClassicCom.OpenDefaultCalendar(logger, "metadata lookup");
if (calendar is null)
{
var applicationType = Type.GetTypeFromProgID("Outlook.Application");
if (applicationType is null)
return null;
}
var startedLocal = startedAt.LocalDateTime;
var windowStart = startedLocal.AddHours(-4);
var windowEnd = startedLocal.AddMinutes(5);
var candidates = new List<OutlookAppointmentCandidate>();
foreach (var item in OutlookClassicCom.EnumerateItems(calendar.Items))
{
if (item is null)
{
return null;
continue;
}
application = Activator.CreateInstance(applicationType);
if (application is null)
var keepAppointment = false;
try
{
return null;
}
session = GetProperty(application, "Session");
calendar = Invoke(session!, "GetDefaultFolder", OutlookCalendarFolder);
items = GetProperty(calendar!, "Items");
Invoke(items!, "Sort", "[Start]");
SetProperty(items!, "IncludeRecurrences", true);
var startedLocal = startedAt.LocalDateTime;
var windowStart = startedLocal.AddHours(-4);
var windowEnd = startedLocal.AddMinutes(5);
var candidates = new List<OutlookAppointmentCandidate>();
foreach (var item in EnumerateItems(items!))
{
if (item is null)
if (!OutlookClassicCom.IsAppointment(item))
{
continue;
}
var keepAppointment = false;
try
var start = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "Start"));
if (start > windowEnd)
{
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;
break;
}
finally
var end = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "End"));
if (end < windowStart || !IsTeamsAppointment(item))
{
if (!keepAppointment)
{
ReleaseComObject(item);
}
continue;
}
candidates.Add(new OutlookAppointmentCandidate(item, start, end));
keepAppointment = true;
}
finally
{
if (!keepAppointment)
{
OutlookClassicCom.ReleaseComObject(item);
}
}
}
try
{
var selected = OutlookMeetingCandidateSelector.Select(
candidates,
startedLocal,
@@ -162,120 +147,28 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
candidate => candidate.End);
if (selected is null)
{
foreach (var candidate in candidates)
{
ReleaseComObject(candidate.Appointment);
}
return null;
}
try
{
var appointment = selected.Appointment;
var title = Convert.ToString(GetProperty(appointment, "Subject")) ?? "";
var attendees = ReadAttendees(appointment);
var body = Convert.ToString(GetProperty(appointment, "Body")) ?? "";
return new MeetingMetadata(
title.Trim(),
attendees,
ExtractAgenda(body),
ToLocalOffset(selected.End));
}
finally
{
foreach (var candidate in candidates)
{
ReleaseComObject(candidate.Appointment);
}
}
var appointment = selected.Appointment;
var title = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")) ?? "";
var attendees = ReadAttendees(appointment);
var body = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? "";
return new MeetingMetadata(
title.Trim(),
attendees,
ExtractAgenda(body),
OutlookClassicCom.ToLocalOffset(selected.End));
}
finally
{
ReleaseComObject(items);
ReleaseComObject(calendar);
ReleaseComObject(session);
ReleaseComObject(application);
}
}
private static IEnumerable<object?> EnumerateItems(object items)
{
if (items is IEnumerable enumerable)
{
foreach (var item in enumerable)
foreach (var candidate in candidates)
{
yield return item;
OutlookClassicCom.ReleaseComObject(candidate.Appointment);
}
}
}
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))
@@ -298,24 +191,6 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
return string.Join(Environment.NewLine, agendaLines).Trim();
}
private static bool IsTeamsAppointment(object appointment)
{
var subject = Convert.ToString(GetProperty(appointment, "Subject")) ?? "";
var location = Convert.ToString(GetProperty(appointment, "Location")) ?? "";
var body = Convert.ToString(GetProperty(appointment, "Body")) ?? "";
return ContainsTeamsMarker(subject) ||
ContainsTeamsMarker(location) ||
ContainsTeamsMarker(body);
}
private static bool ContainsTeamsMarker(string value)
{
return value.Contains("teams.microsoft.com", StringComparison.OrdinalIgnoreCase) ||
value.Contains("Microsoft Teams", StringComparison.OrdinalIgnoreCase) ||
value.Contains("Join the meeting now", StringComparison.OrdinalIgnoreCase) ||
value.Contains("Join Microsoft Teams Meeting", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTeamsSeparator(string line)
{
var trimmed = line.Trim();
@@ -325,13 +200,21 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
private static bool IsTeamsJoinLine(string line)
{
return ContainsTeamsMarker(line);
return TeamsMeetingMarkerDetector.ContainsTeamsMarker(line);
}
private static bool IsTeamsAppointment(object appointment)
{
var subject = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")) ?? "";
var location = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Location")) ?? "";
var body = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? "";
return TeamsMeetingMarkerDetector.IsTeamsAppointment(subject, location, body);
}
private static IReadOnlyList<string> ReadAttendees(object appointment)
{
var attendees = new List<string>();
var organizer = Convert.ToString(GetProperty(appointment, "Organizer"));
var organizer = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Organizer"));
if (!string.IsNullOrWhiteSpace(organizer))
{
attendees.Add(organizer.Trim());
@@ -340,14 +223,14 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
object? recipients = null;
try
{
recipients = GetProperty(appointment, "Recipients");
var count = Convert.ToInt32(GetProperty(recipients!, "Count"));
recipients = OutlookClassicCom.GetProperty(appointment, "Recipients");
var count = Convert.ToInt32(OutlookClassicCom.GetProperty(recipients!, "Count"));
for (var index = 1; index <= count; index++)
{
object? recipient = null;
try
{
recipient = Invoke(recipients!, "Item", index);
recipient = OutlookClassicCom.Invoke(recipients!, "Item", index);
var formatted = FormatRecipient(recipient!);
if (!string.IsNullOrWhiteSpace(formatted))
{
@@ -356,23 +239,18 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
}
finally
{
ReleaseComObject(recipient);
OutlookClassicCom.ReleaseComObject(recipient);
}
}
}
finally
{
ReleaseComObject(recipients);
OutlookClassicCom.ReleaseComObject(recipients);
}
return NormalizeAttendees(attendees);
}
private static DateTimeOffset ToLocalOffset(DateTime value)
{
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local));
}
internal static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
{
return attendees
@@ -427,8 +305,8 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
private static string FormatRecipient(object recipient)
{
var name = Convert.ToString(GetProperty(recipient, "Name"))?.Trim() ?? "";
var email = Convert.ToString(GetProperty(recipient, "Address"))?.Trim() ?? "";
var name = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Name"))?.Trim() ?? "";
var email = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Address"))?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(name))
{
return email;
@@ -443,44 +321,6 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
return $"{name} <{email}>";
}
private static object? GetProperty(object target, string name)
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.GetProperty,
null,
target,
null);
}
private static void SetProperty(object target, string name, object value)
{
target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.SetProperty,
null,
target,
[value]);
}
private static object? Invoke(object target, string name, params object[] arguments)
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.InvokeMethod,
null,
target,
arguments);
}
private static void ReleaseComObject(object? value)
{
if (value is not null && Marshal.IsComObject(value))
{
Marshal.FinalReleaseComObject(value);
}
}
private sealed record OutlookAppointmentCandidate(
object Appointment,
DateTime Start,