Add Outlook Teams recording prompts

This commit is contained in:
2026-06-03 10:45:20 +02:00
parent edade8ab62
commit 50daa88389
17 changed files with 1435 additions and 234 deletions
@@ -0,0 +1,309 @@
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;
}
}
@@ -0,0 +1,110 @@
#if WINDOWS
using MeetingAssistant.MeetingNotes;
namespace MeetingAssistant.Calendar;
public sealed class OutlookClassicCalendarMeetingProvider : ICalendarMeetingProvider
{
private readonly ILogger<OutlookClassicCalendarMeetingProvider> logger;
public OutlookClassicCalendarMeetingProvider(ILogger<OutlookClassicCalendarMeetingProvider> logger)
{
this.logger = logger;
}
public Task<IReadOnlyList<CalendarMeeting>> 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<IReadOnlyList<CalendarMeeting>>([]);
}
}
private IReadOnlyList<CalendarMeeting> 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<CalendarMeeting>();
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
@@ -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);
}
}
@@ -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<string, Func<MeetingStartPromptResponse, CancellationToken, Task>> pendingPrompts = new(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<WindowsMeetingStartPromptService> logger;
private readonly object registrationGate = new();
private bool registered;
public WindowsMeetingStartPromptService(ILogger<WindowsMeetingStartPromptService> logger)
{
this.logger = logger;
}
public Task ShowPromptAsync(
MeetingStartPromptRequest request,
Func<MeetingStartPromptResponse, CancellationToken, Task> 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<string, string> arguments,
string key,
out string value)
{
if (arguments.TryGetValue(key, out value!))
{
return true;
}
value = "";
return false;
}
}
#endif