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
get() =
botMeta()?.title?.takeIf { it.isNotBlank() }
@@ -104,7 +119,15 @@ data class BotRosterMeta(
val groups: List<String>? = null,
val group: String? = 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
data class BotAvatarMeta(
@@ -120,7 +143,62 @@ data class GroupChatSyncSnapshot(
val updatedAt: Long? = null,
val rooms: Map<String, GroupChatRoomMeta>? = 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
data class GroupChatRoomMeta(
@@ -132,15 +210,17 @@ data class GroupChatRoomMeta(
val image: String? = null,
val log: List<GroupChatSyncLogEntry>? = null,
val revision: Long? = null,
val syncRevision: Long? = null,
val updatedAt: Long? = null,
val createdAt: Long? = null,
val tombstone: Boolean? = null,
) {
val memberNames: List<String>
get() =
members?.mapNotNull { el ->
when (el) {
is JsonPrimitive -> el.content
is JsonObject -> el["name"]?.jsonPrimitive?.content
is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content
else -> null
}
}.orEmpty()
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta
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.ProfilesResponse
import com.m57.hermescontrol.data.remote.ApiClient
@@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
enum class BotsTab {
@@ -34,6 +37,7 @@ enum class BotsTab {
data class GroupInfo(
val name: String,
val members: List<ProfileInfo>,
val lastActivity: Long = 0L,
)
data class BotsUiState(
@@ -52,17 +56,104 @@ data class BotsUiState(
val allGroups: List<GroupInfo>
get() {
val groupNames =
profiles
.flatMap { it.botMeta()?.groups.orEmpty() }
.distinct()
.sorted()
return groupNames.map { gName ->
val defaultProfile =
profiles.find { it.is_default == true || it.name == "default" }
?: profiles.firstOrNull()
val syncSnapshot = defaultProfile?.groupChatSyncSnapshot()
val deletedKeys = syncSnapshot?.deleted?.keys.orEmpty().map { it.lowercase() }
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(
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>
@@ -321,13 +412,15 @@ class BotsViewModel(
) {
viewModelScope.launch(ioDispatcher) {
val currentProfiles = _uiState.value.profiles
// 1. Update bot metadata on each member bot
for (name in botNames) {
val bot = currentProfiles.find { it.name == name } ?: continue
val existingGroups = bot.botMeta()?.groups.orEmpty()
if (!existingGroups.contains(groupName)) {
val existingGroups = bot.botMeta()?.allGroups.orEmpty()
if (!existingGroups.any { it.equals(groupName, ignoreCase = true) }) {
val updatedMeta =
(bot.botMeta() ?: BotRosterMeta()).copy(
groups = existingGroups + groupName,
group = existingGroups.firstOrNull() ?: groupName,
)
try {
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()
onSuccess()
}
@@ -346,12 +480,15 @@ class BotsViewModel(
) {
viewModelScope.launch(ioDispatcher) {
val currentProfiles = _uiState.value.profiles
// 1. Remove from all member bots' metadata
for (bot in currentProfiles) {
val existingGroups = bot.botMeta()?.groups.orEmpty()
if (existingGroups.contains(groupName)) {
val existingGroups = bot.botMeta()?.allGroups.orEmpty()
if (existingGroups.any { it.equals(groupName, ignoreCase = true) }) {
val filtered = existingGroups.filterNot { it.equals(groupName, ignoreCase = true) }
val updatedMeta =
(bot.botMeta() ?: BotRosterMeta()).copy(
groups = existingGroups - groupName,
groups = filtered,
group = filtered.firstOrNull(),
)
try {
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()
onSuccess()
}
@@ -28,10 +28,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonPrimitive
import java.util.UUID
data class GroupChatUiState(
@@ -170,11 +168,44 @@ class GroupChatViewModel(
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
val allProfiles = fetchProfiles()
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 {
val botGroups = it.botMeta()?.groups.orEmpty()
botGroups.contains(groupName) || botGroups.any { g -> g.equals(groupName, ignoreCase = true) }
val botGroups = it.botMeta()?.allGroups.orEmpty()
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")
if (groupMembers.isEmpty() && groupName.contains(",")) {
@@ -192,34 +223,19 @@ class GroupChatViewModel(
"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
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()
if (syncedLog.isNotEmpty()) {
initialMessages =
syncedLog.map { entry ->
val isUser = entry.from?.kind != "member"
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(
id = entry.id ?: UUID.randomUUID().toString(),
senderName = memberProfile?.name ?: senderName,
@@ -446,15 +462,9 @@ class GroupChatViewModel(
val allProfiles = fetchProfiles()
val defaultProfile =
allProfiles.find { it.is_default == true || it.name == "default" } ?: return@launch
val existingElement = defaultProfile.ui_meta?.get("hermes-bots-groups")
val existingSnapshot =
existingElement?.let {
try {
json.decodeFromJsonElement<GroupChatSyncSnapshot>(it)
} catch (_: Exception) {
null
}
} ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap())
defaultProfile.groupChatSyncSnapshot()
?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap())
val logEntries =
currentMessages.takeLast(32).map { msg ->
@@ -472,8 +482,13 @@ class GroupChatViewModel(
}
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 =
GroupChatRoomMeta(
(existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = System.currentTimeMillis())).copy(
name = groupName,
members = _uiState.value.members.map { JsonPrimitive(it.name) },
log = logEntries,
@@ -488,7 +503,7 @@ class GroupChatViewModel(
rooms = updatedRooms,
)
val uiMetaPayload = mapOf("hermes-bots-groups" to snapshotToMap(newSnapshot))
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient.request(
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 {
val clean = text.trim().lowercase()
return clean == "(pass)" || clean == "pass"