using System.Runtime.InteropServices; using System.Diagnostics; using System.Collections; namespace MeetingAssistant.MeetingNotes; public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider, IMeetingMetadataDiagnosticProvider { private const int OutlookCalendarFolder = 9; private readonly ILogger logger; public OutlookClassicMeetingMetadataProvider(ILogger logger) { this.logger = logger; } public Task GetCurrentMeetingAsync( DateTimeOffset startedAt, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); try { return RunStaAsync(() => GetCurrentMeeting(startedAt), cancellationToken); } catch (Exception exception) when (exception is not OperationCanceledException) { logger.LogDebug(exception, "Outlook Classic meeting metadata was not available"); return Task.FromResult(null); } } public async Task 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; 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); var startedLocal = startedAt.LocalDateTime; var windowStart = startedLocal.AddHours(-4); var windowEnd = startedLocal.AddMinutes(5); var candidates = new List(); foreach (var item in EnumerateItems(items!)) { if (item is null) { continue; } var keepAppointment = false; try { 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); } } } var selected = OutlookMeetingCandidateSelector.Select( candidates, startedLocal, candidate => candidate.Start, 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); } } } finally { ReleaseComObject(items); ReleaseComObject(calendar); ReleaseComObject(session); ReleaseComObject(application); } } private static IEnumerable 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 RunStaAsync( Func action, CancellationToken cancellationToken) { var completion = new TaskCompletionSource(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)) { return ""; } var lines = body.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n'); var agendaLines = new List(); foreach (var line in lines) { if (IsTeamsSeparator(line) || IsTeamsJoinLine(line)) { break; } agendaLines.Add(line); } 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(); return trimmed.Length >= 8 && trimmed.All(character => character is '_' or '-' or '*' or ' '); } private static bool IsTeamsJoinLine(string line) { return ContainsTeamsMarker(line); } private static IReadOnlyList ReadAttendees(object appointment) { var attendees = new List(); var organizer = Convert.ToString(GetProperty(appointment, "Organizer")); if (!string.IsNullOrWhiteSpace(organizer)) { attendees.Add(organizer.Trim()); } object? recipients = null; try { recipients = GetProperty(appointment, "Recipients"); var count = Convert.ToInt32(GetProperty(recipients!, "Count")); for (var index = 1; index <= count; index++) { object? recipient = null; try { recipient = Invoke(recipients!, "Item", index); var formatted = FormatRecipient(recipient!); if (!string.IsNullOrWhiteSpace(formatted)) { attendees.Add(formatted); } } finally { ReleaseComObject(recipient); } } } finally { ReleaseComObject(recipients); } return NormalizeAttendees(attendees); } private static DateTimeOffset ToLocalOffset(DateTime value) { return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local)); } internal static IReadOnlyList NormalizeAttendees(IEnumerable 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() ?? ""; var email = Convert.ToString(GetProperty(recipient, "Address"))?.Trim() ?? ""; if (string.IsNullOrWhiteSpace(name)) { return email; } if (string.IsNullOrWhiteSpace(email) || email.Contains('/')) { return name; } 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, DateTime End); }