From 50daa883897c0179539c947f24c45976e4310106 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Wed, 3 Jun 2026 10:45:20 +0200 Subject: [PATCH] Add Outlook Teams recording prompts --- .../CalendarRecordingPromptSchedulerTests.cs | 249 ++++++++++++++ .../CalendarRecordingPromptScheduler.cs | 309 ++++++++++++++++++ ...kClassicCalendarMeetingProvider.Windows.cs | 110 +++++++ .../Calendar/TeamsMeetingMarkerDetector.cs | 22 ++ ...indowsMeetingStartPromptService.Windows.cs | 204 ++++++++++++ MeetingAssistant/MeetingAssistantOptions.cs | 11 + .../MeetingNotes/OutlookClassicCom.Windows.cs | 217 ++++++++++++ ...kClassicMeetingMetadataProvider.Windows.cs | 308 +++++------------ MeetingAssistant/Program.cs | 15 + MeetingAssistant/appsettings.json | 5 + README.md | 1 + docs/meeting-assistant-configuration.md | 17 + .../design.md | 67 ++++ .../proposal.md | 13 + .../specs/meeting-session/spec.md | 54 +++ .../tasks.md | 13 + openspec/specs/meeting-session/spec.md | 54 +++ 17 files changed, 1435 insertions(+), 234 deletions(-) create mode 100644 MeetingAssistant.Tests/CalendarRecordingPromptSchedulerTests.cs create mode 100644 MeetingAssistant/Calendar/CalendarRecordingPromptScheduler.cs create mode 100644 MeetingAssistant/Calendar/OutlookClassicCalendarMeetingProvider.Windows.cs create mode 100644 MeetingAssistant/Calendar/TeamsMeetingMarkerDetector.cs create mode 100644 MeetingAssistant/Calendar/WindowsMeetingStartPromptService.Windows.cs create mode 100644 MeetingAssistant/MeetingNotes/OutlookClassicCom.Windows.cs create mode 100644 openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/design.md create mode 100644 openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/proposal.md create mode 100644 openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/specs/meeting-session/spec.md create mode 100644 openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/tasks.md diff --git a/MeetingAssistant.Tests/CalendarRecordingPromptSchedulerTests.cs b/MeetingAssistant.Tests/CalendarRecordingPromptSchedulerTests.cs new file mode 100644 index 0000000..972f1b1 --- /dev/null +++ b/MeetingAssistant.Tests/CalendarRecordingPromptSchedulerTests.cs @@ -0,0 +1,249 @@ +using MeetingAssistant.Calendar; +using MeetingAssistant.Recording; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace MeetingAssistant.Tests; + +public sealed class CalendarRecordingPromptSchedulerTests +{ + [Fact] + public async Task DueTeamsMeetingPromptStartsRecordingWhenAccepted() + { + var harness = CreateHarness(isRecording: false); + var meeting = CreateMeeting(harness.Clock); + harness.Provider.Meetings = [meeting]; + + await SyncThenPromptAsync(harness, meeting); + + Assert.Equal(1, harness.Provider.QueryCount); + Assert.Collection(harness.PromptService.PromptedMeetings, prompted => Assert.Same(meeting, prompted)); + Assert.Equal(1, harness.Recorder.StartCount); + Assert.Equal(0, harness.Recorder.StopCount); + } + + [Fact] + public async Task AcceptedPromptStopsActiveRecordingBeforeStartingNewRecording() + { + var harness = CreateHarness(isRecording: true); + var meeting = CreateMeeting(harness.Clock); + harness.Provider.Meetings = [meeting]; + + await SyncThenPromptAsync(harness, meeting); + + Assert.Equal(["stop", "start"], harness.Recorder.Commands); + } + + [Fact] + public async Task DisabledCalendarPromptsDoNotQueryCalendar() + { + var harness = CreateHarness(enabled: false); + + await harness.Scheduler.SyncOnceAsync(CancellationToken.None); + + Assert.Equal(0, harness.Provider.QueryCount); + } + + [Fact] + public async Task DueMeetingPromptsOnlyOncePerDay() + { + var harness = CreateHarness(); + var meeting = CreateMeeting(harness.Clock); + harness.Provider.Meetings = [meeting]; + + await harness.Scheduler.SyncOnceAsync(CancellationToken.None); + harness.Clock.Now = meeting.Start; + await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None); + await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None); + + Assert.Single(harness.PromptService.PromptedMeetings); + } + + [Fact] + public async Task CachedMeetingPromptDoesNotQueryOutlookAgainAtMeetingStart() + { + var harness = CreateHarness(); + var meeting = CreateMeeting(harness.Clock); + harness.Provider.Meetings = [meeting]; + + await SyncThenPromptAsync(harness, meeting); + + Assert.Equal(1, harness.Provider.QueryCount); + Assert.Single(harness.PromptService.PromptedMeetings); + } + + [Fact] + public async Task CachedDayMeetingsPromptBackToBackMeetingsWithoutInterveningOutlookSync() + { + var harness = CreateHarness(isRecording: false); + var firstMeeting = CreateMeeting( + harness.Clock, + id: "teams-1", + subject: "First sync", + startOffset: TimeSpan.FromMinutes(30), + duration: TimeSpan.FromMinutes(10)); + var secondMeeting = CreateMeeting( + harness.Clock, + id: "teams-2", + subject: "Second sync", + startOffset: TimeSpan.FromMinutes(40), + duration: TimeSpan.FromMinutes(10)); + harness.Provider.Meetings = [firstMeeting, secondMeeting]; + + await harness.Scheduler.SyncOnceAsync(CancellationToken.None); + harness.Clock.Now = firstMeeting.Start; + await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None); + harness.Clock.Now = secondMeeting.Start; + await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None); + + Assert.Equal(1, harness.Provider.QueryCount); + Assert.Equal([firstMeeting, secondMeeting], harness.PromptService.PromptedMeetings); + Assert.Equal(["start", "stop", "start"], harness.Recorder.Commands); + } + + private static async Task SyncThenPromptAsync( + SchedulerHarness harness, + CalendarMeeting meeting) + { + await harness.Scheduler.SyncOnceAsync(CancellationToken.None); + harness.Clock.Now = meeting.Start; + await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None); + } + + private static CalendarMeeting CreateMeeting( + ManualCalendarPromptClock clock, + string id = "teams-1", + string subject = "Project sync", + TimeSpan? startOffset = null, + TimeSpan? duration = null) + { + var start = clock.Now.Add(startOffset ?? TimeSpan.FromMinutes(30)); + return new CalendarMeeting( + id, + subject, + start, + start.Add(duration ?? TimeSpan.FromMinutes(30))); + } + + private static SchedulerHarness CreateHarness( + bool isRecording = false, + bool enabled = true) + { + var clock = new ManualCalendarPromptClock(new DateTimeOffset(2026, 6, 3, 9, 30, 0, TimeSpan.Zero)); + var provider = new RecordingCalendarMeetingProvider([]); + var promptService = new AcceptingMeetingStartPromptService(); + var recorder = new RecordingPromptRecorder(isRecording); + var scheduler = new CalendarRecordingPromptScheduler( + Options.Create(new MeetingAssistantOptions + { + CalendarRecordingPrompts = + { + Enabled = enabled, + SyncInterval = TimeSpan.FromMinutes(30), + PromptWindow = TimeSpan.FromMinutes(5) + } + }), + provider, + promptService, + recorder, + clock, + NullLogger.Instance); + + return new SchedulerHarness(scheduler, provider, promptService, recorder, clock); + } + + private sealed record SchedulerHarness( + CalendarRecordingPromptScheduler Scheduler, + RecordingCalendarMeetingProvider Provider, + AcceptingMeetingStartPromptService PromptService, + RecordingPromptRecorder Recorder, + ManualCalendarPromptClock Clock); + + private sealed class RecordingCalendarMeetingProvider : ICalendarMeetingProvider + { + public RecordingCalendarMeetingProvider(IReadOnlyList meetings) + { + Meetings = meetings; + } + + public IReadOnlyList Meetings { get; set; } + + public int QueryCount { get; private set; } + + public Task> GetRecordingPromptCandidatesForDayAsync( + DateOnly day, + CancellationToken cancellationToken) + { + QueryCount++; + return Task.FromResult(Meetings); + } + } + + private sealed class AcceptingMeetingStartPromptService : IMeetingStartPromptService + { + public List PromptedMeetings { get; } = []; + + public async Task ShowPromptAsync( + MeetingStartPromptRequest request, + Func handleResponseAsync, + CancellationToken cancellationToken) + { + PromptedMeetings.Add(request.Meeting); + await handleResponseAsync(MeetingStartPromptResponse.Record, cancellationToken); + } + } + + private sealed class RecordingPromptRecorder : IMeetingPromptRecordingController + { + public RecordingPromptRecorder(bool isRecording) + { + CurrentStatus = Status(isRecording); + } + + public RecordingStatus CurrentStatus { get; private set; } + + public int StartCount { get; private set; } + + public int StopCount { get; private set; } + + public List Commands { get; } = []; + + public Task StartAsync(CancellationToken cancellationToken) + { + StartCount++; + Commands.Add("start"); + CurrentStatus = Status(isRecording: true); + return Task.FromResult(CurrentStatus); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + StopCount++; + Commands.Add("stop"); + CurrentStatus = Status(isRecording: false); + return Task.FromResult(CurrentStatus); + } + + private static RecordingStatus Status(bool isRecording) + { + return new RecordingStatus( + isRecording, + isRecording ? "transcript.md" : null, + isRecording ? "meeting.md" : null, + isRecording ? "context.md" : null, + isRecording ? "summary.md" : null, + isRecording ? RecordingProcessState.Recording : RecordingProcessState.Idle, + isRecording ? "default" : null); + } + } + + private sealed class ManualCalendarPromptClock : ICalendarPromptClock + { + public ManualCalendarPromptClock(DateTimeOffset now) + { + Now = now; + } + + public DateTimeOffset Now { get; set; } + } +} diff --git a/MeetingAssistant/Calendar/CalendarRecordingPromptScheduler.cs b/MeetingAssistant/Calendar/CalendarRecordingPromptScheduler.cs new file mode 100644 index 0000000..749eda0 --- /dev/null +++ b/MeetingAssistant/Calendar/CalendarRecordingPromptScheduler.cs @@ -0,0 +1,309 @@ +using MeetingAssistant.Recording; +using Microsoft.Extensions.Options; + +namespace MeetingAssistant.Calendar; + +public sealed class CalendarRecordingPromptScheduler : BackgroundService +{ + private readonly IOptions options; + private readonly ICalendarMeetingProvider meetingProvider; + private readonly IMeetingStartPromptService promptService; + private readonly IMeetingPromptRecordingController recordingController; + private readonly ICalendarPromptClock clock; + private readonly ILogger logger; + private readonly HashSet promptedMeetingKeys = new(StringComparer.OrdinalIgnoreCase); + private IReadOnlyList cachedMeetings = []; + private DateOnly promptedMeetingDay; + private DateOnly cachedMeetingDay; + private DateTimeOffset nextSyncAt; + + public CalendarRecordingPromptScheduler( + IOptions options, + ICalendarMeetingProvider meetingProvider, + IMeetingStartPromptService promptService, + IMeetingPromptRecordingController recordingController, + ICalendarPromptClock clock, + ILogger logger) + { + this.options = options; + this.meetingProvider = meetingProvider; + this.promptService = promptService; + this.recordingController = recordingController; + this.clock = clock; + this.logger = logger; + } + + public async Task SyncOnceAsync(CancellationToken cancellationToken) + { + var promptOptions = options.Value.CalendarRecordingPrompts; + if (!promptOptions.Enabled) + { + return; + } + + var now = clock.Now; + var today = DateOnly.FromDateTime(now.LocalDateTime); + ResetPromptedMeetingsIfDayChanged(today); + + try + { + cachedMeetings = await meetingProvider.GetRecordingPromptCandidatesForDayAsync(today, cancellationToken); + cachedMeetingDay = today; + nextSyncAt = now.Add(GetSyncInterval(promptOptions)); + logger.LogInformation( + "Synced {MeetingCount} Outlook Teams calendar meetings for {Day}; next sync at {NextSyncAt}", + cachedMeetings.Count, + today, + nextSyncAt); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogWarning(exception, "Could not read today's Outlook Teams meetings for recording prompts"); + nextSyncAt = now.Add(GetSyncInterval(promptOptions)); + return; + } + } + + public async Task CheckDuePromptsAsync(CancellationToken cancellationToken) + { + var promptOptions = options.Value.CalendarRecordingPrompts; + if (!promptOptions.Enabled) + { + return; + } + + var now = clock.Now; + var today = DateOnly.FromDateTime(now.LocalDateTime); + ResetPromptedMeetingsIfDayChanged(today); + if (cachedMeetingDay != today) + { + cachedMeetings = []; + return; + } + + foreach (var meeting in cachedMeetings.OrderBy(meeting => meeting.Start)) + { + var promptKey = GetPromptKey(meeting); + if (promptedMeetingKeys.Contains(promptKey) || + !IsInPromptWindow(meeting, now, promptOptions)) + { + continue; + } + + promptedMeetingKeys.Add(promptKey); + logger.LogInformation( + "Prompting to record calendar meeting {Subject} scheduled at {Start}", + meeting.Subject, + meeting.Start); + await promptService.ShowPromptAsync( + new MeetingStartPromptRequest(meeting), + HandlePromptResponseAsync, + cancellationToken); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var promptOptions = options.Value.CalendarRecordingPrompts; + if (!promptOptions.Enabled) + { + await Task.Delay(GetSyncInterval(promptOptions), stoppingToken); + continue; + } + + var now = clock.Now; + var today = DateOnly.FromDateTime(now.LocalDateTime); + if (cachedMeetingDay != today || now >= nextSyncAt) + { + await SyncOnceAsync(stoppingToken); + } + + await CheckDuePromptsAsync(stoppingToken); + + var delay = GetDelayUntilNextWork(clock.Now); + if (delay <= TimeSpan.Zero) + { + continue; + } + + await Task.Delay(delay, stoppingToken); + } + } + + private async Task HandlePromptResponseAsync( + MeetingStartPromptResponse response, + CancellationToken cancellationToken) + { + if (response != MeetingStartPromptResponse.Record) + { + return; + } + + if (recordingController.CurrentStatus.IsRecording) + { + await recordingController.StopAsync(cancellationToken); + } + + await recordingController.StartAsync(cancellationToken); + } + + private void ResetPromptedMeetingsIfDayChanged(DateOnly today) + { + if (promptedMeetingDay == today) + { + return; + } + + promptedMeetingDay = today; + promptedMeetingKeys.Clear(); + } + + private static bool IsInPromptWindow( + CalendarMeeting meeting, + DateTimeOffset now, + CalendarRecordingPromptOptions options) + { + var promptWindow = options.PromptWindow > TimeSpan.Zero + ? options.PromptWindow + : TimeSpan.FromMinutes(5); + return now >= meeting.Start && + now <= meeting.Start.Add(promptWindow) && + now < meeting.End; + } + + private TimeSpan GetDelayUntilNextWork(DateTimeOffset now) + { + if (nextSyncAt <= DateTimeOffset.MinValue || now >= nextSyncAt) + { + return TimeSpan.Zero; + } + + var syncDelay = nextSyncAt - now; + + var nextMeetingStart = cachedMeetings + .Where(meeting => !promptedMeetingKeys.Contains(GetPromptKey(meeting))) + .Where(meeting => meeting.End > now) + .Select(meeting => meeting.Start <= now ? now : meeting.Start) + .Order() + .FirstOrDefault(); + if (nextMeetingStart == default) + { + return syncDelay; + } + + var meetingDelay = nextMeetingStart - now; + return meetingDelay <= TimeSpan.Zero + ? TimeSpan.Zero + : meetingDelay < syncDelay ? meetingDelay : syncDelay; + } + + private static TimeSpan GetSyncInterval(CalendarRecordingPromptOptions options) + { + return options.SyncInterval > TimeSpan.Zero + ? options.SyncInterval + : TimeSpan.FromMinutes(30); + } + + private static string GetPromptKey(CalendarMeeting meeting) + { + if (!string.IsNullOrWhiteSpace(meeting.Id)) + { + return meeting.Id; + } + + return $"{meeting.Start:O}|{meeting.End:O}|{meeting.Subject}"; + } +} + +public interface ICalendarMeetingProvider +{ + Task> GetRecordingPromptCandidatesForDayAsync( + DateOnly day, + CancellationToken cancellationToken); +} + +public sealed record CalendarMeeting( + string Id, + string Subject, + DateTimeOffset Start, + DateTimeOffset End); + +public interface IMeetingStartPromptService +{ + Task ShowPromptAsync( + MeetingStartPromptRequest request, + Func handleResponseAsync, + CancellationToken cancellationToken); +} + +public sealed record MeetingStartPromptRequest(CalendarMeeting Meeting); + +public enum MeetingStartPromptResponse +{ + Record, + Skip +} + +public interface IMeetingPromptRecordingController +{ + RecordingStatus CurrentStatus { get; } + + Task StartAsync(CancellationToken cancellationToken); + + Task StopAsync(CancellationToken cancellationToken); +} + +public sealed class MeetingPromptRecordingController : IMeetingPromptRecordingController +{ + private readonly MeetingRecordingCoordinator coordinator; + + public MeetingPromptRecordingController(MeetingRecordingCoordinator coordinator) + { + this.coordinator = coordinator; + } + + public RecordingStatus CurrentStatus => coordinator.CurrentStatus; + + public Task StartAsync(CancellationToken cancellationToken) + { + return coordinator.StartAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return coordinator.StopAsync(cancellationToken); + } +} + +public interface ICalendarPromptClock +{ + DateTimeOffset Now { get; } +} + +public sealed class SystemCalendarPromptClock : ICalendarPromptClock +{ + public DateTimeOffset Now => DateTimeOffset.Now; +} + +public sealed class NoopCalendarMeetingProvider : ICalendarMeetingProvider +{ + public Task> GetRecordingPromptCandidatesForDayAsync( + DateOnly day, + CancellationToken cancellationToken) + { + return Task.FromResult>([]); + } +} + +public sealed class NoopMeetingStartPromptService : IMeetingStartPromptService +{ + public Task ShowPromptAsync( + MeetingStartPromptRequest request, + Func handleResponseAsync, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/MeetingAssistant/Calendar/OutlookClassicCalendarMeetingProvider.Windows.cs b/MeetingAssistant/Calendar/OutlookClassicCalendarMeetingProvider.Windows.cs new file mode 100644 index 0000000..42aa8b5 --- /dev/null +++ b/MeetingAssistant/Calendar/OutlookClassicCalendarMeetingProvider.Windows.cs @@ -0,0 +1,110 @@ +#if WINDOWS +using MeetingAssistant.MeetingNotes; + +namespace MeetingAssistant.Calendar; + +public sealed class OutlookClassicCalendarMeetingProvider : ICalendarMeetingProvider +{ + private readonly ILogger logger; + + public OutlookClassicCalendarMeetingProvider(ILogger logger) + { + this.logger = logger; + } + + public Task> GetRecordingPromptCandidatesForDayAsync( + DateOnly day, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return OutlookClassicCom.RunStaAsync( + () => GetTeamsMeetingsForDay(day), + cancellationToken, + "Meeting Assistant Outlook Calendar Prompt Lookup"); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogDebug(exception, "Outlook Classic calendar meetings were not available"); + return Task.FromResult>([]); + } + } + + private IReadOnlyList GetTeamsMeetingsForDay(DateOnly day) + { + using var calendar = OutlookClassicCom.OpenDefaultCalendar(logger, "calendar prompt lookup"); + if (calendar is null) + { + return []; + } + + var dayStart = day.ToDateTime(TimeOnly.MinValue); + var dayEnd = dayStart.AddDays(1); + var meetings = new List(); + foreach (var item in OutlookClassicCom.EnumerateItems(calendar.Items)) + { + try + { + if (item is null || !OutlookClassicCom.IsAppointment(item)) + { + continue; + } + + var start = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "Start")); + if (start >= dayEnd) + { + break; + } + + var end = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "End")); + if (end <= dayStart || + start < dayStart || + !IsTeamsAppointment(item)) + { + continue; + } + + var rawSubject = Convert.ToString(OutlookClassicCom.GetProperty(item, "Subject"))?.Trim(); + var subject = string.IsNullOrWhiteSpace(rawSubject) ? "Teams meeting" : rawSubject; + meetings.Add(new CalendarMeeting( + GetAppointmentId(item, start, end, subject), + subject, + OutlookClassicCom.ToLocalOffset(start), + OutlookClassicCom.ToLocalOffset(end))); + } + finally + { + OutlookClassicCom.ReleaseComObject(item); + } + } + + return meetings; + } + + private static string GetAppointmentId( + object appointment, + DateTime start, + DateTime end, + string subject) + { + var id = Convert.ToString(OutlookClassicCom.TryGetProperty(appointment, "GlobalAppointmentID")); + if (string.IsNullOrWhiteSpace(id)) + { + id = Convert.ToString(OutlookClassicCom.TryGetProperty(appointment, "EntryID")); + } + + return string.IsNullOrWhiteSpace(id) + ? $"{start:O}|{end:O}|{subject}" + : $"{id}|{start:O}"; + } + + 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); + } +} +#endif diff --git a/MeetingAssistant/Calendar/TeamsMeetingMarkerDetector.cs b/MeetingAssistant/Calendar/TeamsMeetingMarkerDetector.cs new file mode 100644 index 0000000..4edf9a7 --- /dev/null +++ b/MeetingAssistant/Calendar/TeamsMeetingMarkerDetector.cs @@ -0,0 +1,22 @@ +namespace MeetingAssistant.Calendar; + +internal static class TeamsMeetingMarkerDetector +{ + public static bool IsTeamsAppointment( + string subject, + string location, + string body) + { + return ContainsTeamsMarker(subject) || + ContainsTeamsMarker(location) || + ContainsTeamsMarker(body); + } + + public 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); + } +} diff --git a/MeetingAssistant/Calendar/WindowsMeetingStartPromptService.Windows.cs b/MeetingAssistant/Calendar/WindowsMeetingStartPromptService.Windows.cs new file mode 100644 index 0000000..cf372ba --- /dev/null +++ b/MeetingAssistant/Calendar/WindowsMeetingStartPromptService.Windows.cs @@ -0,0 +1,204 @@ +#if WINDOWS +using System.Collections.Concurrent; +using CommunityToolkit.WinUI.Notifications; +using MeetingAssistant.Recording; + +namespace MeetingAssistant.Calendar; + +public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptService, IHostedService, IDisposable +{ + private const string NotificationSource = "meeting-start-prompt"; + private const string NotificationGroup = "meeting-start"; + private readonly ConcurrentDictionary> pendingPrompts = new(StringComparer.OrdinalIgnoreCase); + private readonly ILogger logger; + private readonly object registrationGate = new(); + private bool registered; + + public WindowsMeetingStartPromptService(ILogger logger) + { + this.logger = logger; + } + + public Task ShowPromptAsync( + MeetingStartPromptRequest request, + Func handleResponseAsync, + CancellationToken cancellationToken) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763)) + { + logger.LogWarning("Windows app notifications require Windows 10 version 1809 or later"); + return Task.CompletedTask; + } + + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + try + { + EnsureRegistered(); + var promptId = Guid.NewGuid().ToString("N"); + pendingPrompts[promptId] = handleResponseAsync; + BuildNotification(promptId, request).Show(toast => + { + toast.Group = NotificationGroup; + toast.Tag = promptId; + toast.ExpirationTime = request.Meeting.End; + }); + logger.LogInformation( + "Displayed native Windows meeting-start notification {PromptId} for {Subject}", + promptId, + request.Meeting.Subject); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to display native Windows meeting-start notification"); + } + + return Task.CompletedTask; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763)) + { + logger.LogWarning("Windows app notifications require Windows 10 version 1809 or later"); + return Task.CompletedTask; + } + + EnsureRegistered(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public void Dispose() + { + if (!registered) + { + return; + } + + try + { + ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to detach native Windows meeting-start notification activation handler"); + } + } + + private void EnsureRegistered() + { + if (registered) + { + return; + } + + lock (registrationGate) + { + if (registered) + { + return; + } + + ToastNotificationManagerCompat.OnActivated += OnNotificationInvoked; + registered = true; + logger.LogInformation("Registered native Windows meeting-start notification activation handler"); + } + } + + private static ToastContentBuilder BuildNotification( + string promptId, + MeetingStartPromptRequest request) + { + var yesButton = new ToastButton() + .SetContent("Yes") + .AddArgument("source", NotificationSource) + .AddArgument("promptId", promptId) + .AddArgument("response", "record") + .SetBackgroundActivation(); + + var noButton = new ToastButton() + .SetContent("No") + .AddArgument("source", NotificationSource) + .AddArgument("promptId", promptId) + .AddArgument("response", "skip") + .SetBackgroundActivation(); + + return new ToastContentBuilder() + .AddArgument("source", NotificationSource) + .AddArgument("promptId", promptId) + .AddText("Record meeting?") + .AddText($"{request.Meeting.Subject} starts at {request.Meeting.Start.LocalDateTime:t}.") + .AddButton(yesButton) + .AddButton(noButton); + } + + private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args) + { + var arguments = NotificationActivationArguments.Parse(args.Argument); + if (!TryGetArgument(arguments, "source", out var source) || + !string.Equals(source, NotificationSource, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!TryGetArgument(arguments, "promptId", out var promptId)) + { + logger.LogWarning("Ignoring native Windows meeting-start notification activation without prompt id"); + return; + } + + if (!pendingPrompts.TryRemove(promptId, out var handleResponseAsync)) + { + logger.LogWarning( + "Ignoring native Windows meeting-start notification {PromptId} activation because no pending callback exists", + promptId); + return; + } + + var response = TryGetArgument(arguments, "response", out var responseValue) && + string.Equals(responseValue, "record", StringComparison.OrdinalIgnoreCase) + ? MeetingStartPromptResponse.Record + : MeetingStartPromptResponse.Skip; + _ = Task.Run(async () => + { + try + { + logger.LogInformation( + "Native Windows meeting-start notification {PromptId} activated with response {Response}", + promptId, + response); + await handleResponseAsync(response, CancellationToken.None); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to handle native Windows meeting-start notification response {Response}", + response); + } + }); + } + + private static bool TryGetArgument( + IReadOnlyDictionary arguments, + string key, + out string value) + { + if (arguments.TryGetValue(key, out value!)) + { + return true; + } + + value = ""; + return false; + } +} +#endif diff --git a/MeetingAssistant/MeetingAssistantOptions.cs b/MeetingAssistant/MeetingAssistantOptions.cs index 6954a9d..3144fdc 100644 --- a/MeetingAssistant/MeetingAssistantOptions.cs +++ b/MeetingAssistant/MeetingAssistantOptions.cs @@ -18,6 +18,8 @@ public sealed class MeetingAssistantOptions public AutomationOptions Automation { get; set; } = new(); + public CalendarRecordingPromptOptions CalendarRecordingPrompts { get; set; } = new(); + public ScreenshotOptions Screenshots { get; set; } = new(); public AgentOptions Agent { get; set; } = new(); @@ -32,6 +34,15 @@ public sealed class AutomationOptions public string? RulesPath { get; set; } = "meeting-rules.local.yaml"; } +public sealed class CalendarRecordingPromptOptions +{ + public bool Enabled { get; set; } + + public TimeSpan SyncInterval { get; set; } = TimeSpan.FromMinutes(30); + + public TimeSpan PromptWindow { get; set; } = TimeSpan.FromMinutes(5); +} + public sealed class ScreenshotOptions { public string Hotkey { get; set; } = "Ctrl+Alt+S"; diff --git a/MeetingAssistant/MeetingNotes/OutlookClassicCom.Windows.cs b/MeetingAssistant/MeetingNotes/OutlookClassicCom.Windows.cs new file mode 100644 index 0000000..f95166c --- /dev/null +++ b/MeetingAssistant/MeetingNotes/OutlookClassicCom.Windows.cs @@ -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 RunStaAsync( + Func action, + CancellationToken cancellationToken, + string threadName) + { + var completion = new TaskCompletionSource(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 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 diff --git a/MeetingAssistant/MeetingNotes/OutlookClassicMeetingMetadataProvider.Windows.cs b/MeetingAssistant/MeetingNotes/OutlookClassicMeetingMetadataProvider.Windows.cs index 251d9c2..2e72974 100644 --- a/MeetingAssistant/MeetingNotes/OutlookClassicMeetingMetadataProvider.Windows.cs +++ b/MeetingAssistant/MeetingNotes/OutlookClassicMeetingMetadataProvider.Windows.cs @@ -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 logger; public OutlookClassicMeetingMetadataProvider(ILogger 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(); + 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(); - 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 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 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)) @@ -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 ReadAttendees(object appointment) { var attendees = new List(); - 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 NormalizeAttendees(IEnumerable 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, diff --git a/MeetingAssistant/Program.cs b/MeetingAssistant/Program.cs index f016602..d66e8f2 100644 --- a/MeetingAssistant/Program.cs +++ b/MeetingAssistant/Program.cs @@ -1,4 +1,5 @@ using MeetingAssistant; +using MeetingAssistant.Calendar; using MeetingAssistant.Hotkeys; using MeetingAssistant.LaunchProfiles; using MeetingAssistant.Logging; @@ -49,6 +50,19 @@ builder.Services.AddHostedService(services => builder.Services.AddSingleton(); #endif builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +#if WINDOWS +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(services => + services.GetRequiredService()); +builder.Services.AddHostedService(services => + services.GetRequiredService()); +#else +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +#endif #if WINDOWS builder.Services.AddSingleton(); #else @@ -103,6 +117,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/MeetingAssistant/appsettings.json b/MeetingAssistant/appsettings.json index f648fb6..84d547e 100644 --- a/MeetingAssistant/appsettings.json +++ b/MeetingAssistant/appsettings.json @@ -146,6 +146,11 @@ "Automation": { "RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml" }, + "CalendarRecordingPrompts": { + "Enabled": false, + "SyncInterval": "00:30:00", + "PromptWindow": "00:05:00" + }, "WorkflowRulesEditor": { "Endpoint": "", "KeyEnv": "", diff --git a/README.md b/README.md index d749c3b..31e1ff4 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ The application is intended to: - create an Obsidian markdown note before transcription starts - keep meeting metadata, user notes, detected context, and links to generated output in that note - toggle recording/transcription mode through a configurable global hotkey +- optionally prompt to start recording when Outlook Classic shows a Teams meeting starting - switch the active transcription profile during a meeting with a profile-specific toggle hotkey - abort and discard an active recording through a configurable global hotkey - show a Windows taskbar notification icon with state-aware recording controls diff --git a/docs/meeting-assistant-configuration.md b/docs/meeting-assistant-configuration.md index 75dcb71..640af88 100644 --- a/docs/meeting-assistant-configuration.md +++ b/docs/meeting-assistant-configuration.md @@ -44,6 +44,11 @@ This example is abbreviated so the most common shape is readable. The checked-in "Automation": { "RulesPath": "meeting-rules.local.yaml" }, + "CalendarRecordingPrompts": { + "Enabled": false, + "SyncInterval": "00:30:00", + "PromptWindow": "00:05:00" + }, "WorkflowRulesEditor": { "Endpoint": "", "KeyEnv": "", @@ -288,6 +293,18 @@ See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported va `POST /diagnostics/workflow/reload` reloads application configuration so workflow automation settings such as `Automation:RulesPath` can be changed without restarting the application. A meeting run that already captured its options keeps using those options. +## Calendar Recording Prompts + +`CalendarRecordingPrompts` controls optional Outlook Classic calendar prompts for starting recordings. It is disabled by default. + +When enabled on Windows, Meeting Assistant periodically syncs today's Outlook Classic calendar appointments through COM, filters all Teams meeting markers for the day into an in-memory cache, and schedules prompt checks from that cache. It does not query Outlook for every prompt. The notification asks `Record meeting?` with `Yes` and `No` actions when a cached Teams meeting reaches its start window. Accepting starts a recording. If another recording is active, Meeting Assistant stops that recording through the normal stop path first, then starts the new recording, so the usual empty/too-short cleanup and summary handoff rules still apply. + +| Setting | Purpose | +| --- | --- | +| `Enabled` | Enables scheduled Outlook Teams recording-start prompts. | +| `SyncInterval` | How often Meeting Assistant refreshes today's Outlook calendar cache. Defaults to 30 minutes when unset or invalid. | +| `PromptWindow` | How long after a Teams appointment's scheduled start it remains eligible for the one-time prompt. Defaults to 5 minutes when unset or invalid. | + ## Screenshots `Screenshots:Hotkey` configures a global hotkey that captures the currently active window during an active meeting. Screenshots are written under `Screenshots:AttachmentsFolder`, which defaults to an `Attachments` folder beside the assistant context note, and each capture appends a timestamped markdown image link to the assistant context. diff --git a/openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/design.md b/openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/design.md new file mode 100644 index 0000000..0a78e12 --- /dev/null +++ b/openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/design.md @@ -0,0 +1,67 @@ +## Context + +Meeting Assistant already supports manual recording starts through hotkeys, HTTP endpoints, and the tray menu. It also reads the current Outlook Classic Teams appointment when a recording starts so meeting artifacts can be enriched with title, attendees, agenda, and scheduled end. + +This change adds an optional proactive prompt before Teams meetings. The app must not depend on Teams APIs for the primary meeting flow, and Outlook integration remains Windows-only because it uses Outlook Classic COM. + +## Goals / Non-Goals + +**Goals:** +- Sync today's Outlook Classic Teams appointments when enabled. +- Avoid frequent Outlook COM polling by caching all Teams appointments for the current local day. +- Schedule prompt checks from the in-memory cache so back-to-back short meetings can prompt without another Outlook sync. +- Reuse the existing native Windows app notification pattern with affirmative and negative actions. +- Start recording on acceptance, stopping any active recording through the normal stop path first. + +**Non-Goals:** +- Join, inspect, or control Teams meetings through Teams APIs. +- Detect Zoom, Webex, or other meeting providers in this change. +- Persist the daily prompt cache across application restarts. +- Prompt for meetings from non-Windows builds. + +## Decisions + +### Use a day cache instead of per-minute Outlook polling + +Meeting Assistant syncs all Teams appointments for the current local day into memory on a configurable interval, defaulting to 30 minutes. Prompt checks then operate on that cached list. + +This avoids using Outlook COM as a high-frequency scheduler and supports multiple short meetings close together without requiring a sync between them. The alternative was checking Outlook every minute for due meetings, which is simpler but unnecessarily noisy and risks Outlook responsiveness issues. + +### Keep prompt scheduling independent from Outlook sync + +The hosted scheduler separates `SyncOnceAsync` from due prompt checks. Its runtime loop wakes for the earlier of the next sync or the next cached meeting start. + +This keeps Outlook interaction slow and bounded while preserving timely prompts. The alternative was a fixed timer that checks both cache and Outlook on the same cadence, which either polls Outlook too often or risks late prompts. + +### Use a recording controller adapter over direct coordinator coupling + +Prompt acceptance is handled through a small adapter over `MeetingRecordingCoordinator`. The adapter exposes current status, normal start, and normal stop. + +This keeps the prompt scheduler focused on calendar prompt behavior and leaves minimum-duration cleanup, transcription drain, speaker recognition, and summary continuation in the existing coordinator. The alternative was embedding handoff logic in the scheduler, which would duplicate recording lifecycle policy. + +### Use Windows-specific adapters for COM and notifications + +The Outlook calendar provider and meeting-start prompt service are Windows-only implementations behind platform-neutral interfaces. Non-Windows builds register no-op implementations. + +This preserves cross-target builds and keeps platform-specific packages and COM usage isolated. The alternative was spreading `#if WINDOWS` checks into scheduler logic, which would make the core behavior harder to test. + +## Risks / Trade-offs + +[Missed updates between syncs] -> A meeting created or changed after the last sync may not prompt until the next sync. The default 30-minute sync interval balances freshness against Outlook COM overhead and can be configured lower if needed. + +[App restart loses prompt memory] -> Prompted appointment keys are in memory only. A restart during the day may allow a meeting to prompt again if it is still in the prompt window. This is acceptable for the first version and avoids persistence complexity. + +[Outlook COM availability] -> Outlook Classic may be unavailable or slow. Sync failures are logged and retried on the next sync interval without blocking recording controls. + +[Notification ignored] -> Ignored prompts do not block later prompt checks, but the appointment is marked prompted for the day so the app does not spam repeated notifications. + +## Migration Plan + +The feature is disabled by default. Existing installations keep current manual recording behavior until `CalendarRecordingPrompts:Enabled` is set to `true`. + +Rollback is disabling `CalendarRecordingPrompts:Enabled` or reverting the change. No data migration is required because the cache is in-memory only. + +## Open Questions + +- Should the prompt cache become persistent if duplicate prompts after app restarts become annoying? +- Should future provider detection include Zoom or other meeting links from the same Outlook appointment sync? diff --git a/openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/proposal.md b/openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/proposal.md new file mode 100644 index 0000000..cde729f --- /dev/null +++ b/openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/proposal.md @@ -0,0 +1,13 @@ +## Why +Meeting Assistant can enrich a recording from the current Outlook Teams appointment, but it still relies on the user remembering to start recording manually. + +## What Changes +- Add an optional scheduled Outlook Classic calendar check for today's Teams appointments. +- Prompt the user through native Windows app notifications when a Teams appointment reaches its start time. +- Start recording when the user accepts the prompt. +- If another recording is active when the user accepts, stop it normally first, then start the new recording. + +## Impact +- Adds a Windows-only Outlook calendar reader for daily Teams appointments. +- Adds a Windows notification prompt alongside the existing inactivity prompt. +- Adds a hosted scheduler guarded by configuration. diff --git a/openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/specs/meeting-session/spec.md b/openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/specs/meeting-session/spec.md new file mode 100644 index 0000000..685dc81 --- /dev/null +++ b/openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/specs/meeting-session/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements +### Requirement: Outlook Teams meetings can prompt recording start +Meeting Assistant SHALL provide a disabled-by-default setting that enables scheduled Outlook Classic calendar checks for recording-start prompts. + +When scheduled recording prompts are enabled on Windows, Meeting Assistant SHALL periodically read the user's Outlook Classic calendar appointments for the current local day through COM into an in-memory cache. + +Meeting Assistant SHALL default the Outlook calendar sync interval to 30 minutes when scheduled recording prompts are enabled. + +Meeting Assistant SHALL schedule recording-start prompts from the cached calendar appointments rather than querying Outlook for each prompt. + +Meeting Assistant SHALL consider Teams appointments from Outlook calendar data as initial prompt candidates. The detection MAY be extended later for other meeting providers. + +When a Teams appointment reaches its scheduled start window, Meeting Assistant SHALL show a native Windows app notification asking whether to record the meeting, with affirmative and negative actions. + +Meeting Assistant SHALL prompt at most once per calendar appointment during a local day, regardless of whether the user accepts, declines, or ignores the notification. + +If the user accepts the recording prompt while no recording is active, Meeting Assistant SHALL start a new recording normally. + +If the user accepts the recording prompt while another recording is active, Meeting Assistant SHALL stop the active recording normally and then start the prompted meeting recording. + +When stopping an active recording for an accepted prompt, Meeting Assistant SHALL use the normal stop path so empty or too-short recordings are removed according to existing settings and other completed recordings continue normal transcription, speaker recognition, and summary processing. + +#### Scenario: Teams meeting start prompts the user +- **GIVEN** scheduled Outlook recording prompts are enabled +- **AND** Meeting Assistant has synced Outlook Classic Teams appointments for today +- **WHEN** the appointment reaches its scheduled start window +- **THEN** Meeting Assistant shows a native Windows app notification asking whether to record the meeting +- **AND** marks that appointment as prompted for the day + +#### Scenario: Back-to-back cached Teams meetings prompt without another Outlook sync +- **GIVEN** scheduled Outlook recording prompts are enabled +- **AND** Meeting Assistant has synced two Teams appointments for today that start ten minutes apart +- **WHEN** each appointment reaches its scheduled start window +- **THEN** Meeting Assistant shows a recording prompt for each appointment +- **AND** does not require another Outlook calendar sync between the prompts + +#### Scenario: User accepts prompt while idle +- **GIVEN** scheduled Outlook recording prompts are enabled +- **AND** no meeting recording is active +- **WHEN** the user accepts a Teams meeting recording prompt +- **THEN** Meeting Assistant starts recording normally + +#### Scenario: User accepts prompt while already recording +- **GIVEN** scheduled Outlook recording prompts are enabled +- **AND** a meeting recording is active +- **WHEN** the user accepts a Teams meeting recording prompt +- **THEN** Meeting Assistant stops the active recording normally +- **AND** starts a new recording normally after the stop request + +#### Scenario: Prompt is disabled +- **GIVEN** scheduled Outlook recording prompts are disabled +- **WHEN** a Teams appointment reaches its scheduled start +- **THEN** Meeting Assistant does not query Outlook for recording prompt candidates +- **AND** does not show a recording prompt diff --git a/openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/tasks.md b/openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/tasks.md new file mode 100644 index 0000000..e91cef7 --- /dev/null +++ b/openspec/changes/archive/2026-06-03-add-outlook-teams-start-prompts/tasks.md @@ -0,0 +1,13 @@ +## 1. Specification +- [x] Add scheduled Outlook Teams recording prompt requirements and scenarios. + +## 2. Behavior +- [x] Add settings for enabling scheduled calendar prompts and controlling Outlook sync timing. +- [x] Read today's Teams appointments from Outlook Classic COM on Windows into a day cache. +- [x] Prompt once per appointment when it reaches its start window. +- [x] On accepted prompt, stop any active recording normally and start the new recording. + +## 3. Verification +- [x] Add focused behavior tests for prompt selection and accepted handoff. +- [x] Run relevant tests. +- [x] Validate OpenSpec change. diff --git a/openspec/specs/meeting-session/spec.md b/openspec/specs/meeting-session/spec.md index b2c9a37..648c415 100644 --- a/openspec/specs/meeting-session/spec.md +++ b/openspec/specs/meeting-session/spec.md @@ -98,6 +98,60 @@ The agenda SHALL be extracted from the appointment body content before the Teams - **THEN** Meeting Assistant starts the recording with the default generated meeting title and empty agenda - **AND** omits `scheduled_end` from assistant-context frontmatter +### Requirement: Outlook Teams meetings can prompt recording start +Meeting Assistant SHALL provide a disabled-by-default setting that enables scheduled Outlook Classic calendar checks for recording-start prompts. + +When scheduled recording prompts are enabled on Windows, Meeting Assistant SHALL periodically read the user's Outlook Classic calendar appointments for the current local day through COM into an in-memory cache. + +Meeting Assistant SHALL default the Outlook calendar sync interval to 30 minutes when scheduled recording prompts are enabled. + +Meeting Assistant SHALL schedule recording-start prompts from the cached calendar appointments rather than querying Outlook for each prompt. + +Meeting Assistant SHALL consider Teams appointments from Outlook calendar data as initial prompt candidates. The detection MAY be extended later for other meeting providers. + +When a Teams appointment reaches its scheduled start window, Meeting Assistant SHALL show a native Windows app notification asking whether to record the meeting, with affirmative and negative actions. + +Meeting Assistant SHALL prompt at most once per calendar appointment during a local day, regardless of whether the user accepts, declines, or ignores the notification. + +If the user accepts the recording prompt while no recording is active, Meeting Assistant SHALL start a new recording normally. + +If the user accepts the recording prompt while another recording is active, Meeting Assistant SHALL stop the active recording normally and then start the prompted meeting recording. + +When stopping an active recording for an accepted prompt, Meeting Assistant SHALL use the normal stop path so empty or too-short recordings are removed according to existing settings and other completed recordings continue normal transcription, speaker recognition, and summary processing. + +#### Scenario: Teams meeting start prompts the user +- **GIVEN** scheduled Outlook recording prompts are enabled +- **AND** Meeting Assistant has synced Outlook Classic Teams appointments for today +- **WHEN** the appointment reaches its scheduled start window +- **THEN** Meeting Assistant shows a native Windows app notification asking whether to record the meeting +- **AND** marks that appointment as prompted for the day + +#### Scenario: Back-to-back cached Teams meetings prompt without another Outlook sync +- **GIVEN** scheduled Outlook recording prompts are enabled +- **AND** Meeting Assistant has synced two Teams appointments for today that start ten minutes apart +- **WHEN** each appointment reaches its scheduled start window +- **THEN** Meeting Assistant shows a recording prompt for each appointment +- **AND** does not require another Outlook calendar sync between the prompts + +#### Scenario: User accepts prompt while idle +- **GIVEN** scheduled Outlook recording prompts are enabled +- **AND** no meeting recording is active +- **WHEN** the user accepts a Teams meeting recording prompt +- **THEN** Meeting Assistant starts recording normally + +#### Scenario: User accepts prompt while already recording +- **GIVEN** scheduled Outlook recording prompts are enabled +- **AND** a meeting recording is active +- **WHEN** the user accepts a Teams meeting recording prompt +- **THEN** Meeting Assistant stops the active recording normally +- **AND** starts a new recording normally after the stop request + +#### Scenario: Prompt is disabled +- **GIVEN** scheduled Outlook recording prompts are disabled +- **WHEN** a Teams appointment reaches its scheduled start +- **THEN** Meeting Assistant does not query Outlook for recording prompt candidates +- **AND** does not show a recording prompt + ### Requirement: Meeting automation rules are configurable Meeting Assistant SHALL allow an optional local YAML rules file path to be configured.