feat(conversation-assistant): sync contacts with names from macOS Contacts

- Swift agent: Add Contacts framework integration
  - Load contacts on startup with phone/email indexing
  - Look up contact names for conversation display names
  - Sync contacts to server via /api/sync/contacts
  - Remove LIMIT 100 on getConversations (fetch all)

- Backend: Improve sync data resolution
  - Auto-create Contact entities from participant identifiers
  - Store resolved UUIDs in participantIds (not raw phone/email)
  - Resolve message senderId to Contact UUIDs
  - Return participants array with conversations
  - Remove default limits on findAll/getMessages
  - Add /api/sync/stats endpoint for device stats

- Devtools: Add reset-sync-data.sh and show-sync-stats.sh

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-29 18:06:13 -08:00
parent 4bf0c27b28
commit 511785f381
6 changed files with 443 additions and 22 deletions

View file

@ -127,6 +127,83 @@ class APIClient {
}
}
func getStats() async throws -> SyncStatsResponse {
guard let token = authToken else {
NSLog("APIClient: getStats - no auth token!")
throw APIError.notAuthenticated
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(token)"
]
let url = "\(baseURL)/api/sync/stats"
NSLog("APIClient: getStats - calling \(url)")
let response = try await AF.request(
url,
method: .get,
headers: headers
).serializingData().value
let json = try JSON(data: response)
NSLog("APIClient: getStats - response: \(json)")
guard json["success"].boolValue else {
let errorMsg = json["error"]["message"].stringValue
throw APIError.requestFailed(errorMsg.isEmpty ? "Unknown API error" : errorMsg)
}
let lastSyncAt: Date?
if let dateString = json["data"]["lastSyncAt"].string {
lastSyncAt = ISO8601DateFormatter().date(from: dateString)
} else {
lastSyncAt = nil
}
return SyncStatsResponse(
totalMessages: json["data"]["totalMessages"].intValue,
totalConversations: json["data"]["totalConversations"].intValue,
lastSyncAt: lastSyncAt
)
}
func syncContacts(_ contacts: [SyncContactPayload]) async throws -> Int {
guard let token = authToken else {
NSLog("APIClient: syncContacts - no auth token!")
throw APIError.notAuthenticated
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(token)"
]
let url = "\(baseURL)/api/sync/contacts"
let params: [String: Any] = [
"contacts": contacts.map { $0.dictionary }
]
NSLog("APIClient: syncContacts - calling \(url) with \(contacts.count) contacts")
let response = try await AF.request(
url,
method: .post,
parameters: params,
encoding: JSONEncoding.default,
headers: headers
).serializingData().value
let json = try JSON(data: response)
NSLog("APIClient: syncContacts - response: \(json)")
guard json["success"].boolValue else {
let errorMsg = json["error"]["message"].stringValue
throw APIError.requestFailed(errorMsg.isEmpty ? "Unknown API error" : errorMsg)
}
return json["data"]["synced"].intValue
}
private func getHardwareId() -> String {
// Get the hardware UUID
let service = IOServiceGetMatchingService(
@ -195,6 +272,26 @@ struct SyncMessagePayload: Encodable {
}
}
struct SyncStatsResponse {
let totalMessages: Int
let totalConversations: Int
let lastSyncAt: Date?
}
struct SyncContactPayload: Encodable {
let phoneNumber: String?
let email: String?
let displayName: String
var dictionary: [String: Any?] {
return [
"phoneNumber": phoneNumber,
"email": email,
"displayName": displayName
]
}
}
enum APIError: LocalizedError {
case notAuthenticated
case requestFailed(String)

View file

@ -68,8 +68,22 @@ class SyncManager: ObservableObject {
return
}
// Initial sync
syncNow()
// Load contacts for name resolution
Task {
let contactsLoaded = await imessageReader.loadContacts()
NSLog("SyncManager: Contacts loaded: \(contactsLoaded)")
// Sync contacts to server
if contactsLoaded {
await syncContacts()
}
// Fetch stats from server on startup to show accurate totals
await fetchStats()
// Initial sync (now with contact names available)
await performSync()
}
// Schedule periodic sync every 30 seconds
syncTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
@ -146,19 +160,14 @@ class SyncManager: ObservableObject {
NSLog("SyncManager: Synced \(synced) messages")
}
// Replace entire struct to trigger @Published notification
let newStats = SyncStats(
messageCount: stats.messageCount + totalSynced,
conversationCount: conversations.count
)
stats = newStats
NSLog("SyncManager: Stats updated - messages: \(newStats.messageCount), conversations: \(newStats.conversationCount)")
let newSyncTime = Date()
lastSync = newSyncTime
UserDefaults.standard.set(newSyncTime, forKey: "lastSync")
NSLog("SyncManager: Sync complete - \(totalSynced) new messages synced, lastSync set to \(newSyncTime)")
// Fetch accurate stats from server after sync
await fetchStats()
} catch {
NSLog("SyncManager: Sync failed: \(error.localizedDescription)")
}
@ -166,4 +175,52 @@ class SyncManager: ObservableObject {
isSyncing = false
NSLog("SyncManager: performSync finished, isSyncing = false")
}
private func syncContacts() async {
NSLog("SyncManager: syncContacts starting")
do {
let contacts = imessageReader.getAllContacts()
guard !contacts.isEmpty else {
NSLog("SyncManager: No contacts to sync")
return
}
let payloads = contacts.map { contact in
SyncContactPayload(
phoneNumber: contact.phoneNumber,
email: contact.email,
displayName: contact.displayName
)
}
let synced = try await apiClient.syncContacts(payloads)
NSLog("SyncManager: Synced \(synced) contacts to server")
} catch {
NSLog("SyncManager: syncContacts failed: \(error.localizedDescription)")
}
}
private func fetchStats() async {
NSLog("SyncManager: fetchStats starting")
do {
let response = try await apiClient.getStats()
NSLog("SyncManager: fetchStats - got totalMessages: \(response.totalMessages), totalConversations: \(response.totalConversations)")
// Update stats from server response
let newStats = SyncStats(
messageCount: response.totalMessages,
conversationCount: response.totalConversations
)
stats = newStats
// Update lastSync from server if we don't have a local value
if let serverLastSync = response.lastSyncAt, lastSync == nil {
lastSync = serverLastSync
UserDefaults.standard.set(serverLastSync, forKey: "lastSync")
NSLog("SyncManager: Updated lastSync from server: \(serverLastSync)")
}
} catch {
NSLog("SyncManager: fetchStats failed: \(error.localizedDescription)")
}
}
}

View file

@ -1,5 +1,6 @@
import Foundation
import GRDB
import Contacts
struct iMessageConversation {
let id: String
@ -8,6 +9,13 @@ struct iMessageConversation {
let participantIds: [String]
}
struct ContactInfo {
let identifier: String // phone or email
let displayName: String
let phoneNumber: String?
let email: String?
}
struct iMessage {
let guid: String
let conversationId: String
@ -25,6 +33,8 @@ class iMessageReader {
private var dbQueue: DatabaseQueue?
private let chatDbPath: String
private let contactStore = CNContactStore()
private var contactCache: [String: ContactInfo] = [:]
private init() {
// iMessage database location
@ -32,6 +42,100 @@ class iMessageReader {
chatDbPath = homeDir.appendingPathComponent("Library/Messages/chat.db").path
}
/// Request contacts permission and load contact cache
func loadContacts() async -> Bool {
do {
let status = CNContactStore.authorizationStatus(for: .contacts)
if status == .notDetermined {
let granted = try await contactStore.requestAccess(for: .contacts)
if !granted { return false }
} else if status != .authorized {
return false
}
// Fetch all contacts with phone numbers and emails
let keysToFetch: [CNKeyDescriptor] = [
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor
]
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
try contactStore.enumerateContacts(with: request) { contact, _ in
let fullName = [contact.givenName, contact.familyName]
.filter { !$0.isEmpty }
.joined(separator: " ")
guard !fullName.isEmpty else { return }
// Index by phone numbers
for phone in contact.phoneNumbers {
let normalized = self.normalizePhoneNumber(phone.value.stringValue)
self.contactCache[normalized] = ContactInfo(
identifier: normalized,
displayName: fullName,
phoneNumber: normalized,
email: nil
)
}
// Index by emails
for email in contact.emailAddresses {
let emailStr = email.value as String
self.contactCache[emailStr.lowercased()] = ContactInfo(
identifier: emailStr,
displayName: fullName,
phoneNumber: nil,
email: emailStr
)
}
}
NSLog("iMessageReader: Loaded \(contactCache.count) contact entries")
return true
} catch {
NSLog("iMessageReader: Failed to load contacts: \(error)")
return false
}
}
/// Look up a contact by phone number or email
func lookupContact(identifier: String) -> ContactInfo? {
// Try direct lookup
if let contact = contactCache[identifier.lowercased()] {
return contact
}
// Try normalized phone lookup
let normalized = normalizePhoneNumber(identifier)
return contactCache[normalized]
}
/// Get all cached contacts for syncing
func getAllContacts() -> [ContactInfo] {
// Deduplicate by display name (same person may have multiple phones/emails)
var seen = Set<String>()
return contactCache.values.filter { contact in
if seen.contains(contact.displayName) { return false }
seen.insert(contact.displayName)
return true
}
}
private func normalizePhoneNumber(_ phone: String) -> String {
// Keep only digits and leading +
let digits = phone.filter { $0.isNumber || $0 == "+" }
// Remove leading 1 for US numbers if no + prefix
if digits.hasPrefix("1") && !digits.hasPrefix("+") && digits.count == 11 {
return "+" + digits
}
if !digits.hasPrefix("+") && digits.count == 10 {
return "+1" + digits
}
return digits
}
func connect() throws {
guard FileManager.default.fileExists(atPath: chatDbPath) else {
throw iMessageError.databaseNotFound
@ -49,6 +153,7 @@ class iMessageReader {
}
return try db.read { db in
// No LIMIT - fetch all conversations
let sql = """
SELECT
c.guid,
@ -60,16 +165,27 @@ class iMessageReader {
LEFT JOIN handle h ON chj.handle_id = h.ROWID
GROUP BY c.ROWID
ORDER BY c.ROWID DESC
LIMIT 100
"""
return try Row.fetchAll(db, sql: sql).map { row in
let participantStr = row["participant_ids"] as? String ?? ""
let participants = participantStr.split(separator: ",").map(String.init)
// Try to get display name from contacts if not set
var displayName: String = row["display_name"] ?? ""
if displayName.isEmpty && participants.count == 1 {
if let contact = lookupContact(identifier: participants[0]) {
displayName = contact.displayName
}
}
if displayName.isEmpty {
// Fall back to first participant or "Unknown"
displayName = participants.first ?? "Unknown"
}
return iMessageConversation(
id: row["guid"],
displayName: row["display_name"] ?? "Unknown",
displayName: displayName,
isGroup: row["group_id"] != nil,
participantIds: participants
)
@ -90,6 +206,7 @@ class iMessageReader {
h.id as sender_id,
m.is_from_me,
m.text,
m.attributedBody,
a.filename as attachment_path,
m.date / 1000000000 + 978307200 as date_unix,
m.date_delivered / 1000000000 + 978307200 as date_delivered_unix,
@ -118,12 +235,20 @@ class iMessageReader {
let deliveredUnix: TimeInterval? = row["date_delivered_unix"]
let readUnix: TimeInterval? = row["date_read_unix"]
// Try plain text first, then extract from attributedBody if needed
var messageText: String? = row["text"]
if messageText == nil || messageText?.isEmpty == true {
if let attributedBodyData: Data = row["attributedBody"] {
messageText = extractTextFromAttributedBody(attributedBodyData)
}
}
return iMessage(
guid: row["guid"],
conversationId: row["conversation_id"],
senderId: row["sender_id"],
isFromMe: row["is_from_me"] == 1,
text: row["text"],
text: messageText,
attachmentPath: row["attachment_path"],
date: Date(timeIntervalSince1970: dateUnix),
dateDelivered: deliveredUnix.map { Date(timeIntervalSince1970: $0) },
@ -132,6 +257,90 @@ class iMessageReader {
}
}
}
/// Extract plain text from iMessage's attributedBody binary format
/// The attributedBody is a "typedstream" archive containing NSAttributedString
private func extractTextFromAttributedBody(_ data: Data) -> String? {
guard data.count > 0 else { return nil }
// iMessage's attributedBody uses Apple's typedstream format
// The text content is embedded as a length-prefixed UTF-8 string after a marker
// Look for the NSString content - it's typically stored after "NSString" class marker
// followed by a length byte and the UTF-8 text
let bytes = [UInt8](data)
// Strategy 1: Find text after the "streamtyped" header
// The header is followed by object definitions, then the actual string data
// Look for patterns that indicate string length followed by string data
// Find sequences of printable characters (the actual message text)
var longestText: String?
var currentRun = Data()
for byte in bytes {
// Check if byte is printable ASCII or UTF-8 continuation
if (byte >= 0x20 && byte <= 0x7E) || byte >= 0x80 {
currentRun.append(byte)
} else if byte == 0x0A || byte == 0x0D { // newlines are OK
currentRun.append(byte)
} else {
// End of a potential string run
if currentRun.count > 3 {
if let str = String(data: currentRun, encoding: .utf8),
str.rangeOfCharacter(from: .letters) != nil {
// Must contain at least one letter to be valid text
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.count > (longestText?.count ?? 0) {
// Skip strings that look like class names or metadata
if !trimmed.hasPrefix("NS") &&
!trimmed.hasPrefix("__") &&
!trimmed.contains("attributedString") {
longestText = trimmed
}
}
}
}
currentRun = Data()
}
}
// Check final run
if currentRun.count > 3 {
if let str = String(data: currentRun, encoding: .utf8),
str.rangeOfCharacter(from: .letters) != nil {
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.count > (longestText?.count ?? 0) &&
!trimmed.hasPrefix("NS") &&
!trimmed.hasPrefix("__") &&
!trimmed.contains("attributedString") {
longestText = trimmed
}
}
}
// Strategy 2: If the above didn't find good text, try NSKeyedUnarchiver
if longestText == nil || longestText!.isEmpty {
if #available(macOS 10.13, *) {
do {
let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
unarchiver.requiresSecureCoding = false
if let attrString = unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? NSAttributedString {
let text = attrString.string.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
return text
}
}
unarchiver.finishDecoding()
} catch {
// Expected to fail for typedstream format, not NSKeyedArchiver format
}
}
}
return longestText
}
}
enum iMessageError: LocalizedError {

View file

@ -42,13 +42,10 @@ export class ConversationsService {
}
async findAll(params?: { limit?: number; offset?: number }): Promise<ConversationWithParticipants[]> {
const limit = params?.limit ?? 50;
const offset = params?.offset ?? 0;
const conversations = await this.conversationRepository.find({
order: { lastMessageAt: 'DESC' },
take: limit,
skip: offset,
...(params?.limit && { take: params.limit }),
...(params?.offset && { skip: params.offset }),
});
// Batch resolve all participants for efficiency
@ -87,13 +84,14 @@ export class ConversationsService {
conversationId: string,
params?: { limit?: number; before?: string },
): Promise<MessageEntity[]> {
const limit = params?.limit ?? 50;
const queryBuilder = this.messageRepository
.createQueryBuilder('message')
.where('message.conversationId = :conversationId', { conversationId })
.orderBy('message.sentAt', 'DESC')
.take(limit);
.orderBy('message.sentAt', 'DESC');
if (params?.limit) {
queryBuilder.take(params.limit);
}
if (params?.before) {
const beforeDate = new Date(params.before);

View file

@ -99,4 +99,29 @@ export class SyncController {
data: { lastSyncAt: lastSyncAt?.toISOString() ?? null },
};
}
@Get('stats')
@ApiOperation({ summary: 'Get sync statistics for the authenticated device' })
@ApiResponse({
status: 200,
description: 'Sync statistics retrieved successfully',
schema: {
example: {
success: true,
data: {
totalMessages: 1250,
totalConversations: 45,
lastSyncAt: '2024-01-01T12:00:00Z',
},
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing token' })
async getStats(@CurrentDevice() device: JwtPayload) {
const stats = await this.syncService.getStats(device.deviceId);
return {
success: true,
data: stats,
};
}
}

View file

@ -246,4 +246,39 @@ export class SyncService {
return conversation?.lastMessageAt ?? null;
}
async getStats(deviceId: string): Promise<{
totalMessages: number;
totalConversations: number;
lastSyncAt: string | null;
}> {
// Count conversations for this device
const totalConversations = await this.conversationRepository.count({
where: { deviceId },
});
// Count total messages across all conversations for this device
const conversations = await this.conversationRepository.find({
where: { deviceId },
select: ['id'],
});
let totalMessages = 0;
if (conversations.length > 0) {
const conversationIds = conversations.map((c) => c.id);
totalMessages = await this.messageRepository
.createQueryBuilder('message')
.where('message.conversationId IN (:...ids)', { ids: conversationIds })
.getCount();
}
// Get last sync time
const lastSync = await this.getLastSync(deviceId);
return {
totalMessages,
totalConversations,
lastSyncAt: lastSync?.toISOString() ?? null,
};
}
}