From 9f72d13c30b2a4ec4ea843464d72441db2a0efea Mon Sep 17 00:00:00 2001 From: Thomas Neil James Shadwell Date: Thu, 9 Jan 2025 23:47:14 +0000 Subject: [PATCH] Improve implementation of Amazon Contact Flow Language. This is in preparation for project doorperson, whi needed to use some of these features. 1. Split the language into a globals-less version called 'contact flow composer'. 2. Allow calling lambdas, and getting user input. --- ts/pulumi/lib/contact_flow.ts | 57 +---- ts/pulumi/lib/contact_flow_composer.ts | 290 +++++++++++++++++++++++++ ts/pulumi/lib/contact_flow_language.ts | 283 ++++++++++++++++++++++++ 3 files changed, 574 insertions(+), 56 deletions(-) create mode 100644 ts/pulumi/lib/contact_flow_composer.ts create mode 100644 ts/pulumi/lib/contact_flow_language.ts diff --git a/ts/pulumi/lib/contact_flow.ts b/ts/pulumi/lib/contact_flow.ts index b79a33f151..244eee746d 100644 --- a/ts/pulumi/lib/contact_flow.ts +++ b/ts/pulumi/lib/contact_flow.ts @@ -9,64 +9,9 @@ import { Input, } from '@pulumi/pulumi'; +import { ContactFlowLanguage } from '#root/ts/pulumi/lib/contact_flow_language.js'; import { Err, Ok, Result } from '#root/ts/result.js'; -interface ActionBase { - Identifier: string; - Type: string; - Parameters: unknown; - Transitions?: { - NextAction?: string; - Errors?: string[]; - Conditions?: string[]; - }; -} - -export interface EndFlowExecutionAction extends ActionBase { - Type: 'DisconnectParticipant'; - Parameters: Record; - Transitions?: Record; -} - -export interface MessageParticipantAction extends ActionBase { - Type: 'MessageParticipant'; - Parameters: { - /** - * A prompt ID or prompt ARN to play to the participant along with gathering input. May not be specified if Text or SSML is also specified. - * Must be specified either statically or as a single valid JSONPath identifier - */ - PromptId?: string; - /** - * An optional string that defines text to send to the participant along with gathering input. - * May not be specified if PromptId or SSML is also specified. May be specified statically or dynamically. - */ - Text?: string; - /** - * An optional string that defines SSML to send to the participant along with gathering input. May not be specified if Text or - * PromptId is also specified May be specified statically or dynamically. - */ - SSML?: string; - media?: { - uri: string; - SourceType: 'S3'; - MediaType: 'Audio'; - }; - }; - Transitions: { - NextAction: string; - }; -} - -export type ContactFlowAction = - | MessageParticipantAction - | EndFlowExecutionAction; - -export interface ContactFlowLanguage { - Version: '2019-10-30'; - StartAction: string; - Actions: ContactFlowAction[]; -} - export interface Args extends Omit { content: Input; } diff --git a/ts/pulumi/lib/contact_flow_composer.ts b/ts/pulumi/lib/contact_flow_composer.ts new file mode 100644 index 0000000000..7af8f19f97 --- /dev/null +++ b/ts/pulumi/lib/contact_flow_composer.ts @@ -0,0 +1,290 @@ +import * as regular_contact_flow from '#root/ts/pulumi/lib/contact_flow_language.js'; + +export interface ActionBase { + Type: string; + Parameters: unknown; + Transitions?: { + NextAction?: ContactFlowAction; + Errors?: ErrorTransition[]; + Conditions?: regular_contact_flow.ConditionOperator[]; + }; +} + +export interface ErrorTransition { + NextAction: ContactFlowAction, + ErrorType: string +} + +export interface ConditionTransition { + NextAction: ContactFlowAction, + Condition: unknown // TBD +} + +export interface ErrorTransition { + NextAction: ContactFlowAction, + ErrorType: ErrorName +} + +export interface GetParticipantInputAction extends ActionBase { + Type: 'GetParticipantInput', + + Parameters: { + /** + * A prompt ID or prompt ARN to play to the participant along with + * gathering input. May not be specified if Text or SSML is also + * specified. Must be either statically defined or a single valid JSONPath + * identifier. + */ + PromptId?: string + /** + * An optional string that defines text to send to the participant along + * with gathering input. May not be specified if PromptId or SSML is also + * specified. May be defined statically or dynamically. + */ + Text?: string + /** + * An optional string that defines SSML to send to the participant along + * with gathering input. May not be specified if Text or PromptId is also + * specified. May be defined statically or dynamically. + */ + SSML?: string + /** + * An external media source to play. + */ + Media?: { + Uri: string, + /** + * The source from which the message will be fetched. The only + * supported type is S3. + */ + SourceType: "S3", + /** + * The type of the message to be played. The only supported type is + * Audio. + */ + MediaType: "Audio" + }, + + /** + * The number of seconds to wait for input to be collected before + * proceeding with a timeout error. For the Voice channel this is the + * timeout until the *first* DTMF digit is entered. Must be defined + * statically, and must be a valid integer larger than zero. + */ + InputTimeoutSeconds: string + + StoreInput?: "True" | "False" + + /** + * An object that defines how to validate customer inputs, required if and + * only if StoreInput is True + */ + InputValidation?: { + /** + * Optional, one of the ways to validate inputs, make sure that it's a + * valid phone number. May not be specified if CustomValidation is + * specified. + */ + PhoneNumberValidation?: { + /** + * If "Local" is specified, it is validated to be a local number + * (without the + and the country code), "E164" enforces + * Participant actions 4074 Amazon Connect API Reference that the + * customer input is a fully defined e.164 phone number. Must be + * defined statically. + */ + NumberFormat: "Local" | "E164", + /** + * If the number format is "Local", this must be defined. This is + * the two letter country code to be associated with the input + * number when validating. Must be defined statically. + */ + CountryCode?: string + }, + + /** + * Optional, the other way to validate inputs. May not be specified if + * PhoneNumberValidation is specified. + */ + CustomValidation?: { + /** + * A number representing the maximum length of the input. Must be + * defined statically. + */ + MaxLength: string + }, + + + }, + + /** + * An optional object that defines how to encrypt the customer input. + * May only be specified if "CustomValidation" is provided. + */ + InputEncryption?: { + /** + * The identifier of a key that has been uploaded in the AWS + * console for the purposes of customer input encryption. May be + * specified statically or dynamically. + */ + EncryptionKeyId: string + + /** + * The PEM definition of the public key to use to encrypt this + * data. This key must be signed with the encryption key identified + * by the EncryptionKeyId. May be specified statically or + * dynamically. + */ + Key?: string + }, + + /** + * An optional object to override default DTMF behavior for voice calls. + */ + DTMFConfiguration?: { + /** + * Up to five digits to serve as the terminating sequence when + * gathering DTMF + */ + InputTerminationSequence?: string + + /** + * "True" or "False". If "True", the "*" key doesn't cancel gathering + * DTMF digits. + */ + DisableCancelKey?: "True" | "False" + }, + }, + + Transitions?: { + Errors: ( + ErrorTransition<"NoMatchingConditon"> | + ErrorTransition<"NoMatchingError"> | + ErrorTransition<"InvalidPhoneNumber"> | + ErrorTransition<"InputTimeLimitExceeded"> + )[] + } +} + + +export interface EndFlowExecutionAction extends ActionBase { + Type: 'DisconnectParticipant'; + Parameters: Record; + Transitions?: Record; +} + +export interface MessageParticipantAction extends ActionBase { + Type: 'MessageParticipant'; + Parameters: { + /** + * A prompt ID or prompt ARN to play to the participant along with gathering input. May not be specified if Text or SSML is also specified. + * Must be specified either statically or as a single valid JSONPath identifier + */ + PromptId?: string; + /** + * An optional string that defines text to send to the participant along with gathering input. + * May not be specified if PromptId or SSML is also specified. May be specified statically or dynamically. + */ + Text?: string; + /** + * An optional string that defines SSML to send to the participant along with gathering input. May not be specified if Text or + * PromptId is also specified May be specified statically or dynamically. + */ + SSML?: string; + media?: { + uri: string; + SourceType: 'S3'; + MediaType: 'Audio'; + }; + }; + Transitions: { + NextAction: ContactFlowAction; + }; +} + + +export interface InvokeLambdaFunctionAction extends ActionBase { + Type: 'InvokeLambdaFunction', + Parameters: { + LambdaFunctionARN: string, + InvocationTimeLimitSeconds: string, + ResponseValidation: { + /** + * Validates the response from the lambda is either a JSON map of + * depth 1 ("STRING_MAP"), or arbitrary JSON ("JSON"). + * + * I would strongly argue that "JSON" is a misnomer here, as both + * formats are definitely JSON. + */ + ResponseType: "STRING_MAP" | "JSON" + } + } +} + +export type ContactFlowAction = + | MessageParticipantAction + | EndFlowExecutionAction + | GetParticipantInputAction + | InvokeLambdaFunctionAction; + + + +export function compileContactFlow(main: ContactFlowAction): regular_contact_flow.ContactFlowLanguage { + const [self, actions] = _compileContactFlow(main, "root"); + return { + Version: "2019-10-30", + StartAction: self.Identifier, + Actions: [...actions] as regular_contact_flow.ContactFlowAction[] + + } +} + +function _compileContactFlow(main: ActionBase, id: string): [ + self: regular_contact_flow.ActionBase, + subActions: Set +] { + let actions = new Set(); + const n = { + ...main, + Identifier: id, + } + + let ctr = 0; + + function translateAction(a: ContactFlowAction): string { + const myId = `${id}|${ctr++}`; + const [, subActions] = _compileContactFlow( + a, + `${id}|${ctr++}` + ); + + actions = actions.union( + subActions + ); + + return myId; + } + + const Transitions = n.Transitions + ? { + NextAction: n.Transitions.NextAction ? + translateAction(n.Transitions.NextAction) : + n.Transitions.NextAction, + Errors: n.Transition.Errors?.map( + ({NextAction, ...etc}) => ( { + NextAction: translateAction(NextAction), + ...etc + } ) + ) + + } : n.Transitions; + + const self = { + ...n, + Transitions + }; + + actions.add(self) + + return [ self, actions ] +} diff --git a/ts/pulumi/lib/contact_flow_language.ts b/ts/pulumi/lib/contact_flow_language.ts new file mode 100644 index 0000000000..9ebb0156e3 --- /dev/null +++ b/ts/pulumi/lib/contact_flow_language.ts @@ -0,0 +1,283 @@ + + +interface AbstractConditionOperator { + Operator: string + Operands: string[] +} + +interface Equals extends AbstractConditionOperator { + Operator: "Equals" + Operands: string[] +} + +export type ConditionOperator = Equals + + +export interface ActionBase { + Identifier: string; + Type: string; + Parameters: unknown; + Transitions?: { + NextAction?: string; + Errors?: ErrorTransition[]; + Conditions?: ConditionOperator[]; + }; +} + +export interface ErrorTransition { + NextAction: string, + ErrorType: ErrorName +} + +export interface ConditionTransition { + NextAction: string, + Condition: unknown // TBD +} + + +interface ContactFlowActions { + GetParticipantInputAction: GetParticipantInputAction +} + +export interface GetParticipantInputAction extends ActionBase { + Type: 'GetParticipantInput', + Parameters: { + /** + * A prompt ID or prompt ARN to play to the participant along with + * gathering input. May not be specified if Text or SSML is also + * specified. Must be either statically defined or a single valid JSONPath + * identifier. + */ + PromptId?: string + /** + * An optional string that defines text to send to the participant along + * with gathering input. May not be specified if PromptId or SSML is also + * specified. May be defined statically or dynamically. + */ + Text?: string + /** + * An optional string that defines SSML to send to the participant along + * with gathering input. May not be specified if Text or PromptId is also + * specified. May be defined statically or dynamically. + */ + SSML?: string + /** + * An external media source to play. + */ + Media?: { + Uri: string, + /** + * The source from which the message will be fetched. The only + * supported type is S3. + */ + SourceType: "S3", + /** + * The type of the message to be played. The only supported type is + * Audio. + */ + MediaType: "Audio" + }, + + /** + * The number of seconds to wait for input to be collected before + * proceeding with a timeout error. For the Voice channel this is the + * timeout until the *first* DTMF digit is entered. Must be defined + * statically, and must be a valid integer larger than zero. + */ + InputTimeoutSeconds?: string + + StoreInput?: "True" | "False" + + /** + * An object that defines how to validate customer inputs, required if and + * only if StoreInput is True + */ + InputValidation?: { + /** + * Optional, one of the ways to validate inputs, make sure that it's a + * valid phone number. May not be specified if CustomValidation is + * specified. + */ + PhoneNumberValidation?: { + /** + * If "Local" is specified, it is validated to be a local number + * (without the + and the country code), "E164" enforces + * Participant actions 4074 Amazon Connect API Reference that the + * customer input is a fully defined e.164 phone number. Must be + * defined statically. + */ + NumberFormat: "Local" | "E164", + /** + * If the number format is "Local", this must be defined. This is + * the two letter country code to be associated with the input + * number when validating. Must be defined statically. + */ + CountryCode?: string + }, + + /** + * Optional, the other way to validate inputs. May not be specified if + * PhoneNumberValidation is specified. + */ + CustomValidation?: { + /** + * A number representing the maximum length of the input. Must be + * defined statically. + */ + MaxLength: string + }, + + + }, + + /** + * An optional object that defines how to encrypt the customer input. + * May only be specified if "CustomValidation" is provided. + */ + InputEncryption?: { + /** + * The identifier of a key that has been uploaded in the AWS + * console for the purposes of customer input encryption. May be + * specified statically or dynamically. + */ + EncryptionKeyId: string + + /** + * The PEM definition of the public key to use to encrypt this + * data. This key must be signed with the encryption key identified + * by the EncryptionKeyId. May be specified statically or + * dynamically. + */ + Key?: string + }, + + + /** + * An optional object to override default DTMF behavior for voice calls. + */ + DTMFConfiguration?: { + /** + * Up to five digits to serve as the terminating sequence when + * gathering DTMF + */ + InputTerminationSequence?: string + + /** + * "True" or "False". If "True", the "*" key doesn't cancel gathering + * DTMF digits. + */ + DisableCancelKey?: "True" | "False" + }, + + }, + + Transitions?: { + Errors: ( + ErrorTransition<"NoMatchingConditon"> | + ErrorTransition<"NoMatchingError"> | + ErrorTransition<"InvalidPhoneNumber"> | + ErrorTransition<"InputTimeLimitExceeded"> + )[] + } +} + +interface ContactFlowActions { + EndFlowExecutionAction: EndFlowExecutionAction +} + +export interface EndFlowExecutionAction extends ActionBase { + Type: 'DisconnectParticipant'; + Parameters: Record; + Transitions?: Record; +} + +interface ContactFlowActions { + MessageParticipantAction: MessageParticipantAction +} + +export interface MessageParticipantAction extends ActionBase { + Type: 'MessageParticipant'; + Parameters: { + /** + * A prompt ID or prompt ARN to play to the participant along with gathering input. May not be specified if Text or SSML is also specified. + * Must be specified either statically or as a single valid JSONPath identifier + */ + PromptId?: string; + /** + * An optional string that defines text to send to the participant along with gathering input. + * May not be specified if PromptId or SSML is also specified. May be specified statically or dynamically. + */ + Text?: string; + /** + * An optional string that defines SSML to send to the participant along with gathering input. May not be specified if Text or + * PromptId is also specified May be specified statically or dynamically. + */ + SSML?: string; + media?: { + uri: string; + SourceType: 'S3'; + MediaType: 'Audio'; + }; + }; + Transitions: { + NextAction: string; + }; +} + +interface ContactFlowActions { + InvokeLambdaFunctionAction: + InvokeLambdaFunctionAction +} + +/** + * Invoke a Lambda function from the flow language. + * + * For some reason (none of which can, I think, be justifiable or valid), AWS + * does not have any documentation on how to invoke a lambda in the language + * itsef. + * + * I have extracted this example via the UI to use as reference. + * ``` JSON + * { + "Actions": [ + { + "Parameters": { "LambdaFunctionARN": "$.SystemEndpoint.Address", + "InvocationTimeLimitSeconds": "3", "ResponseValidation": { + "ResponseType": "STRING_MAP" + } + }, + "Identifier": "2cad4f82-34bd-47a7-b4cc-06f65309f973", "Type": + "InvokeLambdaFunction", "Transitions": { "NextAction": "", "Errors": [ + { + "NextAction": "", + "ErrorType": "NoMatchingError" + } + ] + } + } + */ +export interface InvokeLambdaFunctionAction extends ActionBase { + Type: 'InvokeLambdaFunction', + Parameters: { + LambdaFunctionARN: string, + InvocationTimeLimitSeconds: string, + ResponseValidation: { + /** + * Validates the response from the lambda is either a JSON map of + * depth 1 ("STRING_MAP"), or arbitrary JSON ("JSON"). + * + * I would strongly argue that "JSON" is a misnomer here, as both + * formats are definitely JSON. + */ + ResponseType: "STRING_MAP" | "JSON" + } + } +} + +export type ContactFlowAction = ContactFlowActions[keyof ContactFlowActions] + +export interface ContactFlowLanguage { + Version: '2019-10-30'; + StartAction: string; + Actions: ContactFlowAction[]; +}