mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-24 23:01:22 -04:00
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:
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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 (1–50). 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user