core: allow creating a user without making it active

Adds keepActiveUser to NewUser. When set, the new user is created but the
current active user is preserved.

This is needed to create a profile for an invitation: the prepared chat is
resolved under the active user by APIChangePreparedContactUser, so the profile
that owns the invitation has to stay active until the chat has been moved to
the new one. Creating and activating in one step makes that reassignment
impossible without switching back and forth.

BoolDef gives the field omittedField = False, so clients that do not send it -
iOS, the CLI, and any older caller - keep the current behaviour of activating
the new user.

keepActiveUser is ignored when there is no active user to keep, which would
otherwise leave the app with none at all.

Note the response is still CRActiveUser: it carries the created user, which is
not the active one on this path. Documented at the field.

Regenerates the API docs and the generated TypeScript and Python client types,
which are checked by the Bot API docs tests.

The generator emits BoolDef fields as required in the client types, so the two
hand-maintained clients that build a NewUser literal - simplex-chat-python and
simplex-chat-nodejs - stop type-checking until the field is added there too. No
test covers those; a4e3a1ea1 did the same when clientService was added.
This commit is contained in:
Narasimha-sc
2026-08-03 07:59:49 +00:00
parent c38c87e26a
commit bc1e8b6de7
8 changed files with 21 additions and 10 deletions
+1
View File
@@ -3054,6 +3054,7 @@ SubscribeError:
- pastTimestamp: bool
- userChatRelay: bool
- clientService: bool
- keepActiveUser: bool
---
@@ -3348,6 +3348,7 @@ export interface NewUser {
pastTimestamp: boolean
userChatRelay: boolean
clientService: boolean
keepActiveUser: boolean
}
export interface NoteFolder {
+1 -1
View File
@@ -867,7 +867,7 @@ export class ChatApi {
* Network usage: no.
*/
async apiCreateActiveUser(profile?: T.Profile): Promise<T.User> {
const r = await this.sendChatCmd(CC.CreateActiveUser.cmdString({newUser: {profile, pastTimestamp: false, userChatRelay: false, clientService: false}}))
const r = await this.sendChatCmd(CC.CreateActiveUser.cmdString({newUser: {profile, pastTimestamp: false, userChatRelay: false, clientService: false, keepActiveUser: false}}))
if (r.type === "activeUser") return r.user
throw new ChatCommandError("unexpected response", r)
}
@@ -642,7 +642,7 @@ class ChatApi:
raise
async def api_create_active_user(self, profile: T.Profile | None = None) -> T.User:
new_user: T.NewUser = {"pastTimestamp": False, "userChatRelay": False, "clientService": False}
new_user: T.NewUser = {"pastTimestamp": False, "userChatRelay": False, "clientService": False, "keepActiveUser": False}
if profile is not None:
new_user["profile"] = profile
r = await self.send_chat_cmd(CC.CreateActiveUser_cmd_string({"newUser": new_user}))
@@ -2337,6 +2337,7 @@ class NewUser(TypedDict):
pastTimestamp: bool
userChatRelay: bool
clientService: bool
keepActiveUser: bool
class NoteFolder(TypedDict):
noteFolderId: int # int64
+1 -1
View File
@@ -160,7 +160,7 @@ createActiveUser cc CoreChatOpts {chatRelay, headless} createBot_ userDisplayNam
createUser loop False $ mkProfile displayName
mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
createUser onError clientService p =
execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = BoolDef chatRelay, clientService = BoolDef clientService}) 0 `runReaderT` cc >>= \case
execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = BoolDef chatRelay, clientService = BoolDef clientService, keepActiveUser = BoolDef False}) 0 `runReaderT` cc >>= \case
Right (CRActiveUser user) -> pure user
r -> printResponseEvent (Nothing, Nothing) (config cc) r >> onError
+10 -6
View File
@@ -415,7 +415,7 @@ parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace
processChatCommand :: StoreCxt -> NetworkRequestMode -> ChatCommand -> CM ChatResponse
processChatCommand cxt nm = \case
ShowActiveUser -> withUser' $ pure . CRActiveUser
CreateActiveUser NewUser {profile, pastTimestamp, userChatRelay, clientService} -> do
CreateActiveUser NewUser {profile, pastTimestamp, userChatRelay, clientService, keepActiveUser} -> do
forM_ profile $ \p@Profile {displayName, image} -> do
checkValidName displayName
checkProfileImageSize image
@@ -427,17 +427,21 @@ processChatCommand cxt nm = \case
when (n == displayName) . throwChatError $
if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""}
when (isTrue userChatRelay && isTrue userChatRelay') $ throwChatError CEChatRelayExists
(uss, (smp', xftp')) <- chooseServers =<< readTVarIO u
curUser_ <- readTVarIO u
(uss, (smp', xftp')) <- chooseServers curUser_
let service = isTrue clientService
-- keepActiveUser can only be honoured when there is a user to keep,
-- otherwise it would leave no active user at all.
activateNewUser = isNothing curUser_ || not (isTrue keepActiveUser)
auId <- withAgent $ \a -> createUser a service smp' xftp'
ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure
user <- withFastStore $ \db -> do
user <- createUserRecordAt db (AgentUserId auId) (isTrue userChatRelay) service p True ts
user <- createUserRecordAt db (AgentUserId auId) (isTrue userChatRelay) service p activateNewUser ts
mapM_ (setUserServers db user ts) uss
createPresetContactCards db user `catchAllErrors` \_ -> pure ()
createNoteFolder db user
pure user
atomically . writeTVar u $ Just user
when activateNewUser $ atomically . writeTVar u $ Just user
pure $ CRActiveUser user
where
createPresetContactCards :: DB.Connection -> User -> ExceptT StoreError IO ()
@@ -5907,7 +5911,7 @@ chatCommandP =
(cName, shortDescr) <- profileNameDescr
service <- (" service=" *> onOffP) <|> pure False
let profile = Just Profile {displayName = cName, fullName = "", shortDescr, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef relay, clientService = BoolDef service}
pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef relay, clientService = BoolDef service, keepActiveUser = BoolDef False}
newBotUserP = do
files_ <- optional $ "files=" *> onOffP <* A.space
service <- ("service=" *> onOffP <* A.space) <|> pure False
@@ -5916,7 +5920,7 @@ chatCommandP =
Just True -> Nothing
_ -> Just (emptyChatPrefs :: Preferences) {files = Just FilesPreference {allow = FANo}}
profile = Just Profile {displayName = cName, fullName = "", shortDescr, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences, badge = Nothing, contactDomain = Nothing}
pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef False, clientService = BoolDef service}
pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef False, clientService = BoolDef service, keepActiveUser = BoolDef False}
jsonP :: J.FromJSON a => Parser a
jsonP = J.eitherDecodeStrict' <$?> A.takeByteString
groupProfile = do
+5 -1
View File
@@ -154,7 +154,11 @@ data NewUser = NewUser
{ profile :: Maybe Profile,
pastTimestamp :: Bool,
userChatRelay :: BoolDef,
clientService :: BoolDef
clientService :: BoolDef,
-- when set, the user is created without becoming active, preserving the current
-- one; absent/False activates it as before. The response is CRActiveUser either
-- way - it carries the created user, which is then not the active one.
keepActiveUser :: BoolDef
}
deriving (Show)