diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index f90e8e08da..af7b0146f7 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -7,6 +7,7 @@ This file is generated automatically. - [APIDeleteMyAddress](#apideletemyaddress) - [APIShowMyAddress](#apishowmyaddress) - [APISetProfileAddress](#apisetprofileaddress) +- [APISetUserDomain](#apisetuserdomain) - [APISetAddressSettings](#apisetaddresssettings) [Message commands](#message-commands) @@ -246,6 +247,53 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APISetUserDomain + +Set or remove SimpleX name of bot address. The name must be registered with the address short link. + +*Network usage*: interactive. + +**Parameters**: +- userId: int64 +- simplexDomain: string? + +**Syntax**: + +``` +/_set domain [ ] +``` + +```javascript +'/_set domain ' + userId + (simplexDomain ? ' ' + simplexDomain : '') // JavaScript +``` + +```python +'/_set domain ' + str(userId) + ((' ' + simplexDomain) if simplexDomain is not None else '') # Python +``` + +**Responses**: + +UserProfileUpdated: User profile updated. +- type: "userProfileUpdated" +- user: [User](./TYPES.md#user) +- fromProfile: [Profile](./TYPES.md#profile) +- toProfile: [Profile](./TYPES.md#profile) +- updateSummary: [UserProfileUpdateSummary](./TYPES.md#userprofileupdatesummary) + +UserProfileNoChange: User profile was not changed. +- type: "userProfileNoChange" +- user: [User](./TYPES.md#user) + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +**Errors**: +- SimplexDomainNotReady: The name does not resolve to the address short link. + +--- + + ### APISetAddressSettings Set bot address settings. diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 4ffba7bfbe..79820605a8 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -81,6 +81,14 @@ chatCommandsDocsData = ("APIDeleteMyAddress", [], "Delete bot address.", ["CRUserContactLinkDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete_address " <> Param "userId"), ("APIShowMyAddress", [], "Get bot address and settings.", ["CRUserContactLink", "CRChatCmdError"], [], Nothing, "/_show_address " <> Param "userId"), ("APISetProfileAddress", [], "Add address to bot profile.", ["CRUserProfileUpdated", "CRUserProfileNoChange", "CRChatCmdError"], [], Just UNInteractive, "/_profile_address " <> Param "userId" <> " " <> OnOff "enable"), + ( "APISetUserDomain", + [], + "Set or remove SimpleX name of bot address. The name must be registered with the address short link.", + ["CRUserProfileUpdated", "CRUserProfileNoChange", "CRChatCmdError"], + [TD "CESimplexDomainNotReady" "The name does not resolve to the address short link"], + Just UNInteractive, + "/_set domain " <> Param "userId" <> Optional "" (" " <> Param "$0") "simplexDomain" + ), ("APISetAddressSettings", [], "Set bot address settings.", ["CRUserContactLinkUpdated", "CRChatCmdError"], [], Just UNInteractive, "/_address_settings " <> Param "userId" <> OnOffParam "pq_ratchet" "pqRatchet" Nothing <> " " <> Json "settings") ] ), @@ -438,7 +446,6 @@ undocumentedCommands = "APISetServerOperators", "APISetUserContactReceipts", "APISetUserGroupReceipts", - "APISetUserDomain", "APISetUserServers", "APISetUserUIThemes", "APIStandaloneFileInfo", diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index c217ef944c..cc01ae9472 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -66,6 +66,21 @@ export namespace APISetProfileAddress { } } +// Set or remove SimpleX name of bot address. The name must be registered with the address short link. +// Network usage: interactive. +export interface APISetUserDomain { + userId: number // int64 + simplexDomain?: string +} + +export namespace APISetUserDomain { + export type Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError + + export function cmdString(self: APISetUserDomain): string { + return '/_set domain ' + self.userId + (self.simplexDomain ? ' ' + self.simplexDomain : '') + } +} + // Set bot address settings. // Network usage: interactive. export interface APISetAddressSettings { diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index ea8cff6ac0..8d4e75b181 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -422,7 +422,18 @@ export class ChatApi { const r = await this.sendChatCmd(CC.APISetAddressSettings.cmdString({userId, settings})) if (r.type !== "userContactLinkUpdated") { throw new ChatCommandError("error changing user contact address settings", r) - } + } + } + + async apiSetUserDomain(userId: number, simplexDomain?: string): Promise { + const r = await this.sendChatCmd(CC.APISetUserDomain.cmdString({userId, simplexDomain})) + switch (r.type) { + case "userProfileUpdated": + case "userProfileNoChange": + return r.user + default: + throw new ChatCommandError("error setting SimpleX name", r) + } } /** diff --git a/packages/simplex-chat-nodejs/src/bot.ts b/packages/simplex-chat-nodejs/src/bot.ts index 7eb823963e..66d8a8da04 100644 --- a/packages/simplex-chat-nodejs/src/bot.ts +++ b/packages/simplex-chat-nodejs/src/bot.ts @@ -35,6 +35,7 @@ const defaultOpts: Required = { export interface BotConfig { profile: T.Profile, + simplexName?: string, dbOpts: BotDbOpts, options: BotOptions, onMessage?: (chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise, @@ -45,7 +46,7 @@ export interface BotConfig { events?: api.EventSubscribers } -export async function run({profile, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { +export async function run({profile, simplexName, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp, dbOpts.queueSize) const opts = fullOptions(options) if (onMessage || Object.keys(onCommands).length > 0) subscribeChatItems(bot, onMessage, onCommands) @@ -60,7 +61,8 @@ export async function run({profile, dbOpts, options = defaultOpts, onMessage, on console.log(`Bot address: ${addressLink}`) if (opts.useBotProfile) botProfile.contactLink = addressLink } - await updateBotUserProfile(bot, user, botProfile, opts) + const namedUser = await updateBotSimplexName(bot, user, simplexName, opts) + await updateBotUserProfile(bot, namedUser, botProfile, opts) return [bot, user, address] } @@ -180,15 +182,32 @@ async function createOrUpdateAddress(bot: api.ChatApi, user: T.User, opts: Requi } } - return address + return address +} + +async function updateBotSimplexName(bot: api.ChatApi, user: T.User, simplexName: string | undefined, opts: Required): Promise { + const name = simplexName?.toLowerCase() + if (user.profile.contactDomain?.domain === name) return user + if (!opts.updateAddress) { + console.log("Bot SimpleX name changed") + return user + } + console.log("Bot SimpleX name changed, updating...") + try { + return await bot.apiSetUserDomain(user.userId, name) + } catch (e) { + console.log("Error updating bot SimpleX name", e) + return user + } } async function updateBotUserProfile(bot: api.ChatApi, user: T.User, profile: T.Profile, opts: Required): Promise { const {userId} = user - if (!equal(util.fromLocalProfile(user.profile), profile)) { + const {contactDomain, ...currentProfile} = util.fromLocalProfile(user.profile) + if (!equal(currentProfile, profile)) { if (opts.updateProfile) { console.log("Bot profile changed, updating...") - const summary = await bot.apiUpdateProfile(userId, profile) + const summary = await bot.apiUpdateProfile(userId, {...profile, contactDomain}) console.log( summary ? `Bot profile updated: ${summary.updateSuccesses} updated contact(s), ${summary.updateFailures} failed contact update(s).` diff --git a/packages/simplex-chat-nodejs/src/util.ts b/packages/simplex-chat-nodejs/src/util.ts index dffb0ce1bc..6b8915e93a 100644 --- a/packages/simplex-chat-nodejs/src/util.ts +++ b/packages/simplex-chat-nodejs/src/util.ts @@ -53,8 +53,8 @@ export function botAddressSettings({addressSettings}: T.UserContactLink): BotAdd } } -export function fromLocalProfile({displayName, fullName, shortDescr, image, contactLink, preferences, peerType}: T.LocalProfile): T.Profile { - const profile = {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} +export function fromLocalProfile({displayName, fullName, shortDescr, image, contactLink, preferences, peerType, contactDomain}: T.LocalProfile): T.Profile { + const profile = {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, contactDomain: contactDomain && {domain: contactDomain.domain}} for (const key in profile) { if (typeof (profile as any)[key] === "undefined") delete (profile as any)[key] } diff --git a/packages/simplex-chat-nodejs/tests/api.unit.test.ts b/packages/simplex-chat-nodejs/tests/api.unit.test.ts index cbff9c5abf..b6b92bf93b 100644 --- a/packages/simplex-chat-nodejs/tests/api.unit.test.ts +++ b/packages/simplex-chat-nodejs/tests/api.unit.test.ts @@ -30,6 +30,13 @@ describe("documented success responses", () => { await expect(chat.apiSetProfileAddress(1, true)).resolves.toEqual({updateSuccesses: 0, updateFailures: 0, changedContacts: []}) }) + it("apiSetUserDomain sets and removes the name", async () => { + const chat = await chatWithResponse({type: "userProfileNoChange", user}) + await expect(chat.apiSetUserDomain(1, "calc.simplex")).resolves.toEqual(user) + await expect(chat.apiSetUserDomain(1)).resolves.toEqual(user) + expect(jest.mocked(core.chatSendCmd).mock.calls.map(([, cmd]) => cmd)).toEqual(["/_set domain 1 calc.simplex", "/_set domain 1"]) + }) + it("apiReceiveFile reports a file cancelled by sender", async () => { const chat = await chatWithResponse({type: "rcvFileAcceptedSndCancelled", user, rcvFileTransfer: {}}) await expect(chat.apiReceiveFile(3)).rejects.toThrow("file cancelled by sender") diff --git a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts index ac80cb74cb..abdd59c999 100644 --- a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts +++ b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts @@ -1,6 +1,6 @@ import {ChatEvent, T} from "@simplex-chat/types" import * as api from "../src/api" -import {subscribeChatItems} from "../src/bot" +import {run, subscribeChatItems} from "../src/bot" type Handler = (evt: ChatEvent) => Promise @@ -56,3 +56,68 @@ describe("subscribeChatItems", () => { expect(calls).toEqual([]) }) }) + +describe("run", () => { + const address = { + connLinkContact: {connFullLink: "full", connShortLink: "short"}, + addressSettings: {businessAddress: false, autoAccept: {acceptIncognito: false}}, + } as unknown as T.UserContactLink + + function fakeChat(contactDomain?: T.SimplexDomainClaim) { + const user = {userId: 1, profile: {displayName: "Old", fullName: "", contactDomain}} as unknown as T.User + const chat = { + on: jest.fn(), + apiGetActiveUser: jest.fn().mockResolvedValue(user), + startChat: jest.fn(), + apiGetUserAddress: jest.fn().mockResolvedValue(address), + apiSetAddressSettings: jest.fn(), + apiSetUserDomain: jest.fn(async (_userId: number, domain?: string) => ({...user, profile: {...user.profile, contactDomain: domain && {domain}}})), + apiUpdateProfile: jest.fn().mockResolvedValue({updateSuccesses: 0, updateFailures: 0}), + } + jest.spyOn(api.ChatApi, "init").mockResolvedValue(chat as unknown as api.ChatApi) + return chat + } + + const runBot = (simplexName?: string, options = {}) => + run({profile: {displayName: "Calculator", fullName: ""}, simplexName, dbOpts: {type: "sqlite", filePrefix: "unused"}, options}) + + const updatedProfile = (chat: ReturnType) => chat.apiUpdateProfile.mock.calls[0][1] + + beforeEach(() => jest.spyOn(console, "log").mockImplementation(() => {})) + afterEach(() => jest.restoreAllMocks()) + + it("sets the configured SimpleX name", async () => { + const chat = fakeChat() + await runBot("Calc.simplex") + expect(chat.apiSetUserDomain).toHaveBeenCalledWith(1, "calc.simplex") + expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"}) + }) + + it("removes the SimpleX name that is not configured", async () => { + const chat = fakeChat({domain: "calc.simplex"}) + await runBot() + expect(chat.apiSetUserDomain).toHaveBeenCalledWith(1, undefined) + expect(updatedProfile(chat).contactDomain).toBeUndefined() + }) + + it("keeps the SimpleX name when updating the profile", async () => { + const chat = fakeChat({domain: "calc.simplex", proof: {presHeader: "header", signature: "signature"}}) + await runBot("calc.simplex") + expect(chat.apiSetUserDomain).not.toHaveBeenCalled() + expect(updatedProfile(chat).displayName).toBe("Calculator") + expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"}) + }) + + it("continues when the SimpleX name cannot be set", async () => { + const chat = fakeChat() + chat.apiSetUserDomain.mockRejectedValue(new Error("simplexDomainNotReady")) + await expect(runBot("calc.simplex")).resolves.toBeDefined() + expect(updatedProfile(chat).contactDomain).toBeUndefined() + }) + + it("does not change the SimpleX name without updateAddress", async () => { + const chat = fakeChat() + await runBot("calc.simplex", {updateAddress: false}) + expect(chat.apiSetUserDomain).not.toHaveBeenCalled() + }) +}) diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index e6155c9ce7..365eff7e10 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -59,6 +59,19 @@ def APISetProfileAddress_cmd_string(self: APISetProfileAddress) -> str: APISetProfileAddress_Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError +# Set or remove SimpleX name of bot address. The name must be registered with the address short link. +# Network usage: interactive. +class APISetUserDomain(TypedDict): + userId: int # int64 + simplexDomain: NotRequired[str] + + +def APISetUserDomain_cmd_string(self: APISetUserDomain) -> str: + return '/_set domain ' + str(self['userId']) + ((' ' + self.get('simplexDomain')) if self.get('simplexDomain') is not None else '') + +APISetUserDomain_Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError + + # Set bot address settings. # Network usage: interactive. class APISetAddressSettings(TypedDict): diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9cc80fc54b..446d4f1f9e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -446,7 +446,7 @@ data ChatCommand | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus | APIUpdateProfile {userId :: UserId, profile :: Profile} - | APISetUserDomain {userId :: UserId, simplexDomain :: Maybe SimplexDomain} + | APISetUserDomain {userId :: UserId, simplexDomain :: Maybe (StrJSON "SimplexDomain" SimplexDomain)} | APISetContactPrefs {contactId :: ContactId, preferences :: Preferences} | APISetContactAlias {contactId :: ContactId, localAlias :: LocalAlias} | APISetGroupAlias {groupId :: GroupId, localAlias :: LocalAlias} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d02a189b1d..15e3716112 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1594,7 +1594,8 @@ processChatCommand cxt nm = \case withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) - APISetUserDomain userId domain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> + APISetUserDomain userId strDomain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> do + let domain_ = unStrJSON <$> strDomain_ if (claimDomain <$> contactDomain) == domain_ then pure $ CRUserProfileNoChange user else do @@ -6123,7 +6124,7 @@ chatCommandP = "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), "/_call get" $> APIGetCallInvitations, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), - "/_set domain " *> (APISetUserDomain <$> A.decimal <*> optional (A.space *> strP)), + "/_set domain " *> (APISetUserDomain <$> A.decimal <*> optional (A.space *> (StrJSON <$> strP))), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias #" *> (APISetGroupAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),