This commit is contained in:
spaced4ndy
2026-08-12 18:31:55 +04:00
parent 247e327ebe
commit 261e6103f8
22 changed files with 598 additions and 102 deletions
+7
View File
@@ -133,6 +133,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
case apiChangeConnectionUser(connId: Int64, userId: Int64)
case apiConnectPlan(userId: Int64, connLink: String, resolveMode: PlanResolveMode, linkOwnerSig: LinkOwnerSig?)
case apiSendServiceRequest(userId: Int64, target: String, timeoutSec: Double?, requestJSON: String)
case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, verifiedDomain: SimplexDomain?)
case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData, verifiedDomain: SimplexDomain?)
case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64)
@@ -355,6 +356,9 @@ enum ChatCommand: ChatCmdProtocol {
let resolveStr = resolveMode == .unknown ? "" : " resolve=\(resolveMode.rawValue)"
let sigStr = if let linkOwnerSig { " sig=\(encodeJSON(linkOwnerSig))" } else { "" }
return "/_connect plan \(userId) \(connLink)\(resolveStr)\(sigStr)"
case let .apiSendServiceRequest(userId, target, timeoutSec, requestJSON):
let timeoutStr = if let timeoutSec { " timeout=\(timeoutSec)" } else { "" }
return "/_service_request \(userId) \(target)\(timeoutStr) \(requestJSON)"
case let .apiPrepareContact(userId, connLink, contactShortLinkData, verifiedDomain): return "/_prepare contact \(userId) \(connLink.cmdString)\(verifiedDomain.map{ " \($0.cmdString)" } ?? "") \(encodeJSON(contactShortLinkData))"
case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData, verifiedDomain): return "/_prepare group \(userId) \(connLink.cmdString) direct=\(onOff(directLink))\(verifiedDomain.map{ " \($0.cmdString)" } ?? "") \(encodeJSON(groupShortLinkData))"
case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)"
@@ -543,6 +547,7 @@ enum ChatCommand: ChatCmdProtocol {
case .apiSetConnectionIncognito: return "apiSetConnectionIncognito"
case .apiChangeConnectionUser: return "apiChangeConnectionUser"
case .apiConnectPlan: return "apiConnectPlan"
case .apiSendServiceRequest: return "apiSendServiceRequest"
case .apiPrepareContact: return "apiPrepareContact"
case .apiPrepareGroup: return "apiPrepareGroup"
case .apiChangePreparedContactUser: return "apiChangePreparedContactUser"
@@ -821,6 +826,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef)
case connectionPlan(user: UserRef, connLink: CreatedConnLink, planSimplexName: SimplexNameInfo?, otherSimplexName: SimplexNameInfo?, connectionPlan: ConnectionPlan)
case serviceResponse(user: UserRef, responseData: JSONValue)
case newPreparedChat(user: UserRef, chat: ChatData)
case contactUserChanged(user: UserRef, fromContact: Contact, newUser: UserRef, toContact: Contact)
case groupUserChanged(user: UserRef, fromGroup: GroupInfo, newUser: UserRef, toGroup: GroupInfo)
@@ -865,6 +871,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case .connectionIncognitoUpdated: "connectionIncognitoUpdated"
case .connectionUserChanged: "connectionUserChanged"
case .connectionPlan: "connectionPlan"
case .serviceResponse: "serviceResponse"
case .newPreparedChat: "newPreparedChat"
case .contactUserChanged: "contactUserChanged"
case .groupUserChanged: "groupUserChanged"
+21 -4
View File
@@ -298,30 +298,47 @@ class ChatItemDummyModel: ObservableObject {
func sendUpdate() { objectWillChange.send() }
}
// A directory search and a connection can overlap - tapping a result starts a connection while
// the search is still running - so the single progress slot records its owner.
enum ConnectProgressOwner {
case connect
case directorySearch
}
class ConnectProgressManager: ObservableObject {
@Published private var connectInProgress: String? = nil
@Published private var connectProgressByTimeout: Bool = false
private var onCancel: (() -> Void)?
private var owner: ConnectProgressOwner?
static let shared = ConnectProgressManager()
func startConnectProgress(_ text: String, onCancel: (() -> Void)? = nil) {
func startConnectProgress(_ text: String, owner: ConnectProgressOwner = .connect, onCancel: (() -> Void)? = nil) {
connectInProgress = text
self.owner = owner
self.onCancel = onCancel
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.connectProgressByTimeout = self.connectInProgress != nil
}
}
func stopConnectProgress() {
// a late directory search result must not clear the spinner that now belongs to a connection
func stopConnectProgress(_ owner: ConnectProgressOwner = .connect) {
if let current = self.owner, current != owner { return }
connectInProgress = nil
self.owner = nil
onCancel = nil
connectProgressByTimeout = false
}
// a user-initiated cancel, and the takeover in planAndConnect, cancel whatever is running
func cancelConnectProgress() {
onCancel?()
stopConnectProgress()
let cancel = onCancel
owner = nil
onCancel = nil
connectInProgress = nil
connectProgressByTimeout = false
cancel?()
}
var showConnectProgress: String? {
@@ -0,0 +1,73 @@
//
// DirectorySearch.swift
// SimpleX
//
// Created by spaced4ndy on 12.08.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import Foundation
import SimpleXChat
// The directory's contact address, as published in docs/DIRECTORY.md. It must be the short
// link: only that form carries the address DR keys that service requests require, so the full
// links on the What's New cards cannot be substituted here.
let DIRECTORY_SERVICE_LINK = "https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok"
// A service request is a full DR handshake, so it is slower than a local API call; the user
// gets a cancellable spinner while it runs and a retry row if it times out.
let DIRECTORY_SEARCH_TIMEOUT_SEC: Double = 10
struct DirectoryPublicLink: Decodable, Hashable {
var connFullLink: String? = nil
var connShortLink: String? = nil
}
struct DirectoryEntryType: Decodable, Hashable {
var groupType: GroupType? = nil
var summary: GroupSummary
}
struct DirectorySearchEntry: Decodable, Hashable, Identifiable {
var entryType: DirectoryEntryType
var displayName: String
var simplexName: String? = nil
var groupLink: DirectoryPublicLink
var shortDescr: String? = nil
var image: String? = nil
var activeAt: Date? = nil
var createdAt: Date? = nil
// the directory drops entries with no link, but the response is untrusted input
var connectLink: String? { groupLink.connShortLink ?? groupLink.connFullLink }
// the link is stable and unique per entry, so it also de-duplicates across pages
var id: String { connectLink ?? displayName }
}
private struct DirectorySearchResponse: Decodable {
var type: String
var entries: [DirectorySearchEntry]?
var searchCursor: JSONValue?
}
struct DirectorySearchResults {
var entries: [DirectorySearchEntry]
// opaque: stored and echoed back on the next request, never inspected
var cursor: JSONValue?
}
func directorySearchRequestJSON(_ text: String, _ cursor: JSONValue?) -> String {
var req: [String: JSONValue] = ["type": .string("search"), "searchText": .string(text)]
if let cursor { req["searchCursor"] = cursor }
return encodeJSON(JSONValue.object(req))
}
// The response is a tagged object: searchResults or error. Anything else is a failure rather
// than something to parse leniently - it comes from outside the app.
func parseDirectorySearchResponse(_ resp: JSONValue) -> DirectorySearchResults? {
guard let r: DirectorySearchResponse = decodeJSONValue(resp), r.type == "searchResults" else {
return nil
}
return DirectorySearchResults(entries: r.entries ?? [], cursor: r.searchCursor)
}
+17
View File
@@ -1032,6 +1032,23 @@ func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> Pendi
if let r { throw r.unexpected } else { return nil }
}
// Blocks until the directory replies or the timeout elapses.
func apiSearchDirectory(_ text: String, cursor: JSONValue?) async -> DirectorySearchResults? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiSearchDirectory: no current user")
return nil
}
let req = directorySearchRequestJSON(text, cursor)
let r: APIResult<ChatResponse1> = await chatApiSendCmd(
.apiSendServiceRequest(userId: userId, target: DIRECTORY_SERVICE_LINK, timeoutSec: DIRECTORY_SEARCH_TIMEOUT_SEC, requestJSON: req)
)
if case let .result(.serviceResponse(_, responseData)) = r {
return parseDirectorySearchResponse(responseData)
}
logger.error("apiSearchDirectory error: \(String(describing: r))")
return nil
}
func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>) async -> ConnectionPlanResult? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectPlan: no current user")
+44
View File
@@ -716,6 +716,50 @@ private func encodeCJSON<T: Encodable>(_ value: T) -> [CChar] {
encodeJSON(value).cString(using: .utf8)!
}
// Type-erased JSON, so a service response can cross the API layer without it knowing which
// service produced the payload. Callers re-decode it into their own type with decodeJSONValue.
public enum JSONValue: Codable, Hashable {
case null
case bool(Bool)
case number(Double)
case string(String)
case array([JSONValue])
case object([String: JSONValue])
public init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if c.decodeNil() { self = .null }
else if let v = try? c.decode(Bool.self) { self = .bool(v) }
else if let v = try? c.decode(Double.self) { self = .number(v) }
else if let v = try? c.decode(String.self) { self = .string(v) }
else if let v = try? c.decode([JSONValue].self) { self = .array(v) }
else if let v = try? c.decode([String: JSONValue].self) { self = .object(v) }
else {
throw DecodingError.dataCorruptedError(in: c, debugDescription: "invalid JSON value")
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
switch self {
case .null: try c.encodeNil()
case let .bool(v): try c.encode(v)
case let .number(v): try c.encode(v)
case let .string(v): try c.encode(v)
case let .array(v): try c.encode(v)
case let .object(v): try c.encode(v)
}
}
public var stringValue: String? {
if case let .string(v) = self { return v } else { return nil }
}
}
public func decodeJSONValue<T: Decodable>(_ value: JSONValue) -> T? {
decodeJSON(encodeJSON(value))
}
// Spec: spec/api.md#ChatError
public enum ChatError: Decodable, Hashable, Error {
case error(errorType: ChatErrorType)
@@ -45,15 +45,22 @@ import kotlin.collections.ArrayList
import kotlin.random.Random
import kotlin.time.*
// A directory search and a connection can overlap - tapping a result starts a connection while
// the search is still running - so the single progress slot records its owner: a late search
// result must not clear the spinner that now belongs to the connection.
enum class ConnectProgressOwner { Connect, DirectorySearch }
object ConnectProgressManager {
private val connectInProgress = mutableStateOf<String?>(null)
private val connectProgressByTimeout = mutableStateOf(false)
private var onCancel: (() -> Unit)? = null
private var owner: ConnectProgressOwner? = null
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun startConnectProgress(text: String, onCancel: (() -> Unit)? = null) {
fun startConnectProgress(text: String, owner: ConnectProgressOwner = ConnectProgressOwner.Connect, onCancel: (() -> Unit)? = null) {
connectInProgress.value = text
this.owner = owner
this.onCancel = onCancel
coroutineScope.launch {
delay(1000)
@@ -61,15 +68,22 @@ object ConnectProgressManager {
}
}
fun stopConnectProgress() {
fun stopConnectProgress(owner: ConnectProgressOwner = ConnectProgressOwner.Connect) {
if (this.owner != null && this.owner != owner) return
connectInProgress.value = null
this.owner = null
onCancel = null
connectProgressByTimeout.value = false
}
// a user-initiated cancel, and the takeover in planAndConnect, cancel whatever is running
fun cancelConnectProgress() {
onCancel?.invoke()
stopConnectProgress()
val cancel = onCancel
owner = null
onCancel = null
connectInProgress.value = null
connectProgressByTimeout.value = false
cancel?.invoke()
}
val showConnectProgress: String? get() =
@@ -0,0 +1,66 @@
package chat.simplex.common.model
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
// The directory's contact address, as published in docs/DIRECTORY.md. It must be the short
// link: only that form carries the address DR keys that service requests require, so the full
// links on the What's New cards cannot be substituted here.
const val DIRECTORY_SERVICE_LINK = "https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok"
// A service request is a full DR handshake, so it is slower than a local API call; the user
// gets a cancellable spinner while it runs and a retry row if it times out.
const val DIRECTORY_SEARCH_TIMEOUT_SEC = 10.0
@Serializable
data class DirectoryPublicLink(
val connFullLink: String? = null,
val connShortLink: String? = null,
)
@Serializable
data class DirectoryEntryType(
val groupType: GroupType? = null,
val summary: GroupSummary,
)
@Serializable
data class DirectorySearchEntry(
val entryType: DirectoryEntryType,
val displayName: String,
val simplexName: String? = null,
val groupLink: DirectoryPublicLink,
val shortDescr: String? = null,
val image: String? = null,
val activeAt: Instant? = null,
val createdAt: Instant? = null,
) {
// the directory drops entries with no link, but the response is untrusted input
val connectLink: String? get() = groupLink.connShortLink ?: groupLink.connFullLink
}
data class DirectorySearchResults(
val entries: List<DirectorySearchEntry>,
// opaque: stored and echoed back on the next request, never inspected
val cursor: JsonObject?,
)
fun directorySearchRequest(text: String, cursor: JsonObject?): JsonObject = buildJsonObject {
put("type", JsonPrimitive("search"))
put("searchText", JsonPrimitive(text))
if (cursor != null) put("searchCursor", cursor)
}
// The response is a tagged object: searchResults or error. Anything else is treated as a failure
// rather than parsed leniently - it comes from outside the app.
fun parseDirectorySearchResponse(resp: JsonObject): DirectorySearchResults? =
when ((resp["type"] as? JsonPrimitive)?.contentOrNull) {
"searchResults" -> {
val entries = (resp["entries"] as? JsonArray)?.mapNotNull {
runCatching { json.decodeFromJsonElement<DirectorySearchEntry>(it) }.getOrNull()
} ?: emptyList()
DirectorySearchResults(entries, resp["searchCursor"] as? JsonObject)
}
else -> null
}
@@ -1531,6 +1531,17 @@ object ChatController {
return null
}
// Blocks until the directory replies or the timeout elapses, so callers must use
// withLongRunningApi, not the single-threaded withBGApi.
suspend fun apiSearchDirectory(rh: Long?, text: String, cursor: JsonObject?): DirectorySearchResults? {
val userId = kotlin.runCatching { currentUserId("apiSearchDirectory") }.getOrElse { return null }
val req = directorySearchRequest(text, cursor)
val r = sendCmdWithRetry(rh, CC.APISendServiceRequest(userId, DIRECTORY_SERVICE_LINK, DIRECTORY_SEARCH_TIMEOUT_SEC, req))
if (r is API.Result && r.res is CR.CRServiceResponse) return parseDirectorySearchResponse(r.res.responseData)
Log.e(TAG, "apiSearchDirectory error: $r")
return null
}
suspend fun apiConnectPlan(rh: Long?, connLink: String, resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>): ConnectionPlanResult? {
val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null }
val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, resolveMode, linkOwnerSig), inProgress = inProgress)
@@ -3879,6 +3890,7 @@ sealed class CC {
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC()
class APIConnectPlan(val userId: Long, val connLink: String, val resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, val linkOwnerSig: LinkOwnerSig? = null): CC()
class APISendServiceRequest(val userId: Long, val target: String, val timeoutSec: Double?, val request: JsonObject): CC()
class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData, val verifiedDomain: SimplexDomain? = null): CC()
class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData, val verifiedDomain: SimplexDomain? = null): CC()
class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC()
@@ -4092,6 +4104,10 @@ sealed class CC {
val sigStr = if (linkOwnerSig != null) " sig=${json.encodeToString(linkOwnerSig)}" else ""
"/_connect plan $userId $connLink$resolveStr$sigStr"
}
is APISendServiceRequest -> {
val timeoutStr = if (timeoutSec != null) " timeout=$timeoutSec" else ""
"/_service_request $userId $target$timeoutStr ${json.encodeToString(request)}"
}
is APIPrepareContact -> "/_prepare contact $userId ${connLink.cmdString}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(contactShortLinkData)}"
is APIPrepareGroup -> "/_prepare group $userId ${connLink.cmdString} direct=${onOff(directLink)}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(groupShortLinkData)}"
is APIChangePreparedContactUser -> "/_set contact user @$contactId $newUserId"
@@ -4278,6 +4294,7 @@ sealed class CC {
is ApiSetConnectionIncognito -> "apiSetConnectionIncognito"
is ApiChangeConnectionUser -> "apiChangeConnectionUser"
is APIConnectPlan -> "apiConnectPlan"
is APISendServiceRequest -> "apiSendServiceRequest"
is APIPrepareContact -> "apiPrepareContact"
is APIPrepareGroup -> "apiPrepareGroup"
is APIChangePreparedContactUser -> "apiChangePreparedContactUser"
@@ -6564,6 +6581,7 @@ sealed class CR {
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink, val planSimplexName: SimplexNameInfo? = null, val otherSimplexName: SimplexNameInfo? = null, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("serviceResponse") class CRServiceResponse(val user: UserRef, val responseData: JsonObject): CR()
@Serializable @SerialName("newPreparedChat") class NewPreparedChat(val user: UserRef, val chat: Chat): CR()
@Serializable @SerialName("contactUserChanged") class ContactUserChanged(val user: UserRef, val fromContact: Contact, val newUser: UserRef, val toContact: Contact): CR()
@Serializable @SerialName("groupUserChanged") class GroupUserChanged(val user: UserRef, val fromGroup: GroupInfo, val newUser: UserRef, val toGroup: GroupInfo): CR()
@@ -25,6 +25,7 @@ import Control.Applicative (optional, (<|>))
import Data.Attoparsec.Text (Parser)
import qualified Data.Attoparsec.Text as A
import Data.Char (isSpace)
import qualified Data.Aeson as J
import Data.Either (fromRight)
import Data.Functor (($>))
import Data.Maybe (fromMaybe, isNothing)
@@ -66,6 +67,7 @@ data DirectoryEvent
| DEItemEditIgnored Contact
| DEItemDeleteIgnored Contact
| DEContactCommand Contact ChatItemId ADirectoryCmd
| DEServiceRequest AgentInvId J.Object
| DELogChatResponse Text
deriving (Show)
@@ -110,6 +112,7 @@ crDirectoryEvent_ = \case
where
ciId = chatItemId' ci
err = ADC SDRUser DCUnknownCommand
CEvtServiceRequest {requestId, requestData} -> Just $ DEServiceRequest requestId requestData
CEvtMessageError {severity, errorMessage} -> Just $ DELogChatResponse $ "message error: " <> severity <> ", " <> errorMessage
CEvtChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors)
_ -> Nothing
@@ -123,13 +123,8 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma
}
imgData = imgFileData groupLink =<< image
in (de, imgData)
in case publicGroup of
Just PublicGroupProfile {groupLink = sLnk} ->
Just $ entry $ PublicLink Nothing (Just sLnk)
Nothing ->
entry . toPublicLink . connLinkContact <$> gLink_
in entry <$> groupPublicLink g gLink_
where
toPublicLink (CCLink fullLink shortLink) = PublicLink (Just fullLink) shortLink
imgFileData :: PublicLink -> ImageData -> Maybe (FilePath, ByteString)
imgFileData PublicLink {connFullLink, connShortLink} (ImageData img) =
let (img', imgExt) =
@@ -145,6 +140,14 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma
Right img'' -> Just (imgFile, img'')
Left _ -> Nothing
-- a public group publishes its own link in the profile; other groups only have the link the service created
groupPublicLink :: GroupInfo -> Maybe GroupLink -> Maybe PublicLink
groupPublicLink GroupInfo {groupProfile = GroupProfile {publicGroup}} gLink_ = case publicGroup of
Just PublicGroupProfile {groupLink = sLnk} -> Just $ PublicLink Nothing (Just sLnk)
Nothing -> toPublicLink . connLinkContact <$> gLink_
where
toPublicLink (CCLink fullLink shortLink) = PublicLink (Just fullLink) shortLink
generateListing :: FilePath -> [(GroupInfo, GroupReg, Maybe GroupLink)] -> IO ()
generateListing dir gs = do
createDirectoryIfMissing True dir
@@ -0,0 +1,67 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Directory.Rpc where
import qualified Data.Aeson.TH as JQ
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Directory.Listing
import Directory.Search
import Directory.Store
import Simplex.Chat.Types
import Simplex.Messaging.SimplexName (SimplexNameInfo (..), SimplexNameType (..), shortNameInfoStr)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON)
data DirectoryRequest = DRSearch
{ searchText :: Text,
-- opaque to the client: it is stored and echoed back, never inspected
searchCursor :: Maybe SearchCursor
}
data DirectorySearchEntry = DirectorySearchEntry
{ entryType :: DirectoryEntryType,
displayName :: Text,
simplexName :: Maybe Text,
groupLink :: PublicLink,
-- stored text, not DirectoryEntry's MarkdownList: the apps parse markdown locally
shortDescr :: Maybe Text,
image :: Maybe ImageData,
activeAt :: Maybe UTCTime,
createdAt :: Maybe UTCTime
}
data DirectoryResponse
= DRSearchResults
{ entries :: [DirectorySearchEntry],
searchCursor :: Maybe SearchCursor -- Nothing when there are no more results
}
| DRError {errorMessage :: Text}
$(JQ.deriveJSON defaultJSON ''DirectorySearchEntry)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "DR") ''DirectoryRequest)
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "DR") ''DirectoryResponse)
-- Entries without any link are dropped: there would be nothing to connect to.
searchEntry :: UTCTime -> GroupInfo -> Maybe GroupLink -> Maybe DirectorySearchEntry
searchEntry now g@GroupInfo {groupProfile, chatTs, createdAt = groupCreatedAt, groupSummary} gLink_ =
entry <$> groupPublicLink g gLink_
where
GroupProfile {displayName, shortDescr, image, memberAdmission, publicGroup} = groupProfile
entry groupLink =
DirectorySearchEntry
{ entryType = DETGroup ((\PublicGroupProfile {groupType} -> groupType) <$> publicGroup) memberAdmission groupSummary,
displayName,
simplexName = shortNameInfoStr . SimplexNameInfo NTPublicGroup <$> verifiedGroupDomain g,
groupLink,
shortDescr,
image,
activeAt = recentRoundedTime 900 now $ fromMaybe groupCreatedAt chatTs,
createdAt = recentRoundedTime 86400 now groupCreatedAt
}
@@ -1,9 +1,13 @@
{-# LANGUAGE TemplateHaskell #-}
module Directory.Search where
import qualified Data.Aeson.TH as JQ
import Data.Int (Int64)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Simplex.Chat.Types
import Simplex.Messaging.Parsers (defaultJSON)
data SearchRequest = SearchRequest
{ searchType :: SearchType,
@@ -20,3 +24,5 @@ data SearchCursor = SearchCursor
}
data SearchType = STAll | STRecent | STSearch Text
$(JQ.deriveJSON defaultJSON ''SearchCursor)
@@ -20,17 +20,22 @@ where
import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.STM
import Control.Exception (SomeException, try)
import Control.Exception (SomeException, finally, try)
import Control.Logger.Simple
import Control.Monad
import Control.Monad.Except
import Control.Monad.IO.Class
import qualified Data.Attoparsec.Text as A
import qualified Data.Aeson as J
import qualified Data.Aeson.Types as JT
import Data.Bifunctor (first)
import Data.Either (fromRight)
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Either (fromRight, isRight)
import Data.Foldable (foldl')
import Data.List (find, intercalate)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.Map.Strict as M
import Data.Functor (($>))
import Data.Maybe (fromMaybe, isJust, isNothing, maybeToList)
import qualified Data.Set as S
import Data.Text (Text)
@@ -43,6 +48,7 @@ import Directory.Captcha
import Directory.Events
import Directory.Listing
import Directory.Options
import Directory.Rpc
import Directory.Search
import Directory.Store
import Directory.Store.Migrate
@@ -54,7 +60,7 @@ import Simplex.Chat.Core
import Simplex.Chat.Markdown (Format (..), FormattedText (..), SimplexLinkType (..), parseMaybeMarkdownList, viewName)
import Simplex.Chat.Messages
import Simplex.Chat.Options
import Simplex.Chat.Protocol (GroupShortLinkData (..), LinkOwnerSig (..), MsgChatLink (..), MsgContent (..), memberSupportVoiceVersion)
import Simplex.Chat.Protocol (GroupShortLinkData (..), compressServiceBody, LinkOwnerSig (..), MsgChatLink (..), MsgContent (..), memberSupportVoiceVersion)
import Simplex.Chat.Store.Direct (getContact)
import Simplex.Chat.Store.Groups (getGroupLink, getGroupMember, getGroupMemberByMemberId, setGroupCustomData) -- TODO remove setGroupCustomData
import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo)
@@ -103,7 +109,9 @@ data ServiceState = ServiceState
pendingCaptchas :: TMap GroupMemberId PendingCaptcha,
serviceCC :: TMVar ChatController,
eventQ :: TQueue DirectoryEvent,
updateListingsJob :: TMVar ()
updateListingsJob :: TMVar (),
-- service requests carry no caller identity, so the only possible bound is global
serviceRequestsInFlight :: TVar Int
}
data CaptchaMode = CMText | CMAudio
@@ -132,7 +140,14 @@ newServiceState opts = do
serviceCC <- newEmptyTMVarIO
eventQ <- newTQueueIO
updateListingsJob <- newEmptyTMVarIO
pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas, serviceCC, eventQ, updateListingsJob}
serviceRequestsInFlight <- newTVarIO 0
pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas, serviceCC, eventQ, updateListingsJob, serviceRequestsInFlight}
-- Requests are answered off the event loop, which is shared with registrations and captchas,
-- so the bound has to be here rather than in the loop. Over the bound requests are refused
-- immediately: a caller gets an error rather than waiting out its timeout.
maxServiceRequestsInFlight :: Int
maxServiceRequestsInFlight = 8
welcomeGetOpts :: IO DirectoryOpts
welcomeGetOpts = do
@@ -318,7 +333,7 @@ readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, na
pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling}
directoryServiceEvent :: DirectoryOpts -> ServiceState -> User -> ChatController -> DirectoryEvent -> IO ()
directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults, prohibitedToObserver, alwaysCaptcha} env@ServiceState {searchRequests} user@User {userId} cc = \case
directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults, prohibitedToObserver, alwaysCaptcha} env@ServiceState {searchRequests, serviceRequestsInFlight} user@User {userId} cc = \case
DEContactConnected ct -> deContactConnected ct
DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole
DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner
@@ -344,8 +359,64 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o
SDRUser -> deUserCommand ct ciId cmd
SDRAdmin -> deAdminCommand ct ciId cmd
SDRSuperUser -> deSuperUserCommand ct ciId cmd
DEServiceRequest reqId req -> deServiceRequest reqId req
DELogChatResponse r -> logInfo r
where
deServiceRequest :: AgentInvId -> J.Object -> IO ()
deServiceRequest reqId req = do
accepted <- atomically $ stateTVar serviceRequestsInFlight $ \n ->
if n < maxServiceRequestsInFlight then (True, n + 1) else (False, n)
if accepted
then void $ forkIO $ (respond =<< requestResponse) `finally` releaseSlot
else reject "service is busy"
where
releaseSlot = atomically $ modifyTVar' serviceRequestsInFlight (subtract 1)
-- rejecting is cheaper than answering and fails the caller immediately
reject reason =
sendChatCmd cc (APIRejectServiceRequest userId reqId $ Just reason) >>= \case
Right _ -> pure ()
Left e -> logError $ "service reject error: " <> tshow e
requestResponse = case JT.parseMaybe JT.parseJSON (J.Object req) of
Nothing -> pure $ DRError "unsupported request"
Just DRSearch {searchText, searchCursor} -> directorySearch searchText searchCursor
respond resp = case J.toJSON resp of
J.Object o ->
sendChatCmd cc (APISendServiceResponse userId reqId o) >>= \case
Right _ -> pure ()
Left e -> logError $ "service response error: " <> tshow e
_ -> logError "service response is not an object"
directorySearch :: Text -> Maybe SearchCursor -> IO DirectoryResponse
directorySearch searchText cursor_ =
searchListedGroups cc user (STSearch searchText) cursor_ searchResults >>= \case
Left e -> logError ("searchListedGroups error: " <> T.pack e) $> DRError "search failed"
Right (gs, _) ->
getGroupLinks cc user (map fst gs) >>= \case
Left e -> logError ("getGroupLinks error: " <> T.pack e) $> DRError "search failed"
Right links -> do
now <- getCurrentTime
-- rows without a link are dropped, so entries and their cursors must stay paired
let rows = [(gr, e) | (gr, Just e) <- zipWith (\gr@(g, _) l -> (gr, searchEntry now g l)) gs links]
pure $ searchResults_ $ fitRows rows
where
-- send as many entries as the padded envelope allows; a lone entry that does not fit
-- is sent without its image, so paging always makes progress
fitRows [] = []
fitRows rows
| fits rows = rows
| [(gr, e)] <- rows = [(gr, dropImage e)]
| otherwise = fitRows $ init rows
dropImage :: DirectorySearchEntry -> DirectorySearchEntry
dropImage e = e {image = Nothing}
fits = isRight . compressServiceBody . LB.toStrict . J.encode . searchResults_
searchResults_ rows =
DRSearchResults
{ entries = map snd rows,
-- the cursor is the last row actually sent, so truncated rows are not skipped
searchCursor = rowCursor . fst <$> lastMaybe rows
}
lastMaybe = foldl' (\_ x -> Just x) Nothing
rowCursor (GroupInfo {groupId, groupSummary = GroupSummary {currentMembers}}, GroupReg {createdAt}) =
SearchCursor {lastMembers = currentMembers, lastCreatedAt = createdAt, lastGroupId = groupId}
groupLinkText (CCLink cReq sLnk_) = maybe (strEncodeTxt $ simplexChatContact cReq) strEncodeTxt sLnk_
withAdminUsers action = void . forkIO $ do
forM_ superUsers $ \KnownContact {contactId} -> action contactId
@@ -38,6 +38,7 @@ module Directory.Store
listPendingGroups,
getAllListedGroups,
getAllListedGroups_,
getGroupLinks,
searchListedGroups,
verifiedGroupDomain,
groupRegStatusText,
@@ -340,6 +341,12 @@ getAllListedGroups_ db cxt user@User {userId, userContactId} = do
where
withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g)
-- only the RPC search needs links, so they are read for the returned page rather than in the search query
getGroupLinks :: ChatController -> User -> [GroupInfo] -> IO (Either String [Maybe GroupLink])
getGroupLinks cc user gs =
withDB' "getGroupLinks" cc $ \db ->
mapM (\g -> eitherToMaybe <$> runExceptT (getGroupLink db user g)) gs
searchListedGroups :: ChatController -> User -> SearchType -> Maybe SearchCursor -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int))
searchListedGroups cc user@User {userId, userContactId} searchType cursor_ pageSize =
withDB' "searchListedGroups" cc $ \db -> do