package org.session.libsession.messaging.open_groups import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred import nl.komponents.kovenant.functional.map import nl.komponents.kovenant.then import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.file_server.FileServerAPI import org.session.libsession.messaging.utilities.DotNetAPI import org.session.libsession.utilities.DownloadUtilities import org.session.libsignal.utilities.retryIfNeeded import org.session.libsignal.utilities.* import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import java.io.ByteArrayOutputStream import java.text.SimpleDateFormat import java.util.* object OpenGroupAPI: DotNetAPI() { private val moderators: HashMap>> = hashMapOf() // Server URL to (channel ID to set of moderator IDs) // region Settings private val fallbackBatchCount = 64 private val maxRetryCount = 8 // endregion // region Convenience private val channelInfoType = "net.patter-app.settings" private val attachmentType = "net.app.core.oembed" @JvmStatic val openGroupMessageType = "network.loki.messenger.publicChat" @JvmStatic val profilePictureType = "network.loki.messenger.avatar" fun getDefaultChats(): List { return listOf() // Don't auto-join any open groups right now } @JvmStatic fun isUserModerator(hexEncodedPublicKey: String, channel: Long, server: String): Boolean { if (moderators[server] != null && moderators[server]!![channel] != null) { return moderators[server]!![channel]!!.contains(hexEncodedPublicKey) } return false } // endregion // region Public API fun getMessages(channel: Long, server: String): Promise, Exception> { Log.d("Loki", "Getting messages for open group with ID: $channel on server: $server.") val storage = MessagingModuleConfiguration.shared.storage val parameters = mutableMapOf( "include_annotations" to 1 ) val lastMessageServerID = storage.getLastMessageServerID(channel, server) if (lastMessageServerID != null) { parameters["since_id"] = lastMessageServerID } else { parameters["count"] = fallbackBatchCount parameters["include_deleted"] = 0 } return execute(HTTPVerb.GET, server, "channels/$channel/messages", parameters = parameters).then { json -> try { val data = json["data"] as List> val messages = data.mapNotNull { message -> try { val isDeleted = message["is_deleted"] as? Boolean ?: false if (isDeleted) { return@mapNotNull null } // Ignore messages without annotations if (message["annotations"] == null) { return@mapNotNull null } val annotation = (message["annotations"] as List>).find { ((it["type"] as? String ?: "") == openGroupMessageType) && it["value"] != null } ?: return@mapNotNull null val value = annotation["value"] as Map<*, *> val serverID = message["id"] as? Long ?: (message["id"] as? Int)?.toLong() ?: (message["id"] as String).toLong() val user = message["user"] as Map<*, *> val publicKey = user["username"] as String val displayName = user["name"] as? String ?: "Anonymous" var profilePicture: OpenGroupMessage.ProfilePicture? = null if (user["annotations"] != null) { val profilePictureAnnotation = (user["annotations"] as List>).find { ((it["type"] as? String ?: "") == profilePictureType) && it["value"] != null } val profilePictureAnnotationValue = profilePictureAnnotation?.get("value") as? Map<*, *> if (profilePictureAnnotationValue != null && profilePictureAnnotationValue["profileKey"] != null && profilePictureAnnotationValue["url"] != null) { try { val profileKey = Base64.decode(profilePictureAnnotationValue["profileKey"] as String) val url = profilePictureAnnotationValue["url"] as String profilePicture = OpenGroupMessage.ProfilePicture(profileKey, url) } catch (e: Exception) {} } } @Suppress("NAME_SHADOWING") val body = message["text"] as String val timestamp = value["timestamp"] as? Long ?: (value["timestamp"] as? Int)?.toLong() ?: (value["timestamp"] as String).toLong() var quote: OpenGroupMessage.Quote? = null if (value["quote"] != null) { val replyTo = message["reply_to"] as? Long ?: (message["reply_to"] as? Int)?.toLong() ?: (message["reply_to"] as String).toLong() val quoteAnnotation = value["quote"] as? Map<*, *> val quoteTimestamp = quoteAnnotation?.get("id") as? Long ?: (quoteAnnotation?.get("id") as? Int)?.toLong() ?: (quoteAnnotation?.get("id") as? String)?.toLong() ?: 0L val author = quoteAnnotation?.get("author") as? String val text = quoteAnnotation?.get("text") as? String quote = if (quoteTimestamp > 0L && author != null && text != null) OpenGroupMessage.Quote(quoteTimestamp, author, text, replyTo) else null } val attachmentsAsJSON = (message["annotations"] as List>).filter { ((it["type"] as? String ?: "") == attachmentType) && it["value"] != null } val attachments = attachmentsAsJSON.mapNotNull { it["value"] as? Map<*, *> }.mapNotNull { attachmentAsJSON -> try { val kindAsString = attachmentAsJSON["lokiType"] as String val kind = OpenGroupMessage.Attachment.Kind.values().first { it.rawValue == kindAsString } val id = attachmentAsJSON["id"] as? Long ?: (attachmentAsJSON["id"] as? Int)?.toLong() ?: (attachmentAsJSON["id"] as String).toLong() val contentType = attachmentAsJSON["contentType"] as String val size = attachmentAsJSON["size"] as? Int ?: (attachmentAsJSON["size"] as? Long)?.toInt() ?: (attachmentAsJSON["size"] as String).toInt() val fileName = attachmentAsJSON["fileName"] as? String val flags = 0 val url = attachmentAsJSON["url"] as String val caption = attachmentAsJSON["caption"] as? String val linkPreviewURL = attachmentAsJSON["linkPreviewUrl"] as? String val linkPreviewTitle = attachmentAsJSON["linkPreviewTitle"] as? String if (kind == OpenGroupMessage.Attachment.Kind.LinkPreview && (linkPreviewURL == null || linkPreviewTitle == null)) { null } else { OpenGroupMessage.Attachment(kind, server, id, contentType, size, fileName, flags, 0, 0, caption, url, linkPreviewURL, linkPreviewTitle) } } catch (e: Exception) { Log.d("Loki","Couldn't parse attachment due to error: $e.") null } } // Set the last message server ID here to avoid the situation where a message doesn't have a valid signature and this function is called over and over @Suppress("NAME_SHADOWING") val lastMessageServerID = storage.getLastMessageServerID(channel, server) if (serverID > lastMessageServerID ?: 0) { storage.setLastMessageServerID(channel, server, serverID) } val hexEncodedSignature = value["sig"] as String val signatureVersion = value["sigver"] as? Long ?: (value["sigver"] as? Int)?.toLong() ?: (value["sigver"] as String).toLong() val signature = OpenGroupMessage.Signature(Hex.fromStringCondensed(hexEncodedSignature), signatureVersion) val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) format.timeZone = TimeZone.getTimeZone("GMT") val dateAsString = message["created_at"] as String val serverTimestamp = format.parse(dateAsString).time // Verify the message val groupMessage = OpenGroupMessage(serverID, publicKey, displayName, body, timestamp, openGroupMessageType, quote, attachments.toMutableList(), profilePicture, signature, serverTimestamp) if (groupMessage.hasValidSignature()) groupMessage else null } catch (exception: Exception) { Log.d("Loki", "Couldn't parse message for open group with ID: $channel on server: $server from: ${JsonUtil.toJson(message)}. Exception: ${exception.message}") return@mapNotNull null } }.sortedBy { it.serverTimestamp } messages } catch (exception: Exception) { Log.d("Loki", "Couldn't parse messages for open group with ID: $channel on server: $server.") throw exception } } } @JvmStatic fun getDeletedMessageServerIDs(channel: Long, server: String): Promise, Exception> { Log.d("Loki", "Getting deleted messages for open group with ID: $channel on server: $server.") val storage = MessagingModuleConfiguration.shared.storage val parameters = mutableMapOf() val lastDeletionServerID = storage.getLastDeletionServerID(channel, server) if (lastDeletionServerID != null) { parameters["since_id"] = lastDeletionServerID } else { parameters["count"] = fallbackBatchCount } return execute(HTTPVerb.GET, server, "loki/v1/channel/$channel/deletes", parameters = parameters).then { json -> try { val deletedMessageServerIDs = (json["data"] as List>).mapNotNull { deletion -> try { val serverID = deletion["id"] as? Long ?: (deletion["id"] as? Int)?.toLong() ?: (deletion["id"] as String).toLong() val messageServerID = deletion["message_id"] as? Long ?: (deletion["message_id"] as? Int)?.toLong() ?: (deletion["message_id"] as String).toLong() @Suppress("NAME_SHADOWING") val lastDeletionServerID = storage.getLastDeletionServerID(channel, server) if (serverID > (lastDeletionServerID ?: 0)) { storage.setLastDeletionServerID(channel, server, serverID) } messageServerID } catch (exception: Exception) { Log.d("Loki", "Couldn't parse deleted message for open group with ID: $channel on server: $server. Exception: ${exception.message}") return@mapNotNull null } } deletedMessageServerIDs } catch (exception: Exception) { Log.d("Loki", "Couldn't parse deleted messages for open group with ID: $channel on server: $server.") throw exception } } } @JvmStatic fun sendMessage(message: OpenGroupMessage, channel: Long, server: String): Promise { val deferred = deferred() val storage = MessagingModuleConfiguration.shared.storage val userKeyPair = storage.getUserKeyPair() ?: throw Error.Generic val userDisplayName = storage.getUserDisplayName() ?: throw Error.Generic ThreadUtils.queue { val signedMessage = message.sign(userKeyPair.second) if (signedMessage == null) { deferred.reject(Error.SigningFailed) } else { retryIfNeeded(maxRetryCount) { Log.d("Loki", "Sending message to open group with ID: $channel on server: $server.") val parameters = signedMessage.toJSON() execute(HTTPVerb.POST, server, "channels/$channel/messages", parameters = parameters).then { json -> try { val data = json["data"] as Map<*, *> val serverID = (data["id"] as? Long) ?: (data["id"] as? Int)?.toLong() ?: (data["id"] as String).toLong() val text = data["text"] as String val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) format.timeZone = TimeZone.getTimeZone("GMT") val dateAsString = data["created_at"] as String val timestamp = format.parse(dateAsString).time OpenGroupMessage(serverID, userKeyPair.first, userDisplayName, text, timestamp, openGroupMessageType, message.quote, message.attachments, null, signedMessage.signature, timestamp) } catch (exception: Exception) { Log.d("Loki", "Couldn't parse message for open group with ID: $channel on server: $server.") throw exception } } }.success { deferred.resolve(it) }.fail { deferred.reject(it) } } } return deferred.promise } fun deleteMessage(messageServerID: Long, channel: Long, server: String, isSentByUser: Boolean): Promise { return retryIfNeeded(maxRetryCount) { val isModerationRequest = !isSentByUser Log.d("Loki", "Deleting message with ID: $messageServerID from open group with ID: $channel on server: $server (isModerationRequest = $isModerationRequest).") val endpoint = if (isSentByUser) "channels/$channel/messages/$messageServerID" else "loki/v1/moderation/message/$messageServerID" execute(HTTPVerb.DELETE, server, endpoint, isJSONRequired = false).then { Log.d("Loki", "Deleted message with ID: $messageServerID from open group with ID: $channel on server: $server.") messageServerID } } } @JvmStatic fun deleteMessages(messageServerIDs: List, channel: Long, server: String, isSentByUser: Boolean): Promise, Exception> { return retryIfNeeded(maxRetryCount) { val isModerationRequest = !isSentByUser val parameters = mapOf( "ids" to messageServerIDs.joinToString(",") ) Log.d("Loki", "Deleting messages with IDs: ${messageServerIDs.joinToString()} from open group with ID: $channel on server: $server (isModerationRequest = $isModerationRequest).") val endpoint = if (isSentByUser) "loki/v1/messages" else "loki/v1/moderation/messages" execute(HTTPVerb.DELETE, server, endpoint, parameters = parameters, isJSONRequired = false).then { json -> Log.d("Loki", "Deleted messages with IDs: $messageServerIDs from open group with ID: $channel on server: $server.") messageServerIDs } } } @JvmStatic fun getModerators(channel: Long, server: String): Promise, Exception> { return execute(HTTPVerb.GET, server, "loki/v1/channel/$channel/get_moderators").then { json -> try { @Suppress("UNCHECKED_CAST") val moderators = json["moderators"] as? List val moderatorsAsSet = moderators.orEmpty().toSet() if (this.moderators[server] != null) { this.moderators[server]!![channel] = moderatorsAsSet } else { this.moderators[server] = hashMapOf( channel to moderatorsAsSet ) } moderatorsAsSet } catch (exception: Exception) { Log.d("Loki", "Couldn't parse moderators for open group with ID: $channel on server: $server.") throw exception } } } @JvmStatic fun getChannelInfo(channel: Long, server: String): Promise { return retryIfNeeded(maxRetryCount) { val parameters = mapOf( "include_annotations" to 1 ) execute(HTTPVerb.GET, server, "/channels/$channel", parameters = parameters).then { json -> try { val data = json["data"] as Map<*, *> val annotations = data["annotations"] as List> val annotation = annotations.find { (it["type"] as? String ?: "") == channelInfoType } ?: throw Error.ParsingFailed val info = annotation["value"] as Map<*, *> val displayName = info["name"] as String val countInfo = data["counts"] as Map<*, *> val memberCount = countInfo["subscribers"] as? Int ?: (countInfo["subscribers"] as? Long)?.toInt() ?: (countInfo["subscribers"] as String).toInt() val profilePictureURL = info["avatar"] as String val publicChatInfo = OpenGroupInfo(displayName, profilePictureURL, memberCount) MessagingModuleConfiguration.shared.storage.setUserCount(channel, server, memberCount) publicChatInfo } catch (exception: Exception) { Log.d("Loki", "Couldn't parse info for open group with ID: $channel on server: $server.") throw exception } } } } @JvmStatic fun updateProfileIfNeeded(channel: Long, server: String, groupID: String, info: OpenGroupInfo, isForcedUpdate: Boolean) { val storage = MessagingModuleConfiguration.shared.storage storage.setUserCount(channel, server, info.memberCount) storage.updateTitle(groupID, info.displayName) // Download and update profile picture if needed val oldProfilePictureURL = storage.getOpenGroupProfilePictureURL(channel, server) if (isForcedUpdate || oldProfilePictureURL != info.profilePictureURL) { val profilePictureAsByteArray = downloadOpenGroupProfilePicture(server, info.profilePictureURL) ?: return storage.updateProfilePicture(groupID, profilePictureAsByteArray) storage.setOpenGroupProfilePictureURL(channel, server, info.profilePictureURL) } } @JvmStatic fun downloadOpenGroupProfilePicture(server: String, endpoint: String): ByteArray? { val url = "${server.removeSuffix("/")}/${endpoint.removePrefix("/")}" Log.d("Loki", "Downloading open group profile picture from \"$url\".") val outputStream = ByteArrayOutputStream() try { DownloadUtilities.downloadFile(outputStream, url, FileServerAPI.maxFileSize, null) Log.d("Loki", "Open group profile picture was successfully loaded from \"$url\"") return outputStream.toByteArray() } catch (e: Exception) { Log.d("Loki", "Failed to download open group profile picture from \"$url\" due to error: $e.") return null } finally { outputStream.close() } } @JvmStatic fun join(channel: Long, server: String): Promise { return retryIfNeeded(maxRetryCount) { execute(HTTPVerb.POST, server, "/channels/$channel/subscribe").then { Log.d("Loki", "Joined channel with ID: $channel on server: $server.") } } } @JvmStatic fun leave(channel: Long, server: String): Promise { return retryIfNeeded(maxRetryCount) { execute(HTTPVerb.DELETE, server, "/channels/$channel/subscribe").then { Log.d("Loki", "Left channel with ID: $channel on server: $server.") } } } @JvmStatic fun ban(publicKey: String, server: String): Promise { return retryIfNeeded(maxRetryCount) { execute(HTTPVerb.POST, server, "/loki/v1/moderation/blacklist/@$publicKey").then { Log.d("Loki", "Banned user with ID: $publicKey from $server") } } } @JvmStatic fun getDisplayNames(publicKeys: Set, server: String): Promise, Exception> { return getUserProfiles(publicKeys, server, false).map { json -> val mapping = mutableMapOf() for (user in json) { if (user["username"] != null) { val publicKey = user["username"] as String val displayName = user["name"] as? String ?: "Anonymous" mapping[publicKey] = displayName } } mapping } } @JvmStatic fun setDisplayName(newDisplayName: String?, server: String): Promise { Log.d("Loki", "Updating display name on server: $server.") val parameters = mapOf( "name" to (newDisplayName ?: "") ) return execute(HTTPVerb.PATCH, server, "users/me", parameters = parameters).map { Unit } } @JvmStatic fun setProfilePicture(server: String, profileKey: ByteArray, url: String?): Promise { return setProfilePicture(server, Base64.encodeBytes(profileKey), url) } fun setProfilePicture(server: String, profileKey: String, url: String?): Promise { Log.d("Loki", "Updating profile picture on server: $server.") val value = when (url) { null -> null else -> mapOf( "profileKey" to profileKey, "url" to url ) } // TODO: This may actually completely replace the annotations, have to double check it return setSelfAnnotation(server, profilePictureType, value).map { Unit }.fail { Log.d("Loki", "Failed to update profile picture due to error: $it.") } } // endregion }