feat: attach calendar metadata to active meetings
PR and Push Build/Test / build-and-test (push) Successful in 15m2s

This commit is contained in:
2026-08-05 11:58:25 +02:00
parent aa42e8edda
commit 1a341f1eaa
11 changed files with 713 additions and 48 deletions
@@ -98,7 +98,9 @@ public sealed class CalendarRecordingPromptScheduler : BackgroundService
meeting.Subject,
meeting.Start);
await promptService.ShowPromptAsync(
new MeetingStartPromptRequest(meeting),
new MeetingStartPromptRequest(
meeting,
recordingController.CurrentStatus.IsRecording && meeting.Metadata is not null),
(response, token) => HandlePromptResponseAsync(meeting, response, token),
cancellationToken);
}
@@ -139,6 +141,18 @@ public sealed class CalendarRecordingPromptScheduler : BackgroundService
MeetingStartPromptResponse response,
CancellationToken cancellationToken)
{
if (response == MeetingStartPromptResponse.AttachMetadataToCurrentMeeting)
{
if (meeting.Metadata is not null)
{
await recordingController.AttachMetadataToCurrentMeetingAsync(
meeting.Metadata,
cancellationToken);
}
return;
}
if (response != MeetingStartPromptResponse.Record)
{
return;
@@ -244,12 +258,15 @@ public interface IMeetingStartPromptService
CancellationToken cancellationToken);
}
public sealed record MeetingStartPromptRequest(CalendarMeeting Meeting);
public sealed record MeetingStartPromptRequest(
CalendarMeeting Meeting,
bool CanAttachToCurrentMeeting = false);
public enum MeetingStartPromptResponse
{
Record,
Skip
Skip,
AttachMetadataToCurrentMeeting
}
public interface IMeetingPromptRecordingController
@@ -262,6 +279,10 @@ public interface IMeetingPromptRecordingController
MeetingMetadata? metadata,
CancellationToken cancellationToken);
Task<RecordingStatus> AttachMetadataToCurrentMeetingAsync(
MeetingMetadata metadata,
CancellationToken cancellationToken);
Task<RecordingStatus> StopAsync(CancellationToken cancellationToken);
}
@@ -288,6 +309,13 @@ public sealed class MeetingPromptRecordingController : IMeetingPromptRecordingCo
return coordinator.StartFromPromptAsync(metadata, cancellationToken);
}
public Task<RecordingStatus> AttachMetadataToCurrentMeetingAsync(
MeetingMetadata metadata,
CancellationToken cancellationToken)
{
return coordinator.AttachMetadataToCurrentMeetingAsync(metadata, cancellationToken);
}
public Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
{
return coordinator.StopAsync(cancellationToken);
@@ -118,21 +118,10 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic
string promptId,
MeetingStartPromptRequest request)
{
var yesButton = new ToastButton()
.SetContent("Yes")
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.AddArgument("response", "record")
.SetBackgroundActivation();
var yesButton = BuildResponseButton("Yes", promptId, "record");
var noButton = BuildResponseButton("No", promptId, "skip");
var noButton = new ToastButton()
.SetContent("No")
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.AddArgument("response", "skip")
.SetBackgroundActivation();
return new ToastContentBuilder()
var notification = new ToastContentBuilder()
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.SetToastScenario(ToastScenario.Reminder)
@@ -141,6 +130,30 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic
.AddText($"{request.Meeting.Subject} starts at {request.Meeting.Start.LocalDateTime:t}.")
.AddButton(yesButton)
.AddButton(noButton);
if (request.CanAttachToCurrentMeeting)
{
notification.AddButton(
BuildResponseButton(
"Add metadata to current meeting",
promptId,
"attach-metadata"));
}
return notification;
}
private static ToastButton BuildResponseButton(
string content,
string promptId,
string response)
{
return new ToastButton()
.SetContent(content)
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.AddArgument("response", response)
.SetBackgroundActivation();
}
private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args)
@@ -166,10 +179,14 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic
return;
}
var response = TryGetArgument(arguments, "response", out var responseValue) &&
string.Equals(responseValue, "record", StringComparison.OrdinalIgnoreCase)
? MeetingStartPromptResponse.Record
: MeetingStartPromptResponse.Skip;
var response = TryGetArgument(arguments, "response", out var responseValue)
? responseValue.ToLowerInvariant() switch
{
"record" => MeetingStartPromptResponse.Record,
"attach-metadata" => MeetingStartPromptResponse.AttachMetadataToCurrentMeeting,
_ => MeetingStartPromptResponse.Skip
}
: MeetingStartPromptResponse.Skip;
_ = Task.Run(async () =>
{
try
@@ -155,6 +155,44 @@ public sealed class MeetingRecordingCoordinator
return await StartAsync(null, metadata, suppressMetadataLookup: true, cancellationToken);
}
public async Task<RecordingStatus> AttachMetadataToCurrentMeetingAsync(
MeetingMetadata metadata,
CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken);
try
{
var run = currentRun;
if (run is null || run.IsCaptureStopping)
{
return CurrentStatus;
}
var meetingNote = await ApplyAndPersistMeetingMetadataAsync(
run.Artifacts,
await meetingNoteStore.ReadAsync(run.MeetingNotePath, cancellationToken),
metadata,
run.Options,
cancellationToken);
currentMeetingNote = meetingNote;
await transcriptStore.UpdateMetadataAsync(
run.Session,
run.Artifacts,
meetingNote,
cancellationToken);
run.MarkPromptMetadataAttached();
logger.LogInformation(
"Attached prompted calendar metadata to active meeting {MeetingNotePath}",
run.MeetingNotePath);
return CurrentStatus;
}
finally
{
gate.Release();
}
}
public async Task<RecordingStatus> StartAsync(
string? launchProfileName,
CancellationToken cancellationToken)
@@ -213,22 +251,12 @@ public sealed class MeetingRecordingCoordinator
currentMeetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
if (suppliedMetadata is not null)
{
await ApplyMeetingMetadataAsync(
currentMeetingNote = await ApplyAndPersistMeetingMetadataAsync(
currentArtifacts,
currentMeetingNote,
suppliedMetadata,
runOptions,
cancellationToken);
currentMeetingNote = await meetingNoteStore.SaveAsync(
currentMeetingNote,
runOptions,
cancellationToken);
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
currentArtifacts,
currentMeetingNote,
suppliedMetadata.Agenda,
suppliedMetadata.ScheduledEnd,
cancellationToken);
}
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(
@@ -1021,7 +1049,7 @@ public sealed class MeetingRecordingCoordinator
return;
}
if (run.IsAborted)
if (run.IsAborted || run.HasAttachedPromptMetadata)
{
return;
}
@@ -1029,25 +1057,21 @@ public sealed class MeetingRecordingCoordinator
await gate.WaitAsync(CancellationToken.None);
try
{
if (run.IsAborted)
if (run.IsAborted || run.HasAttachedPromptMetadata)
{
return;
}
var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, CancellationToken.None);
await ApplyMeetingMetadataAsync(run.Artifacts, meetingNote, metadata, run.Options, CancellationToken.None);
meetingNote = await meetingNoteStore.SaveAsync(meetingNote, run.Options, CancellationToken.None);
var meetingNote = await ApplyAndPersistMeetingMetadataAsync(
run.Artifacts,
await meetingNoteStore.ReadAsync(run.MeetingNotePath, CancellationToken.None),
metadata,
run.Options,
CancellationToken.None);
if (currentMeetingNote?.Path == meetingNote.Path)
{
currentMeetingNote = meetingNote;
}
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
run.Artifacts,
meetingNote,
metadata.Agenda,
metadata.ScheduledEnd,
CancellationToken.None);
logger.LogInformation(
"Applied Outlook meeting metadata to {MeetingNotePath}",
run.MeetingNotePath);
@@ -1119,6 +1143,32 @@ public sealed class MeetingRecordingCoordinator
}
}
private async Task<MeetingNote> ApplyAndPersistMeetingMetadataAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
MeetingMetadata metadata,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
await ApplyMeetingMetadataAsync(
artifacts,
meetingNote,
metadata,
options,
cancellationToken);
var savedMeetingNote = await meetingNoteStore.SaveAsync(
meetingNote,
options,
cancellationToken);
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
artifacts,
savedMeetingNote,
metadata.Agenda,
metadata.ScheduledEnd,
cancellationToken);
return savedMeetingNote;
}
private async Task<List<string>> TransformAttendeesAsync(
MeetingSessionArtifacts artifacts,
IReadOnlyList<string> attendees,
@@ -1908,6 +1958,8 @@ public sealed class MeetingRecordingCoordinator
public bool HasSwitchedProfile { get; private set; }
public bool HasAttachedPromptMetadata { get; private set; }
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
public DateTimeOffset? InferredEndTime
@@ -1926,6 +1978,11 @@ public sealed class MeetingRecordingCoordinator
CaptureCancellationSource.Cancel();
}
public void MarkPromptMetadataAttached()
{
HasAttachedPromptMetadata = true;
}
public void Abort()
{
IsAborted = true;