fix(bots): sync cross-device group chats and roster from desktop projection (#1006)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-29 13:56:40 +03:00
1 parent 5bc6e5f855
commit 7e24f5eba3
4 files changed
+387 -104

No files matched your search

@@ -49,6 +49,21 @@ data class ProfileInfo(
} }
} }
fun groupChatSyncSnapshot(
json: Json =
Json {
ignoreUnknownKeys = true
isLenient = true
},
): GroupChatSyncSnapshot? {
val element = ui_meta?.get("hermes-bots-groups") ?: return null
return try {
json.decodeFromJsonElement<GroupChatSyncSnapshot>(element)
} catch (_: Exception) {
null
}
}
val effectiveTitle: String val effectiveTitle: String
get() = get() =
botMeta()?.title?.takeIf { it.isNotBlank() } botMeta()?.title?.takeIf { it.isNotBlank() }
@@ -104,7 +119,15 @@ data class BotRosterMeta(
val groups: List<String>? = null, val groups: List<String>? = null,
val group: String? = null, val group: String? = null,
val created: Double? = null, val created: Double? = null,
) ) {
val allGroups: List<String>
get() {
val list = mutableListOf<String>()
groups?.forEach { if (it.isNotBlank()) list.add(it.trim()) }
group?.takeIf { it.isNotBlank() }?.let { if (!list.contains(it.trim())) list.add(it.trim()) }
return list.distinct()
}
}
@Serializable @Serializable
data class BotAvatarMeta( data class BotAvatarMeta(
@@ -120,7 +143,62 @@ data class GroupChatSyncSnapshot(
val updatedAt: Long? = null, val updatedAt: Long? = null,
val rooms: Map<String, GroupChatRoomMeta>? = null, val rooms: Map<String, GroupChatRoomMeta>? = null,
val deleted: Map<String, Long>? = null, val deleted: Map<String, Long>? = null,
) ) {
fun toMap(): Map<String, Any?> {
val roomsMap = mutableMapOf<String, Any?>()
rooms?.forEach { (key, room) ->
val roomMap = mutableMapOf<String, Any?>()
room.name?.let { roomMap["name"] = it }
room.roomId?.let { roomMap["roomId"] = it }
room.picture?.let { roomMap["picture"] = it }
room.image?.let { roomMap["image"] = it }
room.updatedAt?.let { roomMap["updatedAt"] = it }
room.createdAt?.let { roomMap["createdAt"] = it }
room.revision?.let { roomMap["revision"] = it }
room.syncRevision?.let { roomMap["syncRevision"] = it }
room.tombstone?.let { roomMap["tombstone"] = it }
room.members?.let { members ->
roomMap["members"] =
members.mapNotNull {
when (it) {
is JsonPrimitive -> it.content
is JsonObject -> it["name"]?.jsonPrimitive?.content ?: it["handle"]?.jsonPrimitive?.content
else -> null
}
}
}
room.log?.let { log ->
roomMap["log"] =
log.map { entry ->
buildMap<String, Any?> {
entry.id?.let { put("id", it) }
entry.text?.let { put("text", it) }
entry.at?.let { put("at", it) }
entry.thread?.let { put("thread", it) }
entry.from?.let { f ->
put(
"from",
buildMap<String, Any?> {
f.kind?.let { put("kind", it) }
f.name?.let { put("name", it) }
f.source?.let { put("source", it) }
},
)
}
}
}
}
roomsMap[key] = roomMap
}
return buildMap {
put("version", version ?: 3)
put("updatedAt", updatedAt ?: System.currentTimeMillis())
put("rooms", roomsMap)
deleted?.let { put("deleted", it) }
}
}
}
@Serializable @Serializable
data class GroupChatRoomMeta( data class GroupChatRoomMeta(
@@ -132,15 +210,17 @@ data class GroupChatRoomMeta(
val image: String? = null, val image: String? = null,
val log: List<GroupChatSyncLogEntry>? = null, val log: List<GroupChatSyncLogEntry>? = null,
val revision: Long? = null, val revision: Long? = null,
val syncRevision: Long? = null,
val updatedAt: Long? = null, val updatedAt: Long? = null,
val createdAt: Long? = null, val createdAt: Long? = null,
val tombstone: Boolean? = null,
) { ) {
val memberNames: List<String> val memberNames: List<String>
get() = get() =
members?.mapNotNull { el -> members?.mapNotNull { el ->
when (el) { when (el) {
is JsonPrimitive -> el.content is JsonPrimitive -> el.content
is JsonObject -> el["name"]?.jsonPrimitive?.content is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content
else -> null else -> null
} }
}.orEmpty() }.orEmpty()
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.BotAvatarMeta import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta import com.m57.hermescontrol.data.model.BotRosterMeta
import com.m57.hermescontrol.data.model.CreateProfileRequest import com.m57.hermescontrol.data.model.CreateProfileRequest
import com.m57.hermescontrol.data.model.GroupChatRoomMeta
import com.m57.hermescontrol.data.model.GroupChatSyncSnapshot
import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
@@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
enum class BotsTab { enum class BotsTab {
@@ -34,6 +37,7 @@ enum class BotsTab {
data class GroupInfo( data class GroupInfo(
val name: String, val name: String,
val members: List<ProfileInfo>, val members: List<ProfileInfo>,
val lastActivity: Long = 0L,
) )
data class BotsUiState( data class BotsUiState(
@@ -52,17 +56,104 @@ data class BotsUiState(
val allGroups: List<GroupInfo> val allGroups: List<GroupInfo>
get() { get() {
val groupNames = val defaultProfile =
profiles profiles.find { it.is_default == true || it.name == "default" }
.flatMap { it.botMeta()?.groups.orEmpty() } ?: profiles.firstOrNull()
.distinct() val syncSnapshot = defaultProfile?.groupChatSyncSnapshot()
.sorted() val deletedKeys = syncSnapshot?.deleted?.keys.orEmpty().map { it.lowercase() }
return groupNames.map { gName ->
val groupNameMap = linkedMapOf<String, String>()
// 1. Collect from bot metadata (both `groups` array and `group` scalar)
for (profile in profiles) {
for (g in profile.botMeta()?.allGroups.orEmpty()) {
val trimmed = g.trim()
if (trimmed.isNotBlank()) {
val lower = trimmed.lowercase()
if (!groupNameMap.containsKey(lower)) {
groupNameMap[lower] = trimmed
}
}
}
}
// 2. Collect from cross-device snapshot rooms (Desktop parity)
val snapshotRooms = syncSnapshot?.rooms.orEmpty()
for ((key, room) in snapshotRooms) {
if (room.tombstone == true) continue
val roomKeyLower = key.lowercase()
if (deletedKeys.contains(roomKeyLower) ||
deletedKeys.contains("name:${room.name?.lowercase()}") ||
deletedKeys.contains("id:${room.roomId?.lowercase()}")
) {
continue
}
val roomName =
room.name?.trim()?.takeIf { it.isNotBlank() }
?: key.removePrefix("name:").removePrefix("id:").trim()
if (roomName.isNotBlank()) {
val lower = roomName.lowercase()
if (!groupNameMap.containsKey(lower)) {
groupNameMap[lower] = roomName
}
}
}
return groupNameMap.values.map { gName ->
val lower = gName.lowercase()
val matchingRoom =
snapshotRooms[gName]
?: snapshotRooms["name:$gName"]
?: snapshotRooms["id:$gName"]
?: snapshotRooms.values.find { it.name.equals(gName, ignoreCase = true) }
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
val matchedProfiles =
profiles.filter { profile ->
val botGroups = profile.botMeta()?.allGroups.orEmpty()
botGroups.any { it.equals(gName, ignoreCase = true) } ||
roomMemberNames.contains(profile.name.lowercase()) ||
roomMemberNames.contains(profile.effectiveTitle.lowercase())
}.toMutableList()
// If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated
if (matchingRoom != null && roomMemberNames.isNotEmpty()) {
for (mName in matchingRoom.memberNames) {
val exists =
matchedProfiles.any {
it.name.equals(mName, ignoreCase = true) ||
it.effectiveTitle.equals(mName, ignoreCase = true)
}
if (!exists) {
val local = profiles.find { it.name.equals(mName, ignoreCase = true) }
matchedProfiles.add(local ?: ProfileInfo(name = mName))
}
}
}
var groupMembers: List<ProfileInfo> = matchedProfiles
// Fallback: If group name was composed from bot names (e.g. "default, scoutbot")
if (groupMembers.isEmpty() && gName.contains(",")) {
val targetNames = gName.split(",").map { it.trim().lowercase() }
groupMembers = profiles.filter { targetNames.contains(it.name.lowercase()) }
}
val lastAt =
matchingRoom?.log?.lastOrNull()?.at
?: matchingRoom?.updatedAt
?: 0L
GroupInfo( GroupInfo(
name = gName, name = gName,
members = profiles.filter { it.botMeta()?.groups.orEmpty().contains(gName) }, members = groupMembers,
lastActivity = lastAt,
) )
} }.sortedWith(
compareByDescending<GroupInfo> { it.lastActivity }
.thenBy { it.name },
)
} }
val displayGroups: List<GroupInfo> val displayGroups: List<GroupInfo>
@@ -321,13 +412,15 @@ class BotsViewModel(
) { ) {
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
val currentProfiles = _uiState.value.profiles val currentProfiles = _uiState.value.profiles
// 1. Update bot metadata on each member bot
for (name in botNames) { for (name in botNames) {
val bot = currentProfiles.find { it.name == name } ?: continue val bot = currentProfiles.find { it.name == name } ?: continue
val existingGroups = bot.botMeta()?.groups.orEmpty() val existingGroups = bot.botMeta()?.allGroups.orEmpty()
if (!existingGroups.contains(groupName)) { if (!existingGroups.any { it.equals(groupName, ignoreCase = true) }) {
val updatedMeta = val updatedMeta =
(bot.botMeta() ?: BotRosterMeta()).copy( (bot.botMeta() ?: BotRosterMeta()).copy(
groups = existingGroups + groupName, groups = existingGroups + groupName,
group = existingGroups.firstOrNull() ?: groupName,
) )
try { try {
wsClientConfigureBot(name, updatedMeta).await() wsClientConfigureBot(name, updatedMeta).await()
@@ -335,6 +428,47 @@ class BotsViewModel(
} }
} }
} }
// 2. Also register room in hermes-bots-groups sync snapshot on default profile
try {
val defaultProfile =
currentProfiles.find { it.is_default == true || it.name == "default" }
?: currentProfiles.firstOrNull()
if (defaultProfile != null) {
val existingSnapshot =
defaultProfile.groupChatSyncSnapshot()
?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap())
val roomKey = "name:$groupName"
val newRoom =
GroupChatRoomMeta(
name = groupName,
members = botNames.map { JsonPrimitive(it) },
updatedAt = System.currentTimeMillis(),
createdAt = System.currentTimeMillis(),
)
val updatedRooms = existingSnapshot.rooms.orEmpty() + (roomKey to newRoom)
val updatedDeleted =
existingSnapshot.deleted.orEmpty().filterKeys {
!it.equals(roomKey, ignoreCase = true) && !it.equals(groupName, ignoreCase = true)
}
val newSnapshot =
existingSnapshot.copy(
version = 3,
updatedAt = System.currentTimeMillis(),
rooms = updatedRooms,
deleted = updatedDeleted,
)
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
),
).await()
}
} catch (_: Exception) {
}
loadBots() loadBots()
onSuccess() onSuccess()
} }
@@ -346,12 +480,15 @@ class BotsViewModel(
) { ) {
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
val currentProfiles = _uiState.value.profiles val currentProfiles = _uiState.value.profiles
// 1. Remove from all member bots' metadata
for (bot in currentProfiles) { for (bot in currentProfiles) {
val existingGroups = bot.botMeta()?.groups.orEmpty() val existingGroups = bot.botMeta()?.allGroups.orEmpty()
if (existingGroups.contains(groupName)) { if (existingGroups.any { it.equals(groupName, ignoreCase = true) }) {
val filtered = existingGroups.filterNot { it.equals(groupName, ignoreCase = true) }
val updatedMeta = val updatedMeta =
(bot.botMeta() ?: BotRosterMeta()).copy( (bot.botMeta() ?: BotRosterMeta()).copy(
groups = existingGroups - groupName, groups = filtered,
group = filtered.firstOrNull(),
) )
try { try {
wsClientConfigureBot(bot.name, updatedMeta).await() wsClientConfigureBot(bot.name, updatedMeta).await()
@@ -359,6 +496,44 @@ class BotsViewModel(
} }
} }
} }
// 2. Remove room from hermes-bots-groups snapshot and register tombstone in deleted map
try {
val defaultProfile =
currentProfiles.find { it.is_default == true || it.name == "default" }
?: currentProfiles.firstOrNull()
if (defaultProfile != null) {
val existingSnapshot = defaultProfile.groupChatSyncSnapshot()
if (existingSnapshot != null) {
val updatedRooms =
existingSnapshot.rooms.orEmpty().filterKeys { key ->
val room = existingSnapshot.rooms?.get(key)
!key.equals(groupName, ignoreCase = true) &&
!key.equals("name:$groupName", ignoreCase = true) &&
!key.equals("id:$groupName", ignoreCase = true) &&
!(room?.name.equals(groupName, ignoreCase = true))
}
val deletedMap =
existingSnapshot.deleted.orEmpty() + ("name:$groupName" to System.currentTimeMillis())
val newSnapshot =
existingSnapshot.copy(
version = 3,
updatedAt = System.currentTimeMillis(),
rooms = updatedRooms,
deleted = deletedMap,
)
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
),
).await()
}
}
} catch (_: Exception) {
}
loadBots() loadBots()
onSuccess() onSuccess()
} }
@@ -28,10 +28,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonPrimitive
import java.util.UUID import java.util.UUID
data class GroupChatUiState( data class GroupChatUiState(
@@ -170,11 +168,44 @@ class GroupChatViewModel(
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
val allProfiles = fetchProfiles() val allProfiles = fetchProfiles()
if (allProfiles.isNotEmpty()) { if (allProfiles.isNotEmpty()) {
var groupMembers = // Hydrate previous group chat messages from cross-device sync snapshot
val defaultProfile =
allProfiles.find { it.is_default == true || it.name == "default" }
?: allProfiles.firstOrNull()
val syncSnapshot = defaultProfile?.groupChatSyncSnapshot()
val matchingRoom =
syncSnapshot?.rooms?.get(groupName)
?: syncSnapshot?.rooms?.get("name:$groupName")
?: syncSnapshot?.rooms?.get("id:$groupName")
?: syncSnapshot?.rooms?.values?.find { it.name.equals(groupName, ignoreCase = true) }
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
val matchedProfiles =
allProfiles.filter { allProfiles.filter {
val botGroups = it.botMeta()?.groups.orEmpty() val botGroups = it.botMeta()?.allGroups.orEmpty()
botGroups.contains(groupName) || botGroups.any { g -> g.equals(groupName, ignoreCase = true) } botGroups.any { g -> g.equals(groupName, ignoreCase = true) } ||
roomMemberNames.contains(it.name.lowercase()) ||
roomMemberNames.contains(it.effectiveTitle.lowercase())
}.toMutableList()
// If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated
if (matchingRoom != null && roomMemberNames.isNotEmpty()) {
for (mName in matchingRoom.memberNames) {
val exists =
matchedProfiles.any {
it.name.equals(mName, ignoreCase = true) ||
it.effectiveTitle.equals(mName, ignoreCase = true)
}
if (!exists) {
val local = allProfiles.find { it.name.equals(mName, ignoreCase = true) }
matchedProfiles.add(local ?: ProfileInfo(name = mName))
}
} }
}
var groupMembers: List<ProfileInfo> = matchedProfiles
// Fallback: If group name was composed from bot names (e.g. "default, scoutbot") // Fallback: If group name was composed from bot names (e.g. "default, scoutbot")
if (groupMembers.isEmpty() && groupName.contains(",")) { if (groupMembers.isEmpty() && groupName.contains(",")) {
@@ -192,34 +223,19 @@ class GroupChatViewModel(
"Resolved ${groupMembers.size} members for group '$groupName': ${groupMembers.map { it.name }}", "Resolved ${groupMembers.size} members for group '$groupName': ${groupMembers.map { it.name }}",
) )
// Hydrate previous group chat messages from cross-device sync snapshot
val defaultProfile = allProfiles.find { it.is_default == true || it.name == "default" }
val syncSnapshotElement = defaultProfile?.ui_meta?.get("hermes-bots-groups")
val syncSnapshot =
syncSnapshotElement?.let { el ->
try {
json.decodeFromJsonElement<GroupChatSyncSnapshot>(el)
} catch (e: Exception) {
Log.w("GroupChatViewModel", "Failed to decode sync snapshot: ${e.message}")
null
}
}
var initialMessages = _uiState.value.messages var initialMessages = _uiState.value.messages
if (initialMessages.isEmpty() && syncSnapshot?.rooms != null) { if (initialMessages.isEmpty() && syncSnapshot?.rooms != null) {
val matchingRoom =
syncSnapshot.rooms[groupName]
?: syncSnapshot.rooms["name:$groupName"]
?: syncSnapshot.rooms["id:$groupName"]
?: syncSnapshot.rooms.values.find { it.name.equals(groupName, ignoreCase = true) }
val syncedLog = matchingRoom?.log.orEmpty() val syncedLog = matchingRoom?.log.orEmpty()
if (syncedLog.isNotEmpty()) { if (syncedLog.isNotEmpty()) {
initialMessages = initialMessages =
syncedLog.map { entry -> syncedLog.map { entry ->
val isUser = entry.from?.kind != "member" val isUser = entry.from?.kind != "member"
val senderName = entry.from?.name.orEmpty().ifBlank { if (isUser) "user" else "bot" } val senderName = entry.from?.name.orEmpty().ifBlank { if (isUser) "user" else "bot" }
val memberProfile = allProfiles.find { it.name.equals(senderName, ignoreCase = true) } val memberProfile =
allProfiles.find {
it.name.equals(senderName, ignoreCase = true) ||
it.effectiveTitle.equals(senderName, ignoreCase = true)
}
GroupChatMessage( GroupChatMessage(
id = entry.id ?: UUID.randomUUID().toString(), id = entry.id ?: UUID.randomUUID().toString(),
senderName = memberProfile?.name ?: senderName, senderName = memberProfile?.name ?: senderName,
@@ -446,15 +462,9 @@ class GroupChatViewModel(
val allProfiles = fetchProfiles() val allProfiles = fetchProfiles()
val defaultProfile = val defaultProfile =
allProfiles.find { it.is_default == true || it.name == "default" } ?: return@launch allProfiles.find { it.is_default == true || it.name == "default" } ?: return@launch
val existingElement = defaultProfile.ui_meta?.get("hermes-bots-groups")
val existingSnapshot = val existingSnapshot =
existingElement?.let { defaultProfile.groupChatSyncSnapshot()
try { ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap())
json.decodeFromJsonElement<GroupChatSyncSnapshot>(it)
} catch (_: Exception) {
null
}
} ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap())
val logEntries = val logEntries =
currentMessages.takeLast(32).map { msg -> currentMessages.takeLast(32).map { msg ->
@@ -472,8 +482,13 @@ class GroupChatViewModel(
} }
val roomKey = "name:$groupName" val roomKey = "name:$groupName"
val existingRoom =
existingSnapshot.rooms?.get(roomKey)
?: existingSnapshot.rooms?.get(groupName)
?: existingSnapshot.rooms?.values?.find { it.name.equals(groupName, ignoreCase = true) }
val updatedRoom = val updatedRoom =
GroupChatRoomMeta( (existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = System.currentTimeMillis())).copy(
name = groupName, name = groupName,
members = _uiState.value.members.map { JsonPrimitive(it.name) }, members = _uiState.value.members.map { JsonPrimitive(it.name) },
log = logEntries, log = logEntries,
@@ -488,7 +503,7 @@ class GroupChatViewModel(
rooms = updatedRooms, rooms = updatedRooms,
) )
val uiMetaPayload = mapOf("hermes-bots-groups" to snapshotToMap(newSnapshot)) val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient.request( HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE, WsMethods.PROFILES_CONFIGURE,
@@ -503,59 +518,6 @@ class GroupChatViewModel(
} }
} }
private fun snapshotToMap(snapshot: GroupChatSyncSnapshot): Map<String, Any?> {
val roomsMap = mutableMapOf<String, Any?>()
snapshot.rooms?.forEach { (key, room) ->
val roomMap = mutableMapOf<String, Any?>()
room.name?.let { roomMap["name"] = it }
room.roomId?.let { roomMap["roomId"] = it }
room.picture?.let { roomMap["picture"] = it }
room.image?.let { roomMap["image"] = it }
room.updatedAt?.let { roomMap["updatedAt"] = it }
room.createdAt?.let { roomMap["createdAt"] = it }
room.revision?.let { roomMap["revision"] = it }
room.members?.let { members ->
roomMap["members"] =
members.mapNotNull {
when (it) {
is JsonPrimitive -> it.content
is JsonObject -> it["name"]?.jsonPrimitive?.content
else -> null
}
}
}
room.log?.let { log ->
roomMap["log"] =
log.map { entry ->
buildMap<String, Any?> {
entry.id?.let { put("id", it) }
entry.text?.let { put("text", it) }
entry.at?.let { put("at", it) }
entry.thread?.let { put("thread", it) }
entry.from?.let { f ->
put(
"from",
buildMap<String, Any?> {
f.kind?.let { put("kind", it) }
f.name?.let { put("name", it) }
f.source?.let { put("source", it) }
},
)
}
}
}
}
roomsMap[key] = roomMap
}
return buildMap {
put("version", snapshot.version ?: 3)
put("updatedAt", snapshot.updatedAt ?: System.currentTimeMillis())
put("rooms", roomsMap)
snapshot.deleted?.let { put("deleted", it) }
}
}
private fun isPass(text: String): Boolean { private fun isPass(text: String): Boolean {
val clean = text.trim().lowercase() val clean = text.trim().lowercase()
return clean == "(pass)" || clean == "pass" return clean == "(pass)" || clean == "pass"
@@ -4,6 +4,8 @@ import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.BotAvatarMeta import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta import com.m57.hermescontrol.data.model.BotRosterMeta
import com.m57.hermescontrol.data.model.CanonicalSessionInfo import com.m57.hermescontrol.data.model.CanonicalSessionInfo
import com.m57.hermescontrol.data.model.GroupChatRoomMeta
import com.m57.hermescontrol.data.model.GroupChatSyncSnapshot
import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfileWorkerSummary import com.m57.hermescontrol.data.model.ProfileWorkerSummary
import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.model.ProfilesResponse
@@ -24,6 +26,7 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -273,4 +276,67 @@ class BotsViewModelTest {
assertTrue(success) assertTrue(success)
coVerify(exactly = 1) { mockApi.deleteProfile("scout") } coVerify(exactly = 1) { mockApi.deleteProfile("scout") }
} }
@Test
fun testAllGroups_includesDesktopSyncRoomsAndRespectsOrdering() =
runTest {
val syncSnapshot =
GroupChatSyncSnapshot(
version = 3,
updatedAt = 1725000000L,
rooms =
mapOf(
"name:Desktop Room" to
GroupChatRoomMeta(
name = "Desktop Room",
members = listOf(JsonPrimitive("botA"), JsonPrimitive("remoteBot")),
updatedAt = 1725000100L,
),
"id:room-123" to
GroupChatRoomMeta(
name = "Old Archived Room",
members = listOf(JsonPrimitive("botA")),
tombstone = true,
),
),
deleted = mapOf("name:deleted room" to 1725000000L),
)
val defaultProfile =
ProfileInfo(
name = "default",
is_default = true,
ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(syncSnapshot)),
)
val botA =
ProfileInfo(
name = "botA",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(groups = listOf("Local Only Group")),
),
),
)
coEvery { mockApi.getProfiles() } returns Response.success(ProfilesResponse(listOf(defaultProfile, botA)))
coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default"))
val viewModel = BotsViewModel(ioDispatcher = testDispatcher, autoLoad = false)
viewModel.loadBots()
advanceUntilIdle()
val groups = viewModel.uiState.value.allGroups
assertEquals(2, groups.size)
// "Desktop Room" has recent activity 1725000100L, so it should rank first
assertEquals("Desktop Room", groups[0].name)
assertEquals(2, groups[0].members.size)
assertEquals("botA", groups[0].members[0].name)
assertEquals("remoteBot", groups[0].members[1].name)
assertEquals("Local Only Group", groups[1].name)
assertEquals(1, groups[1].members.size)
}
} }