mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
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>
This commit is contained in:
parent
27b59fbb72
commit
1ae2b4bfef
8 changed files with 494 additions and 1 deletions
148
Meshtastic/Intents/SendMessageIntentHandler.swift
Normal file
148
Meshtastic/Intents/SendMessageIntentHandler.swift
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue