Skip to content

Instantly share code, notes, and snippets.

@krisdover
Last active December 16, 2022 00:45
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save krisdover/dbbfe7cec3b0351d5160565240e99b59 to your computer and use it in GitHub Desktop.
Save krisdover/dbbfe7cec3b0351d5160565240e99b59 to your computer and use it in GitHub Desktop.
An AWS Lambda webhook for integrating Lex V2 with Twilio's webchat channel
import {
LexRuntimeV2,
ImageResponseCard,
RecognizeTextCommandInput,
RecognizeTextCommandOutput,
} from "@aws-sdk/client-lex-runtime-v2";
import twilio from "twilio";
import { MessageListInstanceCreateOptions } from "twilio/lib/rest/chat/v2/service/channel/message";
/*
* @see https://www.twilio.com/docs/flex/developer/messaging/api/flow
* @see https://www.twilio.com/docs/chat/webhook-events#webhook-bodies
*/
type ChatMessageEvent = {
request: {
domainName: string;
requestPath: string;
header: Record<string, string>;
path: Record<string, string>;
querystring: Record<string, string>;
};
EventType: "onMessageSent";
MessageSid: string;
Body: string;
From: string;
AccountSid: string;
InstanceSid: string; // chat service Sid
ChannelSid: string;
Attributes: string; // json object with attributes of the message
DateCreated: string;
Index: string; // the message index
Source: string;
RetryCount: string;
WebhookType: "webhook";
WebhookSid: string; // the Sid for this webhook
ClientIdentity: string; // from access token
};
/**
* Converts a Lex `ImageResponseCard` structure into a `MessageListInstanceCreateOptions`
* structure for returning as part of a Twilio Chat response which will display a list
* of option buttons (which is customised Flex Webchat functionality).
*
* @param imageResponseCard
* @returns
*/
const responseCardToTwilioMessage = ({
title,
buttons,
}: ImageResponseCard): MessageListInstanceCreateOptions => ({
body: `${title}:\n${buttons
?.map((btn, i) => `${i + 1}. ${btn.text}`)
.join("\n")}`,
attributes: JSON.stringify({
responses: buttons?.map((btn) => ({
label: btn.text,
message: btn.value,
})),
}),
});
const region = process.env.AWS_REGION;
const lexClient = new LexRuntimeV2({ region });
/**
* This lambda is meant to be invoked as a webhook from Twilio webchat
* to integrate it with a Lex chatbot. It's setup as an async lambda
* function since Twilio expects the webhook to respond in < 5 seconds.
* @see https://www.twilio.com/docs/chat/webhook-events
* @see https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-integration-async.html
*/
export async function handler(event: ChatMessageEvent): Promise<void> {
console.debug("received request: ", event);
const { request, ...chatEvent } = event;
// Only accept valid Twilio signed requests
if (
!twilio.validateRequest(
process.env.TWILIO_AUTH_TOKEN!,
request.header["X-Twilio-Signature"]!,
`https://${request.domainName}${request.requestPath}`,
chatEvent
)
) {
console.warn(
"X-Twilio-Signature validation failed:",
request.header["X-Twilio-Signature"]
);
return;
}
if (chatEvent.EventType !== "onMessageSent") {
return;
}
const channelCtx = twilio(chatEvent.AccountSid,
process.env.TWILIO_AUTH_TOKEN!, {
edge: "sydney",
})
.chat.services(chatEvent.InstanceSid)
.channels(chatEvent.ChannelSid);
console.log("getting chat channel for SID: ", chatEvent.ChannelSid);
const channel = await channelCtx.fetch();
const channelAttribs = JSON.parse(channel.attributes) as {
from: string;
pre_engagement_data: {
friendlyName?: string;
question?: string;
location?: string;
};
botBusy: boolean;
};
// Set busy indicator (don't await)
// Note: this is tied to custom functionality in the WebChat UI for showing a busy indicator
channel.update({
attributes: JSON.stringify({
...channelAttribs,
botBusy: true,
}),
});
// Setup lex bot command
const requestAttributes: LexRequestAttributes = {
channelType: "web",
channelSid: chatEvent.ChannelSid,
userIdentifier: chatEvent.ClientIdentity,
name: channelAttribs?.from,
subject: (channelAttribs?.pre_engagement_data?.question || "").replace(
/\n+/g,
""
),
instanceSid: chatEvent.InstanceSid,
accountSid: chatEvent.AccountSid,
};
const command: RecognizeTextCommandInput = {
botId: process.env.BOT_ID,
localeId: process.env.BOT_LOCALE,
botAliasId: process.env.BOT_ALIAS_ID,
sessionId: chatEvent.ChannelSid,
text: chatEvent.Body,
requestAttributes,
};
let response: RecognizeTextCommandOutput | undefined;
let clearBotBusyPromise: Promise<any> | undefined;
try {
// send message to lex bot
try {
console.log("sending: ", command);
response = await lexClient.recognizeText(command);
console.log(response);
} finally {
// apply any channel status returned by the chatbot and
// clear bot busy indicator (don't await)
const { status } = response?.sessionState?.sessionAttributes || {};
clearBotBusyPromise = channel.update({
attributes: JSON.stringify({
...channelAttribs,
botBusy: false,
status,
}),
});
}
// return bot response to chat user
if (response.messages) {
const from = "Bot";
for (const msg of response.messages) {
const { contentType, content, imageResponseCard } = msg;
if (contentType === "CustomPayload" && content) {
// display the custom message
const createOption: MessageListInstanceCreateOptions =
JSON.parse(content);
await channelCtx.messages.create({
from,
...createOption,
});
} else if (contentType === "PlainText") {
// display a text message
await channelCtx.messages.create({
from,
body: content,
});
} else if (contentType === "ImageResponseCard") {
// display quick-response buttons
await channelCtx.messages.create({
from,
...responseCardToTwilioMessage(imageResponseCard!),
});
}
}
}
const { intent } = response.sessionState || {};
if (intent?.name === "agent-handoff" && intent?.state === "Fulfilled") {
// remove this integration webhook for agent hand-off
await channelCtx.webhooks(chatEvent.WebhookSid).remove();
}
} finally {
// ensure the clearing of the bot busy indicator update completes
if (clearBotBusyPromise) {
await clearBotBusyPromise;
}
}
return;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment