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:
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user