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,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<CalendarRecordingPromptScheduler>.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<CalendarMeeting> meetings)
{
Meetings = meetings;
}
public IReadOnlyList<CalendarMeeting> Meetings { get; set; }
public int QueryCount { get; private set; }
public Task<IReadOnlyList<CalendarMeeting>> GetRecordingPromptCandidatesForDayAsync(
DateOnly day,
CancellationToken cancellationToken)
{
QueryCount++;
return Task.FromResult(Meetings);
}
}
private sealed class AcceptingMeetingStartPromptService : IMeetingStartPromptService
{
public List<CalendarMeeting> PromptedMeetings { get; } = [];
public async Task ShowPromptAsync(
MeetingStartPromptRequest request,
Func<MeetingStartPromptResponse, CancellationToken, Task> 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<string> Commands { get; } = [];
public Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
{
StartCount++;
Commands.Add("start");
CurrentStatus = Status(isRecording: true);
return Task.FromResult(CurrentStatus);
}
public Task<RecordingStatus> 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; }
}
}
@@ -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
@@ -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";
@@ -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<T> RunStaAsync<T>(
Func<T> action,
CancellationToken cancellationToken,
string threadName)
{
var completion = new TaskCompletionSource<T>(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<object?> 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
@@ -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<OutlookClassicMeetingMetadataProvider> logger;
public OutlookClassicMeetingMetadataProvider(ILogger<OutlookClassicMeetingMetadataProvider> 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<OutlookAppointmentCandidate>();
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<OutlookAppointmentCandidate>();
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<object?> 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<T> RunStaAsync<T>(
Func<T> action,
CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<T>(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<string> ReadAttendees(object appointment)
{
var attendees = new List<string>();
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<string> NormalizeAttendees(IEnumerable<string> 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,
+15
View File
@@ -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<IMeetingInactivityPromptService, NoopMeetingInactivityPromptService>();
#endif
builder.Services.AddSingleton<IRecordingDictationWordProvider, RecordingDictationWordProvider>();
builder.Services.AddSingleton<ICalendarPromptClock, SystemCalendarPromptClock>();
builder.Services.AddSingleton<IMeetingPromptRecordingController, MeetingPromptRecordingController>();
#if WINDOWS
builder.Services.AddSingleton<ICalendarMeetingProvider, OutlookClassicCalendarMeetingProvider>();
builder.Services.AddSingleton<WindowsMeetingStartPromptService>();
builder.Services.AddSingleton<IMeetingStartPromptService>(services =>
services.GetRequiredService<WindowsMeetingStartPromptService>());
builder.Services.AddHostedService(services =>
services.GetRequiredService<WindowsMeetingStartPromptService>());
#else
builder.Services.AddSingleton<ICalendarMeetingProvider, NoopCalendarMeetingProvider>();
builder.Services.AddSingleton<IMeetingStartPromptService, NoopMeetingStartPromptService>();
#endif
#if WINDOWS
builder.Services.AddSingleton<IActiveWindowScreenshotCapture, ActiveWindowScreenshotCapture>();
#else
@@ -103,6 +117,7 @@ builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, FunAsrSpeechRec
builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, WhisperLocalSpeechRecognitionPipelineBuilder>();
builder.Services.AddSingleton<ISpeechRecognitionPipelineFactory, ConfiguredSpeechRecognitionPipelineFactory>();
builder.Services.AddSingleton<MeetingRecordingCoordinator>();
builder.Services.AddHostedService<CalendarRecordingPromptScheduler>();
builder.Services.AddHostedService<TemporaryRecordingCleanupHostedService>();
builder.Services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
builder.Services.AddHostedService<SpeechRecognitionPipelineHostedService>();
+5
View File
@@ -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": "",
+1
View File
@@ -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
+17
View File
@@ -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.
@@ -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?
@@ -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.
@@ -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
@@ -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.
+54
View File
@@ -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.