Public Access
Add Outlook Teams recording prompts
This commit is contained in:
@@ -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 AutomationOptions Automation { get; set; } = new();
|
||||||
|
|
||||||
|
public CalendarRecordingPromptOptions CalendarRecordingPrompts { get; set; } = new();
|
||||||
|
|
||||||
public ScreenshotOptions Screenshots { get; set; } = new();
|
public ScreenshotOptions Screenshots { get; set; } = new();
|
||||||
|
|
||||||
public AgentOptions Agent { 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 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 sealed class ScreenshotOptions
|
||||||
{
|
{
|
||||||
public string Hotkey { get; set; } = "Ctrl+Alt+S";
|
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.Diagnostics;
|
||||||
using System.Collections;
|
using MeetingAssistant.Calendar;
|
||||||
|
|
||||||
namespace MeetingAssistant.MeetingNotes;
|
namespace MeetingAssistant.MeetingNotes;
|
||||||
|
|
||||||
public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider, IMeetingMetadataDiagnosticProvider
|
public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider, IMeetingMetadataDiagnosticProvider
|
||||||
{
|
{
|
||||||
private const int OutlookCalendarFolder = 9;
|
|
||||||
private readonly ILogger<OutlookClassicMeetingMetadataProvider> logger;
|
private readonly ILogger<OutlookClassicMeetingMetadataProvider> logger;
|
||||||
|
|
||||||
public OutlookClassicMeetingMetadataProvider(ILogger<OutlookClassicMeetingMetadataProvider> logger)
|
public OutlookClassicMeetingMetadataProvider(ILogger<OutlookClassicMeetingMetadataProvider> logger)
|
||||||
@@ -21,7 +19,10 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
|||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
try
|
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)
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -38,7 +39,10 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
|||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var lookup = RunStaAsync(() => GetCurrentMeeting(startedAt), cancellationToken);
|
var lookup = OutlookClassicCom.RunStaAsync(
|
||||||
|
() => GetCurrentMeeting(startedAt),
|
||||||
|
cancellationToken,
|
||||||
|
"Meeting Assistant Outlook COM Lookup");
|
||||||
var metadata = timeout > TimeSpan.Zero
|
var metadata = timeout > TimeSpan.Zero
|
||||||
? await lookup.WaitAsync(timeout, cancellationToken)
|
? await lookup.WaitAsync(timeout, cancellationToken)
|
||||||
: await lookup.WaitAsync(cancellationToken);
|
: await lookup.WaitAsync(cancellationToken);
|
||||||
@@ -84,77 +88,58 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
|||||||
|
|
||||||
private MeetingMetadata? GetCurrentMeeting(DateTimeOffset startedAt)
|
private MeetingMetadata? GetCurrentMeeting(DateTimeOffset startedAt)
|
||||||
{
|
{
|
||||||
EnsureOutlookRunning();
|
using var calendar = OutlookClassicCom.OpenDefaultCalendar(logger, "metadata lookup");
|
||||||
|
if (calendar is null)
|
||||||
object? application = null;
|
|
||||||
object? session = null;
|
|
||||||
object? calendar = null;
|
|
||||||
object? items = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
var applicationType = Type.GetTypeFromProgID("Outlook.Application");
|
return null;
|
||||||
if (applicationType is 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);
|
var keepAppointment = false;
|
||||||
if (application is null)
|
try
|
||||||
{
|
{
|
||||||
return null;
|
if (!OutlookClassicCom.IsAppointment(item))
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var keepAppointment = false;
|
var start = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "Start"));
|
||||||
try
|
if (start > windowEnd)
|
||||||
{
|
{
|
||||||
if (!IsAppointment(item))
|
break;
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
|
var end = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "End"));
|
||||||
|
if (end < windowStart || !IsTeamsAppointment(item))
|
||||||
{
|
{
|
||||||
if (!keepAppointment)
|
continue;
|
||||||
{
|
}
|
||||||
ReleaseComObject(item);
|
|
||||||
}
|
candidates.Add(new OutlookAppointmentCandidate(item, start, end));
|
||||||
|
keepAppointment = true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!keepAppointment)
|
||||||
|
{
|
||||||
|
OutlookClassicCom.ReleaseComObject(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
var selected = OutlookMeetingCandidateSelector.Select(
|
var selected = OutlookMeetingCandidateSelector.Select(
|
||||||
candidates,
|
candidates,
|
||||||
startedLocal,
|
startedLocal,
|
||||||
@@ -162,120 +147,28 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
|||||||
candidate => candidate.End);
|
candidate => candidate.End);
|
||||||
if (selected is null)
|
if (selected is null)
|
||||||
{
|
{
|
||||||
foreach (var candidate in candidates)
|
|
||||||
{
|
|
||||||
ReleaseComObject(candidate.Appointment);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
var appointment = selected.Appointment;
|
||||||
{
|
var title = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")) ?? "";
|
||||||
var appointment = selected.Appointment;
|
var attendees = ReadAttendees(appointment);
|
||||||
var title = Convert.ToString(GetProperty(appointment, "Subject")) ?? "";
|
var body = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? "";
|
||||||
var attendees = ReadAttendees(appointment);
|
return new MeetingMetadata(
|
||||||
var body = Convert.ToString(GetProperty(appointment, "Body")) ?? "";
|
title.Trim(),
|
||||||
return new MeetingMetadata(
|
attendees,
|
||||||
title.Trim(),
|
ExtractAgenda(body),
|
||||||
attendees,
|
OutlookClassicCom.ToLocalOffset(selected.End));
|
||||||
ExtractAgenda(body),
|
|
||||||
ToLocalOffset(selected.End));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
foreach (var candidate in candidates)
|
|
||||||
{
|
|
||||||
ReleaseComObject(candidate.Appointment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
ReleaseComObject(items);
|
foreach (var candidate in candidates)
|
||||||
ReleaseComObject(calendar);
|
|
||||||
ReleaseComObject(session);
|
|
||||||
ReleaseComObject(application);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<object?> EnumerateItems(object items)
|
|
||||||
{
|
|
||||||
if (items is IEnumerable enumerable)
|
|
||||||
{
|
|
||||||
foreach (var item in enumerable)
|
|
||||||
{
|
{
|
||||||
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)
|
internal static string ExtractAgenda(string body)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(body))
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
@@ -298,24 +191,6 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
|||||||
return string.Join(Environment.NewLine, agendaLines).Trim();
|
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)
|
private static bool IsTeamsSeparator(string line)
|
||||||
{
|
{
|
||||||
var trimmed = line.Trim();
|
var trimmed = line.Trim();
|
||||||
@@ -325,13 +200,21 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
|||||||
|
|
||||||
private static bool IsTeamsJoinLine(string line)
|
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)
|
private static IReadOnlyList<string> ReadAttendees(object appointment)
|
||||||
{
|
{
|
||||||
var attendees = new List<string>();
|
var attendees = new List<string>();
|
||||||
var organizer = Convert.ToString(GetProperty(appointment, "Organizer"));
|
var organizer = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Organizer"));
|
||||||
if (!string.IsNullOrWhiteSpace(organizer))
|
if (!string.IsNullOrWhiteSpace(organizer))
|
||||||
{
|
{
|
||||||
attendees.Add(organizer.Trim());
|
attendees.Add(organizer.Trim());
|
||||||
@@ -340,14 +223,14 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
|||||||
object? recipients = null;
|
object? recipients = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
recipients = GetProperty(appointment, "Recipients");
|
recipients = OutlookClassicCom.GetProperty(appointment, "Recipients");
|
||||||
var count = Convert.ToInt32(GetProperty(recipients!, "Count"));
|
var count = Convert.ToInt32(OutlookClassicCom.GetProperty(recipients!, "Count"));
|
||||||
for (var index = 1; index <= count; index++)
|
for (var index = 1; index <= count; index++)
|
||||||
{
|
{
|
||||||
object? recipient = null;
|
object? recipient = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
recipient = Invoke(recipients!, "Item", index);
|
recipient = OutlookClassicCom.Invoke(recipients!, "Item", index);
|
||||||
var formatted = FormatRecipient(recipient!);
|
var formatted = FormatRecipient(recipient!);
|
||||||
if (!string.IsNullOrWhiteSpace(formatted))
|
if (!string.IsNullOrWhiteSpace(formatted))
|
||||||
{
|
{
|
||||||
@@ -356,23 +239,18 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
ReleaseComObject(recipient);
|
OutlookClassicCom.ReleaseComObject(recipient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
ReleaseComObject(recipients);
|
OutlookClassicCom.ReleaseComObject(recipients);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NormalizeAttendees(attendees);
|
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)
|
internal static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
|
||||||
{
|
{
|
||||||
return attendees
|
return attendees
|
||||||
@@ -427,8 +305,8 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
|||||||
|
|
||||||
private static string FormatRecipient(object recipient)
|
private static string FormatRecipient(object recipient)
|
||||||
{
|
{
|
||||||
var name = Convert.ToString(GetProperty(recipient, "Name"))?.Trim() ?? "";
|
var name = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Name"))?.Trim() ?? "";
|
||||||
var email = Convert.ToString(GetProperty(recipient, "Address"))?.Trim() ?? "";
|
var email = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Address"))?.Trim() ?? "";
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
{
|
{
|
||||||
return email;
|
return email;
|
||||||
@@ -443,44 +321,6 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
|||||||
return $"{name} <{email}>";
|
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(
|
private sealed record OutlookAppointmentCandidate(
|
||||||
object Appointment,
|
object Appointment,
|
||||||
DateTime Start,
|
DateTime Start,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using MeetingAssistant;
|
using MeetingAssistant;
|
||||||
|
using MeetingAssistant.Calendar;
|
||||||
using MeetingAssistant.Hotkeys;
|
using MeetingAssistant.Hotkeys;
|
||||||
using MeetingAssistant.LaunchProfiles;
|
using MeetingAssistant.LaunchProfiles;
|
||||||
using MeetingAssistant.Logging;
|
using MeetingAssistant.Logging;
|
||||||
@@ -49,6 +50,19 @@ builder.Services.AddHostedService(services =>
|
|||||||
builder.Services.AddSingleton<IMeetingInactivityPromptService, NoopMeetingInactivityPromptService>();
|
builder.Services.AddSingleton<IMeetingInactivityPromptService, NoopMeetingInactivityPromptService>();
|
||||||
#endif
|
#endif
|
||||||
builder.Services.AddSingleton<IRecordingDictationWordProvider, RecordingDictationWordProvider>();
|
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
|
#if WINDOWS
|
||||||
builder.Services.AddSingleton<IActiveWindowScreenshotCapture, ActiveWindowScreenshotCapture>();
|
builder.Services.AddSingleton<IActiveWindowScreenshotCapture, ActiveWindowScreenshotCapture>();
|
||||||
#else
|
#else
|
||||||
@@ -103,6 +117,7 @@ builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, FunAsrSpeechRec
|
|||||||
builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, WhisperLocalSpeechRecognitionPipelineBuilder>();
|
builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, WhisperLocalSpeechRecognitionPipelineBuilder>();
|
||||||
builder.Services.AddSingleton<ISpeechRecognitionPipelineFactory, ConfiguredSpeechRecognitionPipelineFactory>();
|
builder.Services.AddSingleton<ISpeechRecognitionPipelineFactory, ConfiguredSpeechRecognitionPipelineFactory>();
|
||||||
builder.Services.AddSingleton<MeetingRecordingCoordinator>();
|
builder.Services.AddSingleton<MeetingRecordingCoordinator>();
|
||||||
|
builder.Services.AddHostedService<CalendarRecordingPromptScheduler>();
|
||||||
builder.Services.AddHostedService<TemporaryRecordingCleanupHostedService>();
|
builder.Services.AddHostedService<TemporaryRecordingCleanupHostedService>();
|
||||||
builder.Services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
|
builder.Services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
|
||||||
builder.Services.AddHostedService<SpeechRecognitionPipelineHostedService>();
|
builder.Services.AddHostedService<SpeechRecognitionPipelineHostedService>();
|
||||||
|
|||||||
@@ -146,6 +146,11 @@
|
|||||||
"Automation": {
|
"Automation": {
|
||||||
"RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml"
|
"RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml"
|
||||||
},
|
},
|
||||||
|
"CalendarRecordingPrompts": {
|
||||||
|
"Enabled": false,
|
||||||
|
"SyncInterval": "00:30:00",
|
||||||
|
"PromptWindow": "00:05:00"
|
||||||
|
},
|
||||||
"WorkflowRulesEditor": {
|
"WorkflowRulesEditor": {
|
||||||
"Endpoint": "",
|
"Endpoint": "",
|
||||||
"KeyEnv": "",
|
"KeyEnv": "",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ The application is intended to:
|
|||||||
- create an Obsidian markdown note before transcription starts
|
- create an Obsidian markdown note before transcription starts
|
||||||
- keep meeting metadata, user notes, detected context, and links to generated output in that note
|
- keep meeting metadata, user notes, detected context, and links to generated output in that note
|
||||||
- toggle recording/transcription mode through a configurable global hotkey
|
- 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
|
- 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
|
- abort and discard an active recording through a configurable global hotkey
|
||||||
- show a Windows taskbar notification icon with state-aware recording controls
|
- show a Windows taskbar notification icon with state-aware recording controls
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ This example is abbreviated so the most common shape is readable. The checked-in
|
|||||||
"Automation": {
|
"Automation": {
|
||||||
"RulesPath": "meeting-rules.local.yaml"
|
"RulesPath": "meeting-rules.local.yaml"
|
||||||
},
|
},
|
||||||
|
"CalendarRecordingPrompts": {
|
||||||
|
"Enabled": false,
|
||||||
|
"SyncInterval": "00:30:00",
|
||||||
|
"PromptWindow": "00:05:00"
|
||||||
|
},
|
||||||
"WorkflowRulesEditor": {
|
"WorkflowRulesEditor": {
|
||||||
"Endpoint": "",
|
"Endpoint": "",
|
||||||
"KeyEnv": "",
|
"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.
|
`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
|
||||||
|
|
||||||
`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.
|
`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.
|
||||||
+54
@@ -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.
|
||||||
@@ -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
|
- **THEN** Meeting Assistant starts the recording with the default generated meeting title and empty agenda
|
||||||
- **AND** omits `scheduled_end` from assistant-context frontmatter
|
- **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
|
### Requirement: Meeting automation rules are configurable
|
||||||
Meeting Assistant SHALL allow an optional local YAML rules file path to be configured.
|
Meeting Assistant SHALL allow an optional local YAML rules file path to be configured.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user