using MeetingAssistant.Recording; using MeetingAssistant.MeetingNotes; 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 (meeting.IsCanceled || 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), (response, token) => HandlePromptResponseAsync(meeting, response, token), 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( CalendarMeeting meeting, MeetingStartPromptResponse response, CancellationToken cancellationToken) { if (response != MeetingStartPromptResponse.Record) { return; } if (recordingController.CurrentStatus.IsRecording) { await recordingController.StopAsync(cancellationToken); } await recordingController.StartFromPromptAsync(meeting.Metadata, 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 => !meeting.IsCanceled) .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, MeetingMetadata? Metadata = null, bool IsCanceled = false); 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 StartFromPromptAsync( MeetingMetadata? metadata, 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 StartFromPromptAsync( MeetingMetadata? metadata, CancellationToken cancellationToken) { return coordinator.StartFromPromptAsync(metadata, 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; } }