40c5f25517
Personal Data Tools: native Calendar, Reminders, Contacts (hidden pending Apple TCC fix in beta), and Location & Maps access via EventKit, Contacts framework, and MapKit. Write actions (create event/reminder, complete reminder) gate through an approval sheet. Four hardened-runtime entitlements added to oAI.entitlements; Info.plist usage strings added for all services. Personal Data section shows a β badge while Contacts is hidden. 2nd Brain always-trust: inline toggle on the Agent Skills row for the skill named "2nd Brain" skips the bash approval dialog when the command contains .brain_helper.py, gated by three runtime checks in MCPService. Research agents: spawn_research_agents tool runs up to 5 concurrent read-only sub-agents (read_file, list_directory, search_files, web_search — no write, no bash, no nesting). Bounded by maxConcurrentAgents setting (default 3) and a hard ceiling of 8 tasks. Added items field to Tool.Function.Parameters.Property for JSON Schema array support; wired into AnthropicProvider.convertParametersToDict. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
251 lines
9.8 KiB
Swift
251 lines
9.8 KiB
Swift
//
|
|
// ContactsService.swift
|
|
// oAI
|
|
//
|
|
// Read-only Contacts integration: search and "my card" lookup
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of oAI.
|
|
//
|
|
// oAI is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
|
// Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public
|
|
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
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)
|
|
}
|
|
}
|