@@ -342,6 +342,12 @@ class NodeRuntime(
342342 val nodesDevicesRefreshing: StateFlow <Boolean > = _nodesDevicesRefreshing .asStateFlow()
343343 private val _nodesDevicesErrorText = MutableStateFlow <String ?>(null )
344344 val nodesDevicesErrorText: StateFlow <String ?> = _nodesDevicesErrorText .asStateFlow()
345+ private val _channelsSummary = MutableStateFlow (GatewayChannelsSummary (channels = emptyList()))
346+ val channelsSummary: StateFlow <GatewayChannelsSummary > = _channelsSummary .asStateFlow()
347+ private val _channelsRefreshing = MutableStateFlow (false )
348+ val channelsRefreshing: StateFlow <Boolean > = _channelsRefreshing .asStateFlow()
349+ private val _channelsErrorText = MutableStateFlow <String ?>(null )
350+ val channelsErrorText: StateFlow <String ?> = _channelsErrorText .asStateFlow()
345351
346352 private val _isForeground = MutableStateFlow (true )
347353 val isForeground: StateFlow <Boolean > = _isForeground .asStateFlow()
@@ -393,6 +399,7 @@ class NodeRuntime(
393399 pendingDevices = emptyList(),
394400 pairedDevices = emptyList(),
395401 )
402+ _channelsSummary .value = GatewayChannelsSummary (channels = emptyList())
396403 chat.applyMainSessionKey(resolveMainSessionKey())
397404 chat.onDisconnected(message)
398405 updateStatus()
@@ -663,6 +670,7 @@ class NodeRuntime(
663670 refreshUsageFromGateway()
664671 refreshSkillsFromGateway()
665672 refreshNodesDevicesFromGateway()
673+ refreshChannelsFromGateway()
666674 }
667675 }
668676
@@ -702,6 +710,12 @@ class NodeRuntime(
702710 }
703711 }
704712
713+ fun refreshChannels () {
714+ scope.launch {
715+ refreshChannelsFromGateway()
716+ }
717+ }
718+
705719 fun requestCanvasRehydrate (
706720 source : String = "manual",
707721 force : Boolean = true,
@@ -1762,6 +1776,31 @@ class NodeRuntime(
17621776 }
17631777 }
17641778
1779+ private suspend fun refreshChannelsFromGateway () {
1780+ _channelsRefreshing .value = true
1781+ _channelsErrorText .value = null
1782+ if (! operatorConnected) {
1783+ _channelsSummary .value = GatewayChannelsSummary (channels = emptyList())
1784+ _channelsRefreshing .value = false
1785+ return
1786+ }
1787+ try {
1788+ val res = operatorSession.request(" channels.status" , """ {"probe":false,"timeoutMs":8000}""" )
1789+ val root = json.parseToJsonElement(res).asObjectOrNull()
1790+ _channelsSummary .value =
1791+ GatewayChannelsSummary (
1792+ updatedAtMs = root.long(" ts" ),
1793+ partial = root.boolean(" partial" ),
1794+ warnings = parseStringArray(root?.get(" warnings" ) as ? JsonArray ),
1795+ channels = parseChannelSummaries(root),
1796+ )
1797+ } catch (_: Throwable ) {
1798+ _channelsErrorText .value = " Could not load channels."
1799+ } finally {
1800+ _channelsRefreshing .value = false
1801+ }
1802+ }
1803+
17651804 private fun parseGatewayModels (models : JsonArray ? ): List <GatewayModelSummary > =
17661805 models
17671806 ?.mapNotNull { item ->
@@ -1941,6 +1980,67 @@ class NodeRuntime(
19411980 )
19421981 }.orEmpty()
19431982
1983+ private fun parseChannelSummaries (root : JsonObject ? ): List <GatewayChannelSummary > {
1984+ val order = parseStringArray(root?.get(" channelOrder" ) as ? JsonArray )
1985+ val labels = parseStringMap(root?.get(" channelLabels" ).asObjectOrNull())
1986+ val channels = root?.get(" channels" ).asObjectOrNull()
1987+ val accounts = root?.get(" channelAccounts" ).asObjectOrNull()
1988+ val ids = (order + channels.orEmpty().keys + accounts.orEmpty().keys).distinct()
1989+ return ids
1990+ .map { id ->
1991+ val summary = channels?.get(id).asObjectOrNull()
1992+ val accountRows = parseChannelAccounts(accounts?.get(id) as ? JsonArray )
1993+ GatewayChannelSummary (
1994+ id = id,
1995+ label = labels[id] ? : channelDisplayLabel(id),
1996+ accountCount = accountRows.size,
1997+ enabled = summary.boolean(" enabled" ) || accountRows.any { it.enabled },
1998+ configured = summary.boolean(" configured" ) || accountRows.any { it.configured },
1999+ linked = summary.boolean(" linked" ) || accountRows.any { it.linked },
2000+ running = summary.boolean(" running" ) || accountRows.any { it.running },
2001+ connected = summary.boolean(" connected" ) || accountRows.any { it.connected },
2002+ error =
2003+ summary
2004+ ?.get(" lastError" )
2005+ .asStringOrNull()
2006+ ?.trim()
2007+ ?.takeIf { it.isNotEmpty() }
2008+ ? : accountRows.firstNotNullOfOrNull { it.error },
2009+ )
2010+ }.sortedWith(compareByDescending<GatewayChannelSummary > { it.enabled || it.configured }.thenBy { it.label.lowercase() })
2011+ }
2012+
2013+ private fun parseChannelAccounts (accounts : JsonArray ? ): List <GatewayChannelAccountSummary > =
2014+ accounts
2015+ ?.mapNotNull { item ->
2016+ val obj = item.asObjectOrNull() ? : return @mapNotNull null
2017+ val accountId = obj[" accountId" ].asStringOrNull()?.trim().orEmpty()
2018+ if (accountId.isEmpty()) return @mapNotNull null
2019+ GatewayChannelAccountSummary (
2020+ enabled = obj.boolean(" enabled" ),
2021+ configured = obj.boolean(" configured" ),
2022+ linked = obj.boolean(" linked" ),
2023+ running = obj.boolean(" running" ),
2024+ connected = obj.boolean(" connected" ),
2025+ error =
2026+ obj[" lastError" ]
2027+ .asStringOrNull()
2028+ ?.trim()
2029+ ?.takeIf { it.isNotEmpty() },
2030+ )
2031+ }.orEmpty()
2032+
2033+ private fun parseStringMap (map : JsonObject ? ): Map <String , String > =
2034+ map
2035+ ?.mapNotNull { (key, value) ->
2036+ value
2037+ .asStringOrNull()
2038+ ?.trim()
2039+ ?.takeIf { it.isNotEmpty() }
2040+ ?.let { key to it }
2041+ }?.toMap()
2042+ .orEmpty()
2043+
19442044 private fun parseStringArray (items : JsonArray ? ): List <String > =
19452045 items
19462046 ?.mapNotNull { item -> item.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } }
@@ -2320,6 +2420,34 @@ data class GatewayDeviceTokenSummary(
23202420 val updatedAtMs : Long? ,
23212421)
23222422
2423+ data class GatewayChannelsSummary (
2424+ val updatedAtMs : Long? = null ,
2425+ val partial : Boolean = false ,
2426+ val warnings : List <String > = emptyList(),
2427+ val channels : List <GatewayChannelSummary >,
2428+ )
2429+
2430+ data class GatewayChannelSummary (
2431+ val id : String ,
2432+ val label : String ,
2433+ val accountCount : Int ,
2434+ val enabled : Boolean ,
2435+ val configured : Boolean ,
2436+ val linked : Boolean ,
2437+ val running : Boolean ,
2438+ val connected : Boolean ,
2439+ val error : String? ,
2440+ )
2441+
2442+ private data class GatewayChannelAccountSummary (
2443+ val enabled : Boolean ,
2444+ val configured : Boolean ,
2445+ val linked : Boolean ,
2446+ val running : Boolean ,
2447+ val connected : Boolean ,
2448+ val error : String? ,
2449+ )
2450+
23232451private fun JsonObject?.long (key : String ): Long? = (this ?.get(key) as ? JsonPrimitive )?.content?.trim()?.toLongOrNull()
23242452
23252453private fun JsonObject?.double (key : String ): Double? = (this ?.get(key) as ? JsonPrimitive )?.content?.trim()?.toDoubleOrNull()
@@ -2343,6 +2471,21 @@ fun providerDisplayName(provider: String): String =
23432471 .ifBlank { " Provider" }
23442472 }
23452473
2474+ fun channelDisplayLabel (channel : String ): String =
2475+ when (channel.trim().lowercase()) {
2476+ " imessage" -> " iMessage"
2477+ " googlechat" -> " Google Chat"
2478+ " whatsapp" -> " WhatsApp"
2479+ else ->
2480+ channel
2481+ .replace(' -' , ' ' )
2482+ .replace(' _' , ' ' )
2483+ .split(' ' )
2484+ .filter { it.isNotBlank() }
2485+ .joinToString(" " ) { token -> token.replaceFirstChar { it.uppercase() } }
2486+ .ifBlank { " Channel" }
2487+ }
2488+
23462489@Serializable
23472490private data class HomeCanvasPayload (
23482491 val gatewayState : String ,
0 commit comments