Switches the project from AGPL to a source-available license that restricts commercial use — selling oAI or any part of it, standalone or bundled into another product/service, now requires a separate commercial license from the copyright holder. Noncommercial use, study, modification, and sharing remain fully permitted. Updates: LICENSE (canonical PolyForm Noncommercial 1.0.0 text + commercial licensing contact note), SPDX headers and file-header boilerplate across all Swift source files, the in-app About dialog's license link (+ its localization catalog entry), README.md and DEVELOPMENT.md license sections. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
249 lines
9.7 KiB
Swift
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://mac.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)
|
|
}
|
|
}
|