iopa-bot
Version:
API-First Bot Framework for Internet of Things (IoT), based on Internet of Protocols Alliance (IOPA) specification
1,200 lines (1,007 loc) • 35 kB
text/typescript
import * as ReactiveCards from 'reactive-cards'
import {
FlowElement,
TableElement,
DialogElement,
ActionElement,
CardElement,
TextElement,
ActionOpenUrlElement,
Element,
CustomElement,
ActionSetElement,
render
} from 'reactive-dialogs'
import * as Iopa from 'iopa'
const { IOPA, SERVER } = Iopa.constants
import { BOT } from '../constants'
import Skill from '../schema/skill'
import { asyncForEachIfTrue } from '../util/forEachAsync'
import { parse_url } from '../polyfill/parse_url'
import { SessionCurrentDialog, useBotSession } from './session'
/** Custom command handlers return true if should continue after, false to stop current flow */
export type CommandHandler = (
command: string,
props: { [key: string]: any },
context: any
) => Promise<boolean>
const defaultPauseInterval = 200
const STARTS_WITH_EXTERNAL_REGEXP = /^(http|https|ftp|tel|sms)/i
/** Reactive Dialogs Capability 'urn:io.iopa.bot:reactive-dialogs' */
export interface ReactiveDialogsCapability {
/** register a reactives-dialog flow or table in the engine; it will not be rendered until renderFlow is called */
use(
/** JSX of dialog flow / table */
jsx: ({ }) => FlowElement | TableElement,
/** property bag of meta data associated with this flow */
meta?: { [key: string]: string }
): void
/** render an anonymous reactive-dialog flow or set of directives without pre-registration;
* used for directives or other elements that don't have their own unique intents */
render(
element: Element,
context: Iopa.Context,
next: () => Promise<void>
): Promise<void>
/** render (perform) a specific dialog and/or dialog step */
renderFlow(
/** id of flow to perform ; use undefined for current flow if there is one executing */
flowId: string | undefined | null,
/** id of dialog step to perform; use undefined for first dialog step in flow */
dialogId: string | undefined | null,
/* Context of current iopa record being executed */
context: Iopa.Context,
/* Iopa pipeline next, called by dialog flow handler if cannot handle this request */
next: () => Promise<void>
): Promise<void>
/** end the current flow if there is one being executed */
end(context: Iopa.Context): Promise<void>
/** map of command name and associated handlers; returns disposer to remove handler */
registerCommand(command: string, handler: CommandHandler): () => void
/** Version of this capability */
'iopa.Version': string
/** meta data for all currently registered flows */
meta: { [key: string]: { [key: string]: string } }
/** All currently registered lists */
lists: { [key: string]: string[] }
/** Meta data for all currently registered tables */
tables: { [key: string]: { [key: string]: string | string[] } }
/** property function that adds scheme for local resources e.g,, app:// */
localResourceProtocolMapper: (partial_url: string) => string
}
interface ReactiveDialogsCapabilityPrivate extends ReactiveDialogsCapability {
/** map of command name and associated handlers, push to this array to register additional platform commands */
_commandHandlers: Map<string, CommandHandler>
/** local version of ReactiveDialogsCapability.localResourceProtocolMapper */
_localResourceProtocolMapper: (partial_url: string) => string
}
export const useReactiveDialogs = (context: Iopa.Context) => {
return context[SERVER.Capabilities][
BOT.CAPABILITIES.ReactiveDialogs
] as ReactiveDialogsCapability
}
const RDM_VERSION = '2.0'
/**
* The ReactiveDialogManager registers bot dialog flows provided as reactive-dialog
* functional components.
*
* It maintains session state to keep track of the current dialog step and directive within a dialog step,
* and manages intents to branch through the flow according to the declared logic
* within the reactive-dialog directives
*/
export default class ReactiveDialogManager {
app: any
private flows: { [key: string]: FlowElement } = {}
private flowsMeta: { [key: string]: { [key: string]: string } } = {}
private tableLists: { [key: string]: string[] } = {}
private tableMeta: { [key: string]: { [key: string]: string | string[] } } = {}
private launchIntentsToFlows: { [key: string]: string } = {}
private commandHandlers: Map<string, CommandHandler>
private _localResourceProtocolMapper: (partial_url: string) => string = (partial_url) => partial_url || ''
/** public IOPA constructor used to register this capability */
constructor(app) {
this.app = app
app.properties[SERVER.CancelTokenSource] =
app.properties[SERVER.CancelTokenSource] ||
new Iopa.util.CancellationTokenSource()
app.properties[SERVER.CancelToken] =
app.properties[SERVER.CancelTokenSource].token
this.commandHandlers = new Map<string, CommandHandler>()
//
// set up useReactiveDialogs() public capability handle
//
app.properties[SERVER.Capabilities][BOT.CAPABILITIES.ReactiveDialogs] = {
'iopa.Version': BOT.VERSION,
use: (jsx: ({ }) => FlowElement, meta?: { [key: string]: string }) => {
this.register(app, jsx, meta)
},
render: (
element: Element,
context: Iopa.Context,
next: () => Promise<void>
) => {
return this.render(element, context, next)
},
renderFlow: (
id: string,
stepId: string | undefined,
context: Iopa.Context,
next: () => Promise<void>
) => {
return this.renderFlowById(id, stepId, context, next)
},
end: async (context: Iopa.Context) => {
return this.endFlow(context, { reason: 'capability.end' })
},
registerCommand: (command: string, handler: CommandHandler) => {
this.commandHandlers.set(command, handler)
return () => {
this.commandHandlers.delete(command)
}
},
_commandHandlers: this.commandHandlers,
meta: this.flowsMeta,
lists: this.tableLists,
tables: this.tableMeta,
set localResourceProtocolMapper(mapper: (partial_url: string) => string) {
this._localResourceProtocolMapper = mapper
ReactiveCards.setLocalResourceProtocolMapper(mapper)
},
get localResourceProtocolMapper() {
return this._localResourceProtocolMapper
}
} as ReactiveDialogsCapabilityPrivate
app.reactivedialogs =
app.properties[SERVER.Capabilities][BOT.CAPABILITIES.ReactiveDialogs]
app.properties[SERVER.Capabilities][BOT.CAPABILITIES.ReactiveDialogs][
IOPA.Version
] = BOT.VERSION
//
// Register well-known intent and default command handlers
//
app.intent(
'reactivedialogs:intents:start',
{
slots: {
FlowId: true
},
utterances: ['/dialog {-|FlowId}']
},
async (context, next) => {
context.response.responseHandled = true
const flowId = context[BOT.Slots]['FlowId']
return this.renderFlowById(flowId, null, context, next)
}
)
this.commandHandlers.set(
'end',
async (_command, _props, context: Iopa.Context) => {
this.endFlow(context, { reason: 'command:end' })
return false
}
)
this.commandHandlers.set(
'pause',
async (_command, props, context: Iopa.Context) => {
await delay(context, props.delay || defaultPauseInterval)
return true
}
)
this.commandHandlers.set(
'return',
async (_command, props, context: Iopa.Context) => {
const botSession = useBotSession(context)[0]
const prevDialog = botSession[BOT.CurrentDialog] as SessionCurrentDialog
if (prevDialog && prevDialog.previousId) {
await this.renderFlowById(
botSession[BOT.Skill],
prevDialog.previousId,
context,
() => Promise.resolve()
)
}
return false
}
)
}
/**
* Public IOPA invoke method that handles the processing of each inbound
* record;
*/
public invoke(
context: Iopa.Context,
next: () => Promise<void>
): Promise<void> {
const flows = useReactiveDialogs(context)
//
// Check for well known context in case its a record to actually invoke
// a new flow
//
if (context['urn:bot:dialog:invoke']) {
let flowId: string = context['urn:bot:dialog:invoke']
let dialogId: string = null!
if (flowId.indexOf('#') >= 0) {
let split = flowId.split('#', 2)
flowId = split[0]
dialogId = split[1]
}
return flows.renderFlow(flowId, dialogId, context, next)
}
//
// Check for intent provided by Intent pre-processor.
// Must have an intent to continue
//
if (!context[BOT.Intent]) {
return next()
}
const botSession = useBotSession(context)[0]
var isV2Dialog = !!botSession[BOT.SkillVersion]
if (!isV2Dialog) return next()
console.log('>> skill', botSession[BOT.Skill])
console.log('>> intent', context[BOT.Intent])
console.log('>> dialog', JSON.stringify(botSession[BOT.CurrentDialog] ? botSession[BOT.CurrentDialog].id : "", null, 2))
//
// Check if we are checking for a new session or continuing an existing session
//
if (!botSession[BOT.CurrentDialog]) {
return this._matchBeginFlow(context, next)
} else {
return this._continueFlow(context, next)
}
}
/** Check if we can process the intent and therefore start this dialog */
private _matchBeginFlow(context, next): Promise<void> {
const reactive = useReactiveDialogs(context)
const intent = context[BOT.Intent]
const flowId = this.launchIntentsToFlows[intent]
if (!flowId) {
console.log('No current V2 dialog, and could not find as launch intent')
// TO DO: Check for global '*'
return next()
}
return reactive.renderFlow(flowId, null, context, next)
}
private async _continueFlow(context: Iopa.Context, next: () => Promise<void>): Promise<void> {
const [botSession, setBotSession] = useBotSession(context)
const intent: string = context[BOT.Intent]
var flowId = botSession[BOT.Skill]
var flow = this.flows[flowId]
if (!flow) {
// not a recognized flow so clear
console.log(
`Dialog Flow ${flowId} in session no longer available in registry`
)
if (this.commandHandlers.has('dialog-abend')) {
this.commandHandlers.get('dialog-abend')!(
'dialog-abend',
{
id: botSession[BOT.Skill],
reason: `Dialog Flow ${flowId} in session no longer available in registry`
},
context
)
}
setBotSession(null)
return this._matchBeginFlow(context, next)
}
if (
botSession[BOT.SkillVersion] &&
flow.props.version.split('.')[0] !==
botSession[BOT.SkillVersion].split('.')[0]
) {
// major version change so clear
console.log(
`Dialog Flow ${flowId} major version ${
flow.props.version
} updated while participant was mid session on version ${
botSession[BOT.SkillVersion]
}`
)
if (this.commandHandlers.has('dialog-abend')) {
this.commandHandlers.get('dialog-abend')!(
'dialog-abend',
{
id: botSession[BOT.Skill],
reason: `Dialog Flow ${flowId} major version ${
flow.props.version
} updated while participant was mid session on version ${
botSession[BOT.SkillVersion]
}`
},
context
)
}
setBotSession(null)
return this._matchBeginFlow(context, next)
}
const {
id: dialogId,
lastDirective,
lastPromptActions,
iopaBotVersion
} = botSession[BOT.CurrentDialog] as SessionCurrentDialog
if (iopaBotVersion !== RDM_VERSION) {
return next()
}
const dialogSeqNo = flow.props.children.findIndex(
directive => directive.type == 'dialog' && directive.props.id == dialogId
)
if (dialogSeqNo == -1) {
//
// not a recognized dialog step so clear
//
console.log(
`Current session dialog step ${dialogId} in flow ${flowId} no longer available in registry`
)
if (this.commandHandlers.has('dialog-abend')) {
this.commandHandlers.get('dialog-abend')!(
'dialog-abend',
{
id: botSession[BOT.Skill],
reason: `Current session dialog step ${dialogId} in flow ${flowId} no longer available in registry`
},
context
)
}
setBotSession(null)
return this._matchBeginFlow(context, next)
}
setBotSession({
[BOT.Variables]: {
...botSession[BOT.Variables],
[`${dialogId}${lastDirective ? `:${lastDirective}` : ''}`]: intent,
[`${dialogId}${
lastDirective ? `:${lastDirective}:raw` : ':raw'
}`]: context[BOT.Text]
}
})
if (dialogSeqNo == flow.props.children.length - 1) {
// TO DO POST READING OF END FLOW WITH ALL PROPERTIES
//
// was at end of flow so end
//
setBotSession({
[BOT.LastDialogEndedDate]: new Date().getTime()
})
await this.endFlow(context, { reason: 'last-response' })
return next()
}
const dialog = flow.props.children[dialogSeqNo]
if (
lastDirective == null ||
lastDirective >= dialog.props.children.length
) {
//
// invalid lastCompletedDirective
// - nevertheless log and continue in case the saved actions are still good enough
//
console.log(
`Last directive sequence #${lastDirective} of dialog #${dialogId} in flow ${flowId} no longer available in registry`
)
}
if (!lastPromptActions) {
//
// was not in a prompt directive so just post the result to session bag
// and continue with next directive or dialog
//
await this.proceedToNextDirective(
context,
flow,
dialog,
dialogSeqNo,
lastDirective
)
return next()
} else {
///
/// match intent to actions Element
///
const intentFilters = lastPromptActions.map(
action =>
action.props.intents || action.props.utterances || [toString(action)]
)
const selectedActionSeqNo = intentFilters.findIndex(
filters => filters.includes(intent) || filters.includes('*')
)
if (selectedActionSeqNo == -1) {
const notUnderstoodDialog = `${dialogId}-not-understood`
const notUnderstoodSkill = `not-understood`
const nextStep: DialogElement | undefined =
flow.props.children.find(
dialog => dialog.props.id == notUnderstoodDialog
) ||
flow.props.children.find(
dialog => dialog.props.id == notUnderstoodSkill
)
if (nextStep) {
await this.renderDialogStep(flow, nextStep, context)
return next()
}
// No matching intent for current flow dialog step, see if we should start another flow
return this._matchBeginFlow(context, next)
}
const action = lastPromptActions[selectedActionSeqNo]
if (action.props.url) {
action.props.type = 'openurl'
}
switch (action.props.type) {
case 'submit':
await this.proceedToNextDirective(
context,
flow,
dialog,
dialogSeqNo,
lastDirective
)
return next()
case 'openurl':
await this.renderActionOpenUrl(action as ActionOpenUrlElement, context)
return next()
default:
console.log(
`card type ${action.props.type} not yet supported in reactive-dialogs manager`
)
await this.proceedToNextDirective(
context,
flow,
dialog,
dialogSeqNo,
lastDirective
)
return next()
}
}
}
protected async proceedToNextDirective(
context: Iopa.Context,
flow: FlowElement,
dialog: DialogElement,
dialogSeqNo: number,
lastDirective: number | null
): Promise<void> {
if (
lastDirective !== null &&
lastDirective < dialog.props.children.length - 1
) {
//
// not at end of dialog step
// no op as we got a participant response while we were still handling directives for this step
//
return Promise.resolve()
} else if (dialogSeqNo < flow.props.children.length - 1) {
//
// end of directives in current dialog step, but not at end of flow
//
const nextStep = flow.props.children[dialogSeqNo + 1]
return this.renderDialogStep(flow, nextStep, context)
} else {
//
// at end of flow
//
return this.endFlow(context, { reason: 'last-directive' })
}
}
/** helper method to register a jsx flow or table element in this capability's inventory */
protected register(app: Iopa.App, jsx: ({ }) => FlowElement | TableElement, meta: { [key: string]: string } = {}): void {
const flow: FlowElement | TableElement = jsx({})
if (!flow) {
return
}
if ((flow.type !== 'flow') && (flow.type !== 'table')) {
return throwErr(
'Tried to register a flow that is not a reactive-dialogs type'
) as any
}
if (flow.type == 'table') {
//
// Register Table Lists in main inventory
//
const tableId = flow.props.id
const lists = flow.props.children
this.tableMeta[tableId] = Object.assign({ lists: [] }, meta)
lists.forEach(list => {
const listid = list.props.id
const items = list.props.children
this.tableLists[listid] = items
; (this.tableMeta[tableId].lists as string[]).push(listid)
})
return
}
//
// Register Flow in main inventory
//
const flowId = flow.props.id
if (flowId in this.flows) {
return throwErr(
`Tried to register a dialog flow with id ${flowId} that already has been registered; restart engine first`
) as any
}
this.flows[flowId] = flow
this.flowsMeta[flowId] = meta
const skill = app.properties[SERVER.Capabilities][
BOT.CAPABILITIES.Skills
].add(flowId) as Skill
if (!meta.global) {
skill.global(false)
}
//
// Register all intents used in this flow
//
flow.props.children.forEach(dialog => {
this.registerDialogStep(dialog, skill)
})
//
// Add this flow's launch intents to main inventory of launch intents
//
if (flow.props.utterances && flow.props.utterances.length > 0) {
if (!Array.isArray(flow.props.utterances)) {
throwErr('utterances on <flow> must be an array of strings')
}
const skills =
app.properties[SERVER.Capabilities][BOT.CAPABILITIES.Skills].skills
const launchSkill = flow.props.canLaunchFromGlobal
? skills.default
: skill
const existingIntent = launchSkill.lookupIntent(flow.props.utterances)
if (existingIntent) {
this.launchIntentsToFlows[existingIntent] = flowId
} else {
const launchName = `reactiveDialogs:flow:${flowId}:launchIntent`
launchSkill.intent(launchName, { utterances: flow.props.utterances })
this.launchIntentsToFlows[launchName] = flowId
}
}
}
/** helper method to register a single dialog step in this skills inventory */
protected registerDialogStep(dialog: DialogElement, skill: Skill) {
dialog.props.children
.filter(
dialogChild =>
dialogChild.type == 'card' || typeof dialogChild.type == 'function'
)
.forEach(dialogChild => {
if (dialogChild.type == 'card') {
this.registerDialogCard(dialogChild as CardElement, skill)
} else {
this.registerDialogFuction(
(dialogChild as any) as CustomElement,
skill
)
}
})
}
/** helper method to register a single card in this skills inventory */
protected registerDialogFuction(fn: CustomElement, skill: Skill) {
const dialogChild = fn.type(
Object.assign({}, fn.props, fn.type.defaultProps)
)
if (dialogChild == null) {
return
}
if (dialogChild.type == 'card') {
this.registerDialogCard(dialogChild as CardElement, skill)
} else if (typeof dialogChild.type == 'function') {
this.registerDialogFuction((dialogChild as any) as CustomElement, skill)
}
}
/** helper method to register a single card in this skills inventory */
protected registerDialogCard(card: CardElement, skill: Skill) {
card.props.children
.filter(cardChild => cardChild.type == 'actionset')
.forEach(actionset => {
this.registerDialogCardActions(actionset as ActionSetElement, skill)
})
// TO DO CASCADE THROUGH CONTAINER actionsets (V1.2+)
}
/** helper method to register a single card action set in this skills inventory */
protected registerDialogCardActions(
actionset: ActionSetElement,
skill: Skill
) {
actionset.props.children
.filter(action => action.type == 'action')
.forEach(action => {
let response = toString(action).toLowerCase()
const utterances = action.props.utterances || [response]
const name = this.registerUtterances(
utterances[0].replace(/[\W_]+/g, ''),
utterances,
skill
)
action.props.intents = action.props.intents || []
action.props.intents.push(name)
})
}
/** helper method to register a single card action set in this skills inventory */
protected registerUtterances(
name,
utterances: string[],
skill: Skill
): string {
const schemaUtterances = utterances.map(s => s.toLowerCase()).sort()
const existingIntent = skill.lookupIntent(schemaUtterances)
if (existingIntent) {
return existingIntent
}
skill.intent(name, { utterances: schemaUtterances })
return name
}
/** helper method to render an anonymous reactive-dialog flow or set of directives without pre-registration; */
protected render(
element: Element,
context: Iopa.Context,
next: () => Promise<void>
): Promise<void> {
return throwErr(
'Inline render of unregistered reactive-dialogs elements not yet implemented'
)
}
/** find in inventory and render a specific flow and/or flow dialog step */
protected async renderFlowById(
id: string | null | undefined,
dialogId: string | null | undefined,
context: Iopa.Context,
next: () => Promise<void>
) {
const [botSession, setBotSession] = useBotSession(context)
if (id && botSession[BOT.Skill] && id !== botSession[BOT.Skill]) {
if (this.commandHandlers.has('dialog-end')) {
this.commandHandlers.get('dialog-end')!(
'dialog-end',
{
id: botSession[BOT.Skill],
reason: `switch ${id}`
},
context
)
}
}
const flowId = id || botSession[BOT.Skill]
if (!flowId) {
console.log(
`Cannot infer blank id to render when not in a current flow; continuing with pipeline`
)
return next()
}
const flow: FlowElement = this.flows[flowId]
if (!flow) {
console.log(
`Dialog Flow ${flowId} not found in V2 handler; continuing with pipeline`
)
return next()
}
if (flow.props.children.length == 0) {
console.log(`Dialog Flow ${flowId} is empty`)
return Promise.resolve()
}
let dialogStep: DialogElement
if (
!dialogId &&
!id &&
botSession[BOT.CurrentDialog] &&
botSession[BOT.CurrentDialog].id
) {
// find next step if both flow id and dialog id are blank
const currentDialogId: string = botSession[BOT.CurrentDialog].id
const currentSeq = flow.props.children.findIndex(
dialog => dialog.props.id == currentDialogId
)
if (currentSeq < flow.props.children.length - 1) {
dialogStep = flow.props.children[currentSeq + 1]
} else {
dialogStep = flow.props.children[0]
}
} else if (dialogId) {
dialogStep = flow.props.children.find(c => c.props.id === dialogId)!
if (!dialogStep) {
console.error(
`Step ${dialogId} not found on dialog ${flowId};`
)
return Promise.resolve()
}
} else {
dialogStep = flow.props.children[0]
}
if (!botSession[BOT.Skill] || flow.props.id !== botSession[BOT.Skill]) {
if (this.commandHandlers.has('dialog-start')) {
this.commandHandlers.get('dialog-start')!(
'dialog-start',
{
id: flow.props.id,
intent: context['urn:bot:dialog:invoke']
? 'urn:bot:dialog:invoke'
: context[BOT.Intent]
},
context
)
}
}
setBotSession({
[BOT.Skill]: flow.props.id,
[BOT.SkillVersion]: flow.props.version,
[BOT.CurrentDialog]: null,
[BOT.Variables]: botSession[BOT.Variables] || {}
})
await this.renderDialogStep(flow, dialogStep, context)
return next()
}
/** render a given react-dialogs dialog step element to the host platform */
protected async renderDialogStep(
flow: FlowElement,
dialog: DialogElement,
context
): Promise<void> {
console.log('Starting flow dialog step #', dialog.props.id)
const [botSession, setBotSession] = useBotSession(context)
if (!botSession) {
/** dialog manager must have been disposed */ return
}
const prevDialog = botSession[BOT.CurrentDialog]
const currentDialog: SessionCurrentDialog = {
id: dialog.props.id,
previousId: prevDialog ? prevDialog.id : undefined,
iopaBotVersion: RDM_VERSION,
lastDirective: null,
lastPromptActions: null
} as SessionCurrentDialog
setBotSession({
[BOT.CurrentDialog]: currentDialog,
[BOT.isMultiChoicePrompt]: false
})
const isNotWaitingOnPrompt = await asyncForEachIfTrue(
dialog.props.children,
async (directive, i) => {
if (this.app.properties[SERVER.CancelToken].isCancelled) return false
console.log(`Performing dialog step ${dialog.props.id} directive ${i}`)
currentDialog.lastDirective = i
setBotSession({
[BOT.CurrentDialog]: currentDialog
})
const isNotWaitingOnPrompt = await this.renderDirective(directive, context)
return isNotWaitingOnPrompt
}
)
if (isNotWaitingOnPrompt) {
const isLastItem =
flow.props.children[flow.props.children.length - 1] === dialog
if (isLastItem) {
return this.endFlow(context, { reason: 'last-response' })
}
const currentSeq = flow.props.children.findIndex(
d => d.props.id == dialog.props.id
)
if (currentSeq !== -1) {
const nextStep = flow.props.children[currentSeq + 1]
return this.renderDialogStep(flow, nextStep, context)
}
}
return
}
/** end the current flow if there is one being executed */
protected async endFlow(context: Iopa.Context, props): Promise<void> {
const [botSession, setBotSession] = useBotSession(context)
console.log(`Ending dialog flow ${botSession[BOT.Skill]}`)
if (this.commandHandlers.has('dialog-end')) {
this.commandHandlers.get('dialog-end')!(
'dialog-end',
{
id: botSession[BOT.Skill],
success: true,
...props
},
context
)
}
await setBotSession(null)
context.response[BOT.ShouldEndSession] = true
}
protected renderDirective(
element: TextElement | CardElement | ActionElement | CustomElement,
context: Iopa.Context
): Promise<boolean> {
const vdom = render<TextElement | CardElement | ActionElement>(element)
switch (vdom.type) {
case 'text':
return this.renderText(vdom, context)
case 'card':
this.saveActionsFromCard(vdom, context)
return this.renderCard(vdom, context)
case 'action':
return this.renderAction(vdom, context)
default:
throwErr(
`invalid dialog flow: <${
(element as any).type
}> not a valid dialog directive or card type`
)
return Promise.resolve(false)
}
}
protected async renderText(
element: TextElement,
context: Iopa.Context
): Promise<boolean> {
const text = toString(element)
const pause = element.props.pause || defaultPauseInterval
await context.response.sendAll([text])
await delay(context, pause || defaultPauseInterval)
return true
}
protected async renderCard(
element: CardElement,
context: Iopa.Context
): Promise<boolean> {
const [botSession, setBotSession] = useBotSession(context)
const actionset: ActionSetElement | undefined = element.props.children.find(
child => child.type == 'actionset'
) as ActionSetElement | undefined
if (actionset) {
actionset.props.children.forEach(action => {
if (action.props.type === 'openurl') {
//
// render openurl as submit for non external links
//
if (!(STARTS_WITH_EXTERNAL_REGEXP.test(action.props.url))) {
action.props.type = 'submit'
}
action.props.data = action.props.utterances
? action.props.utterances[0]
: toString(action).toLowerCase()
}
})
await setBotSession({ [BOT.isMultiChoicePrompt]: true })
}
const meta = this.flowsMeta[botSession[BOT.Skill]]
const resourceRoot = (meta && meta["nkar"]) ? `${meta["nkar"]}/` : ''
const card = ReactiveCards.render(element, resourceRoot)
const pause = element.props.pause || defaultPauseInterval
await context.response.sendAll([{ text: '', attachments: [card] }])
await delay(context, pause || defaultPauseInterval)
return !card.actions || (card.actions.length == 0)
}
private saveActionsFromCard(
element: CardElement,
context: Iopa.Context
): void {
const [botSession, setBotSession] = useBotSession(context)
const currentDialog: SessionCurrentDialog = botSession[BOT.CurrentDialog]!
const actionset = element.props.children.find(
actionset => actionset.type == 'actionset'
) as ActionSetElement | undefined
if (!actionset) {
return
}
currentDialog.lastPromptActions = actionset.props.children.filter(
action => action.type == 'action'
)
setBotSession({ [BOT.CurrentDialog]: currentDialog })
}
protected renderAction(
element: ActionElement,
context: Iopa.Context
): Promise<boolean> {
switch (element.props.type) {
case 'openurl':
return this.renderActionOpenUrl(element as ActionOpenUrlElement, context)
case 'showcard':
case 'submit':
default:
throwErr(
`Invalid action type '${element.props.type}' when used as a direct child of <step>`
)
return Promise.resolve(true)
}
}
protected async renderActionOpenUrl(
element: ActionOpenUrlElement,
context: Iopa.Context
): Promise<boolean> {
if (!element.props.url) {
// continue with dialog next step
return this.renderActionDialogFlow('', '', element, context)
}
if (element.props.url.indexOf(':') == -1) {
element.props.url = `dialog:/` + element.props.url
}
const url = parse_url(element.props.url)
switch (url.protocol) {
case 'dialog:':
const flowId = url.pathname.replace(/^\/*/, '')
const dialogId = url.hash ? url.hash.replace(/^#/, '') : undefined
if (!url.hash && !flowId) {
console.log('found blank action url in dialog, continuing')
return Promise.resolve(true)
}
await this.renderActionDialogFlow(flowId, dialogId, element, context)
return Promise.resolve(false)
case 'https:':
case 'http:':
case 'tel:':
case 'sms:':
await this.renderActionCommand('openurl', { url }, element, context)
return Promise.resolve(false)
case 'command:':
//
// <action type="openurl" url="command:pause?delay=500" />
//
return this.renderActionCommand(
url.pathname.replace(/^\/*/, ''),
getJsonFromUrl(url.query),
element,
context
)
default:
throwErr(`unknown protocol ${url.protocol} on ${element.props.url}`)
return Promise.resolve(true)
}
}
protected async renderActionDialogFlow(
id: string,
dialogId: string | undefined,
element: ActionOpenUrlElement,
context: Iopa.Context
): Promise<boolean> {
const reactive = useReactiveDialogs(
context
) as ReactiveDialogsCapabilityPrivate
await reactive.renderFlow(id, dialogId, context, () => Promise.resolve())
return false
}
protected logStartOfDialog(context: Iopa.Context) { }
protected logAbandondedDialog(context: Iopa.Context) { }
protected logCompletedDialog(context: Iopa.Context) { }
protected renderActionCommand(
command: string,
params: { [key: string]: any },
element: ActionOpenUrlElement,
context: Iopa.Context
): Promise<boolean> {
const reactive = useReactiveDialogs(
context
) as ReactiveDialogsCapabilityPrivate
const handler = reactive._commandHandlers.get(command)
if (handler) {
return handler(
command,
Object.assign(
{ url: element.props.url, data: element.props.data },
params
),
context
)
} else {
throwErr(
`No handler registered for the command ${command} on ${element.props.url}`
)
return Promise.resolve(true)
}
}
}
const toString = child => {
return child.props.children.join('')
}
const delay = (context, interval) => {
return new Promise<void>(resolve => {
setTimeout(resolve, context.response[BOT.isDelayDisabled] ? 40 : interval)
})
}
function throwErr(...args): Promise<void> {
var message = Array.prototype.slice.call(args).join(' ')
throw new Error(message)
}
function camelize(str) {
return str
.replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) {
return index == 0 ? word.toLowerCase() : word.toUpperCase()
})
.replace(/\s+/g, '')
}
export function getJsonFromUrl(url) {
var query = url.substr(1);
var result = {};
query.split("&").forEach(function (part) {
var item = part.split("=");
result[item[0]] = decodeURIComponent(item[1]);
});
return result;
}