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.
This commit is contained in:
sh
2026-05-12 08:52:50 +00:00
committed by GitHub
parent 78f987a448
commit 8841c73fb2
5 changed files with 109 additions and 12 deletions
+38 -3
View File
@@ -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", () => {
+32 -7
View File
@@ -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<string> {
const out = new Set<string>()
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<number>()
// 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<string>
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) {
@@ -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
+1 -1
View File
@@ -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()}
}
@@ -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()
})
})