diff --git a/bun.lock b/bun.lock index 291d66517..4c8ad348b 100644 --- a/bun.lock +++ b/bun.lock @@ -606,7 +606,7 @@ }, "packages/embeds/js": { "name": "@typebot.io/js", - "version": "0.8.8", + "version": "0.8.11", "devDependencies": { "@ai-sdk/ui-utils": "1.2.2", "@ark-ui/solid": "5.11.0", @@ -644,7 +644,7 @@ }, "packages/embeds/react": { "name": "@typebot.io/react", - "version": "0.8.8", + "version": "0.8.11", "dependencies": { "@typebot.io/js": "workspace:*", "react": "18.3.1", diff --git a/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.test.ts b/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.test.ts index 90411a2ae..8057fe215 100644 --- a/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.test.ts +++ b/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.test.ts @@ -8,7 +8,6 @@ const createMockItem = ( ): ChoiceInputBlock["items"][number] => ({ id, content, - value: content, outgoingEdgeId: "edge1", }); @@ -54,10 +53,38 @@ describe("parseMultipleChoiceReply", () => { expect(result).toEqual({ status: "success", content: "item, second item" }); }); + it("should work with dirty values / input", () => { + const result = parseMultipleChoiceReply("item and second item", { + items: [ + createMockItem("id1", "item\n"), + createMockItem("id2", " second item "), + ], + }); + expect(result).toEqual({ status: "success", content: "item, second item" }); + + const result2 = parseMultipleChoiceReply(" item and second item \n", { + items: [ + createMockItem("id1", "item"), + createMockItem("id2", "second item"), + ], + }); + expect(result2).toEqual({ + status: "success", + content: "item, second item", + }); + }); + it("should not work when the choice is inside a longer word", () => { const result = parseMultipleChoiceReply("tests", { items: [createMockItem("id1", "Just a few tests")], }); expect(result).toEqual({ status: "fail" }); }); + + it("should work with korean characters", () => { + const result = parseMultipleChoiceReply("아이템", { + items: [createMockItem("id1", "아이템")], + }); + expect(result).toEqual({ status: "success", content: "아이템" }); + }); }); diff --git a/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.ts b/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.ts index b357d6636..10616bf8a 100644 --- a/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.ts +++ b/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.ts @@ -1,5 +1,5 @@ import type { ChoiceInputBlock } from "@typebot.io/blocks-inputs/choice/schema"; -import { sortByContentLength } from "../../../helpers/choiceMatchers"; +import { sortByContentLengthDesc } from "../../../helpers/choiceMatchers"; import type { ParsedReply } from "../../../types"; /** @@ -11,31 +11,32 @@ export const parseMultipleChoiceReply = ( { items }: { items: ChoiceInputBlock["items"] }, ): ParsedReply => { let remainingInput = reply; - const remainingItems = sortByContentLength(items); + const remainingItems = sortByContentLengthDesc(items); // We match the IDs first and then filter through the items to have an order independent result const matchedItemIds: string[] = []; // Match by ID for (const item of remainingItems) { - if (item.id && matchIsolatedWord(remainingInput, item.id)) { + if (item.id && includesWholePhrase(remainingInput, item.id)) { remainingInput = remainingInput.replace(item.id, "").trim(); matchedItemIds.push(item.id); - remainingItems.splice(remainingItems.indexOf(item), 1); } } // Match by internal value - for (const item of remainingItems) { - if (item.value && matchIsolatedWord(remainingInput, item.value)) { + for (const item of remainingItems.filter( + (item) => !matchedItemIds.includes(item.id), + )) + if (item.value && includesWholePhrase(remainingInput, item.value)) { remainingInput = remainingInput.replace(item.value, "").trim(); matchedItemIds.push(item.id); - remainingItems.splice(remainingItems.indexOf(item), 1); } - } // Match by content - for (const item of remainingItems) { - if (item.content && matchIsolatedWord(remainingInput, item.content)) { + for (const item of remainingItems.filter( + (item) => !matchedItemIds.includes(item.id), + )) { + if (item.content && includesWholePhrase(remainingInput, item.content)) { remainingInput = remainingInput.replace(item.content, "").trim(); matchedItemIds.push(item.id); remainingItems.splice(remainingItems.indexOf(item), 1); @@ -44,15 +45,8 @@ export const parseMultipleChoiceReply = ( // Match by index for (const [idx, item] of items.entries()) { - console.log( - item, - idx, - matchIsolatedWord(remainingInput, `${idx + 1}`), - item, - remainingItems, - ); if ( - matchIsolatedWord(remainingInput, `${idx + 1}`) && + includesWholePhrase(remainingInput, `${idx + 1}`) && remainingItems.some(({ id }) => id === item.id) ) { remainingInput = remainingInput.replace(`${idx + 1}`, "").trim(); @@ -67,13 +61,31 @@ export const parseMultipleChoiceReply = ( status: "success", content: items .filter((item) => matchedItemIds.includes(item.id)) - .map((item) => item.value ?? item.content) + .map((item) => (item.value ?? item.content)?.trim()) .join(", "), }; }; /** - * Matches a word that is not part of a longer word. + * Test whether `phrase` appears in `text` as a **stand-alone sequence of words**. */ -const matchIsolatedWord = (input: string, word: string) => - new RegExp(`\\b${word}\\b`).test(input); +const includesWholePhrase = (text: string, phrase: string) => { + if (!phrase || !text) return false; + + // Normalise: trim ends and split on whitespace, removing empties. + const words = phrase.trim().split(/\s+/u).filter(Boolean); + if (!words.length) return false; + + // Escape any regex metacharacters the caller may have put in `phrase`. + const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + + const inner = escaped.join("\\s+"); + + // Unicode “word” characters = Letter | Number | Mark + const boundary = "[^\\p{L}\\p{N}\\p{M}]"; + + // (^|boundary) inner (?=$|boundary) + const pattern = `(?:^|${boundary})${inner}(?=$|${boundary})`; + + return new RegExp(pattern, "ui").test(text); +}; diff --git a/packages/bot-engine/src/blocks/inputs/buttons/parseSingleChoiceReply.ts b/packages/bot-engine/src/blocks/inputs/buttons/parseSingleChoiceReply.ts index e3cf818de..853f6cef4 100644 --- a/packages/bot-engine/src/blocks/inputs/buttons/parseSingleChoiceReply.ts +++ b/packages/bot-engine/src/blocks/inputs/buttons/parseSingleChoiceReply.ts @@ -1,7 +1,7 @@ import type { ChoiceInputBlock } from "@typebot.io/blocks-inputs/choice/schema"; import { getItemContent, - sortByContentLength, + sortByContentLengthDesc, } from "../../../helpers/choiceMatchers"; import type { ParsedReply } from "../../../types"; @@ -9,7 +9,7 @@ export const parseSingleChoiceReply = ( displayedItems: ChoiceInputBlock["items"], inputValue: string, ): ParsedReply => { - const matchedItem = sortByContentLength(displayedItems).find( + const matchedItem = sortByContentLengthDesc(displayedItems).find( (item) => item.id === inputValue || (item.value && inputValue.trim() === item.value.trim()) || diff --git a/packages/bot-engine/src/blocks/inputs/pictureChoice/parsePictureChoicesReply.ts b/packages/bot-engine/src/blocks/inputs/pictureChoice/parsePictureChoicesReply.ts index c1da10cc5..2b873d289 100644 --- a/packages/bot-engine/src/blocks/inputs/pictureChoice/parsePictureChoicesReply.ts +++ b/packages/bot-engine/src/blocks/inputs/pictureChoice/parsePictureChoicesReply.ts @@ -5,7 +5,7 @@ import { getItemContent, matchByIndex, matchByKey, - sortByContentLength, + sortByContentLengthDesc, } from "../../../helpers/choiceMatchers"; import type { ParsedReply } from "../../../types"; import { injectVariableValuesInPictureChoiceBlock } from "./injectVariableValuesInPictureChoiceBlock"; @@ -15,7 +15,7 @@ const parseMultipleChoiceReply = ( inputValue: string, ): ParsedReply => { const valueMatches = matchByKey({ - items: sortByContentLength(displayedItems, "value"), + items: sortByContentLengthDesc(displayedItems, "value"), inputValue, key: "value", }); @@ -54,7 +54,7 @@ const parseSingleChoiceReply = ( displayedItems: PictureChoiceBlock["items"], inputValue: string, ): ParsedReply => { - const matchedItem = sortByContentLength(displayedItems).find( + const matchedItem = sortByContentLengthDesc(displayedItems).find( (item) => item.id === inputValue || item.value?.toLowerCase().trim() === inputValue.toLowerCase().trim() || diff --git a/packages/bot-engine/src/helpers/choiceMatchers.ts b/packages/bot-engine/src/helpers/choiceMatchers.ts index 66d6358d6..522ebd37d 100644 --- a/packages/bot-engine/src/helpers/choiceMatchers.ts +++ b/packages/bot-engine/src/helpers/choiceMatchers.ts @@ -27,7 +27,7 @@ export type MatchFunction = ( idx?: number, ) => boolean; -export const sortByContentLength = ( +export const sortByContentLengthDesc = ( items: T[], contentKey = "content", ): T[] =>