@botonic/plugin-flow-builder
Version:
Use Flow Builder to show your contents
330 lines (291 loc) • 10.3 kB
text/typescript
import {
Plugin,
PluginPreRequest,
ResolvedPlugins,
Session,
} from '@botonic/core'
import { ActionRequest } from '@botonic/react'
import { v7 as uuidv7 } from 'uuid'
import { FlowBuilderApi } from './api'
import {
FLOW_BUILDER_API_URL_PROD,
SEPARATOR,
SOURCE_INFO_SEPARATOR,
} from './constants'
import {
FlowAiAgent,
FlowBotAction,
FlowCarousel,
FlowContent,
FlowHandoff,
FlowImage,
FlowKnowledgeBase,
FlowRating,
FlowText,
FlowVideo,
FlowWhatsappButtonList,
FlowWhatsappCtaUrlButtonNode,
} from './content-fields'
import {
HtBotActionNode,
HtFlowBuilderData,
HtFunctionArgument,
HtFunctionArguments,
HtFunctionNode,
HtNodeComponent,
HtNodeWithContent,
HtNodeWithContentType,
} from './content-fields/hubtype-fields'
import { DEFAULT_FUNCTIONS } from './functions'
import {
AiAgentFunction,
BotonicPluginFlowBuilderOptions,
ContentFilter,
FlowBuilderJSONVersion,
InShadowingConfig,
KnowledgeBaseFunction,
PayloadParamsBase,
TrackEventFunction,
} from './types'
import { getNodeByUserInput } from './user-input'
import { SmartIntentsInferenceConfig } from './user-input/smart-intent'
import { inputHasTextData, resolveGetAccessToken } from './utils'
// TODO: Create a proper service to wrap all calls and allow api versioning
export default class BotonicPluginFlowBuilder implements Plugin {
public cmsApi: FlowBuilderApi
private flow?: HtFlowBuilderData
private functions: Record<any, any>
private currentRequest: PluginPreRequest
public getAccessToken: (session: Session) => string
public trackEvent?: TrackEventFunction
public getKnowledgeBaseResponse?: KnowledgeBaseFunction
public getAiAgentResponse?: AiAgentFunction
public smartIntentsConfig: SmartIntentsInferenceConfig
public inShadowing: InShadowingConfig
public contentFilters: ContentFilter[]
// TODO: Rethink how we construct FlowBuilderApi to be simpler
public jsonVersion: FlowBuilderJSONVersion
public apiUrl: string
public customRatingMessageEnabled: boolean
constructor(options: BotonicPluginFlowBuilderOptions<ResolvedPlugins, any>) {
this.apiUrl = options.apiUrl || FLOW_BUILDER_API_URL_PROD
this.jsonVersion = options.jsonVersion || FlowBuilderJSONVersion.LATEST
this.flow = options.flow
this.getAccessToken = resolveGetAccessToken(options.getAccessToken)
this.trackEvent = options.trackEvent
this.getKnowledgeBaseResponse = options.getKnowledgeBaseResponse
this.getAiAgentResponse = options.getAiAgentResponse
this.smartIntentsConfig = {
...options?.smartIntentsConfig,
useLatest: this.jsonVersion === FlowBuilderJSONVersion.LATEST,
}
const customFunctions = options.customFunctions || {}
this.functions = { ...DEFAULT_FUNCTIONS, ...customFunctions }
this.inShadowing = {
allowKeywords: options.inShadowing?.allowKeywords || false,
allowSmartIntents: options.inShadowing?.allowSmartIntents || false,
allowKnowledgeBases: options.inShadowing?.allowKnowledgeBases || false,
}
this.contentFilters = options.contentFilters || []
this.customRatingMessageEnabled =
options.customRatingMessageEnabled || false
}
resolveFlowUrl(request: PluginPreRequest): string {
if (request.session.is_test_integration) {
return `${this.apiUrl}/v1/bot_flows/{bot_id}/versions/${FlowBuilderJSONVersion.DRAFT}/`
}
return `${this.apiUrl}/v1/bot_flows/{bot_id}/versions/${this.jsonVersion}/`
}
async pre(request: PluginPreRequest): Promise<void> {
this.currentRequest = request
this.cmsApi = await FlowBuilderApi.create({
flowUrl: this.resolveFlowUrl(request),
url: this.apiUrl,
flow: this.flow,
accessToken: this.getAccessToken(request.session),
request: this.currentRequest,
})
const checkUserTextInput =
inputHasTextData(request.input) && !request.input.payload
if (checkUserTextInput) {
const resolvedLocale = this.cmsApi.getResolvedLocale()
const nodeByUserInput = await getNodeByUserInput(
this.cmsApi,
resolvedLocale,
request as unknown as ActionRequest,
this.smartIntentsConfig
)
request.input.payload = this.cmsApi.getPayload(nodeByUserInput?.target)
}
this.updateRequestBeforeRoutes(request)
}
private updateRequestBeforeRoutes(request: PluginPreRequest): void {
if (request.input.payload) {
request.input.payload = this.removeSourceSuffix(request.input.payload)
if (this.cmsApi.isBotAction(request.input.payload)) {
const cmsBotAction = this.cmsApi.getNodeById<HtBotActionNode>(
request.input.payload
)
request.input.payload =
this.cmsApi.createPayloadWithParams(cmsBotAction)
}
}
}
private removeSourceSuffix(payload: string): string {
return payload.split(SOURCE_INFO_SEPARATOR)[0]
}
post(request: PluginPreRequest): void {
request.input.nluResolution = undefined
}
async getContentsByContentID(
contentID: string,
prevContents?: FlowContent[]
): Promise<FlowContent[]> {
const node = this.cmsApi.getNodeByContentID(contentID) as HtNodeWithContent
return await this.getContentsByNode(node, prevContents)
}
getUUIDByContentID(contentID: string): string {
const node = this.cmsApi.getNodeByContentID(contentID)
return node.id
}
private async getContentsById(
id: string,
prevContents?: FlowContent[]
): Promise<FlowContent[]> {
const node = this.cmsApi.getNodeById(id) as HtNodeWithContent
return await this.getContentsByNode(node, prevContents)
}
async getStartContents(): Promise<FlowContent[]> {
const startNode = this.cmsApi.getStartNode()
this.currentRequest.session.flow_thread_id = uuidv7()
return await this.getContentsByNode(startNode)
}
async getContentsByNode(
node: HtNodeWithContent,
prevContents?: FlowContent[]
): Promise<FlowContent[]> {
const contents = prevContents || []
const resolvedLocale = this.cmsApi.getResolvedLocale()
if (node.type === HtNodeWithContentType.FUNCTION) {
const targetId = await this.callFunction(node, resolvedLocale)
return this.getContentsById(targetId, contents)
}
const content = this.getFlowContent(node, resolvedLocale)
if (content) {
contents.push(content)
}
// If node is BOT_ACTION not add more contents to render, next nodes render after execute action
if (node.type === HtNodeWithContentType.BOT_ACTION) {
return contents
}
// TODO: prevent infinite recursive calls
if (node.follow_up) {
return this.getContentsById(node.follow_up.id, contents)
}
return contents
}
private getFlowContent(
hubtypeContent: HtNodeComponent,
locale: string
): FlowContent | undefined {
switch (hubtypeContent.type) {
case HtNodeWithContentType.TEXT:
return FlowText.fromHubtypeCMS(hubtypeContent, locale, this.cmsApi)
case HtNodeWithContentType.IMAGE:
return FlowImage.fromHubtypeCMS(hubtypeContent, locale)
case HtNodeWithContentType.CAROUSEL:
return FlowCarousel.fromHubtypeCMS(hubtypeContent, locale, this.cmsApi)
case HtNodeWithContentType.VIDEO:
return FlowVideo.fromHubtypeCMS(hubtypeContent, locale)
case HtNodeWithContentType.WHATSAPP_BUTTON_LIST:
return FlowWhatsappButtonList.fromHubtypeCMS(
hubtypeContent,
locale,
this.cmsApi
)
case HtNodeWithContentType.WHATSAPP_CTA_URL_BUTTON:
return FlowWhatsappCtaUrlButtonNode.fromHubtypeCMS(
hubtypeContent,
locale,
this.cmsApi
)
case HtNodeWithContentType.HANDOFF:
return FlowHandoff.fromHubtypeCMS(hubtypeContent, locale, this.cmsApi)
case HtNodeWithContentType.KNOWLEDGE_BASE:
return FlowKnowledgeBase.fromHubtypeCMS(hubtypeContent)
case HtNodeWithContentType.AI_AGENT:
return FlowAiAgent.fromHubtypeCMS(hubtypeContent)
case HtNodeWithContentType.RATING:
return FlowRating.fromHubtypeCMS(hubtypeContent, locale)
case HtNodeWithContentType.BOT_ACTION:
return FlowBotAction.fromHubtypeCMS(hubtypeContent, locale, this.cmsApi)
default:
return undefined
}
}
private async callFunction(
functionNode: HtFunctionNode,
locale: string
): Promise<string> {
const functionNodeId = functionNode.id
const functionArguments = this.getArgumentsByLocale(
functionNode.content.arguments,
locale
)
const nameValues = functionArguments.map(arg => {
return { [arg.name]: arg.value }
})
const args = Object.assign(
{
request: this.currentRequest,
results: functionNode.content.result_mapping.map(r => r.result),
},
...nameValues
)
const functionResult =
await this.functions[functionNode.content.action](args)
// TODO define result_mapping per locale??
const result = functionNode.content.result_mapping.find(
r => r.result === functionResult
)
if (!result?.target) {
throw new Error(
`No result found for result_mapping for node with id: ${functionNodeId}`
)
}
return result.target.id
}
private getArgumentsByLocale(
args: HtFunctionArguments[],
locale: string
): HtFunctionArgument[] {
let resultArguments: HtFunctionArgument[] = []
for (const arg of args) {
if ('locale' in arg && arg.locale === locale) {
resultArguments = [...resultArguments, ...arg.values]
}
if ('type' in arg) {
resultArguments = [...resultArguments, arg]
}
}
return resultArguments
}
getPayloadParams<T extends PayloadParamsBase>(payload: string): T {
const payloadParams = JSON.parse(payload.split(SEPARATOR)[1] || '{}')
return payloadParams
}
getFlowName(flowId: string): string {
return this.cmsApi.getFlowName(flowId)
}
}
export * from './action'
export * from './content-fields'
export { HtBotActionNode } from './content-fields/hubtype-fields'
export { trackFlowContent } from './tracking'
export {
BotonicPluginFlowBuilderOptions,
ContentFilter,
FlowBuilderJSONVersion,
PayloadParamsBase,
} from './types'
export * from './webview'