using System.Runtime.InteropServices; namespace MeetingAssistant.MeetingNotes; public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider { 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 Task.FromResult(GetCurrentMeeting(startedAt)); } catch (Exception exception) when (exception is not OperationCanceledException) { logger.LogDebug(exception, "Outlook Classic meeting metadata was not available"); return Task.FromResult(null); } } private MeetingMetadata? GetCurrentMeeting(DateTimeOffset startedAt) { object? application = null; object? session = null; object? calendar = null; object? items = null; object? restrictedItems = 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"); SetProperty(items!, "IncludeRecurrences", true); Invoke(items!, "Sort", "[Start]"); var startedLocal = startedAt.LocalDateTime; var windowStart = startedLocal.AddMinutes(-1); var windowEnd = startedLocal.AddMinutes(5); var filter = $"[Start] <= '{windowEnd:g}' AND [End] >= '{windowStart:g}'"; restrictedItems = Invoke(items!, "Restrict", filter); var candidates = new List(); var count = Convert.ToInt32(GetProperty(restrictedItems!, "Count")); for (var index = 1; index <= count; index++) { var appointment = Invoke(restrictedItems!, "Item", index); if (appointment is not null && IsTeamsAppointment(appointment)) { candidates.Add(new OutlookAppointmentCandidate( appointment, Convert.ToDateTime(GetProperty(appointment, "Start")), Convert.ToDateTime(GetProperty(appointment, "End")))); } else { ReleaseComObject(appointment); } } 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(restrictedItems); ReleaseComObject(items); ReleaseComObject(calendar); ReleaseComObject(session); ReleaseComObject(application); } } 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 attendees .Where(attendee => !string.IsNullOrWhiteSpace(attendee)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } private static DateTimeOffset ToLocalOffset(DateTime value) { return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local)); } 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); }