2026-04-16 12:12:14 -07:00
|
|
|
//
|
|
|
|
|
// 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 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)]
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 21:58:13 -07:00
|
|
|
let recipient = recipients[0]
|
|
|
|
|
let handleValue = recipient.personHandle?.value ?? ""
|
|
|
|
|
|
|
|
|
|
// If this is a channel handle, accept it directly
|
|
|
|
|
if IntentMessageConverters.channelIndex(fromHandleOrName: handleValue) != nil {
|
|
|
|
|
return [.success(with: recipient)]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If the handle resolves to a node number, accept it directly
|
|
|
|
|
if IntentMessageConverters.directMessageNodeNum(from: handleValue) != nil {
|
|
|
|
|
return [.success(with: recipient)]
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 15:20:37 -07:00
|
|
|
let searchTerm = recipient.displayName.isEmpty ? handleValue : recipient.displayName
|
2026-04-16 12:12:14 -07:00
|
|
|
let matchingUsers = await MainActor.run {
|
2026-04-18 15:20:37 -07:00
|
|
|
let context = PersistenceController.shared.context
|
|
|
|
|
return IntentMessageConverters.findUsers(matching: searchTerm, in: context)
|
2026-04-16 12:12:14 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 matchingChannels = await MainActor.run {
|
2026-04-18 15:20:37 -07:00
|
|
|
let context = PersistenceController.shared.context
|
|
|
|
|
return IntentMessageConverters.findChannels(matching: groupName.spokenPhrase, in: context)
|
2026-04-16 12:12:14 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if matchingChannels.count == 1, let channel = matchingChannels.first {
|
2026-04-17 21:58:13 -07:00
|
|
|
let speakable = INSpeakableString(
|
|
|
|
|
spokenPhrase: IntentMessageConverters.channelDisplayName(for: channel.index, named: channel.name)
|
|
|
|
|
)
|
2026-04-16 12:12:14 -07:00
|
|
|
return .success(with: speakable)
|
|
|
|
|
} else if matchingChannels.count > 1 {
|
|
|
|
|
let speakables = matchingChannels.map {
|
2026-04-17 21:58:13 -07:00
|
|
|
INSpeakableString(
|
|
|
|
|
spokenPhrase: IntentMessageConverters.channelDisplayName(for: $0.index, named: $0.name)
|
|
|
|
|
)
|
2026-04-16 12:12:14 -07:00
|
|
|
}
|
|
|
|
|
return .disambiguation(with: speakables)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return .unsupported()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 channelIndex = await MainActor.run {
|
2026-04-18 15:20:37 -07:00
|
|
|
let context = PersistenceController.shared.context
|
|
|
|
|
return IntentMessageConverters.channelIndex(for: groupName.spokenPhrase, in: context)
|
2026-04-16 12:12:14 -07:00
|
|
|
}
|
|
|
|
|
try await AccessoryManager.shared.sendMessage(
|
|
|
|
|
message: content,
|
|
|
|
|
toUserNum: 0,
|
|
|
|
|
channel: Int32(channelIndex),
|
|
|
|
|
isEmoji: false,
|
|
|
|
|
replyID: 0
|
|
|
|
|
)
|
|
|
|
|
} else if let recipient = intent.recipients?.first,
|
2026-04-17 21:58:13 -07:00
|
|
|
let handleValue = recipient.personHandle?.value {
|
|
|
|
|
if let channelIndex = IntentMessageConverters.channelIndex(fromHandleOrName: handleValue) {
|
|
|
|
|
try await AccessoryManager.shared.sendMessage(
|
|
|
|
|
message: content,
|
|
|
|
|
toUserNum: 0,
|
|
|
|
|
channel: Int32(channelIndex),
|
|
|
|
|
isEmoji: false,
|
|
|
|
|
replyID: 0
|
|
|
|
|
)
|
|
|
|
|
} else if let nodeNum = IntentMessageConverters.directMessageNodeNum(from: handleValue) {
|
2026-04-16 12:12:14 -07:00
|
|
|
// Direct message to a single node
|
2026-04-17 21:58:13 -07:00
|
|
|
try await AccessoryManager.shared.sendMessage(
|
|
|
|
|
message: content,
|
|
|
|
|
toUserNum: nodeNum,
|
|
|
|
|
channel: 0,
|
|
|
|
|
isEmoji: false,
|
|
|
|
|
replyID: 0
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
|
|
|
|
|
}
|
2026-04-16 12:12:14 -07:00
|
|
|
} 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|