Files
meeting-assistant/MeetingAssistant/Calendar/CalendarRecordingPromptScheduler.cs
T

310 lines
9.5 KiB
C#

using MeetingAssistant.Recording;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Calendar;
public sealed class CalendarRecordingPromptScheduler : BackgroundService
{
private readonly IOptions<MeetingAssistantOptions> options;
private readonly ICalendarMeetingProvider meetingProvider;
private readonly IMeetingStartPromptService promptService;
private readonly IMeetingPromptRecordingController recordingController;
private readonly ICalendarPromptClock clock;
private readonly ILogger<CalendarRecordingPromptScheduler> logger;
private readonly HashSet<string> promptedMeetingKeys = new(StringComparer.OrdinalIgnoreCase);
private IReadOnlyList<CalendarMeeting> cachedMeetings = [];
private DateOnly promptedMeetingDay;
private DateOnly cachedMeetingDay;
private DateTimeOffset nextSyncAt;
public CalendarRecordingPromptScheduler(
IOptions<MeetingAssistantOptions> options,
ICalendarMeetingProvider meetingProvider,
IMeetingStartPromptService promptService,
IMeetingPromptRecordingController recordingController,
ICalendarPromptClock clock,
ILogger<CalendarRecordingPromptScheduler> 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<IReadOnlyList<CalendarMeeting>> 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<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken);
}
public sealed record MeetingStartPromptRequest(CalendarMeeting Meeting);
public enum MeetingStartPromptResponse
{
Record,
Skip
}
public interface IMeetingPromptRecordingController
{
RecordingStatus CurrentStatus { get; }
Task<RecordingStatus> StartAsync(CancellationToken cancellationToken);
Task<RecordingStatus> 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<RecordingStatus> StartAsync(CancellationToken cancellationToken)
{
return coordinator.StartAsync(cancellationToken);
}
public Task<RecordingStatus> 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<IReadOnlyList<CalendarMeeting>> GetRecordingPromptCandidatesForDayAsync(
DateOnly day,
CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<CalendarMeeting>>([]);
}
}
public sealed class NoopMeetingStartPromptService : IMeetingStartPromptService
{
public Task ShowPromptAsync(
MeetingStartPromptRequest request,
Func<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}