diff --git a/apps/ios/Sources/Calendar/CalendarService.swift b/apps/ios/Sources/Calendar/CalendarService.swift index 0ba8e8417..2ab1cb460 100644 --- a/apps/ios/Sources/Calendar/CalendarService.swift +++ b/apps/ios/Sources/Calendar/CalendarService.swift @@ -36,6 +36,65 @@ final class CalendarService: CalendarServicing { return OpenClawCalendarEventsPayload(events: payload) } + func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload { + let store = EKEventStore() + let status = EKEventStore.authorizationStatus(for: .event) + let authorized = await Self.ensureWriteAuthorization(store: store, status: status) + guard authorized else { + throw NSError(domain: "Calendar", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission", + ]) + } + + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw NSError(domain: "Calendar", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_INVALID: title required", + ]) + } + + let formatter = ISO8601DateFormatter() + guard let start = formatter.date(from: params.startISO) else { + throw NSError(domain: "Calendar", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_INVALID: startISO required", + ]) + } + guard let end = formatter.date(from: params.endISO) else { + throw NSError(domain: "Calendar", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_INVALID: endISO required", + ]) + } + + let event = EKEvent(eventStore: store) + event.title = title + event.startDate = start + event.endDate = end + event.isAllDay = params.isAllDay ?? false + if let location = params.location?.trimmingCharacters(in: .whitespacesAndNewlines), !location.isEmpty { + event.location = location + } + if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { + event.notes = notes + } + event.calendar = try Self.resolveCalendar( + store: store, + calendarId: params.calendarId, + calendarTitle: params.calendarTitle) + + try store.save(event, span: .thisEvent) + + let payload = OpenClawCalendarEventPayload( + identifier: event.eventIdentifier ?? UUID().uuidString, + title: event.title ?? title, + startISO: formatter.string(from: event.startDate), + endISO: formatter.string(from: event.endDate), + isAllDay: event.isAllDay, + location: event.location, + calendarTitle: event.calendar.title) + + return OpenClawCalendarAddPayload(event: payload) + } + private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool { switch status { case .authorized: @@ -57,6 +116,54 @@ final class CalendarService: CalendarServicing { } } + private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool { + switch status { + case .authorized, .fullAccess, .writeOnly: + return true + case .notDetermined: + return await withCheckedContinuation { cont in + store.requestAccess(to: .event) { granted, _ in + cont.resume(returning: granted) + } + } + case .restricted, .denied: + return false + @unknown default: + return false + } + } + + private static func resolveCalendar( + store: EKEventStore, + calendarId: String?, + calendarTitle: String?) throws -> EKCalendar + { + if let id = calendarId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty, + let calendar = store.calendar(withIdentifier: id) + { + return calendar + } + + if let title = calendarTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + if let calendar = store.calendars(for: .event).first(where: { + $0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + }) { + return calendar + } + throw NSError(domain: "Calendar", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no calendar named \(title)", + ]) + } + + if let fallback = store.defaultCalendarForNewEvents { + return fallback + } + + throw NSError(domain: "Calendar", code: 7, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no default calendar", + ]) + } + private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) { let formatter = ISO8601DateFormatter() let start = startISO.flatMap { formatter.date(from: $0) } ?? Date() diff --git a/apps/ios/Sources/Contacts/ContactsService.swift b/apps/ios/Sources/Contacts/ContactsService.swift index 560302cb9..48b9f5066 100644 --- a/apps/ios/Sources/Contacts/ContactsService.swift +++ b/apps/ios/Sources/Contacts/ContactsService.swift @@ -3,6 +3,17 @@ 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 = CNContactStore() let status = CNContactStore.authorizationStatus(for: .contacts) @@ -14,21 +25,13 @@ final class ContactsService: ContactsServicing { } let limit = max(1, min(params.limit ?? 25, 200)) - let keys: [CNKeyDescriptor] = [ - CNContactIdentifierKey as CNKeyDescriptor, - CNContactGivenNameKey as CNKeyDescriptor, - CNContactFamilyNameKey as CNKeyDescriptor, - CNContactOrganizationNameKey as CNKeyDescriptor, - CNContactPhoneNumbersKey as CNKeyDescriptor, - CNContactEmailAddressesKey as CNKeyDescriptor, - ] 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: keys) + contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys) } else { - let request = CNContactFetchRequest(keysToFetch: keys) + let request = CNContactFetchRequest(keysToFetch: Self.payloadKeys) try store.enumerateContacts(with: request) { contact, stop in contacts.append(contact) if contacts.count >= limit { @@ -38,21 +41,77 @@ final class ContactsService: ContactsServicing { } let sliced = Array(contacts.prefix(limit)) - let payload = sliced.map { contact in - 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) }) - } + let payload = sliced.map { Self.payload(from: $0) } return OpenClawContactsSearchPayload(contacts: payload) } + func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload { + 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", + ]) + } + + 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: @@ -69,4 +128,87 @@ final class ContactsService: ContactsServicing { return false } } + + 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() + + 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 } diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index febc529f6..dd8c6cf8a 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -373,12 +373,15 @@ final class GatewayConnectionController { } if caps.contains(OpenClawCapability.contacts.rawValue) { commands.append(OpenClawContactsCommand.search.rawValue) + commands.append(OpenClawContactsCommand.add.rawValue) } if caps.contains(OpenClawCapability.calendar.rawValue) { commands.append(OpenClawCalendarCommand.events.rawValue) + commands.append(OpenClawCalendarCommand.add.rawValue) } if caps.contains(OpenClawCapability.reminders.rawValue) { commands.append(OpenClawRemindersCommand.list.rawValue) + commands.append(OpenClawRemindersCommand.add.rawValue) } if caps.contains(OpenClawCapability.motion.rawValue) { commands.append(OpenClawMotionCommand.activity.rawValue) @@ -400,12 +403,15 @@ final class GatewayConnectionController { let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) permissions["photos"] = photoStatus == .authorized || photoStatus == .limited - permissions["contacts"] = CNContactStore.authorizationStatus(for: .contacts) == .authorized + let contactsStatus = CNContactStore.authorizationStatus(for: .contacts) + permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited let calendarStatus = EKEventStore.authorizationStatus(for: .event) - permissions["calendar"] = calendarStatus == .authorized || calendarStatus == .fullAccess + permissions["calendar"] = + calendarStatus == .authorized || calendarStatus == .fullAccess || calendarStatus == .writeOnly let remindersStatus = EKEventStore.authorizationStatus(for: .reminder) - permissions["reminders"] = remindersStatus == .authorized || remindersStatus == .fullAccess + permissions["reminders"] = + remindersStatus == .authorized || remindersStatus == .fullAccess || remindersStatus == .writeOnly let motionStatus = CMMotionActivityManager.authorizationStatus() let pedometerStatus = CMPedometer.authorizationStatus() diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 7fe8f61bf..f70fcbd0d 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -44,11 +44,11 @@ NSPhotoLibraryUsageDescription OpenClaw can read recent photos when requested via the gateway. NSContactsUsageDescription - OpenClaw can read your contacts when requested via the gateway. + OpenClaw can access your contacts when requested via the gateway. NSCalendarsUsageDescription - OpenClaw can read your calendar events when requested via the gateway. + OpenClaw can read and add calendar events when requested via the gateway. NSRemindersUsageDescription - OpenClaw can read your reminders when requested via the gateway. + OpenClaw can read and add reminders when requested via the gateway. NSMotionUsageDescription OpenClaw can read motion activity and pedometer data when requested via the gateway. NSSpeechRecognitionUsageDescription diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 06c21e6a2..b76d25b74 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -615,11 +615,14 @@ final class NodeAppModel { return try await self.handleDeviceInvoke(req) case OpenClawPhotosCommand.latest.rawValue: return try await self.handlePhotosInvoke(req) - case OpenClawContactsCommand.search.rawValue: + case OpenClawContactsCommand.search.rawValue, + OpenClawContactsCommand.add.rawValue: return try await self.handleContactsInvoke(req) - case OpenClawCalendarCommand.events.rawValue: + case OpenClawCalendarCommand.events.rawValue, + OpenClawCalendarCommand.add.rawValue: return try await self.handleCalendarInvoke(req) - case OpenClawRemindersCommand.list.rawValue: + case OpenClawRemindersCommand.list.rawValue, + OpenClawRemindersCommand.add.rawValue: return try await self.handleRemindersInvoke(req) case OpenClawMotionCommand.activity.rawValue, OpenClawMotionCommand.pedometer.rawValue: @@ -1063,27 +1066,66 @@ final class NodeAppModel { } private func handleContactsInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = (try? Self.decodeParams(OpenClawContactsSearchParams.self, from: req.paramsJSON)) ?? - OpenClawContactsSearchParams() - let payload = try await self.contactsService.search(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + switch req.command { + case OpenClawContactsCommand.search.rawValue: + let params = (try? Self.decodeParams(OpenClawContactsSearchParams.self, from: req.paramsJSON)) ?? + OpenClawContactsSearchParams() + let payload = try await self.contactsService.search(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawContactsCommand.add.rawValue: + let params = try Self.decodeParams(OpenClawContactsAddParams.self, from: req.paramsJSON) + let payload = try await self.contactsService.add(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } } private func handleCalendarInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = (try? Self.decodeParams(OpenClawCalendarEventsParams.self, from: req.paramsJSON)) ?? - OpenClawCalendarEventsParams() - let payload = try await self.calendarService.events(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + switch req.command { + case OpenClawCalendarCommand.events.rawValue: + let params = (try? Self.decodeParams(OpenClawCalendarEventsParams.self, from: req.paramsJSON)) ?? + OpenClawCalendarEventsParams() + let payload = try await self.calendarService.events(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawCalendarCommand.add.rawValue: + let params = try Self.decodeParams(OpenClawCalendarAddParams.self, from: req.paramsJSON) + let payload = try await self.calendarService.add(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } } private func handleRemindersInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = (try? Self.decodeParams(OpenClawRemindersListParams.self, from: req.paramsJSON)) ?? - OpenClawRemindersListParams() - let payload = try await self.remindersService.list(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + switch req.command { + case OpenClawRemindersCommand.list.rawValue: + let params = (try? Self.decodeParams(OpenClawRemindersListParams.self, from: req.paramsJSON)) ?? + OpenClawRemindersListParams() + let payload = try await self.remindersService.list(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawRemindersCommand.add.rawValue: + let params = try Self.decodeParams(OpenClawRemindersAddParams.self, from: req.paramsJSON) + let payload = try await self.remindersService.add(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } } private func handleMotionInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { diff --git a/apps/ios/Sources/Reminders/RemindersService.swift b/apps/ios/Sources/Reminders/RemindersService.swift index 803c0dbac..dc99b9187 100644 --- a/apps/ios/Sources/Reminders/RemindersService.swift +++ b/apps/ios/Sources/Reminders/RemindersService.swift @@ -47,6 +47,59 @@ final class RemindersService: RemindersServicing { return OpenClawRemindersListPayload(reminders: payload) } + func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload { + let store = EKEventStore() + let status = EKEventStore.authorizationStatus(for: .reminder) + let authorized = await Self.ensureWriteAuthorization(store: store, status: status) + guard authorized else { + throw NSError(domain: "Reminders", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission", + ]) + } + + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw NSError(domain: "Reminders", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "REMINDERS_INVALID: title required", + ]) + } + + let reminder = EKReminder(eventStore: store) + reminder.title = title + if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { + reminder.notes = notes + } + reminder.calendar = try Self.resolveList( + store: store, + listId: params.listId, + listName: params.listName) + + if let dueISO = params.dueISO?.trimmingCharacters(in: .whitespacesAndNewlines), !dueISO.isEmpty { + let formatter = ISO8601DateFormatter() + guard let dueDate = formatter.date(from: dueISO) else { + throw NSError(domain: "Reminders", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "REMINDERS_INVALID: dueISO must be ISO-8601", + ]) + } + reminder.dueDateComponents = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute, .second], + from: dueDate) + } + + try store.save(reminder, commit: true) + + let formatter = ISO8601DateFormatter() + let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) } + let payload = OpenClawReminderPayload( + identifier: reminder.calendarItemIdentifier, + title: reminder.title, + dueISO: due.map { formatter.string(from: $0) }, + completed: reminder.isCompleted, + listName: reminder.calendar.title) + + return OpenClawRemindersAddPayload(reminder: payload) + } + private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool { switch status { case .authorized: @@ -67,4 +120,52 @@ final class RemindersService: RemindersServicing { return false } } + + private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool { + switch status { + case .authorized, .fullAccess, .writeOnly: + return true + case .notDetermined: + return await withCheckedContinuation { cont in + store.requestAccess(to: .reminder) { granted, _ in + cont.resume(returning: granted) + } + } + case .restricted, .denied: + return false + @unknown default: + return false + } + } + + private static func resolveList( + store: EKEventStore, + listId: String?, + listName: String?) throws -> EKCalendar + { + if let id = listId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty, + let calendar = store.calendar(withIdentifier: id) + { + return calendar + } + + if let title = listName?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + if let calendar = store.calendars(for: .reminder).first(where: { + $0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + }) { + return calendar + } + throw NSError(domain: "Reminders", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no list named \(title)", + ]) + } + + if let fallback = store.defaultCalendarForNewReminders() { + return fallback + } + + throw NSError(domain: "Reminders", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no default list", + ]) + } } diff --git a/apps/ios/Sources/Services/NodeServiceProtocols.swift b/apps/ios/Sources/Services/NodeServiceProtocols.swift index 728bca955..002c87ad9 100644 --- a/apps/ios/Sources/Services/NodeServiceProtocols.swift +++ b/apps/ios/Sources/Services/NodeServiceProtocols.swift @@ -41,14 +41,17 @@ protocol PhotosServicing: Sendable { protocol ContactsServicing: Sendable { func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload + func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload } protocol CalendarServicing: Sendable { func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload + func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload } protocol RemindersServicing: Sendable { func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload + func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload } protocol MotionServicing: Sendable { diff --git a/apps/ios/Tests/ContactsServiceTests.swift b/apps/ios/Tests/ContactsServiceTests.swift new file mode 100644 index 000000000..beac84bbf --- /dev/null +++ b/apps/ios/Tests/ContactsServiceTests.swift @@ -0,0 +1,20 @@ +import Contacts +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct ContactsServiceTests { + @Test func matchesPhoneOrEmailForDedupe() { + let contact = CNMutableContact() + contact.givenName = "Test" + contact.phoneNumbers = [ + CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: "+1 (555) 000-0000")), + ] + contact.emailAddresses = [ + CNLabeledValue(label: CNLabelHome, value: "test@example.com" as NSString), + ] + + #expect(ContactsService._test_matches(contact: contact, phoneNumbers: ["15550000000"], emails: [])) + #expect(ContactsService._test_matches(contact: contact, phoneNumbers: [], emails: ["TEST@example.com"])) + #expect(!ContactsService._test_matches(contact: contact, phoneNumbers: ["999"], emails: ["nope@example.com"])) + } +} diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 44c82d137..43543b4aa 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -99,6 +99,9 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> #expect(commands.contains(OpenClawDeviceCommand.status.rawValue)) #expect(commands.contains(OpenClawDeviceCommand.info.rawValue)) + #expect(commands.contains(OpenClawContactsCommand.add.rawValue)) + #expect(commands.contains(OpenClawCalendarCommand.add.rawValue)) + #expect(commands.contains(OpenClawRemindersCommand.add.rawValue)) } } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 30ad207ec..d6c0306c0 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -108,18 +108,24 @@ private struct TestPhotosService: PhotosServicing { } private struct TestContactsService: ContactsServicing { - let payload: OpenClawContactsSearchPayload - func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload { payload } + let searchPayload: OpenClawContactsSearchPayload + let addPayload: OpenClawContactsAddPayload + func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload { searchPayload } + func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload { addPayload } } private struct TestCalendarService: CalendarServicing { - let payload: OpenClawCalendarEventsPayload - func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload { payload } + let eventsPayload: OpenClawCalendarEventsPayload + let addPayload: OpenClawCalendarAddPayload + func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload { eventsPayload } + func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload { addPayload } } private struct TestRemindersService: RemindersServicing { - let payload: OpenClawRemindersListPayload - func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload { payload } + let listPayload: OpenClawRemindersListPayload + let addPayload: OpenClawRemindersAddPayload + func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload { listPayload } + func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload { addPayload } } private struct TestMotionService: MotionServicing { @@ -316,13 +322,41 @@ private func decodePayload(_ json: String?, as type: T.Type) throw appVersion: "dev", appBuild: "0", locale: "en-US")) + let emptyContact = OpenClawContactPayload( + identifier: "c0", + displayName: "", + givenName: "", + familyName: "", + organizationName: "", + phoneNumbers: [], + emails: []) + let emptyEvent = OpenClawCalendarEventPayload( + identifier: "e0", + title: "Test", + startISO: "2024-01-01T00:00:00Z", + endISO: "2024-01-01T00:30:00Z", + isAllDay: false, + location: nil, + calendarTitle: nil) + let emptyReminder = OpenClawReminderPayload( + identifier: "r0", + title: "Test", + dueISO: nil, + completed: false, + listName: nil) let appModel = makeTestAppModel( notificationCenter: notifier, deviceStatusService: deviceStatus, photosService: TestPhotosService(payload: OpenClawPhotosLatestPayload(photos: [])), - contactsService: TestContactsService(payload: OpenClawContactsSearchPayload(contacts: [])), - calendarService: TestCalendarService(payload: OpenClawCalendarEventsPayload(events: [])), - remindersService: TestRemindersService(payload: OpenClawRemindersListPayload(reminders: [])), + contactsService: TestContactsService( + searchPayload: OpenClawContactsSearchPayload(contacts: []), + addPayload: OpenClawContactsAddPayload(contact: emptyContact)), + calendarService: TestCalendarService( + eventsPayload: OpenClawCalendarEventsPayload(events: []), + addPayload: OpenClawCalendarAddPayload(event: emptyEvent)), + remindersService: TestRemindersService( + listPayload: OpenClawRemindersListPayload(reminders: []), + addPayload: OpenClawRemindersAddPayload(reminder: emptyReminder)), motionService: TestMotionService( activityPayload: OpenClawMotionActivityPayload(activities: []), pedometerPayload: OpenClawPedometerPayload( @@ -383,6 +417,15 @@ private func decodePayload(_ json: String?, as type: T.Type) throw phoneNumbers: ["+1"], emails: ["jane@example.com"]), ]) + let contactsAddPayload = OpenClawContactsAddPayload( + contact: OpenClawContactPayload( + identifier: "c2", + displayName: "Added", + givenName: "Added", + familyName: "", + organizationName: "", + phoneNumbers: ["+2"], + emails: ["add@example.com"])) let calendarPayload = OpenClawCalendarEventsPayload( events: [ OpenClawCalendarEventPayload( @@ -394,6 +437,15 @@ private func decodePayload(_ json: String?, as type: T.Type) throw location: nil, calendarTitle: "Work"), ]) + let calendarAddPayload = OpenClawCalendarAddPayload( + event: OpenClawCalendarEventPayload( + identifier: "e2", + title: "Added Event", + startISO: "2024-01-02T00:00:00Z", + endISO: "2024-01-02T01:00:00Z", + isAllDay: false, + location: "HQ", + calendarTitle: "Work")) let remindersPayload = OpenClawRemindersListPayload( reminders: [ OpenClawReminderPayload( @@ -403,6 +455,13 @@ private func decodePayload(_ json: String?, as type: T.Type) throw completed: false, listName: "Inbox"), ]) + let remindersAddPayload = OpenClawRemindersAddPayload( + reminder: OpenClawReminderPayload( + identifier: "r2", + title: "Added Reminder", + dueISO: "2024-01-03T00:00:00Z", + completed: false, + listName: "Inbox")) let motionPayload = OpenClawMotionActivityPayload( activities: [ OpenClawMotionActivityEntry( @@ -429,9 +488,15 @@ private func decodePayload(_ json: String?, as type: T.Type) throw statusPayload: deviceStatusPayload, infoPayload: deviceInfoPayload), photosService: TestPhotosService(payload: photosPayload), - contactsService: TestContactsService(payload: contactsPayload), - calendarService: TestCalendarService(payload: calendarPayload), - remindersService: TestRemindersService(payload: remindersPayload), + contactsService: TestContactsService( + searchPayload: contactsPayload, + addPayload: contactsAddPayload), + calendarService: TestCalendarService( + eventsPayload: calendarPayload, + addPayload: calendarAddPayload), + remindersService: TestRemindersService( + listPayload: remindersPayload, + addPayload: remindersAddPayload), motionService: TestMotionService( activityPayload: motionPayload, pedometerPayload: pedometerPayload)) @@ -460,18 +525,62 @@ private func decodePayload(_ json: String?, as type: T.Type) throw let decodedContacts = try decodePayload(contactsRes.payloadJSON, as: OpenClawContactsSearchPayload.self) #expect(decodedContacts == contactsPayload) + let contactsAddParams = OpenClawContactsAddParams( + givenName: "Added", + phoneNumbers: ["+2"], + emails: ["add@example.com"]) + let contactsAddData = try JSONEncoder().encode(contactsAddParams) + let contactsAddReq = BridgeInvokeRequest( + id: "contacts-add", + command: OpenClawContactsCommand.add.rawValue, + paramsJSON: String(decoding: contactsAddData, as: UTF8.self)) + let contactsAddRes = await appModel._test_handleInvoke(contactsAddReq) + #expect(contactsAddRes.ok == true) + let decodedContactsAdd = try decodePayload(contactsAddRes.payloadJSON, as: OpenClawContactsAddPayload.self) + #expect(decodedContactsAdd == contactsAddPayload) + let calendarReq = BridgeInvokeRequest(id: "calendar", command: OpenClawCalendarCommand.events.rawValue) let calendarRes = await appModel._test_handleInvoke(calendarReq) #expect(calendarRes.ok == true) let decodedCalendar = try decodePayload(calendarRes.payloadJSON, as: OpenClawCalendarEventsPayload.self) #expect(decodedCalendar == calendarPayload) + let calendarAddParams = OpenClawCalendarAddParams( + title: "Added Event", + startISO: "2024-01-02T00:00:00Z", + endISO: "2024-01-02T01:00:00Z", + location: "HQ", + calendarTitle: "Work") + let calendarAddData = try JSONEncoder().encode(calendarAddParams) + let calendarAddReq = BridgeInvokeRequest( + id: "calendar-add", + command: OpenClawCalendarCommand.add.rawValue, + paramsJSON: String(decoding: calendarAddData, as: UTF8.self)) + let calendarAddRes = await appModel._test_handleInvoke(calendarAddReq) + #expect(calendarAddRes.ok == true) + let decodedCalendarAdd = try decodePayload(calendarAddRes.payloadJSON, as: OpenClawCalendarAddPayload.self) + #expect(decodedCalendarAdd == calendarAddPayload) + let remindersReq = BridgeInvokeRequest(id: "reminders", command: OpenClawRemindersCommand.list.rawValue) let remindersRes = await appModel._test_handleInvoke(remindersReq) #expect(remindersRes.ok == true) let decodedReminders = try decodePayload(remindersRes.payloadJSON, as: OpenClawRemindersListPayload.self) #expect(decodedReminders == remindersPayload) + let remindersAddParams = OpenClawRemindersAddParams( + title: "Added Reminder", + dueISO: "2024-01-03T00:00:00Z", + listName: "Inbox") + let remindersAddData = try JSONEncoder().encode(remindersAddParams) + let remindersAddReq = BridgeInvokeRequest( + id: "reminders-add", + command: OpenClawRemindersCommand.add.rawValue, + paramsJSON: String(decoding: remindersAddData, as: UTF8.self)) + let remindersAddRes = await appModel._test_handleInvoke(remindersAddReq) + #expect(remindersAddRes.ok == true) + let decodedRemindersAdd = try decodePayload(remindersAddRes.payloadJSON, as: OpenClawRemindersAddPayload.self) + #expect(decodedRemindersAdd == remindersAddPayload) + let motionReq = BridgeInvokeRequest(id: "motion", command: OpenClawMotionCommand.activity.rawValue) let motionRes = await appModel._test_handleInvoke(motionReq) #expect(motionRes.ok == true) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift index 8d12e9776..9935b81ba 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift @@ -2,6 +2,7 @@ import Foundation public enum OpenClawCalendarCommand: String, Codable, Sendable { case events = "calendar.events" + case add = "calendar.add" } public struct OpenClawCalendarEventsParams: Codable, Sendable, Equatable { @@ -16,6 +17,37 @@ public struct OpenClawCalendarEventsParams: Codable, Sendable, Equatable { } } +public struct OpenClawCalendarAddParams: Codable, Sendable, Equatable { + public var title: String + public var startISO: String + public var endISO: String + public var isAllDay: Bool? + public var location: String? + public var notes: String? + public var calendarId: String? + public var calendarTitle: String? + + public init( + title: String, + startISO: String, + endISO: String, + isAllDay: Bool? = nil, + location: String? = nil, + notes: String? = nil, + calendarId: String? = nil, + calendarTitle: String? = nil) + { + self.title = title + self.startISO = startISO + self.endISO = endISO + self.isAllDay = isAllDay + self.location = location + self.notes = notes + self.calendarId = calendarId + self.calendarTitle = calendarTitle + } +} + public struct OpenClawCalendarEventPayload: Codable, Sendable, Equatable { public var identifier: String public var title: String @@ -51,3 +83,11 @@ public struct OpenClawCalendarEventsPayload: Codable, Sendable, Equatable { self.events = events } } + +public struct OpenClawCalendarAddPayload: Codable, Sendable, Equatable { + public var event: OpenClawCalendarEventPayload + + public init(event: OpenClawCalendarEventPayload) { + self.event = event + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift index 4163ac12d..d99f6b9e7 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift @@ -2,6 +2,7 @@ import Foundation public enum OpenClawContactsCommand: String, Codable, Sendable { case search = "contacts.search" + case add = "contacts.add" } public struct OpenClawContactsSearchParams: Codable, Sendable, Equatable { @@ -14,6 +15,31 @@ public struct OpenClawContactsSearchParams: Codable, Sendable, Equatable { } } +public struct OpenClawContactsAddParams: Codable, Sendable, Equatable { + public var givenName: String? + public var familyName: String? + public var organizationName: String? + public var displayName: String? + public var phoneNumbers: [String]? + public var emails: [String]? + + public init( + givenName: String? = nil, + familyName: String? = nil, + organizationName: String? = nil, + displayName: String? = nil, + phoneNumbers: [String]? = nil, + emails: [String]? = nil) + { + self.givenName = givenName + self.familyName = familyName + self.organizationName = organizationName + self.displayName = displayName + self.phoneNumbers = phoneNumbers + self.emails = emails + } +} + public struct OpenClawContactPayload: Codable, Sendable, Equatable { public var identifier: String public var displayName: String @@ -49,3 +75,11 @@ public struct OpenClawContactsSearchPayload: Codable, Sendable, Equatable { self.contacts = contacts } } + +public struct OpenClawContactsAddPayload: Codable, Sendable, Equatable { + public var contact: OpenClawContactPayload + + public init(contact: OpenClawContactPayload) { + self.contact = contact + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift index a909f5df1..ac275d803 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift @@ -2,6 +2,7 @@ import Foundation public enum OpenClawRemindersCommand: String, Codable, Sendable { case list = "reminders.list" + case add = "reminders.add" } public enum OpenClawReminderStatusFilter: String, Codable, Sendable { @@ -20,6 +21,28 @@ public struct OpenClawRemindersListParams: Codable, Sendable, Equatable { } } +public struct OpenClawRemindersAddParams: Codable, Sendable, Equatable { + public var title: String + public var dueISO: String? + public var notes: String? + public var listId: String? + public var listName: String? + + public init( + title: String, + dueISO: String? = nil, + notes: String? = nil, + listId: String? = nil, + listName: String? = nil) + { + self.title = title + self.dueISO = dueISO + self.notes = notes + self.listId = listId + self.listName = listName + } +} + public struct OpenClawReminderPayload: Codable, Sendable, Equatable { public var identifier: String public var title: String @@ -49,3 +72,11 @@ public struct OpenClawRemindersListPayload: Codable, Sendable, Equatable { self.reminders = reminders } } + +public struct OpenClawRemindersAddPayload: Codable, Sendable, Equatable { + public var reminder: OpenClawReminderPayload + + public init(reminder: OpenClawReminderPayload) { + self.reminder = reminder + } +} diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index f22611404..d3dfe9766 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -20,6 +20,20 @@ const LOCATION_COMMANDS = ["location.get"]; const SMS_COMMANDS = ["sms.send"]; +const DEVICE_COMMANDS = ["device.status", "device.info"]; + +const PHOTOS_COMMANDS = ["photos.latest"]; + +const CONTACTS_COMMANDS = ["contacts.search", "contacts.add"]; + +const CALENDAR_COMMANDS = ["calendar.events", "calendar.add"]; + +const REMINDERS_COMMANDS = ["reminders.list", "reminders.add"]; + +const MOTION_COMMANDS = ["motion.activity", "motion.pedometer"]; + +const SYSTEM_NOTIFY_COMMANDS = ["system.notify"]; + const SYSTEM_COMMANDS = [ "system.run", "system.which", @@ -30,7 +44,19 @@ const SYSTEM_COMMANDS = [ ]; const PLATFORM_DEFAULTS: Record = { - ios: [...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS], + ios: [ + ...CANVAS_COMMANDS, + ...CAMERA_COMMANDS, + ...SCREEN_COMMANDS, + ...LOCATION_COMMANDS, + ...SYSTEM_NOTIFY_COMMANDS, + ...DEVICE_COMMANDS, + ...PHOTOS_COMMANDS, + ...CONTACTS_COMMANDS, + ...CALENDAR_COMMANDS, + ...REMINDERS_COMMANDS, + ...MOTION_COMMANDS, + ], android: [ ...CANVAS_COMMANDS, ...CAMERA_COMMANDS,