core: allow creating a user without making it active

CreateActiveUser gains keepActiveUser. Creating a user activates it, and
APIChangePreparedContactUser resolves the chat under the active user, so
create-then-reassign cannot find the chat the invitation is for. With the
flag the sequence has nothing to undo: create, reassign while the old
profile still owns it, switch once.

BoolDef makes the field default to False, so older clients and both CLI
parsers are unaffected. It is ignored when there is no active user to
keep, which would otherwise leave none at all.

Includes the regenerated NewUser type in the three generated client
artifacts, and the two hand-synced clients that build a NewUser literal.
This commit is contained in:
Narasimha-sc
2026-09-05 11:27:36 +00:00
parent 76a577afb3
commit b137ff459e
9 changed files with 50 additions and 10 deletions
+1
View File
@@ -3068,6 +3068,7 @@ SubscribeError:
- pastTimestamp: bool
- userChatRelay: bool
- clientService: bool
- keepActiveUser: bool
---
@@ -3367,6 +3367,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)
}
@@ -660,7 +660,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}))
@@ -2356,6 +2356,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
+8 -6
View File
@@ -420,7 +420,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
@@ -432,17 +432,19 @@ 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
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 ()
@@ -5930,7 +5932,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
@@ -5939,7 +5941,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
+2 -1
View File
@@ -155,7 +155,8 @@ data NewUser = NewUser
{ profile :: Maybe Profile,
pastTimestamp :: Bool,
userChatRelay :: BoolDef,
clientService :: BoolDef
clientService :: BoolDef,
keepActiveUser :: BoolDef
}
deriving (Show)
+34
View File
@@ -52,6 +52,7 @@ chatProfileTests = do
it "reject profile image that is too large" testSetProfileImageTooLarge
it "set profile image from file" testSetProfileImageFromFile
it "use multiword profile names" testMultiWordProfileNames
it "create user without keepActiveUser activates it" testCreateUserWithoutKeepActiveUser
it "auto-accept group invitations" testAutoAcceptGroupInvitations
it "auto-accept group invitations on a second profile" testAutoAcceptGroupInvitationsSecondProfile
it "auto-accept group invitations on an inactive profile" testAutoAcceptGroupInvitationsInactiveProfile
@@ -156,6 +157,7 @@ shortLinkTests = do
it "connect to prepared contact incognito (via address)" testShortLinkAddressConnectPreparedContactIncognito
it "change prepared contact user" testShortLinkChangePreparedContactUser
it "change prepared contact user, new user has contact with the same name" testShortLinkChangePreparedContactUserDuplicate
it "create user keeping active user, then change prepared contact user" testCreateUserKeepingActiveUser
it "connect to prepared group incognito" testShortLinkConnectPreparedGroupIncognito
it "change prepared group user" testShortLinkChangePreparedGroupUser
it "change prepared group user, new user has group with the same name" testShortLinkChangePreparedGroupUserDuplicate
@@ -4050,6 +4052,38 @@ testShortLinkChangePreparedContactUser = testChat2 aliceProfile bobProfile test
bob @@@ []
bob `hasContactProfiles` ["bob"]
testCreateUserKeepingActiveUser :: HasCallStack => TestParams -> IO ()
testCreateUserKeepingActiveUser = testChat2 aliceProfile bobProfile test
where
test alice bob = do
bob ##> "/_create user {\"profile\":{\"displayName\":\"robert\",\"fullName\":\"\"},\"pastTimestamp\":false,\"keepActiveUser\":true}"
showActiveUser bob "robert"
bob ##> "/u"
showActiveUser bob "bob (Bob)"
bob ##> "/users"
bob <## "bob (Bob) (active)"
bob <## "robert"
alice ##> "/_connect 1"
(shortLink, fullLink) <- getInvitations alice
bob ##> ("/_connect plan 1 " <> shortLink)
bob <## "invitation link: ok to connect"
contactSLinkData <- getTermLine bob
bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData)
bob <## "alice: contact is prepared"
bob ##> "/_set contact user @4 2"
bob <## "contact alice changed from user bob to user robert"
testCreateUserWithoutKeepActiveUser :: HasCallStack => TestParams -> IO ()
testCreateUserWithoutKeepActiveUser = testChat aliceProfile test
where
test alice = do
alice ##> "/_create user {\"profile\":{\"displayName\":\"alisa\",\"fullName\":\"\"},\"pastTimestamp\":false}"
showActiveUser alice "alisa"
alice ##> "/u"
showActiveUser alice "alisa"
testShortLinkChangePreparedContactUserDuplicate :: HasCallStack => TestParams -> IO ()
testShortLinkChangePreparedContactUserDuplicate = testChat2 aliceProfile bobProfile test
where