import { createId } from "@paralleldrive/cuid2"; import { TRPCError } from "@trpc/server"; import { isDefined, omit, isNotEmpty } from "indite-js/lib"; import { isInputBlock } from "indite-js/schemas/helpers"; import { Variable, VariableWithValue, Theme, GoogleAnalyticsBlock, PixelBlock, SessionState, BotInSession, Block, SetVariableHistoryItem, } from "indite-js/schemas"; import { StartChatInput, StartChatResponse, StartPreviewChatInput, StartBot, startBotSchema, } from "indite-js/schemas/features/chat/schema"; import parse, { NodeType } from "node-html-parser"; import { parseDynamicTheme } from "./parseDynamicTheme"; import { findBot } from "./queries/findBot"; import { findPublicBot } from "./queries/findPublicBot"; import { findResult } from "./queries/findResult"; import { startBotFlow } from "./startBotFlow"; import { prefillVariables } from "indite-js/variables/prefillVariables"; import { deepParseVariables } from "indite-js/variables/deepParseVariables"; import { injectVariablesFromExistingResult } from "indite-js/variables/injectVariablesFromExistingResult"; import { getNextGroup } from "./getNextGroup"; import { upsertResult } from "./queries/upsertResult"; import { continueBotFlow } from "./continueBotFlow"; import { getVariablesToParseInfoInText, parseVariables, } from "indite-js/variables/parseVariables"; import { defaultSettings } from "indite-js/schemas/features/bot/settings/constants"; import { IntegrationBlockType } from "indite-js/schemas/features/blocks/integrations/constants"; import { VisitedEdge } from "indite-js/prisma"; import { env } from "indite-js/env"; import { getFirstEdgeId } from "./getFirstEdgeId"; import { Reply } from "./types"; import { defaultGuestAvatarIsEnabled, defaultHostAvatarIsEnabled, } from "indite-js/schemas/features/bot/theme/constants"; import { BubbleBlockType } from "indite-js/schemas/features/blocks/bubbles/constants"; import { LogicBlockType } from "indite-js/schemas/features/blocks/logic/constants"; import { parseVariablesInRichText } from "./parseBubbleBlock"; type StartParams = | ({ type: "preview"; userId?: string; } & StartPreviewChatInput) | ({ type: "live"; } & StartChatInput); type Props = { version: 1 | 2; startParams: StartParams; initialSessionState?: Pick; }; export const startSession = async ({ version, startParams, initialSessionState, }: Props): Promise< Omit & { newSessionState: SessionState; visitedEdges: VisitedEdge[]; setVariableHistory: SetVariableHistoryItem[]; resultId?: string; } > => { const bot = await getBot(startParams); const prefilledVariables = startParams.prefilledVariables ? prefillVariables(bot.variables, startParams.prefilledVariables) : bot.variables; const result = await getResult({ resultId: startParams.type === "live" ? startParams.resultId : undefined, isPreview: startParams.type === "preview", botId: bot.id, prefilledVariables, isRememberUserEnabled: bot.settings.general?.rememberUser?.isEnabled ?? (isDefined(bot.settings.general?.isNewResultOnRefreshEnabled) ? !bot.settings.general?.isNewResultOnRefreshEnabled : defaultSettings.general.rememberUser.isEnabled), }); const startVariables = result && result.variables.length > 0 ? injectVariablesFromExistingResult(prefilledVariables, result.variables) : prefilledVariables; const botInSession = convertStartBotToBotInSession(bot, startVariables); const initialState: SessionState = { version: "3", botsQueue: [ { resultId: result?.id, bot: botInSession, answers: result ? result.answers.map((answer) => { const block = bot.groups .flatMap((group) => group.blocks) .find((block) => block.id === answer.blockId); if (!block || !isInputBlock(block)) return { key: "unknown", value: answer.content, }; const key = (block.options?.variableId ? startVariables.find( (variable) => variable.id === block.options?.variableId )?.name : bot.groups.find((group) => group.blocks.find( (blockInGroup) => blockInGroup.id === block.id ) )?.title) ?? "unknown"; return { key, value: answer.content, }; }) : [], }, ], dynamicTheme: parseDynamicThemeInState(bot.theme), isStreamEnabled: startParams.isStreamEnabled, typingEmulation: bot.settings.typingEmulation, allowedOrigins: startParams.type === "preview" ? undefined : bot.settings.security?.allowedOrigins, progressMetadata: initialSessionState?.whatsApp ? undefined : bot.theme.general?.progressBar?.isEnabled ? { totalAnswers: 0 } : undefined, setVariableIdsForHistory: extractVariableIdsUsedForTranscript(botInSession), ...initialSessionState, }; if (startParams.isOnlyRegistering) { return { newSessionState: initialState, bot: { id: bot.id, settings: deepParseVariables(initialState.botsQueue[0].bot.variables)( bot.settings ), theme: sanitizeAndParseTheme(bot.theme, { variables: initialState.botsQueue[0].bot.variables, }), }, dynamicTheme: parseDynamicTheme(initialState), messages: [], visitedEdges: [], setVariableHistory: [], }; } let chatReply = await startBotFlow({ version, state: initialState, startFrom: startParams.type === "preview" ? startParams.startFrom : undefined, startTime: Date.now(), textBubbleContentFormat: startParams.textBubbleContentFormat, }); // If params has message and first block is an input block, we can directly continue the bot flow if (startParams.message) { const firstEdgeId = getFirstEdgeId({ bot: chatReply.newSessionState.botsQueue[0].bot, startEventId: startParams.type === "preview" && startParams.startFrom?.type === "event" ? startParams.startFrom.eventId : undefined, }); const nextGroup = await getNextGroup({ state: chatReply.newSessionState, edgeId: firstEdgeId, isOffDefaultPath: false, }); const newSessionState = nextGroup.newSessionState; const firstBlock = nextGroup.group?.blocks.at(0); if (firstBlock && isInputBlock(firstBlock)) { const resultId = newSessionState.botsQueue[0].resultId; if (resultId) await upsertResult({ hasStarted: true, isCompleted: false, resultId, bot: newSessionState.botsQueue[0].bot, }); chatReply = await continueBotFlow(startParams.message, { version, state: { ...newSessionState, currentBlockId: firstBlock.id, }, textBubbleContentFormat: startParams.textBubbleContentFormat, }); } } const { messages, input, clientSideActions: startFlowClientActions, newSessionState, logs, visitedEdges, setVariableHistory, } = chatReply; const clientSideActions = startFlowClientActions ?? []; const startClientSideAction = parseStartClientSideAction(bot); const startLogs = logs ?? []; if (isDefined(startClientSideAction)) { if (!result) { if ("startPropsToInject" in startClientSideAction) { const { customHeadCode, googleAnalyticsId, pixelIds, gtmId } = startClientSideAction.startPropsToInject; let toolsList = ""; if (customHeadCode) toolsList += "Custom head code, "; if (googleAnalyticsId) toolsList += "Google Analytics, "; if (pixelIds) toolsList += "Pixel, "; if (gtmId) toolsList += "Google Tag Manager, "; toolsList = toolsList.slice(0, -2); startLogs.push({ description: `${toolsList} ${ toolsList.includes(",") ? "are not" : "is not" } enabled in Preview mode`, status: "info", }); } } else { clientSideActions.unshift(startClientSideAction); } } const clientSideActionsNeedSessionId = clientSideActions?.some( (action) => action.expectsDedicatedReply ); if (!input && !clientSideActionsNeedSessionId) return { newSessionState, messages, clientSideActions: clientSideActions.length > 0 ? clientSideActions : undefined, bot: { id: bot.id, settings: deepParseVariables( newSessionState.botsQueue[0].bot.variables )(bot.settings), theme: sanitizeAndParseTheme(bot.theme, { variables: initialState.botsQueue[0].bot.variables, }), publishedAt: bot.updatedAt, }, dynamicTheme: parseDynamicTheme(newSessionState), logs: startLogs.length > 0 ? startLogs : undefined, visitedEdges, setVariableHistory, }; return { newSessionState, resultId: result?.id, bot: { id: bot.id, settings: deepParseVariables(newSessionState.botsQueue[0].bot.variables)( bot.settings ), theme: sanitizeAndParseTheme(bot.theme, { variables: initialState.botsQueue[0].bot.variables, }), publishedAt: bot.updatedAt, }, messages, input, clientSideActions: clientSideActions.length > 0 ? clientSideActions : undefined, dynamicTheme: parseDynamicTheme(newSessionState), logs: startLogs.length > 0 ? startLogs : undefined, visitedEdges, setVariableHistory, }; }; const getBot = async (startParams: StartParams): Promise => { if (startParams.type === "preview" && startParams.bot) return startParams.bot; if ( startParams.type === "preview" && !startParams.userId && !env.NEXT_PUBLIC_E2E_TEST ) throw new TRPCError({ code: "UNAUTHORIZED", message: "You need to be authenticated to perform this action", }); const botQuery = startParams.type === "preview" ? await findBot({ id: startParams.botId, userId: startParams.userId, }) : await findPublicBot({ publicId: startParams.publicId }); const parsedBot = botQuery && "bot" in botQuery ? { id: botQuery.botId, ...omit(botQuery.bot, "workspace"), ...omit(botQuery, "bot", "botId"), } : botQuery; if (!parsedBot || parsedBot.isArchived) throw new TRPCError({ code: "NOT_FOUND", message: "Bot not found", }); const isQuarantinedOrSuspended = botQuery && "bot" in botQuery && (botQuery.bot.workspace.isQuarantined || botQuery.bot.workspace.isSuspended); if ( ("isClosed" in parsedBot && parsedBot.isClosed) || isQuarantinedOrSuspended ) throw new TRPCError({ code: "BAD_REQUEST", message: "Bot is closed", }); return startBotSchema.parse(parsedBot); }; const getResult = async ({ isPreview, resultId, prefilledVariables, isRememberUserEnabled, }: { resultId: string | undefined; isPreview: boolean; botId: string; prefilledVariables: Variable[]; isRememberUserEnabled: boolean; }) => { if (isPreview) return; const existingResult = resultId && isRememberUserEnabled ? await findResult({ id: resultId }) : undefined; const prefilledVariableWithValue = prefilledVariables.filter( (prefilledVariable) => isDefined(prefilledVariable.value) ); const updatedResult = { variables: prefilledVariableWithValue.concat( existingResult?.variables.filter( (resultVariable) => isDefined(resultVariable.value) && !prefilledVariableWithValue.some( (prefilledVariable) => prefilledVariable.name === resultVariable.name ) ) ?? [] ) as VariableWithValue[], }; return { id: existingResult?.id ?? createId(), variables: updatedResult.variables, answers: existingResult?.answers ?? [], }; }; const parseDynamicThemeInState = (theme: Theme) => { const hostAvatarUrl = theme.chat?.hostAvatar?.isEnabled ?? defaultHostAvatarIsEnabled ? theme.chat?.hostAvatar?.url : undefined; const guestAvatarUrl = theme.chat?.guestAvatar?.isEnabled ?? defaultGuestAvatarIsEnabled ? theme.chat?.guestAvatar?.url : undefined; if (!hostAvatarUrl?.startsWith("{{") && !guestAvatarUrl?.startsWith("{{")) return; return { hostAvatarUrl: hostAvatarUrl?.startsWith("{{") ? hostAvatarUrl : undefined, guestAvatarUrl: guestAvatarUrl?.startsWith("{{") ? guestAvatarUrl : undefined, }; }; const parseStartClientSideAction = ( bot: StartBot ): NonNullable[number] | undefined => { const blocks = bot.groups.flatMap((group) => group.blocks); const pixelBlocks = ( blocks.filter( (block) => block.type === IntegrationBlockType.PIXEL && isNotEmpty(block.options?.pixelId) && block.options?.isInitSkip !== true ) as PixelBlock[] ).map((pixelBlock) => pixelBlock.options?.pixelId as string); const startPropsToInject = { customHeadCode: isNotEmpty(bot.settings.metadata?.customHeadCode) ? sanitizeAndParseHeadCode( bot.settings.metadata?.customHeadCode as string ) : undefined, gtmId: bot.settings.metadata?.googleTagManagerId, googleAnalyticsId: ( blocks.find( (block) => block.type === IntegrationBlockType.GOOGLE_ANALYTICS && block.options?.trackingId ) as GoogleAnalyticsBlock | undefined )?.options?.trackingId, pixelIds: pixelBlocks.length > 0 ? pixelBlocks : undefined, }; if ( !startPropsToInject.customHeadCode && !startPropsToInject.gtmId && !startPropsToInject.googleAnalyticsId && !startPropsToInject.pixelIds ) return; return { type: "startPropsToInject", startPropsToInject }; }; const sanitizeAndParseTheme = ( theme: Theme, { variables }: { variables: Variable[] } ): Theme => ({ general: theme.general ? deepParseVariables(variables)(theme.general) : undefined, chat: theme.chat ? deepParseVariables(variables)(theme.chat) : undefined, customCss: theme.customCss ? removeLiteBadgeCss(parseVariables(variables)(theme.customCss)) : undefined, }); const sanitizeAndParseHeadCode = (code: string) => { code = removeLiteBadgeCss(code); return parse(code) .childNodes.filter((child) => child.nodeType !== NodeType.TEXT_NODE) .join("\n"); }; const removeLiteBadgeCss = (code: string) => { const liteBadgeCssRegex = /.*#lite-badge.*{[\s\S][^{]*}/gm; return code.replace(liteBadgeCssRegex, ""); }; const convertStartBotToBotInSession = ( bot: StartBot, startVariables: Variable[] ): BotInSession => bot.version === "6" ? { version: bot.version, id: bot.id, groups: bot.groups, edges: bot.edges, variables: startVariables, events: bot.events, } : { version: bot.version, id: bot.id, groups: bot.groups, edges: bot.edges, variables: startVariables, events: bot.events, }; const extractVariableIdsUsedForTranscript = (bot: BotInSession): string[] => { const variableIds: Set = new Set(); const parseVarParams = { variables: bot.variables, takeLatestIfList: bot.version !== "6", }; bot.groups.forEach((group) => { group.blocks.forEach((block) => { if (block.type === BubbleBlockType.TEXT) { const { parsedVariableIds } = parseVariablesInRichText( block.content?.richText ?? [], parseVarParams ); parsedVariableIds.forEach((variableId) => variableIds.add(variableId)); } if ( block.type === BubbleBlockType.IMAGE || block.type === BubbleBlockType.VIDEO || block.type === BubbleBlockType.AUDIO ) { if (!block.content?.url) return; const variablesInfo = getVariablesToParseInfoInText( block.content.url, parseVarParams ); variablesInfo.forEach((variableInfo) => variableInfo.variableId ? variableIds.add(variableInfo.variableId ?? "") : undefined ); } if (block.type === LogicBlockType.CONDITION) { block.items.forEach((item) => item.content?.comparisons?.forEach((comparison) => { if (comparison.variableId) variableIds.add(comparison.variableId); if (comparison.value) { const variableIdsInValue = getVariablesToParseInfoInText( comparison.value, parseVarParams ); variableIdsInValue.forEach((variableInfo) => { variableInfo.variableId ? variableIds.add(variableInfo.variableId) : undefined; }); } }) ); } }); }); return [...variableIds]; };