Meshtastic-Apple/Meshtastic/Intents/SendMessageIntentHandler.swift
Copilot 1ae2b4bfef
Implement SiriKit intents for CarPlay messaging (#1664)
* Add SiriKit intent handlers for CarPlay messaging (INSendMessageIntent, INSearchForMessagesIntent, INSetMessageAttributeIntent)

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/8ef2a78b-83ee-4d9f-82b9-17b766c96312

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Address code review: consolidate intent routing, support multiple recipients, improve error for long messages

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/8ef2a78b-83ee-4d9f-82b9-17b766c96312

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Fix unnecessary nil-coalescing in conversationIdentifier

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/8ef2a78b-83ee-4d9f-82b9-17b766c96312

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Restrict INSendMessageIntent to single recipient (channel or direct message, not both)

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/1798a03a-53b3-4a97-94e1-8281b552217a

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Fix Mac Catalyst build errors in SiriKit intent handlers

- SendMessageIntentHandler: guard `.noHandlesForValue` with
  #if targetEnvironment(macCatalyst) since the reason enum is iOS-only
- IntentMessageConverters: use .text instead of .tapback; INMessageType.tapback
  is unavailable on Mac Catalyst
- SearchForMessagesIntentHandler: replace .startDate/.endDate (iOS-only) with
  .startDateComponents/.endDateComponents + Calendar.date(from:) which work
  on all platforms

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/9b61aad5-652c-4330-83b3-2303f10e4f12

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Add Siri authorization request at startup and NSSiriUsageDescription in Info.plist

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/be245ecb-2f0a-48d4-b931-4df889a6b6cc

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
2026-04-16 12:12:14 -07:00

148 lines
4.7 KiB
Swift

//
// SendMessageIntentHandler.swift
// Meshtastic
//
// Handles INSendMessageIntent for CarPlay and Siri messaging.
// Meshtastic supports exactly one destination per message: either a single
// direct-message recipient (a mesh node) or a channel (speakableGroupName).
// Multiple recipients are not supported.
//
import CoreData
import Intents
import OSLog
final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
// MARK: - Resolution
func resolveRecipients(for intent: INSendMessageIntent) async -> [INSendMessageRecipientResolutionResult] {
guard let recipients = intent.recipients, !recipients.isEmpty else {
if intent.speakableGroupName != nil {
return []
}
return [.needsValue()]
}
// Meshtastic only supports a single direct-message recipient.
if recipients.count > 1 {
return [.unsupported(forReason: .noAccount)]
}
let context = PersistenceController.shared.container.viewContext
let searchTerm = recipients[0].displayName
let matchingUsers = await MainActor.run {
IntentMessageConverters.findUsers(matching: searchTerm, in: context)
}
if matchingUsers.isEmpty {
return [.unsupported(forReason: .noAccount)]
} else if matchingUsers.count == 1, let user = matchingUsers.first {
return [.success(with: IntentMessageConverters.inPerson(from: user))]
} else {
let persons = matchingUsers.map { IntentMessageConverters.inPerson(from: $0) }
return [.disambiguation(with: persons)]
}
}
func resolveContent(for intent: INSendMessageIntent) async -> INStringResolutionResult {
guard let content = intent.content, !content.isEmpty else {
return .needsValue()
}
guard let data = content.data(using: .utf8), data.count <= 200 else {
return .unsupported()
}
return .success(with: content)
}
func resolveSpeakableGroupName(for intent: INSendMessageIntent) async -> INSpeakableStringResolutionResult {
guard let groupName = intent.speakableGroupName else {
if let recipients = intent.recipients, !recipients.isEmpty {
return .notRequired()
}
return .needsValue()
}
let context = PersistenceController.shared.container.viewContext
let matchingChannels = await MainActor.run {
IntentMessageConverters.findChannels(matching: groupName.spokenPhrase, in: context)
}
if matchingChannels.count == 1, let channel = matchingChannels.first {
let speakable = INSpeakableString(spokenPhrase: channel.name ?? "Channel \(channel.index)")
return .success(with: speakable)
} else if matchingChannels.count > 1 {
let speakables = matchingChannels.map {
INSpeakableString(spokenPhrase: $0.name ?? "Channel \($0.index)")
}
return .disambiguation(with: speakables)
}
#if targetEnvironment(macCatalyst)
return .unsupported()
#else
return .unsupported(forReason: .noHandlesForValue)
#endif
}
// MARK: - Confirmation
func confirm(intent: INSendMessageIntent) async -> INSendMessageIntentResponse {
let connected = await AccessoryManager.shared.isConnected
guard connected else {
return INSendMessageIntentResponse(code: .failureRequiringAppLaunch, userActivity: nil)
}
return INSendMessageIntentResponse(code: .ready, userActivity: nil)
}
// MARK: - Handling
func handle(intent: INSendMessageIntent) async -> INSendMessageIntentResponse {
let connected = await AccessoryManager.shared.isConnected
guard connected else {
return INSendMessageIntentResponse(code: .failureRequiringAppLaunch, userActivity: nil)
}
guard let content = intent.content, !content.isEmpty else {
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
}
do {
if let groupName = intent.speakableGroupName {
// Channel message
let context = PersistenceController.shared.container.viewContext
let channelIndex = await MainActor.run {
IntentMessageConverters.channelIndex(for: groupName.spokenPhrase, in: context)
}
try await AccessoryManager.shared.sendMessage(
message: content,
toUserNum: 0,
channel: Int32(channelIndex),
isEmoji: false,
replyID: 0
)
} else if let recipient = intent.recipients?.first,
let handleValue = recipient.personHandle?.value,
let nodeNum = Int64(handleValue) {
// Direct message to a single node
try await AccessoryManager.shared.sendMessage(
message: content,
toUserNum: nodeNum,
channel: 0,
isEmoji: false,
replyID: 0
)
} else {
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
}
Logger.services.info("CarPlay/Siri: Message sent successfully")
return INSendMessageIntentResponse(code: .success, userActivity: nil)
} catch {
Logger.services.error("CarPlay/Siri: Failed to send message: \(error.localizedDescription)")
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
}
}
}