Files
oai-swift/oAI/Services/ContactsService.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

249 lines
9.7 KiB
Swift

//
// ContactsService.swift
// oAI
//
// Read-only Contacts integration: search and "my card" lookup
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import Contacts
import Foundation
import os
@Observable
class ContactsService {
static let shared = ContactsService()
private let store = CNContactStore()
private let maxResults = 20
private let maxScan = 5000
private init() {}
// MARK: - Authorization
var authStatus: CNAuthorizationStatus {
CNContactStore.authorizationStatus(for: .contacts)
}
var authorized: Bool {
authStatus == .authorized
}
var accessState: PersonalDataAccessState {
let status = authStatus
Log.mcp.debug("ContactsService.accessState -> status=\(Self.describe(status)) (raw=\(status.rawValue))")
switch status {
case .authorized: return .granted
case .notDetermined: return .notDetermined
default: return .denied
}
}
@discardableResult
func requestAccess() async -> Bool {
let before = CNContactStore.authorizationStatus(for: .contacts)
Log.mcp.info("ContactsService.requestAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
return await withCheckedContinuation { continuation in
store.requestAccess(for: .contacts) { granted, error in
let after = CNContactStore.authorizationStatus(for: .contacts)
if let error {
Log.mcp.error("ContactsService.requestAccess: error=\(error.localizedDescription); granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
} else {
Log.mcp.info("ContactsService.requestAccess: granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
}
continuation.resume(returning: granted)
}
}
}
nonisolated static func describe(_ status: CNAuthorizationStatus) -> String {
switch status {
case .notDetermined: return "notDetermined"
case .restricted: return "restricted"
case .denied: return "denied"
case .authorized: return "authorized"
case .limited: return "limited"
@unknown default: return "unknown"
}
}
// MARK: - Tool Schemas
func getToolSchemas() -> [Tool] {
[
makeTool(
name: "contacts_search",
description: "Search Contacts by name, phone number, or email address. Returns matching contacts with their phone numbers and emails. This does NOT match relationship labels like 'mother' or 'spouse' — for those, call contacts_get_me first to find the related person's name, then search for that name.",
properties: [
"query": prop("string", "Name, phone number, or email fragment to search for")
],
required: ["query"]
),
makeTool(
name: "contacts_get_me",
description: "Get the user's own contact card (\"My Card\" in Contacts.app), if configured. Includes any defined relationships (e.g. mother, spouse, child) with the related person's name — use contacts_search with that name to find their phone/email.",
properties: [:],
required: []
)
]
}
// MARK: - Tool Execution
func executeTool(name: String, arguments: String) async -> [String: Any] {
Log.mcp.info("Executing Contacts tool: \(name)")
guard authorized else {
return ["error": "Contacts permission not granted. Grant access in Settings > MCP."]
}
switch name {
case "contacts_search":
guard let data = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let query = args["query"] as? String, !query.isEmpty else {
return ["error": "Missing required parameter: query"]
}
return search(query: query)
case "contacts_get_me":
return getMe()
default:
return ["error": "Unknown Contacts tool: \(name)"]
}
}
// MARK: - Implementation
private let keysToFetch: [CNKeyDescriptor] = [
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactOrganizationNameKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
CNContactRelationsKey as CNKeyDescriptor
]
private func search(query: String) -> [String: Any] {
var matches: [CNContact] = []
// Fast path: name predicate
let namePredicate = CNContact.predicateForContacts(matchingName: query)
if let nameMatches = try? store.unifiedContacts(matching: namePredicate, keysToFetch: keysToFetch) {
matches.append(contentsOf: nameMatches)
}
// Fallback: scan for phone/email substring matches
if matches.isEmpty {
let lowerQuery = query.lowercased()
let digitsQuery = query.filter(\.isNumber)
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
var scanned = 0
try? store.enumerateContacts(with: request) { contact, stop in
scanned += 1
if scanned > self.maxScan || matches.count >= self.maxResults {
stop.pointee = true
return
}
let emailMatch = contact.emailAddresses.contains {
($0.value as String).lowercased().contains(lowerQuery)
}
let phoneMatch = !digitsQuery.isEmpty && contact.phoneNumbers.contains {
$0.value.stringValue.filter(\.isNumber).contains(digitsQuery)
}
if emailMatch || phoneMatch {
matches.append(contact)
}
}
}
let deduped = dedupContacts(matches)
let formatted = deduped.prefix(maxResults).map(formatContact)
return ["count": formatted.count, "contacts": Array(formatted)]
}
/// Collapses contacts that share a phone number or email — Contacts.app's "linked contacts"
/// merge doesn't catch every real-world duplicate card, so do a best-effort merge here too.
private func dedupContacts(_ contacts: [CNContact]) -> [CNContact] {
var result: [CNContact] = []
outer: for contact in contacts {
let phones = Set(contact.phoneNumbers.map { $0.value.stringValue.filter(\.isNumber) })
let emails = Set(contact.emailAddresses.map { ($0.value as String).lowercased() })
for existing in result {
let existingPhones = Set(existing.phoneNumbers.map { $0.value.stringValue.filter(\.isNumber) })
let existingEmails = Set(existing.emailAddresses.map { ($0.value as String).lowercased() })
if !phones.isDisjoint(with: existingPhones) || !emails.isDisjoint(with: existingEmails) {
continue outer
}
}
result.append(contact)
}
return result
}
private func getMe() -> [String: Any] {
guard let me = try? store.unifiedMeContactWithKeys(toFetch: keysToFetch) else {
return ["error": "No 'My Card' is configured in Contacts.app"]
}
return formatContact(me)
}
private func formatContact(_ contact: CNContact) -> [String: Any] {
var item: [String: Any] = [
"given_name": contact.givenName,
"family_name": contact.familyName
]
if !contact.organizationName.isEmpty {
item["organization"] = contact.organizationName
}
if !contact.phoneNumbers.isEmpty {
item["phones"] = contact.phoneNumbers.map { $0.value.stringValue }
}
if !contact.emailAddresses.isEmpty {
item["emails"] = contact.emailAddresses.map { $0.value as String }
}
if !contact.contactRelations.isEmpty {
item["relations"] = contact.contactRelations.map { labeled -> [String: String] in
let label = labeled.label.map { CNLabeledValue<CNContactRelation>.localizedString(forLabel: $0) } ?? "relation"
return ["label": label, "name": labeled.value.name]
}
}
return item
}
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
Tool(
type: "function",
function: Tool.Function(
name: name,
description: description,
parameters: Tool.Function.Parameters(
type: "object",
properties: properties,
required: required
)
)
)
}
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
}
}