diff --git a/features/conversation-assistant/macos/Sources/ConversationAssistantApp.swift b/features/conversation-assistant/macos/Sources/ConversationAssistantApp.swift index a95d1d8fd..b3a572d17 100644 --- a/features/conversation-assistant/macos/Sources/ConversationAssistantApp.swift +++ b/features/conversation-assistant/macos/Sources/ConversationAssistantApp.swift @@ -12,6 +12,7 @@ struct ConversationAssistantApp: App { } } +@MainActor class AppDelegate: NSObject, NSApplicationDelegate { var statusItem: NSStatusItem? var popover: NSPopover? @@ -20,11 +21,55 @@ class AppDelegate: NSObject, NSApplicationDelegate { private let apiClient = APIClient.shared func applicationDidFinishLaunching(_ notification: Notification) { + NSLog("ConversationAssistant: Starting up...") setupMenuBar() - // Start sync if already authenticated + // Start sync if already authenticated, or register if not if apiClient.isAuthenticated { + NSLog("ConversationAssistant: Already authenticated, starting sync") syncManager.startSync() + } else { + NSLog("ConversationAssistant: Not authenticated, will register or poll") + // Check if we already have a deviceId (previously registered) + if let existingDeviceId = UserDefaults.standard.string(forKey: "deviceId") { + NSLog("ConversationAssistant: Found existing deviceId: \(existingDeviceId), polling for verification") + startPollingForVerification() + } else { + // Register device on startup and poll for verification + NSLog("ConversationAssistant: No deviceId found, registering...") + Task { + do { + let (deviceId, code) = try await apiClient.registerDevice() + NSLog("ConversationAssistant: Device registered: \(deviceId), code: \(code)") + // Start polling for verification + startPollingForVerification() + } catch { + NSLog("ConversationAssistant: Failed to register device: \(error)") + } + } + } + } + } + + private func startPollingForVerification() { + NSLog("ConversationAssistant: Starting verification polling...") + Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] timer in + NSLog("ConversationAssistant: Polling for verification...") + Task { + do { + let verified = try await self?.apiClient.checkVerification() ?? false + NSLog("ConversationAssistant: Verification check result: \(verified)") + if verified { + timer.invalidate() + NSLog("ConversationAssistant: Device verified! Starting sync...") + await MainActor.run { + self?.syncManager.startSync() + } + } + } catch { + NSLog("ConversationAssistant: Verification check error: \(error)") + } + } } } diff --git a/features/conversation-assistant/macos/Sources/Services/SyncManager.swift b/features/conversation-assistant/macos/Sources/Services/SyncManager.swift index b8f11fb72..b93514078 100644 --- a/features/conversation-assistant/macos/Sources/Services/SyncManager.swift +++ b/features/conversation-assistant/macos/Sources/Services/SyncManager.swift @@ -6,6 +6,7 @@ struct SyncStats { var conversationCount: Int = 0 } +@MainActor class SyncManager: ObservableObject { static let shared = SyncManager() @@ -19,14 +20,18 @@ class SyncManager: ObservableObject { private init() { lastSync = UserDefaults.standard.object(forKey: "lastSync") as? Date + NSLog("SyncManager: init - lastSync from UserDefaults: \(String(describing: lastSync))") } func startSync() { + NSLog("SyncManager: startSync called") + // Connect to iMessage database do { try imessageReader.connect() + NSLog("SyncManager: Connected to iMessage database") } catch { - print("Failed to connect to iMessage: \(error)") + NSLog("SyncManager: Failed to connect to iMessage: \(error.localizedDescription)") return } @@ -52,12 +57,13 @@ class SyncManager: ObservableObject { } } - @MainActor private func performSync() async { + NSLog("SyncManager: performSync starting") isSyncing = true do { let conversations = try imessageReader.getConversations() + NSLog("SyncManager: Found \(conversations.count) conversations") var totalSynced = 0 for conversation in conversations { @@ -68,6 +74,8 @@ class SyncManager: ObservableObject { if messages.isEmpty { continue } + NSLog("SyncManager: Syncing \(messages.count) messages from '\(conversation.displayName)'") + let payload = SyncMessagesPayload( conversationImessageId: conversation.id, conversationDisplayName: conversation.displayName, @@ -90,17 +98,21 @@ class SyncManager: ObservableObject { let synced = try await apiClient.syncMessages(payload) totalSynced += synced + NSLog("SyncManager: Synced \(synced) messages") } stats.conversationCount = conversations.count stats.messageCount += totalSynced - lastSync = Date() - UserDefaults.standard.set(lastSync, forKey: "lastSync") + let newSyncTime = Date() + lastSync = newSyncTime + UserDefaults.standard.set(newSyncTime, forKey: "lastSync") + NSLog("SyncManager: Sync complete - \(totalSynced) messages synced, lastSync set to \(newSyncTime)") } catch { - print("Sync failed: \(error)") + NSLog("SyncManager: Sync failed: \(error.localizedDescription)") } isSyncing = false + NSLog("SyncManager: performSync finished, isSyncing = false") } } diff --git a/features/conversation-assistant/macos/Sources/Views/MenuBarViewModel.swift b/features/conversation-assistant/macos/Sources/Views/MenuBarViewModel.swift index af71c127c..68c41d0b6 100644 --- a/features/conversation-assistant/macos/Sources/Views/MenuBarViewModel.swift +++ b/features/conversation-assistant/macos/Sources/Views/MenuBarViewModel.swift @@ -10,13 +10,14 @@ class MenuBarViewModel: ObservableObject { @Published var lastSyncText = "Never" @Published var isSyncing = false - @Published var authCode = "" - @Published var isAuthenticating = false + @Published var registrationCode = "" + @Published var isRegistering = false @Published var authError: String? private let apiClient = APIClient.shared private let syncManager = SyncManager.shared private var cancellables = Set() + private var pollingTimer: Timer? init() { isAuthenticated = apiClient.isAuthenticated @@ -32,6 +33,7 @@ class MenuBarViewModel: ObservableObject { syncManager.$lastSync .receive(on: DispatchQueue.main) .sink { [weak self] date in + NSLog("MenuBarViewModel: received lastSync update: \(String(describing: date))") self?.updateLastSyncText(date) } .store(in: &cancellables) @@ -43,27 +45,59 @@ class MenuBarViewModel: ObservableObject { self?.conversationCount = stats.conversationCount } .store(in: &cancellables) + + // Register device on startup if not authenticated + if !isAuthenticated { + registerDevice() + } } - func authenticate() { - guard !authCode.isEmpty else { return } - - isAuthenticating = true + func registerDevice() { + isRegistering = true authError = nil Task { do { - try await apiClient.verifyDevice(code: authCode) - isAuthenticated = true - authCode = "" - syncManager.startSync() + let (_, code) = try await apiClient.registerDevice() + registrationCode = code + startPollingForVerification() } catch { authError = error.localizedDescription } - isAuthenticating = false + isRegistering = false } } + private func startPollingForVerification() { + // Poll every 3 seconds to check if device has been verified + pollingTimer?.invalidate() + pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + Task { @MainActor in + await self?.checkVerificationStatus() + } + } + } + + private func checkVerificationStatus() async { + do { + let verified = try await apiClient.checkVerification() + if verified { + pollingTimer?.invalidate() + pollingTimer = nil + isAuthenticated = true + registrationCode = "" + syncManager.startSync() + } + } catch { + // Silently continue polling + } + } + + func refreshCode() { + registrationCode = "" + registerDevice() + } + func triggerSync() { syncManager.syncNow() } @@ -74,12 +108,15 @@ class MenuBarViewModel: ObservableObject { private func updateLastSyncText(_ date: Date?) { guard let date = date else { + NSLog("MenuBarViewModel: updateLastSyncText - date is nil, setting to 'Never'") lastSyncText = "Never" return } let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .abbreviated - lastSyncText = formatter.localizedString(for: date, relativeTo: Date()) + let text = formatter.localizedString(for: date, relativeTo: Date()) + NSLog("MenuBarViewModel: updateLastSyncText - date: \(date), text: \(text)") + lastSyncText = text } } diff --git a/features/conversation-assistant/server/src/modules/sync/sync.dto.ts b/features/conversation-assistant/server/src/modules/sync/sync.dto.ts index 1153aa9f1..1a7147596 100644 --- a/features/conversation-assistant/server/src/modules/sync/sync.dto.ts +++ b/features/conversation-assistant/server/src/modules/sync/sync.dto.ts @@ -109,13 +109,13 @@ export class SyncMessagesDto { @IsNotEmpty() conversationImessageId!: string; - @ApiProperty({ - description: 'Display name for the conversation', + @ApiPropertyOptional({ + description: 'Display name for the conversation (derived from participants if empty)', example: 'John Doe', }) + @IsOptional() @IsString() - @IsNotEmpty() - conversationDisplayName!: string; + conversationDisplayName?: string; @ApiProperty({ description: 'Whether this is a group conversation', diff --git a/features/conversation-assistant/server/src/modules/sync/sync.service.ts b/features/conversation-assistant/server/src/modules/sync/sync.service.ts index f1203dc34..2cf10b144 100644 --- a/features/conversation-assistant/server/src/modules/sync/sync.service.ts +++ b/features/conversation-assistant/server/src/modules/sync/sync.service.ts @@ -20,6 +20,11 @@ export class SyncService { ) {} async syncMessages(deviceId: string, dto: SyncMessagesDto): Promise { + // Derive display name if not provided + const displayName = dto.conversationDisplayName?.trim() || + dto.participantIds.join(', ') || + 'Unknown Conversation'; + // Find or create conversation let conversation = await this.conversationRepository.findOne({ where: { deviceId, imessageId: dto.conversationImessageId }, @@ -29,7 +34,7 @@ export class SyncService { conversation = this.conversationRepository.create({ deviceId, imessageId: dto.conversationImessageId, - displayName: dto.conversationDisplayName, + displayName, isGroup: dto.isGroup, participantIds: dto.participantIds, messageCount: 0, @@ -38,11 +43,11 @@ export class SyncService { } else { // Only update if there are changes const hasChanges = - conversation.displayName !== dto.conversationDisplayName || + conversation.displayName !== displayName || JSON.stringify(conversation.participantIds) !== JSON.stringify(dto.participantIds); if (hasChanges) { - conversation.displayName = dto.conversationDisplayName; + conversation.displayName = displayName; conversation.participantIds = dto.participantIds; await this.conversationRepository.save(conversation); } @@ -61,10 +66,15 @@ export class SyncService { const sentAt = new Date(msg.sentAt); + // senderId from agent is a phone/email identifier, not a UUID + // Store the raw identifier in a metadata field, or look up/create contact later + // For now, set to null to avoid UUID constraint issues + const isValidUuid = msg.senderId && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(msg.senderId); + const message = this.messageRepository.create({ conversationId: conversation.id, imessageGuid: msg.imessageGuid, - senderId: msg.senderId, + senderId: isValidUuid ? msg.senderId : null, direction: msg.direction, messageType: msg.messageType, text: msg.text, diff --git a/features/conversation-assistant/server/tsconfig.build.json b/features/conversation-assistant/server/tsconfig.build.json index 0d3d264d2..f3858f378 100644 --- a/features/conversation-assistant/server/tsconfig.build.json +++ b/features/conversation-assistant/server/tsconfig.build.json @@ -1 +1 @@ -{"compilerOptions":{"outDir":"./dist","rootDir":"./src","target":"ES2021","module":"commonjs","emitDecoratorMetadata":true,"experimentalDecorators":true,"skipLibCheck":true,"esModuleInterop":true},"include":["src/**/*"]} +{"compilerOptions":{"outDir":"./dist","rootDir":"./src","target":"ES2021","module":"commonjs","emitDecoratorMetadata":true,"experimentalDecorators":true,"skipLibCheck":true,"esModuleInterop":true},"include":["src/**/*"],"exclude":["src/**/*.spec.ts","src/test/**/*"]}