using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using PlotLine.Data; using PlotLine.Models; using PlotLine.Services; using System.Security.Claims; namespace PlotLine.Hubs; [Authorize] public sealed class WordCompanionFollowHub( IWordCompanionPresenceService presence, IOnboardingRepository onboarding, IManuscriptScanPreviewStore scanStore, IOnboardingService onboardingService) : Hub { public override async Task OnConnectedAsync() { if (TryGetUserId(out var userId)) { await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId)); } await base.OnConnectedAsync(); } public async Task WatchCompanionPresence() { var userId = RequireUserId(); await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId)); return await presence.GetStatusAsync(userId); } public async Task RegisterCompanion(WordCompanionPresenceRegistration registration) { var userId = RequireUserId(); if (registration.UserID.HasValue && registration.UserID.Value != userId) { throw new HubException("The Companion is signed in as a different user."); } var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration); await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status); return status; } public async Task CompanionHeartbeat(WordCompanionPresenceHeartbeat heartbeat) { var userId = RequireUserId(); var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat); await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status); return status; } public async Task StartOnboardingManuscriptScan() { var userId = RequireUserId(); var state = await onboarding.GetAsync(userId); if (state is null || !IsMicrosoftWordState(state) || !state.ProjectID.HasValue || !state.BookID.HasValue) { throw new HubException("Finish choosing a Project and Book before scanning."); } var presenceStatus = await presence.GetStatusAsync(userId); if (!presenceStatus.IsConnected) { throw new HubException("The Word Companion is not connected. Open Word and try again."); } if (!presenceStatus.DocumentOpen) { throw new HubException("Open your manuscript in Word before scanning."); } var companionConnectionId = await presence.GetCompanionConnectionIdAsync(userId); if (string.IsNullOrWhiteSpace(companionConnectionId)) { throw new HubException("The Word Companion connection is not ready. Try reconnecting the Companion."); } var scanState = await scanStore.StartAsync(userId, state.UserOnboardingStateID, state.ProjectID.Value, state.BookID.Value); await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanStateChanged", scanState); await Clients.Client(companionConnectionId).SendAsync("ScanCurrentDocument", new ManuscriptScanCommand { UserID = userId, OnboardingID = state.UserOnboardingStateID, ProjectID = state.ProjectID.Value, BookID = state.BookID.Value, RequestedUtc = DateTime.UtcNow }); return scanState; } public async Task ReportOnboardingScanProgress(ManuscriptScanProgress progress) { var userId = RequireUserId(); var scanState = await scanStore.ProgressAsync(userId, progress); await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanProgress", scanState); return scanState; } public async Task CompleteOnboardingScan(ManuscriptScanPreview preview) { var userId = RequireUserId(); var state = await onboarding.GetAsync(userId); if (state is not null) { preview = NormalisePreviewContext(preview, userId, state); } if (state is null || state.UserOnboardingStateID != preview.OnboardingID || state.ProjectID != preview.ProjectID || state.BookID != preview.BookID) { throw new HubException("The scan result does not match your onboarding setup."); } var scanState = await scanStore.CompleteAsync(userId, preview); await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanCompleted", scanState); return scanState; } public async Task FailOnboardingScan(ManuscriptScanFailure failure) { var userId = RequireUserId(); var scanState = await scanStore.FailAsync(userId, failure.OnboardingID, failure.Message); await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanFailed", scanState); return scanState; } public async Task StartOnboardingProjectBuild(Guid previewId) { var userId = RequireUserId(); await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress { Status = "Running", Message = "Creating chapters...", PercentComplete = 0, PreviewID = previewId }); var result = await onboardingService.BuildApprovedStructureAsync( previewId, progress => Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", progress)); if (result is null) { throw new HubException("The reviewed scan could not be found."); } var companionConnectionId = await presence.GetCompanionConnectionIdAsync(userId); if (!string.IsNullOrWhiteSpace(companionConnectionId)) { await Clients.Client(companionConnectionId).SendAsync("UpdateOnboardingBuildMarkers", result); } await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildCompleted", result); return result; } public override async Task OnDisconnectedAsync(Exception? exception) { var status = await presence.DisconnectAsync(Context.ConnectionId); if (status is not null) { await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status); var failedScans = await scanStore.FailRunningForUserAsync( status.UserID, "The Word Companion disconnected before the scan finished. Reopen Word and try again."); foreach (var failedScan in failedScans) { await Clients.Group(PresenceGroup(status.UserID)).SendAsync("OnboardingScanFailed", failedScan); } } await base.OnDisconnectedAsync(exception); } private int RequireUserId() => TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use the Word Companion."); private bool TryGetUserId(out int userId) => int.TryParse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier), out userId); private static string PresenceGroup(int userId) => $"word-companion-presence:{userId}"; private static bool IsMicrosoftWordState(UserOnboardingState state) => string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal) || state.UsesWordCompanion == true; private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state) => new() { PreviewID = preview.PreviewID, UserID = preview.UserID == 0 ? userId : preview.UserID, OnboardingID = preview.OnboardingID == 0 ? state.UserOnboardingStateID : preview.OnboardingID, ProjectID = preview.ProjectID == 0 && state.ProjectID.HasValue ? state.ProjectID.Value : preview.ProjectID, BookID = preview.BookID == 0 && state.BookID.HasValue ? state.BookID.Value : preview.BookID, Source = preview.Source, DocumentTitle = preview.DocumentTitle, CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier, TotalWordCount = preview.TotalWordCount, ChapterCount = preview.ChapterCount, SceneCount = preview.SceneCount, CharacterCandidateCount = preview.CharacterCandidateCount, CreatedUtc = preview.CreatedUtc, Chapters = preview.Chapters, Scenes = preview.Scenes, CharacterCandidates = preview.CharacterCandidates }; }