From 8841c73fb287c17a064e10f2b5c3278a2ff95195 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 12 May 2026 08:52:50 +0000 Subject: [PATCH] support bot, simplex-chat-nodejs: fix bot command parsing (#6964) ciBotCommand's regex was unanchored, so a customer message like "follow/read blog posts?" parsed as a /read command and the Grok handler silently dropped the message. Anchor the regex with ^ so a command requires `/` at the start of the (trimmed) message. In the support bot, filter customer command parsing by the registered keyword set: any unknown `/word` from a customer (e.g. /help) is now routed as plain text instead of being silently dropped by Grok. This also makes /grok when Grok is disabled behave consistently as text, removing the previous ad-hoc workaround. --- apps/simplex-support-bot/bot.test.ts | 41 +++++++++++++++++-- apps/simplex-support-bot/src/bot.ts | 39 ++++++++++++++---- .../test/__mocks__/simplex-chat.js | 2 +- packages/simplex-chat-nodejs/src/util.ts | 2 +- .../simplex-chat-nodejs/tests/util.test.ts | 37 +++++++++++++++++ 5 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 packages/simplex-chat-nodejs/tests/util.test.ts diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index fccd2af6b5..64155cdefb 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -216,13 +216,15 @@ const GROK_LOCAL_GROUP_ID = 200 const CUSTOMER_ID = "customer-1" // Commands passed into SupportBot; matches what index.ts constructs when -// Grok is enabled. Tests that disable grokApi still pass the full list -// because the ctor doesn't care; the value is pushed to a group's -// groupPreferences on the first sendToGroup() call. +// Grok is enabled. The ctor uses this to decide which `/keyword` messages +// from customers are commands vs. plain text — tests that disable grokApi +// should pass a list that excludes "grok" to mirror production wiring (see +// index.ts where `grokEnabled` gates that entry). const DESIRED_COMMANDS = [ {type: "command" as const, keyword: "grok", label: "Ask Grok"}, {type: "command" as const, keyword: "team", label: "Switch to team"}, ] +const DESIRED_COMMANDS_NO_GROK = [DESIRED_COMMANDS[1]] // ─── Member factories ─── @@ -733,6 +735,28 @@ describe("Grok Conversation", () => { expect(grokApi.calls.length).toBe(0) }) + test("Grok answers messages containing a slash mid-word", async () => { + // Regression: an unanchored regex in ciBotCommand once parsed `/read` + // inside "follow/read" as a command, causing Grok to skip the message. + grokApi.willRespond("We post on X and Mastodon.") + await bot.onGrokNewChatItems(grokViewCustomerMessage( + "What social media do you use? Anything I can follow/read for updates?" + )) + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe( + "What social media do you use? Anything I can follow/read for updates?" + ) + }) + + test("Grok answers an unknown slash-prefixed message", async () => { + // `/help` is not in desiredCommands, so it should be treated as plain + // text and reach Grok rather than being silently dropped. + grokApi.willRespond("Sure, here's what I can do.") + await bot.onGrokNewChatItems(grokViewCustomerMessage("/help me with groups")) + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe("/help me with groups") + }) + test("Grok per-message: history includes prior Grok sent response as assistant", async () => { addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID) addBotMessage("To create a group, tap + then New Group.", GROK_LOCAL_GROUP_ID) @@ -977,6 +1001,17 @@ describe("One-Way Gate with Grok Disabled", () => { // Grok should not respond (grokApi is null) expect(grokApi.calls.length).toBe(0) }) + + test("Grok disabled: customer /grok is treated as text and queued", async () => { + // When Grok is disabled, index.ts excludes "grok" from desiredCommands, + // so /grok from a customer parses as an unknown command → routed as + // plain text → first-message-in-WELCOME transitions to QUEUE. + setup() + bot = new SupportBot(chat as any, null, config as any, MAIN_USER_ID, null, DESIRED_COMMANDS_NO_GROK) + bot.cards = cards + await bot.onNewChatItems(customerMessage("/grok")) + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + }) }) describe("Team Member Lifecycle", () => { diff --git a/apps/simplex-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts index 553602712b..e9727109d2 100644 --- a/apps/simplex-support-bot/src/bot.ts +++ b/apps/simplex-support-bot/src/bot.ts @@ -10,6 +10,22 @@ import { } from "./messages.js" import {profileMutex, log, logError, getGroupInfo} from "./util.js" +// Collects the keyword of every "command" entry in the bot's registered +// commands tree, descending into "menu" entries. Used to distinguish real +// commands from arbitrary text that happens to start with `/` (e.g. URLs, +// "/help" the user invented). +function commandKeywords(commands: T.ChatBotCommand[]): Set { + const out = new Set() + const visit = (cmds: T.ChatBotCommand[]): void => { + for (const c of cmds) { + if (c.type === "command") out.add(c.keyword) + else if (c.type === "menu") visit(c.commands) + } + } + visit(commands) + return out +} + // True for any non-terminal status — invited but not yet accepted, through // connected. Used to decide whether a contact is already in the group so we // don't trigger a re-invite (the SimpleX API resends the invitation for a @@ -62,6 +78,11 @@ export class SupportBot { // send to each group. private syncedGroups = new Set() + // Keywords from desiredCommands. A customer message is treated as a + // command only when its parsed keyword is in this set; anything else + // (URLs, "/help", arbitrary slashes) is routed as plain text. + private readonly customerKeywords: ReadonlySet + constructor( private chat: api.ChatApi, private grokApi: GrokApiClient | null, @@ -71,6 +92,12 @@ export class SupportBot { private desiredCommands: T.ChatBotCommand[], ) { this.cards = new CardManager(chat, config, mainUserId, config.cardFlushSeconds * 1000) + this.customerKeywords = commandKeywords(desiredCommands) + } + + private customerCommand(chatItem: T.ChatItem): util.BotCommand | undefined { + const cmd = util.ciBotCommand(chatItem) + return cmd && this.customerKeywords.has(cmd.keyword) ? cmd : undefined } private get grokEnabled(): boolean { @@ -357,7 +384,7 @@ export class SupportBot { if (chatInfo.type !== "group") continue if (chatItem.chatDir.type !== "groupRcv") continue if (!util.ciContentText(chatItem)?.trim()) continue - if (util.ciBotCommand(chatItem)) continue + if (this.customerCommand(chatItem)) continue const bc = chatInfo.groupInfo.businessChat if (!bc) continue if (chatItem.chatDir.groupMember.memberId !== bc.customerId) continue @@ -444,9 +471,7 @@ export class SupportBot { // 8. Customer message → derive state and dispatch const state = await this.cards.deriveState(groupId) - const rawCmd = util.ciBotCommand(chatItem) - // When Grok is disabled, ignore /grok so it behaves like an unknown command - const cmd = rawCmd?.keyword === "grok" && !this.grokEnabled ? null : rawCmd + const cmd = this.customerCommand(chatItem) const text = util.ciContentText(chatItem)?.trim() || null switch (state) { @@ -547,7 +572,7 @@ export class SupportBot { if (!text) return // ignore non-text // Ignore bot commands - if (util.ciBotCommand(chatItem)) return + if (this.customerCommand(chatItem)) return // Only respond in business groups (survives restart without in-memory maps) const bc = groupInfo.businessChat @@ -569,7 +594,7 @@ export class SupportBot { history.push({role: "assistant", content: histText}) } else if (histCi.chatDir.type === "groupRcv" && histCi.chatDir.groupMember.memberId === bc.customerId - && !util.ciBotCommand(histCi)) { + && !this.customerCommand(histCi)) { history.push({role: "user", content: histText}) } } @@ -706,7 +731,7 @@ export class SupportBot { if (ci.chatDir.type !== "groupRcv") continue if (!grokBc || ci.chatDir.groupMember.memberId !== grokBc.customerId) continue const t = util.ciContentText(ci)?.trim() - if (t && !util.ciBotCommand(ci)) customerMessages.push(t) + if (t && !this.customerCommand(ci)) customerMessages.push(t) } if (customerMessages.length === 0) { diff --git a/apps/simplex-support-bot/test/__mocks__/simplex-chat.js b/apps/simplex-support-bot/test/__mocks__/simplex-chat.js index 97a7b866ca..92e05a4178 100644 --- a/apps/simplex-support-bot/test/__mocks__/simplex-chat.js +++ b/apps/simplex-support-bot/test/__mocks__/simplex-chat.js @@ -9,7 +9,7 @@ function ciContentText(chatItem) { function ciBotCommand(chatItem) { const text = ciContentText(chatItem)?.trim() if (text) { - const r = text.match(/\/([^\s]+)(.*)/) + const r = text.match(/^\/([^\s]+)(.*)/) if (r && r.length >= 3) return {keyword: r[1], params: r[2].trim()} } return undefined diff --git a/packages/simplex-chat-nodejs/src/util.ts b/packages/simplex-chat-nodejs/src/util.ts index 52e7843a95..f7365e731c 100644 --- a/packages/simplex-chat-nodejs/src/util.ts +++ b/packages/simplex-chat-nodejs/src/util.ts @@ -78,7 +78,7 @@ export interface BotCommand { export function ciBotCommand(chatItem: T.ChatItem): BotCommand | undefined { const msg = ciContentText(chatItem)?.trim() if (msg) { - const r = msg.match(/\/([^\s]+)(.*)/) + const r = msg.match(/^\/([^\s]+)(.*)/) if (r && r.length >= 3) { return {keyword: r[1], params: r[2].trim()} } diff --git a/packages/simplex-chat-nodejs/tests/util.test.ts b/packages/simplex-chat-nodejs/tests/util.test.ts new file mode 100644 index 0000000000..4fe3140edc --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/util.test.ts @@ -0,0 +1,37 @@ +import {T} from "@simplex-chat/types" +import {ciBotCommand} from "../src/util" + +function rcvText(text: string): T.ChatItem { + return {content: {type: "rcvMsgContent", msgContent: {type: "text", text}}} as T.ChatItem +} + +describe("ciBotCommand", () => { + it("parses a command at the start of the message", () => { + expect(ciBotCommand(rcvText("/grok hello"))).toEqual({keyword: "grok", params: "hello"}) + }) + + it("returns undefined for a slash in the middle of a word", () => { + expect(ciBotCommand(rcvText("What follow/read blog posts?"))).toBeUndefined() + }) + + it("returns undefined for a slash after a space", () => { + expect(ciBotCommand(rcvText("see /home for details"))).toBeUndefined() + }) + + it("strips leading whitespace before matching", () => { + expect(ciBotCommand(rcvText(" /grok ask this"))).toEqual({keyword: "grok", params: "ask this"}) + }) + + it("returns command with empty params when only the keyword is present", () => { + expect(ciBotCommand(rcvText("/team"))).toEqual({keyword: "team", params: ""}) + }) + + it("returns undefined for plain text without slash", () => { + expect(ciBotCommand(rcvText("hello there"))).toBeUndefined() + }) + + it("returns undefined for non-text chat item content", () => { + const ci = {content: {type: "rcvDeleted"}} as T.ChatItem + expect(ciBotCommand(ci)).toBeUndefined() + }) +})