mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-14 08:15:24 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user