From d4b33ef4eea9834266b0b203f2ce9edf4e8f7743 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Tue, 26 Aug 2025 11:46:26 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20(picture=20choice)=20Fix=20inval?= =?UTF-8?q?id=20matching=20when=20title=20equals=20indices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../buttons/parseMultipleChoiceReply.test.ts | 24 ++++ .../buttons/parseMultipleChoiceReply.ts | 18 ++- .../inputs/buttons/parseSingleChoiceReply.ts | 33 ++++-- .../pictureChoice/parsePictureChoicesReply.ts | 94 --------------- packages/bot-engine/src/continueBotFlow.ts | 14 ++- .../bot-engine/src/helpers/choiceMatchers.ts | 110 ------------------ .../src/helpers/parseItemContent.ts | 11 ++ 7 files changed, 76 insertions(+), 228 deletions(-) delete mode 100644 packages/bot-engine/src/blocks/inputs/pictureChoice/parsePictureChoicesReply.ts delete mode 100644 packages/bot-engine/src/helpers/choiceMatchers.ts create mode 100644 packages/bot-engine/src/helpers/parseItemContent.ts 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 8057fe215..d8e61fa81 100644 --- a/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.test.ts +++ b/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.test.ts @@ -1,4 +1,5 @@ import type { ChoiceInputBlock } from "@typebot.io/blocks-inputs/choice/schema"; +import type { PictureChoiceBlock } from "@typebot.io/blocks-inputs/pictureChoice/schema"; import { describe, expect, it } from "vitest"; import { parseMultipleChoiceReply } from "./parseMultipleChoiceReply"; @@ -11,6 +12,15 @@ const createMockItem = ( outgoingEdgeId: "edge1", }); +const createMockPictureItem = ( + id: string, + title: string, +): PictureChoiceBlock["items"][number] => ({ + id, + title, + outgoingEdgeId: "edge1", +}); + describe("parseMultipleChoiceReply", () => { it("should return fail if no items match", () => { const result = parseMultipleChoiceReply("test", { @@ -43,6 +53,20 @@ describe("parseMultipleChoiceReply", () => { expect(result).toEqual({ status: "success", content: "item, second item" }); }); + it("should work with number titles", () => { + const result = parseMultipleChoiceReply("4, 2, 1", { + items: [ + createMockPictureItem("id1", "6"), + createMockPictureItem("id2", "5"), + createMockPictureItem("id3", "4"), + createMockPictureItem("id4", "3"), + createMockPictureItem("id5", "2"), + createMockPictureItem("id6", "1"), + ], + }); + expect(result).toEqual({ status: "success", content: "4, 2, 1" }); + }); + it("should work when the choices are overlapping", () => { const result = parseMultipleChoiceReply("item and second item", { items: [ diff --git a/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.ts b/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.ts index 10616bf8a..b879519ec 100644 --- a/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.ts +++ b/packages/bot-engine/src/blocks/inputs/buttons/parseMultipleChoiceReply.ts @@ -1,5 +1,6 @@ import type { ChoiceInputBlock } from "@typebot.io/blocks-inputs/choice/schema"; -import { sortByContentLengthDesc } from "../../../helpers/choiceMatchers"; +import type { PictureChoiceBlock } from "@typebot.io/blocks-inputs/pictureChoice/schema"; +import { parseItemContent } from "../../../helpers/parseItemContent"; import type { ParsedReply } from "../../../types"; /** @@ -8,10 +9,14 @@ import type { ParsedReply } from "../../../types"; */ export const parseMultipleChoiceReply = ( reply: string, - { items }: { items: ChoiceInputBlock["items"] }, + { items }: { items: ChoiceInputBlock["items"] | PictureChoiceBlock["items"] }, ): ParsedReply => { let remainingInput = reply; - const remainingItems = sortByContentLengthDesc(items); + const remainingItems = [...items].sort((a, b) => { + const aContent = parseItemContent(a); + const bContent = parseItemContent(b); + return (bContent?.length ?? 0) - (aContent?.length ?? 0); + }); // We match the IDs first and then filter through the items to have an order independent result const matchedItemIds: string[] = []; @@ -36,8 +41,9 @@ export const parseMultipleChoiceReply = ( for (const item of remainingItems.filter( (item) => !matchedItemIds.includes(item.id), )) { - if (item.content && includesWholePhrase(remainingInput, item.content)) { - remainingInput = remainingInput.replace(item.content, "").trim(); + const content = parseItemContent(item); + if (content && includesWholePhrase(remainingInput, content)) { + remainingInput = remainingInput.replace(content, "").trim(); matchedItemIds.push(item.id); remainingItems.splice(remainingItems.indexOf(item), 1); } @@ -61,7 +67,7 @@ export const parseMultipleChoiceReply = ( status: "success", content: items .filter((item) => matchedItemIds.includes(item.id)) - .map((item) => (item.value ?? item.content)?.trim()) + .map((item) => item.value ?? parseItemContent(item)?.trim()) .join(", "), }; }; diff --git a/packages/bot-engine/src/blocks/inputs/buttons/parseSingleChoiceReply.ts b/packages/bot-engine/src/blocks/inputs/buttons/parseSingleChoiceReply.ts index 853f6cef4..4272a2ff1 100644 --- a/packages/bot-engine/src/blocks/inputs/buttons/parseSingleChoiceReply.ts +++ b/packages/bot-engine/src/blocks/inputs/buttons/parseSingleChoiceReply.ts @@ -1,26 +1,35 @@ import type { ChoiceInputBlock } from "@typebot.io/blocks-inputs/choice/schema"; -import { - getItemContent, - sortByContentLengthDesc, -} from "../../../helpers/choiceMatchers"; +import type { PictureChoiceBlock } from "@typebot.io/blocks-inputs/pictureChoice/schema"; +import { parseItemContent } from "../../../helpers/parseItemContent"; import type { ParsedReply } from "../../../types"; export const parseSingleChoiceReply = ( - displayedItems: ChoiceInputBlock["items"], inputValue: string, + { + items, + }: { + items: ChoiceInputBlock["items"] | PictureChoiceBlock["items"]; + }, ): ParsedReply => { - const matchedItem = sortByContentLengthDesc(displayedItems).find( - (item) => - item.id === inputValue || - (item.value && inputValue.trim() === item.value.trim()) || - (item.content && inputValue.trim() === item.content.trim()), - ); + const matchedItem = [...items] + .sort((a, b) => { + const aContent = parseItemContent(a); + const bContent = parseItemContent(b); + return (bContent?.length ?? 0) - (aContent?.length ?? 0); + }) + .find((item) => { + if (item.id === inputValue) return true; + if (item.value && inputValue.trim() === item.value.trim()) return true; + const itemContent = parseItemContent(item); + if (itemContent && inputValue.trim() === itemContent.trim()) return true; + return false; + }); if (!matchedItem) return { status: "fail" }; return { status: "success", - content: getItemContent(matchedItem, ["value", "content"]), + content: matchedItem.value ?? parseItemContent(matchedItem) ?? "", outgoingEdgeId: matchedItem.outgoingEdgeId, }; }; diff --git a/packages/bot-engine/src/blocks/inputs/pictureChoice/parsePictureChoicesReply.ts b/packages/bot-engine/src/blocks/inputs/pictureChoice/parsePictureChoicesReply.ts deleted file mode 100644 index 2b873d289..000000000 --- a/packages/bot-engine/src/blocks/inputs/pictureChoice/parsePictureChoicesReply.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { PictureChoiceBlock } from "@typebot.io/blocks-inputs/pictureChoice/schema"; -import type { SessionState } from "@typebot.io/chat-session/schemas"; -import type { SessionStore } from "@typebot.io/runtime-session-store"; -import { - getItemContent, - matchByIndex, - matchByKey, - sortByContentLengthDesc, -} from "../../../helpers/choiceMatchers"; -import type { ParsedReply } from "../../../types"; -import { injectVariableValuesInPictureChoiceBlock } from "./injectVariableValuesInPictureChoiceBlock"; - -const parseMultipleChoiceReply = ( - displayedItems: PictureChoiceBlock["items"], - inputValue: string, -): ParsedReply => { - const valueMatches = matchByKey({ - items: sortByContentLengthDesc(displayedItems, "value"), - inputValue, - key: "value", - }); - - const contentMatches = matchByKey({ - items: valueMatches.remaining, - inputValue, - key: "title", - }); - - const indexMatches = matchByIndex(contentMatches.remaining, { - strippedInput: inputValue, - matchedItemIds: contentMatches.matchedItemIds, - }); - - const matchedItems = displayedItems.filter((item) => - [ - ...valueMatches.matchedItemIds, - ...contentMatches.matchedItemIds, - ...indexMatches.matchedItemIds, - ].includes(item.id), - ); - - if (matchedItems.length === 0) return { status: "fail" }; - const content = matchedItems - .map((item) => getItemContent(item, ["value", "title", "pictureSrc"])) - .join(", "); - - return { - status: "success", - content, - }; -}; - -const parseSingleChoiceReply = ( - displayedItems: PictureChoiceBlock["items"], - inputValue: string, -): ParsedReply => { - const matchedItem = sortByContentLengthDesc(displayedItems).find( - (item) => - item.id === inputValue || - item.value?.toLowerCase().trim() === inputValue.toLowerCase().trim() || - item.title?.toLowerCase().trim() === inputValue.toLowerCase().trim() || - item.pictureSrc?.toLowerCase().trim() === inputValue.toLowerCase().trim(), - ); - - if (!matchedItem) return { status: "fail" }; - - return { - status: "success", - outgoingEdgeId: matchedItem.outgoingEdgeId, - content: getItemContent(matchedItem, ["value", "title", "pictureSrc"]), - }; -}; - -export const parsePictureChoicesReply = ( - inputValue: string, - { - block, - state, - sessionStore, - }: { - block: PictureChoiceBlock; - state: SessionState; - sessionStore: SessionStore; - }, -): ParsedReply => { - const displayedItems = injectVariableValuesInPictureChoiceBlock(block, { - variables: state.typebotsQueue[0].typebot.variables, - sessionStore, - }).items; - - return block.options?.isMultipleChoice - ? parseMultipleChoiceReply(displayedItems, inputValue) - : parseSingleChoiceReply(displayedItems, inputValue); -}; diff --git a/packages/bot-engine/src/continueBotFlow.ts b/packages/bot-engine/src/continueBotFlow.ts index 77235411e..b7584e842 100644 --- a/packages/bot-engine/src/continueBotFlow.ts +++ b/packages/bot-engine/src/continueBotFlow.ts @@ -50,7 +50,7 @@ import { parseDateReply } from "./blocks/inputs/date/parseDateReply"; import { formatEmail } from "./blocks/inputs/email/formatEmail"; import { parseNumber } from "./blocks/inputs/number/parseNumber"; import { formatPhoneNumber } from "./blocks/inputs/phone/formatPhoneNumber"; -import { parsePictureChoicesReply } from "./blocks/inputs/pictureChoice/parsePictureChoicesReply"; +import { injectVariableValuesInPictureChoiceBlock } from "./blocks/inputs/pictureChoice/injectVariableValuesInPictureChoiceBlock"; import { validateRatingReply } from "./blocks/inputs/rating/validateRatingReply"; import { parseTime } from "./blocks/inputs/time/parseTime"; import { saveDataInResponseVariableMapping } from "./blocks/integrations/httpRequest/saveDataInResponseVariableMapping"; @@ -850,7 +850,7 @@ const parseReply = async ( }).items; if (block.options?.isMultipleChoice) return parseMultipleChoiceReply(reply.text, { items: displayedItems }); - return parseSingleChoiceReply(displayedItems, reply.text); + return parseSingleChoiceReply(reply.text, { items: displayedItems }); } case InputBlockType.NUMBER: { if (!reply || reply.type !== "text") return { status: "fail" }; @@ -915,11 +915,13 @@ const parseReply = async ( } case InputBlockType.PICTURE_CHOICE: { if (!reply || reply.type !== "text") return { status: "fail" }; - return parsePictureChoicesReply(reply.text, { - block, - state, + const displayedItems = injectVariableValuesInPictureChoiceBlock(block, { + variables: state.typebotsQueue[0].typebot.variables, sessionStore, - }); + }).items; + if (block.options?.isMultipleChoice) + return parseMultipleChoiceReply(reply.text, { items: displayedItems }); + return parseSingleChoiceReply(reply.text, { items: displayedItems }); } case InputBlockType.TEXT: { if (!reply) return { status: "fail" }; diff --git a/packages/bot-engine/src/helpers/choiceMatchers.ts b/packages/bot-engine/src/helpers/choiceMatchers.ts deleted file mode 100644 index 522ebd37d..000000000 --- a/packages/bot-engine/src/helpers/choiceMatchers.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { isNotEmpty } from "@typebot.io/lib/utils"; - -export type ChoiceItem = { - id: string; - outgoingEdgeId?: string; - [key: string]: any; -}; - -export type MatchResult = { - strippedInput: string; - matchedItemIds: string[]; -}; - -export type MatcherResult = MatchResult & { - remaining: T[]; -}; - -export type MatchContext = { - items: T[]; - inputValue: string; - key: string; -}; - -export type MatchFunction = ( - item: T, - input: string, - idx?: number, -) => boolean; - -export const sortByContentLengthDesc = ( - items: T[], - contentKey = "content", -): T[] => - [...items].sort( - (a, b) => (b[contentKey]?.length ?? 0) - (a[contentKey]?.length ?? 0), - ); - -export const createMatchReducer = - ( - matchFn: MatchFunction, - contentKey = "content", - valueKey = "value", - ) => - (acc: MatchResult, item: T, idx?: number) => { - const input = acc.strippedInput; - if (matchFn(item, input, idx)) { - const matchValue = item[contentKey] ?? item[valueKey] ?? `${idx! + 1}`; - return { - strippedInput: acc.strippedInput.replace(matchValue, ""), - matchedItemIds: [...acc.matchedItemIds, item.id], - }; - } - return acc; - }; - -export const matchByKey = ({ - items, - inputValue, - key = "content", -}: MatchContext): MatcherResult => { - const matchFn: MatchFunction = (item, input) => - Boolean( - item[key] && input.toLowerCase().includes(item[key].trim().toLowerCase()), - ); - - const matchingItems = items.reduce(createMatchReducer(matchFn, key), { - strippedInput: inputValue.trim(), - matchedItemIds: [], - }); - - return { - ...matchingItems, - remaining: items.filter( - (item) => !matchingItems.matchedItemIds.includes(item.id), - ), - }; -}; - -export const matchByIndex = ( - items: T[], - { strippedInput }: MatchResult, -): MatcherResult => { - const matchFn: MatchFunction = (_, input, idx) => { - const indexStr = `${idx! + 1}`; - const regex = new RegExp(`\\b${indexStr}\\b`); - return regex.test(input); - }; - - const matchingItems = items.reduce(createMatchReducer(matchFn), { - strippedInput, - matchedItemIds: [], - }); - - return { - ...matchingItems, - remaining: items.filter( - (item) => !matchingItems.matchedItemIds.includes(item.id), - ), - }; -}; - -export const getItemContent = ( - item: T, - contentKeys: string[], -): string => { - for (const contentKey of contentKeys) { - if (isNotEmpty(item[contentKey])) return item[contentKey]; - } - return ""; -}; diff --git a/packages/bot-engine/src/helpers/parseItemContent.ts b/packages/bot-engine/src/helpers/parseItemContent.ts new file mode 100644 index 000000000..5925919cf --- /dev/null +++ b/packages/bot-engine/src/helpers/parseItemContent.ts @@ -0,0 +1,11 @@ +import type { ChoiceInputBlock } from "@typebot.io/blocks-inputs/choice/schema"; +import type { PictureChoiceBlock } from "@typebot.io/blocks-inputs/pictureChoice/schema"; + +export const parseItemContent = ( + item: ChoiceInputBlock["items"][number] | PictureChoiceBlock["items"][number], +) => { + // Buttons + if ("content" in item) return item.content; + // Picture choice + if ("title" in item) return item.title ?? item.pictureSrc; +};