voxa
Version:
A fsm (state machine) framework for Alexa, Dialogflow, Facebook Messenger and Botframework apps using Node.js
823 lines (689 loc) • 21 kB
text/typescript
/*
* Copyright (c) 2018 Rain Agency <contact@rain.agency>
* Author: Rain Agency <contact@rain.agency>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import {
dialog,
Directive,
er,
interfaces,
Response,
Slot,
ui,
} from "ask-sdk-model";
import * as _ from "lodash";
import { IDirective } from "../../directives";
import { ITransition } from "../../StateMachine";
import { IVoxaEvent } from "../../VoxaEvent";
import { IVoxaReply } from "../../VoxaReply";
import { AlexaReply } from "./AlexaReply";
function isCard(card: any): card is ui.Card {
if (!("type" in card)) {
return false;
}
return _.includes(
["Standard", "Simple", "LinkAccount", "AskForPermissionsConsent"],
card.type,
);
}
export abstract class AlexaDirective {
public directive?: Directive | Directive[];
protected addDirective(reply: IVoxaReply) {
const response: Response = (reply as AlexaReply).response;
if (!response.directives) {
response.directives = [];
}
if (!this.directive) {
throw new Error("The directive can't be empty");
}
if (_.isArray(this.directive)) {
response.directives = _.concat(response.directives, this.directive);
} else {
response.directives.push(this.directive);
}
}
}
export abstract class MultimediaAlexaDirective extends AlexaDirective {
protected validateReply(reply: IVoxaReply) {
if (reply.hasDirective("AudioPlayer.Play")) {
throw new Error(
"Do not include both an AudioPlayer.Play" +
" directive and a VideoApp.Launch directive in the same response",
);
}
}
}
export class HomeCard implements IDirective {
public static platform: string = "alexa";
public static key: string = "alexaCard";
constructor(public viewPath: string | ui.Card) {}
public async writeToReply(
reply: IVoxaReply,
event: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
if (reply.hasDirective("card")) {
throw new Error("At most one card can be specified in a response");
}
let card: ui.Card;
if (_.isString(this.viewPath)) {
card = await event.renderer.renderPath(this.viewPath, event);
if (!isCard(card)) {
throw new Error("The view should return a Card like object");
}
} else if (isCard(this.viewPath)) {
card = this.viewPath;
} else {
throw new Error("Argument should be a viewPath or a Card like object");
}
(reply as AlexaReply).response.card = card;
}
}
export class Hint implements IDirective {
public static platform: string = "alexa";
public static key: string = "alexaHint";
constructor(public viewPath: string) {}
public async writeToReply(
reply: IVoxaReply,
event: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
if (reply.hasDirective("Hint")) {
throw new Error(
"At most one Hint directive can be specified in a response",
);
}
const response: Response = (reply as AlexaReply).response || {};
if (!response.directives) {
response.directives = [];
}
const text = await event.renderer.renderPath(this.viewPath, event);
response.directives.push({
hint: {
text,
type: "PlainText",
},
type: "Hint",
});
(reply as AlexaReply).response = response;
}
}
export class DialogDelegate extends AlexaDirective implements IDirective {
public static platform: string = "alexa";
public static key: string = "alexaDialogDelegate";
public directive!: dialog.DelegateDirective;
constructor(public slots?: any) {
super();
}
public async writeToReply(
reply: IVoxaReply,
event: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
this.buildDirective(event);
this.buildSlots(event);
this.addDirective(reply);
}
protected buildSlots(event: IVoxaEvent) {
if (!event.intent) {
throw new Error("An intent is required");
}
if (!this.slots) {
return;
}
const directiveSlots = _(this.slots)
.map((value, key) => {
const data: any = {
confirmationStatus: "NONE",
name: key,
};
if (value) {
data.value = value;
}
return [key, data];
})
.fromPairs()
.value();
this.directive.updatedIntent = {
confirmationStatus: "NONE",
name: event.intent.name,
slots: directiveSlots,
};
}
protected buildDirective(event: IVoxaEvent) {
this.directive = {
type: "Dialog.Delegate",
};
}
}
export interface IElicitDialogOptions {
slotToElicit: string;
slots: { [key: string]: Slot };
}
export class DialogElicitSlot extends AlexaDirective implements IDirective {
public static platform: string = "alexa";
public static key: string = "alexaElicitDialog";
private static validate(
options: IElicitDialogOptions,
reply: IVoxaReply,
event: IVoxaEvent,
transition: ITransition,
) {
if (reply.hasDirective("Dialog.ElicitSlot")) {
throw new Error(
"At most one Dialog.ElicitSlot directive can be specified in a response",
);
}
if (
transition.to &&
transition.to !== "die" &&
transition.to !== _.get(event, "rawEvent.request.intent.name")
) {
throw new Error(
"You cannot transition to a new intent while using a Dialog.ElicitSlot directive",
);
}
if (!options.slotToElicit) {
throw new Error(
"slotToElicit is required for the Dialog.ElicitSlot directive",
);
}
if (
!_.has(event, "rawEvent.request.dialogState") ||
_.get(event, "rawEvent.request.dialogState") === "COMPLETED"
) {
throw new Error(
"Intent is missing dialogState or has already completed this dialog and cannot elicit any slots",
);
}
}
public directive!: dialog.ElicitSlotDirective;
constructor(public options: IElicitDialogOptions) {
super();
}
public async writeToReply(
reply: IVoxaReply,
event: IVoxaEvent,
transition: ITransition,
): Promise<void> {
DialogElicitSlot.validate(this.options, reply, event, transition);
this.buildDirective(event);
// Alexa is always going to return to this intent with the results of this dialog
// so we can't move anywhere else.
transition.flow = "yield";
transition.to = _.get(event, "rawEvent.request.intent.name");
this.addDirective(reply);
}
protected buildDirective(event: IVoxaEvent) {
const intent = _.get(event, "rawEvent.request.intent");
const slots = intent.slots;
if (this.options.slots) {
_.forOwn(this.options.slots, (value, key) => {
if (_.has(slots, key)) {
if (!_.has(value, "name")) {
_.set(value, "name", key);
}
slots[key] = value;
}
});
}
this.directive = {
slotToElicit: this.options.slotToElicit,
type: "Dialog.ElicitSlot",
updatedIntent: {
confirmationStatus: "NONE",
name: intent.name,
slots,
},
};
}
}
export class RenderTemplate extends AlexaDirective implements IDirective {
public static key: string = "alexaRenderTemplate";
public static platform: string = "alexa";
public viewPath?: string;
public token?: string;
public directive?: interfaces.display.RenderTemplateDirective;
constructor(viewPath: string | interfaces.display.RenderTemplateDirective) {
super();
if (_.isString(viewPath)) {
this.viewPath = viewPath;
} else {
this.directive = viewPath;
}
}
public async writeToReply(
reply: IVoxaReply,
event: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
this.validateReply(reply);
if (!_.includes(event.supportedInterfaces, "Display")) {
return;
}
if (this.viewPath) {
this.directive = await event.renderer.renderPath(this.viewPath, event);
}
this.addDirective(reply);
}
private validateReply(reply: IVoxaReply) {
if (reply.hasDirective("Display.RenderTemplate")) {
throw new Error(
"At most one Display.RenderTemplate directive can be specified in a response",
);
}
}
}
export class APLTemplate extends AlexaDirective implements IDirective {
public static key: string = "alexaAPLTemplate";
public static platform: string = "alexa";
public viewPath?: string;
public directive?: interfaces.alexa.presentation.apl.RenderDocumentDirective;
constructor(
viewPath: string | interfaces.alexa.presentation.apl.RenderDocumentDirective,
) {
super();
if (_.isString(viewPath)) {
this.viewPath = viewPath;
} else {
this.directive = viewPath;
}
}
public async writeToReply(
reply: IVoxaReply,
event: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
this.validateReply(reply);
if (!_.includes(event.supportedInterfaces, "Alexa.Presentation.APL")) {
return;
}
if (this.viewPath) {
this.directive = await event.renderer.renderPath(this.viewPath, event);
}
this.addDirective(reply);
}
private validateReply(reply: IVoxaReply) {
if (reply.hasDirective("Alexa.Presentation.APL.RenderDocument")) {
throw new Error(
"At most one Alexa.Presentation.APL.RenderDocument directive can be specified in a response",
);
}
}
}
export class APLCommand extends AlexaDirective implements IDirective {
public static key: string = "alexaAPLCommand";
public static platform: string = "alexa";
public viewPath?: string;
public directive?: interfaces.alexa.presentation.apl.ExecuteCommandsDirective;
constructor(
viewPath:
| string
| interfaces.alexa.presentation.apl.ExecuteCommandsDirective,
) {
super();
if (_.isString(viewPath)) {
this.viewPath = viewPath;
} else {
this.directive = viewPath;
}
}
public async writeToReply(
reply: IVoxaReply,
event: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
this.validateReply(reply);
if (!_.includes(event.supportedInterfaces, "Alexa.Presentation.APL")) {
return;
}
if (this.viewPath) {
this.directive = await event.renderer.renderPath(this.viewPath, event);
}
this.addDirective(reply);
}
private validateReply(reply: IVoxaReply) {
if (reply.hasDirective("Alexa.Presentation.APL.ExecuteCommands")) {
throw new Error(
"At most one Alexa.Presentation.APL.ExecuteCommands directive can be specified in a response",
);
}
}
}
export class APLTTemplate extends AlexaDirective implements IDirective {
public static key: string = "alexaAPLTTemplate";
public static platform: string = "alexa";
public viewPath?: string;
public directive?: interfaces.alexa.presentation.aplt.RenderDocumentDirective;
constructor(
viewPath:
| string
| interfaces.alexa.presentation.aplt.RenderDocumentDirective,
) {
super();
if (_.isString(viewPath)) {
this.viewPath = viewPath;
} else {
this.directive = viewPath;
}
}
public async writeToReply(
reply: IVoxaReply,
event: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
this.validateReply(reply);
if (!_.includes(event.supportedInterfaces, "Alexa.Presentation.APLT")) {
return;
}
if (this.viewPath) {
this.directive = await event.renderer.renderPath(this.viewPath, event);
}
this.addDirective(reply);
}
private validateReply(reply: IVoxaReply) {
if (reply.hasDirective("Alexa.Presentation.APLT.RenderDocument")) {
throw new Error(
"At most one Alexa.Presentation.APLT.RenderDocument directive can be specified in a response",
);
}
}
}
export class APLTCommand extends AlexaDirective implements IDirective {
public static key: string = "alexaAPLTCommand";
public static platform: string = "alexa";
public viewPath?: string;
public directive?: interfaces.alexa.presentation.aplt.ExecuteCommandsDirective;
constructor(
viewPath:
| string
| interfaces.alexa.presentation.aplt.ExecuteCommandsDirective,
) {
super();
if (_.isString(viewPath)) {
this.viewPath = viewPath;
} else {
this.directive = viewPath;
}
}
public async writeToReply(
reply: IVoxaReply,
event: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
this.validateReply(reply);
if (!_.includes(event.supportedInterfaces, "Alexa.Presentation.APLT")) {
return;
}
if (this.viewPath) {
this.directive = await event.renderer.renderPath(this.viewPath, event);
}
this.addDirective(reply);
}
private validateReply(reply: IVoxaReply) {
if (reply.hasDirective("Alexa.Presentation.APLT.ExecuteCommands")) {
throw new Error(
"At most one Alexa.Presentation.APLT.ExecuteCommands directive can be specified in a response",
);
}
}
}
export class AccountLinkingCard implements IDirective {
public static key: string = "alexaAccountLinkingCard";
public static platform: string = "alexa";
public async writeToReply(
reply: IVoxaReply,
event?: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
if (reply.hasDirective("card")) {
throw new Error("At most one card can be specified in a response");
}
const card: ui.Card = { type: "LinkAccount" };
(reply as AlexaReply).response.card = card;
}
}
export interface IAlexaPlayAudioDataOptions {
url: string;
token: string;
offsetInMilliseconds?: number;
behavior?: interfaces.audioplayer.PlayBehavior;
metadata?: interfaces.audioplayer.AudioItemMetadata;
}
export class PlayAudio extends MultimediaAlexaDirective implements IDirective {
public static key: string = "alexaPlayAudio";
public static platform: string = "alexa";
public directive?: interfaces.audioplayer.PlayDirective;
constructor(public data: IAlexaPlayAudioDataOptions) {
super();
}
public async writeToReply(
reply: IVoxaReply,
event?: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
this.validateReply(reply);
this.directive = {
audioItem: {
metadata: this.data.metadata || {},
stream: {
offsetInMilliseconds: this.data.offsetInMilliseconds || 0,
token: this.data.token,
url: this.data.url,
},
},
playBehavior: this.data.behavior || "REPLACE_ALL",
type: "AudioPlayer.Play",
};
this.addDirective(reply);
}
}
export class StopAudio extends AlexaDirective implements IDirective {
public static key: string = "alexaStopAudio";
public static platform: string = "alexa";
public directive: interfaces.audioplayer.StopDirective = {
type: "AudioPlayer.Stop",
};
public async writeToReply(
reply: IVoxaReply,
event?: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
this.addDirective(reply);
}
}
export class GadgetControllerLightDirective extends AlexaDirective
implements IDirective {
public static key: string = "alexaGadgetControllerLightDirective";
public static platform: string = "alexa";
constructor(
public directive:
| interfaces.gadgetController.SetLightDirective
| interfaces.gadgetController.SetLightDirective[],
) {
super();
}
public async writeToReply(
reply: IVoxaReply,
event?: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
this.addDirective(reply);
}
}
export class GameEngineStartInputHandler extends AlexaDirective
implements IDirective {
public static key: string = "alexaGameEngineStartInputHandler";
public static platform: string = "alexa";
constructor(
public directive: interfaces.gameEngine.StartInputHandlerDirective,
) {
super();
}
public async writeToReply(
reply: IVoxaReply,
event?: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
this.addDirective(reply);
const response = (reply as AlexaReply).response;
delete response.shouldEndSession;
}
}
export class GameEngineStopInputHandler extends AlexaDirective
implements IDirective {
public static key: string = "alexaGameEngineStopInputHandler";
public static platform: string = "alexa";
constructor(public originatingRequestId: string) {
super();
}
public async writeToReply(
reply: IVoxaReply,
event?: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
this.directive = {
originatingRequestId: this.originatingRequestId,
type: "GameEngine.StopInputHandler",
};
this.addDirective(reply);
}
}
export class ConnectionsSendRequest extends AlexaDirective
implements IDirective {
public static key: string = "alexaConnectionsSendRequest";
public static platform: string = "alexa";
public name?: string;
public directive?: interfaces.connections.SendRequestDirective;
public type?: string = "Connections.SendRequest";
constructor(
name: string | interfaces.connections.SendRequestDirective,
public payload: any,
public token: string,
) {
super();
if (_.isString(name)) {
this.name = name;
} else {
this.directive = name;
}
}
public async writeToReply(
reply: IVoxaReply,
event?: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
if (this.name) {
this.directive = {
name: this.name,
payload: this.payload,
token: this.token || "token",
type: "Connections.SendRequest",
};
}
this.addDirective(reply);
}
}
export interface IAlexaVideoDataOptions {
source: string;
title?: string;
subtitle?: string;
}
export class VideoAppLaunch extends MultimediaAlexaDirective {
public static key: string = "alexaVideoAppLaunch";
public static platform: string = "alexa";
public directive?: interfaces.videoapp.LaunchDirective;
constructor(public options: IAlexaVideoDataOptions | string) {
super();
}
public async writeToReply(
reply: IVoxaReply,
event: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
this.validateReply(reply);
if (!_.includes(event.supportedInterfaces, "VideoApp")) {
return;
}
let options: IAlexaVideoDataOptions;
if (_.isString(this.options)) {
options = await event.renderer.renderPath(this.options, event);
} else {
options = this.options;
}
this.directive = {
type: "VideoApp.Launch",
videoItem: {
metadata: {
subtitle: options.subtitle,
title: options.title,
},
source: options.source,
},
};
this.addDirective(reply);
}
}
export class DynamicEntitiesDirective extends AlexaDirective
implements IDirective {
public static key: string = "alexaDynamicEntities";
public static platform: string = "alexa";
public viewPath?: string;
public types?: er.dynamic.EntityListItem[];
public directive?: dialog.DynamicEntitiesDirective;
constructor(
viewPath:
| string
| dialog.DynamicEntitiesDirective
| er.dynamic.EntityListItem[],
) {
super();
if (_.isString(viewPath)) {
this.viewPath = viewPath;
} else if (_.isArray(viewPath)) {
this.types = viewPath;
} else {
this.directive = viewPath;
}
}
public async writeToReply(
reply: IVoxaReply,
event: IVoxaEvent,
transition?: ITransition,
): Promise<void> {
let types = [];
if (this.viewPath) {
types = await event.renderer.renderPath(this.viewPath, event);
this.directive = {
type: "Dialog.UpdateDynamicEntities",
types,
updateBehavior: "REPLACE",
};
}
if (this.types) {
this.directive = {
type: "Dialog.UpdateDynamicEntities",
types: this.types,
updateBehavior: "REPLACE",
};
}
this.addDirective(reply);
}
}