feat(ai): Add getRecentMessages and getUnreadSummary App Functions

Phase 3 of App Functions integration: read-only message history
functions that enable 'catch me up' voice queries via system AI.

New functions:
- getRecentMessages: Retrieve recent messages with optional contact
  filter and configurable limit (1-50, default 20)
- getUnreadSummary: Per-contact unread breakdown excluding muted
  contacts, sorted by most recent

Implementation details:
- KMP interface + sealed result types in core:data
- Android @AppFunction declarations with @AppFunctionSerializable models
- Fuzzy name resolution for contact filtering
- Channel name resolution for broadcast contacts
- Tests for contact-not-found and empty unread scenarios

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-21 21:25:25 -05:00
parent ec62ea4dd2
commit 144a3ea66e
7 changed files with 490 additions and 5 deletions

View File

@@ -180,3 +180,56 @@ data class GetMeshMetricsResponse(
val meshUptimeSeconds: Long,
val channelUtilizationPercent: Int?,
)
/**
* Response containing recent messages from the mesh network.
*
* @property messages List of recent messages ordered by most recent first.
*/
@AppFunctionSerializable(isDescribedByKDoc = true)
data class GetRecentMessagesResponse(val messages: List<MessageInfo>)
/**
* Information about a single mesh message.
*
* @property senderName Display name of the message sender.
* @property text The message text content.
* @property contactName Name of the channel or contact the message belongs to.
* @property receivedTime Timestamp when the message was received (ms since epoch).
* @property fromLocal True if this message was sent by the local user.
* @property read True if this message has been read by the user.
*/
@AppFunctionSerializable(isDescribedByKDoc = true)
data class MessageInfo(
val senderName: String,
val text: String,
val contactName: String,
val receivedTime: Long,
val fromLocal: Boolean,
val read: Boolean,
)
/**
* Response containing a summary of unread messages across all contacts.
*
* @property totalUnreadCount Total number of unread messages across all non-muted contacts.
* @property contacts Per-contact breakdown of unread messages, sorted by most recent.
*/
@AppFunctionSerializable(isDescribedByKDoc = true)
data class GetUnreadSummaryResponse(val totalUnreadCount: Int, val contacts: List<ContactUnreadInfo>)
/**
* Unread message details for a single contact or channel.
*
* @property name Display name of the contact or channel.
* @property unreadCount Number of unread messages from this contact.
* @property lastMessagePreview Preview text of the most recent message (up to 100 chars), or null if unavailable.
* @property lastMessageTime Timestamp of the most recent message (ms since epoch), or null if unavailable.
*/
@AppFunctionSerializable(isDescribedByKDoc = true)
data class ContactUnreadInfo(
val name: String,
val unreadCount: Int,
val lastMessagePreview: String?,
val lastMessageTime: Long?,
)

View File

@@ -325,4 +325,89 @@ class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
throw AppFunctionInvalidArgumentException(result.reason)
}
}
/**
* Retrieve recent messages received over the Meshtastic mesh radio network.
*
* Returns a list of recent messages from the local message history. Messages are stored locally and do not require
* an active mesh connection. Useful for catching up on conversations or reviewing recent communications.
*
* @param context The app function invocation context provided by the system.
* @param contactName Optional name of a node or channel to filter messages from. If omitted, returns messages from
* all contacts sorted by most recent.
* @param limit Maximum number of messages to return (150). Defaults to 20.
* @return A [GetRecentMessagesResponse] containing the list of recent messages.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun getRecentMessages(
context: AppFunctionContext,
contactName: String? = null,
limit: Int = AiFunctionProvider.DEFAULT_MESSAGE_LIMIT,
): GetRecentMessagesResponse {
val result =
try {
provider.getRecentMessages(contactName, limit)
} catch (_: TimeoutCancellationException) {
throw AppFunctionInvalidArgumentException("Request timed out. Try again or reduce the message limit.")
}
return when (result) {
is org.meshtastic.core.data.ai.GetRecentMessagesResult.Success ->
GetRecentMessagesResponse(
messages =
result.messages.map { msg ->
MessageInfo(
senderName = msg.senderName,
text = msg.text,
contactName = msg.contactName,
receivedTime = msg.receivedTime,
fromLocal = msg.fromLocal,
read = msg.read,
)
},
)
is org.meshtastic.core.data.ai.GetRecentMessagesResult.ContactNotFound ->
throw AppFunctionInvalidArgumentException(result.message)
is org.meshtastic.core.data.ai.GetRecentMessagesResult.Error ->
throw AppFunctionInvalidArgumentException(result.reason)
}
}
/**
* Get a summary of unread messages across all Meshtastic mesh contacts.
*
* Returns the total unread count and a per-contact breakdown showing who sent unread messages, how many are unread,
* and a preview of the last message. Muted contacts are excluded. Does not require an active mesh connection.
*
* @param context The app function invocation context provided by the system.
* @return A [GetUnreadSummaryResponse] with the total unread count and per-contact details.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun getUnreadSummary(context: AppFunctionContext): GetUnreadSummaryResponse {
val result =
try {
provider.getUnreadSummary()
} catch (_: TimeoutCancellationException) {
throw AppFunctionInvalidArgumentException("Request timed out. Try again.")
}
return when (result) {
is org.meshtastic.core.data.ai.GetUnreadSummaryResult.Success ->
GetUnreadSummaryResponse(
totalUnreadCount = result.summary.totalUnreadCount,
contacts =
result.summary.contacts.map { contact ->
ContactUnreadInfo(
name = contact.name,
unreadCount = contact.unreadCount,
lastMessagePreview = contact.lastMessagePreview,
lastMessageTime = contact.lastMessageTime,
)
},
)
is org.meshtastic.core.data.ai.GetUnreadSummaryResult.Error ->
throw AppFunctionInvalidArgumentException(result.reason)
}
}
}

View File

@@ -80,4 +80,33 @@ interface AiFunctionProvider {
* @return Success with mesh metrics, or failure if not connected.
*/
suspend fun getMeshMetrics(): GetMeshMetricsResult
/**
* Get recent messages from the mesh network.
*
* Messages are returned from the local cache — an active radio connection is not required.
*
* @param contactName Optional contact/channel name to filter by. Uses fuzzy matching.
* @param limit Maximum number of messages to return (default 20, max 50).
* @return Success with list of messages, or failure if contact not found.
*/
suspend fun getRecentMessages(
contactName: String? = null,
limit: Int = DEFAULT_MESSAGE_LIMIT,
): GetRecentMessagesResult
/**
* Get a summary of unread messages grouped by contact.
*
* Returns the total unread count and a per-contact breakdown with the last message preview. Muted contacts are
* excluded.
*
* @return Unread summary with per-contact breakdown.
*/
suspend fun getUnreadSummary(): GetUnreadSummaryResult
companion object {
const val DEFAULT_MESSAGE_LIMIT = 20
const val MAX_MESSAGE_LIMIT = 50
}
}

View File

@@ -22,6 +22,7 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.usecase.SendMessageUseCase
@@ -33,11 +34,13 @@ import kotlin.time.Duration.Companion.seconds
* Implementation of [AiFunctionProvider] that bridges AI function invocations to existing Meshtastic repositories and
* use cases.
*/
@Suppress("TooManyFunctions")
@Single(binds = [AiFunctionProvider::class])
class AiFunctionProviderImpl(
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
private val packetRepository: PacketRepository,
private val sendMessageUseCase: SendMessageUseCase,
private val fuzzyNameResolver: FuzzyNameResolver,
private val rateLimiter: RateLimiter,
@@ -293,6 +296,159 @@ class AiFunctionProviderImpl(
}
}
@Suppress("ReturnCount", "TooGenericExceptionCaught")
override suspend fun getRecentMessages(contactName: String?, limit: Int): GetRecentMessagesResult =
withTimeout(OPERATION_TIMEOUT) {
try {
val effectiveLimit = limit.coerceIn(1, AiFunctionProvider.MAX_MESSAGE_LIMIT)
// Resolve contact key if a name filter is provided
val contactKey =
if (contactName != null) {
resolveContactKeyForRead(contactName)
?: return@withTimeout GetRecentMessagesResult.ContactNotFound(
"Contact not found: $contactName",
)
} else {
null
}
val messages =
if (contactKey != null) {
// Fetch messages from a specific contact
packetRepository
.getMessagesFrom(
contact = contactKey,
limit = effectiveLimit,
includeFiltered = false,
getNode = { userId -> nodeRepository.getNode(userId ?: "") },
)
.first()
} else {
// Fetch recent messages across all contacts
val contacts = packetRepository.getContacts().first()
contacts.keys
.flatMap { key ->
packetRepository
.getMessagesFrom(
contact = key,
limit = MESSAGES_PER_CONTACT,
includeFiltered = false,
getNode = { userId -> nodeRepository.getNode(userId ?: "") },
)
.first()
}
.sortedByDescending { it.receivedTime }
.take(effectiveLimit)
}
val channelSet = radioConfigRepository.channelSetFlow.first()
val summaries =
messages.map { msg ->
MessageSummary(
senderName = msg.node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${msg.node.num}",
text = msg.text,
contactName = resolveContactDisplayName(msg, channelSet),
receivedTime = msg.receivedTime,
fromLocal = msg.fromLocal,
read = msg.read,
)
}
GetRecentMessagesResult.Success(summaries)
} catch (ex: Exception) {
if (ex is CancellationException) throw ex
GetRecentMessagesResult.Error("Failed to retrieve messages: ${ex.message}")
}
}
@Suppress("TooGenericExceptionCaught")
override suspend fun getUnreadSummary(): GetUnreadSummaryResult = withTimeout(OPERATION_TIMEOUT) {
try {
val contacts = packetRepository.getContacts().first()
val settings = packetRepository.getContactSettings().first()
val channelSet = radioConfigRepository.channelSetFlow.first()
val nodeMap = nodeRepository.nodeDBbyNum.first()
val nonMutedContacts = contacts.filter { (key, _) -> settings[key]?.isMuted != true }
val contactUnreads =
nonMutedContacts.mapNotNull { (contactKey, lastPacket) ->
val unreadCount = packetRepository.getUnreadCount(contactKey)
if (unreadCount <= 0) return@mapNotNull null
val isBroadcast = lastPacket.to == DataPacket.ID_BROADCAST
val displayName =
if (isBroadcast) {
val channelIndex = contactKey.firstOrNull()?.digitToIntOrNull() ?: 0
channelSet.settings.getOrNull(channelIndex)?.name?.ifBlank { "Channel $channelIndex" }
?: "Channel $channelIndex"
} else {
val userId = lastPacket.from ?: ""
val node = nodeMap.values.find { it.user.id == userId }
node?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Unknown"
}
ContactUnread(
name = displayName,
unreadCount = unreadCount,
lastMessagePreview = lastPacket.text?.take(MESSAGE_PREVIEW_MAX_LENGTH),
lastMessageTime = lastPacket.time.takeIf { it > 0 },
)
}
val totalUnread = contactUnreads.sumOf { it.unreadCount }
GetUnreadSummaryResult.Success(
UnreadSummary(
totalUnreadCount = totalUnread,
contacts = contactUnreads.sortedByDescending { it.lastMessageTime },
),
)
} catch (ex: Exception) {
if (ex is CancellationException) throw ex
GetUnreadSummaryResult.Error("Failed to retrieve unread summary: ${ex.message}")
}
}
/**
* Resolve a contact name (node or channel) to a contact key for reading messages. Returns null if the name cannot
* be resolved.
*/
@Suppress("ReturnCount")
private suspend fun resolveContactKeyForRead(name: String): String? {
// Try node name first
when (val nodeResult = fuzzyNameResolver.resolveNodeName(name)) {
is NodeNameResult.Found -> {
val channelIndex = DataPacket.PKC_CHANNEL_INDEX
return "${channelIndex}${nodeResult.userId}"
}
is NodeNameResult.Ambiguous -> return null
is NodeNameResult.NotFound -> {
/* fall through to channel */
}
}
// Try channel name
return when (val channelResult = fuzzyNameResolver.resolveChannelName(name)) {
is ChannelNameResult.Found -> "${channelResult.channelIndex}${DataPacket.ID_BROADCAST}"
is ChannelNameResult.Ambiguous -> null
is ChannelNameResult.NotFound -> null
}
}
private fun resolveContactDisplayName(
msg: org.meshtastic.core.model.Message,
channelSet: org.meshtastic.proto.ChannelSet,
): String {
// For broadcast messages, use channel name
val channelIndex = msg.node.channel
return channelSet.settings.getOrNull(channelIndex)?.name?.ifBlank { "Channel $channelIndex" }
?: "Channel $channelIndex"
}
@Suppress("ReturnCount")
private suspend fun resolveContactKey(recipientName: String?, channelName: String?): ResolvedContact? {
// Direct message to a specific node
@@ -352,6 +508,8 @@ class AiFunctionProviderImpl(
private const val HEALTH_SCORE_ONLINE_RATIO = 50
private const val HEALTH_SCORE_DEGRADED = 10
private const val HEALTH_SCORE_MAX = 100
private const val MESSAGES_PER_CONTACT = 5
private const val MESSAGE_PREVIEW_MAX_LENGTH = 100
/** Standard Meshtastic message payload limit (bytes). */
const val MAX_MESSAGE_LENGTH = 237

View File

@@ -208,3 +208,60 @@ data class MeshMetrics(
/** Estimated channel utilization percentage (0-100), or null if unavailable. */
val channelUtilizationPercent: Int?,
)
/** Result of a [AiFunctionProvider.getRecentMessages] invocation. */
sealed class GetRecentMessagesResult {
/** Successfully retrieved recent messages. */
data class Success(val messages: List<MessageSummary>) : GetRecentMessagesResult()
/** The specified contact was not found via fuzzy matching. */
data class ContactNotFound(val message: String) : GetRecentMessagesResult()
/** An error occurred retrieving messages. */
data class Error(val reason: String) : GetRecentMessagesResult()
}
/** Summary of a single mesh message suitable for AI consumption. */
data class MessageSummary(
/** Display name of the message sender. */
val senderName: String,
/** The message text content. */
val text: String,
/** Channel or contact name this message belongs to. */
val contactName: String,
/** When the message was received (milliseconds since epoch). */
val receivedTime: Long,
/** Whether this message was sent by the local user. */
val fromLocal: Boolean,
/** Whether this message has been read. */
val read: Boolean,
)
/** Result of a [AiFunctionProvider.getUnreadSummary] invocation. */
sealed class GetUnreadSummaryResult {
/** Successfully retrieved unread summary. */
data class Success(val summary: UnreadSummary) : GetUnreadSummaryResult()
/** An error occurred retrieving unread summary. */
data class Error(val reason: String) : GetUnreadSummaryResult()
}
/** Unread message summary across all contacts. */
data class UnreadSummary(
/** Total number of unread messages across all contacts. */
val totalUnreadCount: Int,
/** Per-contact breakdown of unread messages (excludes muted contacts). */
val contacts: List<ContactUnread>,
)
/** Unread info for a single contact or channel. */
data class ContactUnread(
/** Display name of the contact or channel. */
val name: String,
/** Number of unread messages from this contact. */
val unreadCount: Int,
/** Preview of the last message text, or null if none. */
val lastMessagePreview: String?,
/** Timestamp of the last message (milliseconds since epoch), or null if none. */
val lastMessageTime: Long?,
)

View File

@@ -26,6 +26,7 @@ import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.usecase.SendMessageUseCase
@@ -46,6 +47,7 @@ class AiFunctionProviderImplTest {
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val sendMessageUseCase: SendMessageUseCase = mock(MockMode.autofill)
private val fuzzyNameResolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
private val packetRepository: PacketRepository = mock(MockMode.autofill)
private val clock = TestClock(Instant.fromEpochSeconds(1_700_000_000))
private val rateLimiter = RateLimiter(clock)
@@ -55,6 +57,7 @@ class AiFunctionProviderImplTest {
radioConfigRepository = radioConfigRepository,
sendMessageUseCase = sendMessageUseCase,
fuzzyNameResolver = fuzzyNameResolver,
packetRepository = packetRepository,
rateLimiter = rateLimiter,
clock = clock,
)
@@ -233,6 +236,35 @@ class AiFunctionProviderImplTest {
val result = provider.sendMessage("hello", null, null)
assertIs<SendMessageResult.RateLimited>(result)
}
// --- getRecentMessages tests ---
@Test
fun getRecentMessages_contact_not_found() = runTest {
val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
every { nodeRepository.nodeDBbyNum } returns nodeMap
every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet())
val provider = createProvider()
val result = provider.getRecentMessages("NonExistent", 10)
assertIs<GetRecentMessagesResult.ContactNotFound>(result)
}
// --- getUnreadSummary tests ---
@Test
fun getUnreadSummary_returns_empty_when_no_unread() = runTest {
every { packetRepository.getContacts() } returns flowOf(emptyMap())
every { packetRepository.getContactSettings() } returns flowOf(emptyMap())
every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet())
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
val provider = createProvider()
val result = provider.getUnreadSummary()
assertIs<GetUnreadSummaryResult.Success>(result)
assertEquals(0, result.summary.totalUnreadCount)
assertEquals(0, result.summary.contacts.size)
}
}
private class TestClock(var currentTime: Instant) : Clock {

View File

@@ -229,14 +229,85 @@ androidApp/ (Google flavor)
1. **Exact rate limit values**: Is 5 messages/60 seconds the right threshold, or should it align with a specific radio duty-cycle calculation?
2. **Background invocation**: Can App Functions be invoked when the app is in the background but the service is running? (Likely yes, since `AppFunctionService` runs in the app process)
## Future Considerations (Phase 2+)
## Future Considerations (Phase 3+)
- **listNodes**: "Who's on the mesh?" → return online nodes with names and last-heard
- **getRecentMessages**: "Any new messages?" → return unread messages with sender/text/time
- **getNodePosition**: "Where is Jake?" → return GPS coordinates (gated by privacy settings)
- **Waypoint management**: Create/delete waypoints via AI
- **Traceroute**: "Can I reach node X?" → invoke traceroute and return hop count
- **Channel info**: "What channels am I on?" → list configured channels
- **Device telemetry**: "How's my radio's battery?" → return device metrics
- **Location sharing**: "Share my location on the mesh" → trigger position broadcast
- **requestTraceroute**: Non-destructive route diagnostic via fuzzy node name
- **sendQuickChat**: Voice-triggered pre-configured message shortcuts
- **findNearbyNodes**: Location-aware proximity query sorted by distance
- **requestNodePosition**: Ask a specific node to share its GPS coordinates
- **Desktop/iOS parity**: Implement `AiFunctionProvider` via local MCP server (Desktop) or App Intents (iOS)
---
## Phase 3: Message History Functions
### User Story 6 - Read Recent Messages via AI (Priority: P1)
As a user returning from an activity, I want to ask "What messages did I miss?" and get a summary of recent mesh messages without opening the app.
**Why this priority**: "Catch me up" is the #1 voice query pattern for communication apps. Mesh messages arrive asynchronously during hikes/outdoor activities where the phone is pocketed.
**Independent Test**: Invoke `getRecentMessages` and confirm returned messages match the app's message list.
**Acceptance Scenarios**:
1. **Given** the device is connected and messages exist, **When** the AI invokes `getRecentMessages` without filters, **Then** the most recent messages (up to limit) are returned with sender name, text, channel, and timestamp
2. **Given** a contactName is provided, **When** the AI invokes `getRecentMessages` with that filter, **Then** only messages from that contact/channel are returned
3. **Given** no messages exist, **When** the AI invokes `getRecentMessages`, **Then** an empty list is returned (not an error)
4. **Given** the device is disconnected, **When** the AI invokes `getRecentMessages`, **Then** cached messages are still returned (message history is local)
---
### User Story 7 - Unread Message Summary via AI (Priority: P1)
As a user, I want to ask "Do I have unread messages?" and get a per-contact breakdown showing who messaged me and a preview of their last message.
**Why this priority**: Unread summaries let users decide whether to open the app, reducing unnecessary screen time during outdoor activities.
**Independent Test**: Invoke `getUnreadSummary` and confirm counts match the app's contact list badges.
**Acceptance Scenarios**:
1. **Given** unread messages exist from multiple contacts, **When** the AI invokes `getUnreadSummary`, **Then** the total unread count and per-contact breakdown (name, unread count, last message preview) are returned
2. **Given** no unread messages exist, **When** the AI invokes `getUnreadSummary`, **Then** totalUnreadCount is 0 and the contacts list is empty
3. **Given** a contact has been muted, **When** the AI invokes `getUnreadSummary`, **Then** muted contacts are excluded from the breakdown
---
### Functional Requirements (Phase 3)
- **FR-012**: `getRecentMessages` MUST return recent messages sorted newest-first, limited to a configurable count (default 20, max 50)
- **FR-013**: `getRecentMessages` MUST support optional `contactName` filter using the existing `FuzzyNameResolver`
- **FR-014**: `getRecentMessages` MUST NOT require an active radio connection (messages are cached locally)
- **FR-015**: `getUnreadSummary` MUST return total unread count and per-contact breakdown with last message preview
- **FR-016**: `getUnreadSummary` MUST exclude muted contacts from the breakdown
- **FR-017**: Both functions MUST respect the 5-second timeout constraint (NFR-002)
### Architecture Addition
```
commonMain/ai/
├── AiFunctionProvider.kt # + getRecentMessages(), getUnreadSummary()
├── AiFunctionResult.kt # + GetRecentMessagesResult, GetUnreadSummaryResult
androidApp/ (Google flavor)
├── appfunctions/
│ ├── MeshtasticAppFunctions.kt # + getRecentMessages, getUnreadSummary
│ └── AppFunctionModels.kt # + MessageInfo, UnreadSummaryResponse, ContactUnreadInfo
```
### Data Flow (Message History)
```
AiFunctionProvider.getRecentMessages()
PacketRepository.getMessagesFrom() / getContacts()
NodeRepository (resolve sender names)
Return MessageInfo list
```