diff --git a/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md index 0344ea4e9e..dd725e88e8 100644 --- a/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md +++ b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md @@ -13,7 +13,8 @@ SimpleX Chat support bot — standalone Node.js app using `simplex-chat-nodejs` │ chat: ChatApi ← ChatApi.init("./data/simplex") │ │ Single database, two user profiles │ │ │ -│ mainUserId ← "Ask SimpleX Team" profile │ +│ mainUserId ← non-Grok user (default name: │ +│ "Ask SimpleX Team") │ │ • Business address, event routing, state mgmt │ │ • Controls group membership │ │ │ @@ -27,7 +28,7 @@ SimpleX Chat support bot — standalone Node.js app using `simplex-chat-nodejs` ``` - Single Node.js process, single `ChatApi` instance via native NAPI -- Two user profiles in one database — resolved at startup via `apiListUsers()` by display name +- Two user profiles in one database — resolved at startup via `apiListUsers()`: the Grok profile by its known display name (`"Grok"`/`"Grok AI"`), the main profile by exclusion (any non-Grok user). The main profile's displayName is set only on fresh-DB user creation (`"Ask SimpleX Team"`) and is never rewritten by bot code thereafter — `bot.run()` is invoked with `updateProfile: false`. Bot commands (`/grok`, `/team`) are the only profile field the bot pushes at runtime, and only when the desired list differs from what's already in the DB. - `profileMutex` serializes `apiSetActiveUser(userId)` + the subsequent SimpleX API call. Grok HTTP API calls run **outside** the mutex. - Events delivered for all profiles — routed by `event.user` field (main → main handler, Grok → Grok handler) - Business address auto-accept creates a group per customer @@ -472,6 +473,7 @@ const [chat, mainUser, mainAddress] = await bot.run({ {type: "command", keyword: "team", label: "Switch to team"}, ], useBotProfile: true, + updateProfile: false, // bot code never rewrites displayName/image/etc. }, events: { acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt), @@ -488,7 +490,7 @@ const [chat, mainUser, mainAddress] = await bot.run({ }) ``` -Note: `/grok` and `/team` registered as customer commands via `bot.run()`. `/join` registered as a team group command separately — after team group is resolved, call `apiUpdateGroupProfile(teamGroupId, groupProfile)` with `groupPreferences` including the `/join` command definition. Customer sending `/join` in a customer group → treated as ordinary message (unrecognized command). +Note: `/grok` and `/team` are passed in `options.commands` so `bot.run()` can build the desired `profile.preferences.commands`, but since `updateProfile: false` is set, `bot.run()` never writes the profile. Immediately after `bot.run()`, the bot compares `mainUser.profile.preferences.commands` against the desired list; if they differ, it calls `apiUpdateProfile(mainUser.userId, profile)` with `displayName` copied back from `mainUser.profile` (so core's fast path at `src/Simplex/Chat/Store/Profiles.hs:311` fires — `contact_profiles.preferences.commands` is updated, `contact_profiles.display_name` is rewritten to the same value it already had). `/join` is registered as a team group command separately — after team group is resolved, call `apiUpdateGroupProfile(teamGroupId, groupProfile)` with `groupPreferences` including the `/join` command definition. Customer sending `/join` in a customer group → treated as ordinary message (unrecognized command). **Grok profile** — resolved from same ChatApi instance: @@ -577,7 +579,13 @@ Cleanup: entries in `customDataMutexes` are bounded by the number of customer gr **Profile images:** Both profiles have base64-encoded JPEG profile pictures (128x128, quality 85, under the 12,500-char data URI limit enforced by iOS/Android clients) set via the `image` field in `T.Profile`. The images are defined as `data:image/jpg;base64,...` string constants in `index.ts`. The main profile image is passed to `bot.run()` which handles update-on-change automatically. The Grok profile image is passed to `apiCreateActiveUser()` on first run; on subsequent runs, the bot compares the current profile against the desired one using `util.fromLocalProfile()` and calls `apiUpdateProfile()` if any field differs — this sends the update to all Grok contacts. **Startup sequence:** -0. **Active user recovery:** On restart, the active user may be Grok (if the previous run was killed mid-profile-switch). `bot.run()` uses `apiGetActiveUser()` and would rename Grok → `duplicateName` error. Fix: pre-init the DB with a temporary `ChatApi`, check active user, if not "Ask SimpleX Team" then `startChat()` + find the main user via `apiListUsers()` + `apiSetActiveUser()`, then `close()`. This ensures `bot.run()` always finds the correct active user. +0. **Active user recovery + name preservation:** Two related safeguards. + + **(a) Active user recovery.** On restart, the active user may be Grok (if the previous run was killed mid-profile-switch). `bot.run()` uses `apiGetActiveUser()` and would then operate against Grok's `userId` as if it were the main user. Fix: pre-init the DB with a temporary `ChatApi`, and if the active user's `displayName` is `"Grok"` or `"Grok AI"`, `apiListUsers()` + `apiSetActiveUser()` to the first non-Grok user. Close the temporary `ChatApi` before `bot.run()` reopens it. + + **(b) Never rewrite the main profile.** The core auto-creates a preset contact named `"Ask SimpleX Team"` in every user's DB (`src/Simplex/Chat/Library/Internal.hs:2749`, exact name from commit `362bdc328` 2025-07-12). That collides with the bot's preferred main-profile displayName within the user's `display_names` namespace (`UNIQUE (user_id, local_display_name)`), so any attempt to rename the main profile to `"Ask SimpleX Team"` fails with `duplicateName`. Worse, `bot.run`'s internal `updateBotUserProfile` (`packages/simplex-chat-nodejs/dist/bot.js:176`) re-syncs image, preferences, and `contactLink` on every startup, and on a DB where `users.local_display_name` has drifted from `contact_profiles.display_name`, the fast path (`src/Simplex/Chat/Store/Profiles.hs:311`) silently rewrites the customer-facing `contact_profiles.display_name`. Fix: pass `options.updateProfile: false` to `bot.run()` so the bot code never calls `apiUpdateProfile` on its own initiative. Whatever displayName the CLI saw is what stays. + + **(c) Commands-only push.** The one profile field we *do* want to push from code is the bot's command list (`/grok`, `/team`). After `bot.run()` returns, compare `mainUser.profile.preferences.commands` (via `util.fromLocalProfile`) against the desired list (using `JSON.stringify` equality, same pattern as team-group-commands diff at `src/index.ts:227`). If equal, skip. If different, call `apiUpdateProfile(mainUser.userId, profile)` where `profile` is a shallow copy of `mainUser.profile` with only `preferences.commands` replaced. Because we pass the profile's own current `displayName` back verbatim, core's fast path writes only `contact_profiles` fields (including the new commands) without any rename attempt. All other profile fields (displayName, image, fullName, contactLink, other preference flags) are preserved byte-for-byte. 1. `bot.run()` → init ChatApi, create/resolve main profile (with profile image), business address. Print business address link to stdout. 2. Resolve Grok profile via `apiListUsers()` (create with profile image if missing; if existing, compare profile and update via `apiUpdateProfile()` if changed — pushes to contacts) 3. Read `{dbPrefix}_state.json` for `teamGroupId` and `grokContactId` diff --git a/apps/simplex-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts index aa8bb11a8a..557a421429 100644 --- a/apps/simplex-support-bot/src/bot.ts +++ b/apps/simplex-support-bot/src/bot.ts @@ -57,12 +57,15 @@ export class SupportBot { // Bot's business address link businessAddress: string | null = null + private commandsSynced = false + constructor( private chat: api.ChatApi, private grokApi: GrokApiClient | null, private config: Config, private mainUserId: number, private grokUserId: number | null, + private desiredCommands: T.ChatBotCommand[], ) { this.cards = new CardManager(chat, config, mainUserId, config.cardFlushSeconds * 1000) } @@ -93,10 +96,34 @@ export class SupportBot { private async withMainProfile(fn: () => Promise): Promise { return profileMutex.runExclusive(async () => { await this.chat.apiSetActiveUser(this.mainUserId) + await this.syncCommands() return fn() }) } + // Push the bot's command list into the main profile if the DB's + // preferences.commands doesn't already match. Passing the profile's own + // current displayName back keeps core's updateUserProfile on the fast + // path (src/Simplex/Chat/Store/Profiles.hs:311) — no rename. + private async syncCommands(): Promise { + if (this.commandsSynced) return + const user = await this.chat.apiGetActiveUser() + if (!user) return + const current = JSON.stringify(user.profile.preferences?.commands ?? []) + const desired = JSON.stringify(this.desiredCommands) + if (current === desired) { + this.commandsSynced = true + return + } + const profile = util.fromLocalProfile(user.profile) + await this.chat.apiUpdateProfile(user.userId, { + ...profile, + preferences: {...(profile.preferences ?? {}), commands: this.desiredCommands}, + }) + log(`Bot commands updated (displayName preserved: "${profile.displayName}")`) + this.commandsSynced = true + } + private async withGrokProfile(fn: () => Promise): Promise { if (this.grokUserId === null) throw new Error("Grok is disabled (no GROK_API_KEY)") const grokUserId = this.grokUserId diff --git a/apps/simplex-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts index ae8bed5f16..800a2c696e 100644 --- a/apps/simplex-support-bot/src/index.ts +++ b/apps/simplex-support-bot/src/index.ts @@ -40,24 +40,24 @@ async function main(): Promise { let supportBot: SupportBot | undefined // On restart, the active user may be Grok (if the previous run was killed - // mid-profile-switch). bot.run() uses apiGetActiveUser() and would then try - // to rename Grok to "Ask SimpleX Team" → duplicateName error. - // Fix: pre-init the DB, find the main user, set it active, then close. + // mid-profile-switch). bot.run() uses apiGetActiveUser() and would then + // operate against the Grok userId as if it were the main user. Restore the + // main user as active before bot.run(). Main user = any user NOT named + // "Grok"/"Grok AI". { const preChat = await api.ChatApi.init(config.dbPrefix) const activeUser = await preChat.apiGetActiveUser() - if (activeUser && activeUser.profile.displayName !== "Ask SimpleX Team") { + const isGrokName = (n: string) => n === "Grok" || n === "Grok AI" + if (activeUser && isGrokName(activeUser.profile.displayName)) { await preChat.startChat() const users = await preChat.apiListUsers() - const mainUserInfo = users.find(u => u.user.profile.displayName === "Ask SimpleX Team") + const mainUserInfo = users.find(u => !isGrokName(u.user.profile.displayName)) if (mainUserInfo) { await preChat.apiSetActiveUser(mainUserInfo.user.userId) - log("Restored active user to Ask SimpleX Team") + log(`Restored active user to ${mainUserInfo.user.profile.displayName}`) } - await preChat.close() - } else { - await preChat.close() } + await preChat.close() } // Profile images (base64-encoded JPEG) @@ -80,6 +80,7 @@ async function main(): Promise { {type: "command", keyword: "team", label: "Switch to team"}, ], useBotProfile: true, + updateProfile: false, }, events: { acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt), @@ -96,6 +97,41 @@ async function main(): Promise { }) log(`Main bot user: ${mainUser.profile.displayName} (userId=${mainUser.userId})`) + // Explicit commands-only profile update. bot.run() is configured with + // updateProfile: false, so it never rewrites the profile on startup — + // which in turn preserves displayName, image, etc. exactly as the CLI + // sees them. Here we push *only* the commands list, and only when it + // differs from what's already in contact_profiles.preferences.commands. + // Pass the current displayName back unchanged so core's updateUserProfile + // takes the fast path (src/Simplex/Chat/Store/Profiles.hs:311) — which + // does not rename. + { + const desiredCommands: T.ChatBotCommand[] = [ + ...(grokEnabled ? [{type: "command" as const, keyword: "grok", label: "Ask Grok"}] : []), + {type: "command", keyword: "team", label: "Switch to team"}, + ] + const currentProfile = util.fromLocalProfile(mainUser.profile) + const currentCommands = currentProfile.preferences?.commands ?? [] + if (JSON.stringify(currentCommands) !== JSON.stringify(desiredCommands)) { + const newProfile: T.Profile = { + ...currentProfile, + preferences: { + ...(currentProfile.preferences ?? {}), + commands: desiredCommands, + }, + } + log(`Bot commands changed, updating (displayName preserved: "${currentProfile.displayName}")...`) + const summary = await chat.apiUpdateProfile(mainUser.userId, newProfile) + if (summary) { + log(`Bot commands updated: ${summary.updateSuccesses} contact(s), ${summary.updateFailures} failed`) + } else { + log("Bot commands update: no change reported by core") + } + } else { + log("Bot commands unchanged — skipping profile update") + } + } + // Step 2: Resolve Grok profile from same ChatApi instance let grokUser: T.User | null = null if (grokEnabled) {