Public Access
310 lines
9.5 KiB
C#
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;
|
|
}
|
|
}
|