File size: 8,816 Bytes
fc93158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
import Contacts
import Foundation
import OpenClawKit

final class ContactsService: ContactsServicing {
    private static var payloadKeys: [CNKeyDescriptor] {
        [
            CNContactIdentifierKey as CNKeyDescriptor,
            CNContactGivenNameKey as CNKeyDescriptor,
            CNContactFamilyNameKey as CNKeyDescriptor,
            CNContactOrganizationNameKey as CNKeyDescriptor,
            CNContactPhoneNumbersKey as CNKeyDescriptor,
            CNContactEmailAddressesKey as CNKeyDescriptor,
        ]
    }

    func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload {
        let store = try await Self.authorizedStore()

        let limit = max(1, min(params.limit ?? 25, 200))

        var contacts: [CNContact] = []
        if let query = params.query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty {
            let predicate = CNContact.predicateForContacts(matchingName: query)
            contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
        } else {
            let request = CNContactFetchRequest(keysToFetch: Self.payloadKeys)
            try store.enumerateContacts(with: request) { contact, stop in
                contacts.append(contact)
                if contacts.count >= limit {
                    stop.pointee = true
                }
            }
        }

        let sliced = Array(contacts.prefix(limit))
        let payload = sliced.map { Self.payload(from: $0) }

        return OpenClawContactsSearchPayload(contacts: payload)
    }

    func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload {
        let store = try await Self.authorizedStore()

        let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines)
        let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines)
        let organizationName = params.organizationName?.trimmingCharacters(in: .whitespacesAndNewlines)
        let displayName = params.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
        let phoneNumbers = Self.normalizeStrings(params.phoneNumbers)
        let emails = Self.normalizeStrings(params.emails, lowercased: true)

        let hasName = !(givenName ?? "").isEmpty || !(familyName ?? "").isEmpty || !(displayName ?? "").isEmpty
        let hasOrg = !(organizationName ?? "").isEmpty
        let hasDetails = !phoneNumbers.isEmpty || !emails.isEmpty
        guard hasName || hasOrg || hasDetails else {
            throw NSError(domain: "Contacts", code: 2, userInfo: [
                NSLocalizedDescriptionKey: "CONTACTS_INVALID: include a name, organization, phone, or email",
            ])
        }

        if !phoneNumbers.isEmpty || !emails.isEmpty {
            if let existing = try Self.findExistingContact(
                store: store,
                phoneNumbers: phoneNumbers,
                emails: emails)
            {
                return OpenClawContactsAddPayload(contact: Self.payload(from: existing))
            }
        }

        let contact = CNMutableContact()
        contact.givenName = givenName ?? ""
        contact.familyName = familyName ?? ""
        contact.organizationName = organizationName ?? ""
        if contact.givenName.isEmpty && contact.familyName.isEmpty, let displayName {
            contact.givenName = displayName
        }
        contact.phoneNumbers = phoneNumbers.map {
            CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0))
        }
        contact.emailAddresses = emails.map {
            CNLabeledValue(label: CNLabelHome, value: $0 as NSString)
        }

        let save = CNSaveRequest()
        save.add(contact, toContainerWithIdentifier: nil)
        try store.execute(save)

        let persisted: CNContact
        if !contact.identifier.isEmpty {
            persisted = try store.unifiedContact(
                withIdentifier: contact.identifier,
                keysToFetch: Self.payloadKeys)
        } else {
            persisted = contact
        }

        return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
    }

    private static func ensureAuthorization(store: CNContactStore, status: CNAuthorizationStatus) async -> Bool {
        switch status {
        case .authorized, .limited:
            return true
        case .notDetermined:
            // Don’t prompt during node.invoke; the caller should instruct the user to grant permission.
            // Prompts block the invoke and lead to timeouts in headless flows.
            return false
        case .restricted, .denied:
            return false
        @unknown default:
            return false
        }
    }

    private static func authorizedStore() async throws -> CNContactStore {
        let store = CNContactStore()
        let status = CNContactStore.authorizationStatus(for: .contacts)
        let authorized = await Self.ensureAuthorization(store: store, status: status)
        guard authorized else {
            throw NSError(domain: "Contacts", code: 1, userInfo: [
                NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
            ])
        }
        return store
    }

    private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
        (values ?? [])
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
            .map { lowercased ? $0.lowercased() : $0 }
    }

    private static func findExistingContact(
        store: CNContactStore,
        phoneNumbers: [String],
        emails: [String]) throws -> CNContact?
    {
        if phoneNumbers.isEmpty && emails.isEmpty {
            return nil
        }

        var matches: [CNContact] = []

        for phone in phoneNumbers {
            let predicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: phone))
            let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
            matches.append(contentsOf: contacts)
        }

        for email in emails {
            let predicate = CNContact.predicateForContacts(matchingEmailAddress: email)
            let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
            matches.append(contentsOf: contacts)
        }

        return Self.matchContacts(contacts: matches, phoneNumbers: phoneNumbers, emails: emails)
    }

    private static func matchContacts(
        contacts: [CNContact],
        phoneNumbers: [String],
        emails: [String]) -> CNContact?
    {
        let normalizedPhones = Set(phoneNumbers.map { normalizePhone($0) }.filter { !$0.isEmpty })
        let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty })
        var seen = Set<String>()

        for contact in contacts {
            guard seen.insert(contact.identifier).inserted else { continue }
            let contactPhones = Set(contact.phoneNumbers.map { normalizePhone($0.value.stringValue) })
            let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() })

            if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) {
                return contact
            }
            if !normalizedEmails.isEmpty, !contactEmails.isDisjoint(with: normalizedEmails) {
                return contact
            }
        }

        return nil
    }

    private static func normalizePhone(_ phone: String) -> String {
        let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
        let digits = trimmed.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) }
        let normalized = String(String.UnicodeScalarView(digits))
        return normalized.isEmpty ? trimmed : normalized
    }

    private static func payload(from contact: CNContact) -> OpenClawContactPayload {
        OpenClawContactPayload(
            identifier: contact.identifier,
            displayName: CNContactFormatter.string(from: contact, style: .fullName)
                ?? "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespacesAndNewlines),
            givenName: contact.givenName,
            familyName: contact.familyName,
            organizationName: contact.organizationName,
            phoneNumbers: contact.phoneNumbers.map { $0.value.stringValue },
            emails: contact.emailAddresses.map { String($0.value) })
    }

#if DEBUG
    static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
        matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
    }
#endif
}