-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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