using System.Diagnostics; using MeetingAssistant.Calendar; namespace MeetingAssistant.MeetingNotes; public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider, IMeetingMetadataDiagnosticProvider { private readonly ILogger logger; public OutlookClassicMeetingMetadataProvider(ILogger logger) { this.logger = logger; } public Task GetCurrentMeetingAsync( DateTimeOffset startedAt, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); try { return OutlookClassicCom.RunStaAsync( () => GetCurrentMeeting(startedAt), cancellationToken, "Meeting Assistant Outlook COM Lookup"); } 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 = OutlookClassicCom.RunStaAsync( () => GetCurrentMeeting(startedAt), cancellationToken, "Meeting Assistant Outlook COM Lookup"); 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) { using var calendar = OutlookClassicCom.OpenDefaultCalendar(logger, "metadata lookup"); if (calendar is null) { return null; } var startedLocal = startedAt.LocalDateTime; var windowStart = startedLocal.AddHours(-4); var windowEnd = startedLocal.AddMinutes(5); var candidates = new List(); foreach (var item in OutlookClassicCom.EnumerateItems(calendar.Items)) { if (item is null) { continue; } var keepAppointment = false; try { if (!OutlookClassicCom.IsAppointment(item)) { continue; } var start = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "Start")); if (start > windowEnd) { break; } var end = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "End")); if (end < windowStart || !IsTeamsAppointment(item)) { continue; } candidates.Add(new OutlookAppointmentCandidate(item, start, end)); keepAppointment = true; } finally { if (!keepAppointment) { OutlookClassicCom.ReleaseComObject(item); } } } try { var selected = OutlookMeetingCandidateSelector.Select( candidates, startedLocal, candidate => candidate.Start, candidate => candidate.End); if (selected is null) { return null; } 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 { foreach (var candidate in candidates) { OutlookClassicCom.ReleaseComObject(candidate.Appointment); } } } 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 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 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 ReadAttendees(object appointment) { var attendees = new List(); var organizer = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Organizer")); if (!string.IsNullOrWhiteSpace(organizer)) { attendees.Add(organizer.Trim()); } object? recipients = null; try { 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 = OutlookClassicCom.Invoke(recipients!, "Item", index); var formatted = FormatRecipient(recipient!); if (!string.IsNullOrWhiteSpace(formatted)) { attendees.Add(formatted); } } finally { OutlookClassicCom.ReleaseComObject(recipient); } } } finally { OutlookClassicCom.ReleaseComObject(recipients); } return NormalizeAttendees(attendees); } 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(OutlookClassicCom.GetProperty(recipient, "Name"))?.Trim() ?? ""; var email = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Address"))?.Trim() ?? ""; if (string.IsNullOrWhiteSpace(name)) { return email; } if (string.IsNullOrWhiteSpace(email) || email.Contains('/')) { return name; } return $"{name} <{email}>"; } private sealed record OutlookAppointmentCandidate( object Appointment, DateTime Start, DateTime End); }