Create an SMS chatbot using C#, Amazon Lex, and Twilio SMS

January 17, 2023
Written by
Volkan Paksoy
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Create a chatbot using C#, Amazon Lex, and Twilio SMS

There is a tough competition in the business world to acquire and retain customers. One key step to achieve this goal is to keep your customers happy with your customer support. Having an automated chatbot helps your business provide a faster and more accessible customer support to your customers. In this article, you will learn how to build a chatbot using C#, AWS Lambda, Amazon Lex, Amazon DynamoDB, and Twilio SMS.  

Prerequisites

You'll need the following things in this tutorial:

What is Amazon Lex?

Amazon Lex is an artificial intelligence service that allows developers to create voice or text-based conversational interfaces. This service powers Amazon's own Alexa.

Lex provides automatic speech recognition and natural language understanding technologies. It takes the user's input, runs it through a Natural Language Processing (NLP) engine and determines the user's intent. The value of this is the user does not need to remember a set of commands to interact with your bot. They can talk to the bot just like they would to a human being.

This project uses several AWS services: Lex, Lambda and DynamoDB. To follow along, you will need an IAM user setup in your development environment. Proceed to the next section for the IAM setup. If you already have it configured, you can skip the next section and move on to the Project Overview.

If you created a new AWS account, this project shouldn't cost you anything, as all these services have free tiers. If you are on an older account, it shouldn't cost too much. Still, I recommend checking the pricing pages of the services anyway: Amazon Lex Pricing, AWS Lambda Pricing and Amazon DynamoDB pricing.

Set up AWS IAM User

You will need credentials to deploy your application to AWS from the command line. To create the credentials, follow the steps below:

First, go to the AWS IAM Users Dashboard and click the Add users button.

Enter the user name, such as twilio-webhook-user and tick the Access key - Programmatic access checkbox:

IAM user creation page with the "user name" field set to "twilio-webhook-user", and "AWS credential type"  set to "Access key - Programmatic access".

Click the Next: Permissions button at the bottom right.

Then, select Attach existing policies directly and select AdministratorAccess:

Set permissions page where the user selected the "Attach existing policies directly" tab, and selected the "AdministractorAccess" policy.

Click the Next: Tags button at the bottom right. Tags are optional (and quite valuable information), and it’s a good practice to add descriptive tags to the resources you create. Since this is a demo project, you can skip this step and click the Next: Review button at the bottom.

Confirm your selection on the review page. It should look like this:

IAM user creation review page showing the previous selections. "User name" is "twilio-webhook-user", "AWS access type" is "Programmatic access - with an access key", the user is given "AdministratorAccess".

Then, click the Create user button.

In the final step of the user creation process, you should see your credentials for the first and the last time.

Take note of your Access key ID and Secret access key before you press the close button.

Now, open a terminal window and run the following command:

aws configure

You should see a prompt for AWS Access Key ID. Copy and paste your access key ID and press enter.

Then, copy and paste your secret access key and press enter.

Terminal window showing access key id and secret access key have been entered and default region name is prompted

When prompted, type us-east-1 as the default region name and press enter.

In this example, I will use the us-east-1 region. Regions are geographical locations where AWS have their data centers. It is a good practice to deploy as close to your customers as possible for production deployments to reduce latency. Since this is a demo project, you can use us-east-1 for convenience as it’s the default region in AWS Management Console. You can find more on AWS regions in this document: Regions and Availability Zones.

As the default output format, type json and press enter.

To confirm you have configured your AWS profile correctly, run the following command:

aws configure list

The output should look like this:

Terminal window showing the details of the AWS account configured in the previous steps. Profile: not set, access_key: partially masked, secret_key: partially masked, region: us-east-1.

Now that you have set up your AWS credentials, you can move on to the demo project.

Project Overview

The application you will implement is an imaginary online stock broker customer service. It will accept requests like buy, sell, show portfolio, etc.

Take a look at this diagram of the application flow:

Application flow diagram
  1. Customer sends an SMS to customer service (which is a Twilio Phone Number).
  2. Twilio receives the message and invokes the Amazon Lex callback.
  3. Amazon Lex identifies the user's intent and calls the corresponding Lambda function.
  4. The Lambda function executes the application logic based on the request, gets the customer info from the DynamoDB database, and prepares the response.
  5. Lex sends the response to Twilio using the Twilio SMS integration.
  6. Twilio delivers the response message to the customer's phone.

Without further ado, let's get the demo application and start exploring the existing code.

Set up the Demo Project

The focus of this article is developing a chatbot using Amazon Lex and Twilio SMS. To save time, the fundamental business logic of the fictional stock broker is implemented in the starter project.

Clone the project to get started:

git clone https://github.com/cloudinternals/amazon-lex-stock-broker-bot-with-twilio-sms.git --branch starter-project

Open the solution (src/StockBrokerBot/StockBrokerBot.sln) in your IDE and take a look at the project structure. The StockBrokerBot.Core project looks like this:

File structure of StockBroker.Core class library showing Entities, Exceptions, Persistence, and Services directories. Shows the IPortfolioService.cs and IStockMarketService.cs files which contain the main functionality.

The main functionality is in 2 services: IPortfolioService and IStockMarketService.

IPortfolioService shows the behavior of the service:

public interface IPortfolioService
{
    Task<UserPortfolio> GetUserPortfolio(string userId);
    Task<UserPortfolio> BuyStocks(string userId, string stockName, decimal numberOfShares);
    Task<UserPortfolio> SellStocks(string userId, string stockName, decimal numberOfShares);
}

It supports 3 operations: Get portfolio, buy stocks, and sell stocks.

The core library includes one implementation of the portfolio service called PortfolioService. It depends on a stock market service and a portfolio data provider:

private readonly IStockMarketService _stockMarketService;
private readonly IPortfolioDataProvider _dataProvider;

public PortfolioService(IStockMarketService stockMarketService, IPortfolioDataProvider dataProvider)
{
    _stockMarketService = stockMarketService;
    _dataProvider = dataProvider;
}

PortfolioService performs validations during buy and sell operations (user has sufficient funds to buy, or shares available to sell, etc.)

IStockMarketService is responsible for fetching the current stock price:

public interface IStockMarketService
{
    Task<decimal> GetStockPrice(string stockName);
}

There are 2 different stock market service implementations: StockMarketService, and FluctuatingStockMarketService.

StockMarketService simply fetches the stock price from its data provider. FluctuatingStockMarketService is meant to "spice things up" a little bit. It calculates a random price by adding a small price swing within 2%. You can, of course, change this rate to create higher swings. This way, you will get a new price every time. So you can buy low and sell high and make some imaginary profits!

The starter project also includes a demo console application. The demo application uses JSON files to persist user portfolios and stock prices. In the actual chatbot, you will use DynamoDB tables. The demo project uses the FluctuatingStockMarketService. You can replace it with StockMarketService to get more consistent results.

UserPortfolio and Stock entities are already annotated to be used as DynamoDB entities:

DynamoDBTable("user-portfolio")]
public class UserPortfolio
{
    [DynamoDBHashKey]
    public string UserId { get; set; }

In a real project, I wouldn't recommend creating a dependency on a storage provider from your business library but for the sake of brevity, the same entities will be used in this project.

Open a terminal, navigate to the demo project, and run the application by running:

cd StockBrokerBot.Demo
dotnet run

You should see the results that look like this:

Terminal showing the output of demo application

Take some time to look into the core library and the consuming demo application. There is also a Lambda function project in the solution called StockBrokerBot.ChatbotLambda, but it's empty at the moment. You will implement it while following this article.

When you are more familiar with the project, move on to the next section to create the chatbot.

Create the Chatbot with Amazon Lex

To start implementing your bot, go to Lex Console and click Create bot.

Lex dashboard showing existing bots and a Create bot button

In the settings, leave the Create a blank bot option selected.

In the Bot name field, enter StockBrokerBot.

Bot configuration panel showing bot name as StockBrokerBot and description empty

In IAM permissions, select Create a role with basic Amazon Lex permissions.

IAM permissions showing Create a role with basic Amazon Lex permissions option selected and a disabled input field populated with an auto-generated role name

Select No in the COPPA section and click Next.

In the Add languages step, leave the default language (English (US)). You can choose to support multiple languages and even assign a different voice to each language. In this article, you will use a single language and text interaction only. Click the Voice interaction dropdown, scroll to the bottom and select None. This is only a text-based application option.

Click Done to create your bot.

Your bot has now been created, and Lex redirects you to create your first intent. An intent is an action your bot takes to fulfil a user's request.

In the Intent name field, enter CheckStockPrice.

Intent details section showing CheckStockPrice entered in the Intent name field

Leave Contexts blank and scroll to Sample utterances.

An utterance is a phrase that corresponds to this intent. In conversations, we use many different phrases to express the same thing. For example, if you have a Hello intent, sample utterances can be "Hello", "Hi", "Hey" etc.

Click Plain Text and paste the following utterances in the field:

check the {stockName} price
check {stockName}
how much is {stockName} ?
what is the price of {stockName} ?
get the price of {stockName}
{stockName}
price {stockName}
what is the current {stockName} price?
check the current price of {stockName}
price of {stockName} ?

In the above text block, you can see many occurrences of {stockName}. This is what is called a slot. It's essentially a placeholder for a piece of data you need Lex to extract for you and pass it on to your code. If you recall the core library introduced earlier in the article, GetStockPrice method requires the stock name. A user might express their intention in a lot of different ways. Extracting this data is Lex's responsibility so that, as the bot developer, you can focus on your bot's business logic.

The space between the slot and the question mark at the end is intentional. Lex requires spaces surrounding the slots. If you remove those spaces, you will get an error while saving the intent.

Scroll down to the Slots section.

As discussed above, you're using a slot in your utterances, but it's not defined yet. Lex needs to know the type and whether or not it's mandatory. Lex performs much better if you train what kind of data it's looking for. In the stock name example, there is a finite set of company names, so you will create your own data type to train the model better. Skip adding a slot for now. You'll revisit this part very soon.

Click the Save intent button and the Back to intents list link on the left pane.

Left pane on the screen showing Back to intents list link and the intent names

FallbackIntent is one of the built-in intents. If the user's request doesn't match any of the intents, FallbackIntent is invoked. You can read more about Lex's built-in intents here.

On the left menu, click Slot types which is right under Intents.

Click Add slot type and select Add blank slot type.

Slot types section showing Add slot type button clicked and a list of options displayed. Add blank slot type link is to be clicked.

Enter StockName in the Slot type name field.

In the Slot value resolution, leave the Expand values option selected. You can also choose to restrict the slot values, but then you will need to enter every stock name that your service supports. It almost becomes a lookup table. Lex is smart enough to identify similar values based on your training set. The more comprehensive your training set is, the better results you will get.

Enter Apple, Alphabet, Microsoft, Tesla, and Twilio as stock names and click Save slot type.

Slot type values list showing Apple, Alphabet, Microsoft, Tesla, and Twilio entered as values

Click the Slot types link on the left, then Intents, and finally, click on the CheckStockPrice intent to get back to intent settings.

Scroll down to the Slots section and click Add slot.

Leave Required for this intent checkbox ticked.

Enter stockName in the Name field and select StockName in the Slot type list.

Add slot dialog showing stockName entered in the Name field and slot type dropdown list expanded and StockName custom type selected

In the Prompts field, enter What is the name of the stock? 

This is a very useful feature. You don't have to worry about asking the user for the stock name if it's missing. Lex will automatically ask the user and fill in the missing values, so you can rest assured that it will always deliver the required values to your bot's backend. You'll see this in practice while testing the bot later on.

Click Add to close the dialog and then click Save Intent.

Now focus on the top of the screen.

Top of the intent page showing the draft version, English (US) language, "Not built" label, "English(US) has not built changes" info message and Build and Test buttons at the end

You should see the version you're looking at (Draft version), the language (English (US)) and a label next to it that says "Not built".

Click the Build button for Lex to build the machine learning (ML) model for your bot. You cannot test your bot without creating the ML model first. When the build is complete, you will see a notification on your screen.

Notification the says the build was successful

Click the Test button.

In the bottom field (with the Type a message placeholder), enter "What is the price of Apple?" and press enter.

You should see a message that says "Intent CheckStockPrice is fulfilled"

Test dialog showing the intent is fullfilled

Click the Inspect button to see more details. You can see the stockName slot has Apple as the value. If you recall, you made the stockName a required slot. To test what happens if a user enters a message without providing sufficient information, enter "price" as the message and press enter.  

Note the title of the dialog says "Test Draft version". When you test, you test the entire model for the selected language, not a single intent, even if you open the dialog while you are on an intent page.

You should see Lex now asks the name of the stock explicitly. The question it asks is the prompt message you entered when you created the slot type.

Test dialog showing Lex asking the name of the stock by showing the prompt message.

Just to emphasize how it works, it's not directly looking up the utterances and matching strings to determine the intent. For example, you can express the same intent by entering "show the price of Tesla stock", and you should still see the intent is fulfilled message even though it's not part of the utterance list. If the expected intent is not fulfilled, you can modify your utterances, rebuild, and retest the model.

Before you test, make sure to build your model if you've made any changes. Otherwise, you'd be testing the previous model.

After the intent is recognized, what Lex will do with it is determined by the Fulfillment settings. By default, fulfillment is not active. Scroll down to the Fulfillment section and click the Active radio button. Lex invokes the Lambda function associated with your bot by default. You can confirm this behavior by expanding the parameters and clicking the Advanced options button.

Fulfillment advanced options pane is open and showing Use a Lambda function for fulfilment checkbox ticked

Set the Fulfillment to active and ensure the Use a Lambda function for fulfillment option is ticked.

Click Save Intent and then click the Back to intents list link on the left.

Click the Add Intent button. It will show you two options: Add empty intent and Use built-in intent.

Select Add empty intent.

Enter GetPortfolio as intent name in the dialog and click Add.

Add empty intent dialog showing GetPortfolio entered as name and an Add button at the bottom

In the Sample utterances section, switch to Plain Text view and paste the following utterances:

​​get portfolio
show my portfolio
my portfolio
my stocks
show me the money!

In the Fulfillment section, set the Active option to true.

Click the Save intent button and Back to intents list link on the left.

Click Add intent again and set the intent name to BuyStocks. Update the utterances with the ones below as you did before:

buy {numberOfShares} of {stockName} 
buy shares {numberOfShares} ,stock {stockName}
buy {numberOfShares} {stockName}
purchase {numberOfShares} of {stockName} stock
get {numberOfShares} shares of {stockName}
buy {stockName} {numberOfShares}  shares
buy {numberOfShares} shares of {stockName}
buy {numberOfShares} shares of {stockName} stock

In the Slots section, click the Add slot button.

In the Add slot dialog, set Required for this intent to true, enter stockName as the name, select StockName as slot type and "What is the name of the stock?" as the prompt.

Click Add.

Click the Add slot button again.

This time, set the name to numberOfShares, slot type to AMAZON.Number and the prompt to "How many shares?".

Click Add again to save the second slot.

In the Fulfilment section, set the Active option to true.

Click the Save intent button and Back to intents list link on the left.

Click Add intent for one last time and set the intent name to SellStocks. Update the utterances with the ones below as you did before:

sell {numberOfShares} shares of {stockName}
sell shares, number: {numberOfShares} , stock: {stockName}
sell {numberOfShares} of {stockName}

SellStocks intent is very similar to the BuyStocks intent. Create the same slot types as the BuyStocks intent as described above.

In the Fulfilment section, set the Active option to true.

Click the Save Intent button.

Now that all the intents have been described, click the Build button to rebuild the model.

After a show while, you should get a Successfully built notification:

Dismiss the notification and click the Bot: StockBrokerBot link in the breadcrumb.

In the left menu, under your bot there is a Bot versions link, and under it the Draft version which you've been working on.

Click Bot versions, and then click Create version.

Versions list showing the Draft version and Create version button at the top

In the Description field, enter a description such as "Initial version with four intents" and click Create button at the bottom of the page.

You don't assign version numbers, they are auto-incrementing integers. After you've created the version, it should appear in the version list:

Updated version list with Version 1 added

A version is essentially a read-only snapshot of your bot. You cannot modify a version after you've published it.

Now, take a look at another important concept: Aliases.

Click Aliases link on the left menu. It should show the default TestBotAlias:

Aliases page showing the default TestBotalias associate with the Draft version

An alias is associated with a specific version of your bot. The benefit of this is you can have multiple aliases such as test and live. If you publish a new version, you can point the test alias to the new version. This way your live alias is not affected until you test your changes. After you're satisfied your new version is ready to go live, you can simply associate the live alias with the new version and all the new requests will come to the new version of your bot. Also, if you experience issues with your latest version, you can simply assign the previous version to your alias to roll back. This kind of separation between the versions and aliases makes change management a lot easier.

Click the Create alias button.

Enter Live as Alias name.

In the Associate with a version section, choose Version 1. The language comes already enabled, so leave it like that.

Associate with a version section showing Version selected, English (US) is Enabled in alias and a Create button at the bottom

Click the Create button.

You should see the alias is successfully created and shown in the list:

Updated aliases list shows Live alias associated with Version 1

Now Version 1 of your bot has been published.

You will assign a Lambda function to your bot, but first, move on to the next section to create the backend of your bot.

Create the Backend

Open a terminal and navigate to the directory that will be the root of your project.

You will need the Amazon Lambda Tools .NET tool to deploy the function via the command line. You can install it by running the command below:

dotnet tool install -g Amazon.Lambda.Tools

As shown in the demo project, you will need two data sources: one to store users' portfolios and the other to store stock prices. (The following scripts can be found in the Setup/InfrastructureSetup.sh file in the ChatbotLambda project.)

To create the user portfolio table, run the following command:

aws dynamodb create-table \
    --table-name user-portfolio \
    --attribute-definitions \
        AttributeName=UserId,AttributeType=S \
    --key-schema \
        AttributeName=UserId,KeyType=HASH \
    --provisioned-throughput \
        ReadCapacityUnits=5,WriteCapacityUnits=5 \
    --table-class STANDARD

The script above creates a DynamoDB table with the UserId partition key, which you will use to query and fetch the users' records.

To keep things simple, the account creation process is omitted. To create the account, add your user's portfolio directly to the database by running the following command (replace { YOUR PHONE NUMBER WITH COUNTRY CODE } with your actual phone number before you run):

This phone number will be used to identify the user, so it must match the number sent by Twilio. It will be sent in the SessionId field, and it will not start with a "+". So, for example, if your country code is 1, enter the number as 17407593063, without the leading plus sign.

aws dynamodb put-item \
    --table-name user-portfolio \
    --item \
      '{"UserId": {"S": "{ YOUR PHONE NUMBER WITH COUNTRY CODE }"}, "AvailableCash": {"N": "1000"}, "StockPortfolio": {"L": []}}'

This scripts creates you an account with no stocks and $1000 available cash.

Since the customers will come to your chatbot via SMS, UserId is used as the unique customer id. In a more complex scenario, you would have a different unique id to identify users. Twilio sends the phone number with the county code so you create your record in the same format for convenience.

Similarly, to create the stock prices table, run the following command:

aws dynamodb create-table \
     --table-name stock-prices \
     --attribute-definitions \
         AttributeName=Name,AttributeType=S \
     --key-schema \
         AttributeName=Name,KeyType=HASH \
     --provisioned-throughput \
         ReadCapacityUnits=5,WriteCapacityUnits=5 \
     --table-class STANDARD

Then, run the following to add some stock prices:

aws dynamodb put-item --table-name stock-prices --item '{"Name": {"S": "Apple"}, "Price": {"N": "144.00"} }'
aws dynamodb put-item --table-name stock-prices --item '{"Name": {"S": "Alphabet"}, "Price": {"N": "96.00"} }'
aws dynamodb put-item --table-name stock-prices --item '{"Name": {"S": "Microsoft"}, "Price": {"N": "144.00"} }'
aws dynamodb put-item --table-name stock-prices --item '{"Name": {"S": "Tesla"}, "Price": {"N": "182.00"} }'
aws dynamodb put-item --table-name stock-prices --item '{"Name": {"S": "Twilio"}, "Price": {"N": "46.00"} }'

Now that the database is ready, prepare the IAM roles and policies that your Lambda function will need. The easiest way to set those up is to run the following commands when you're in the root of the cloned project:

cd src/StockBrokerBot/StockBrokerBot.ChatbotLambda/Setup/
aws iam create-role --role-name stockbrokerbot-lambda-role --assume-role-policy-document file://LambdaBasicRole.json
aws iam attach-role-policy --role-name stockbrokerbot-lambda-role --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam put-role-policy --role-name stockbrokerbot-lambda-role --policy-name dynamodb-table-access --policy-document file://LambdaDynamoDBAccessPolicy.json

LambdaBasicRole.json contains the role for the Lambda function by assuming Lambda service role. Then you attach AWS-managed AWSLambdaBasicExecutionRole policy that grants access to CloudWatch logs. Then, you attach the custom policy specified in LambdaDynamoDBAccessPolicy.json file that grants permissions to access the two DynamoDB tables you created earlier.

Enough with the infrastructure stuff; now it's time to write some code!

Your Lambda function will receive events from the Amazon Lex service and will use the Amazon DynamoDB service to read/write data. In your terminal, navigate to the root of the Lambda project (src/StockBrokerBot/StockBrokerBot.ChatbotLambda) and run the following commands to add the necessary NuGet packages to your project:

dotnet add package Amazon.Lambda.LexV2Events
dotnet add package AWSSDK.DynamoDBv2

In the Lambda project, create a new directory called IntentProcessors, and under it, a file called AbstractIntentProcessor.cs and set its contents as shown below:

using Amazon.Lambda.Core;
using Amazon.Lambda.LexV2Events;

namespace StockBrokerBot.ChatbotLambda.IntentProcessors;

public abstract class AbstractIntentProcessor
{
    internal const string MessageContentType = "PlainText";
    internal const string IntentStateFulfilled = "Fulfilled";
    internal const string IntentStateFailed = "Failed";
    internal const string DialogActionClose = "Close";
    
    public abstract Task<LexV2Response> Process(LexV2Event lexEvent, ILambdaContext context);
    
    protected LexV2Response Close(string intentName, Dictionary<string, string> sessionAttributes, string fulfillmentState, string responseMessage)
    {
        return new LexV2Response
        {
            SessionState = new LexV2SessionState
            {
                Intent = new LexV2Intent { Name = intentName, State = fulfillmentState },
                SessionAttributes = sessionAttributes,
                DialogAction = new LexV2DialogAction  { Type = DialogActionClose }
            },
            Messages = new List<LexV2Message>
            {
                new()
                {
                    ContentType = MessageContentType,
                    Content = responseMessage
                }
            }
        };
    }
}

The abstract class leaves the Process method abstract to be implemented by the inheriting intent processors. It also contains the constants and Close method, which is shared among all the intent processors, so they are placed in the base class to avoid repetition.

In the demo application, you used two JSON-based data providers to manage user portfolio and stock price data. This approach doesn't work with Lambda functions, as the JSON files will be gone when the function returns. Every time a new copy will be created from scratch, which doesn't work for databases.

To persist data, you will need DynamoDB providers. Similar to the demo console application, create a directory named Persistence and, under it, create a new file called PortfolioDynamoDBDataProvider.cs.

Update the code as shown below:

using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.DataModel;
using StockBrokerBot.Core.Entities;
using StockBrokerBot.Core.Persistence;

namespace StockBrokerBot.ChatbotLambda.Persistence;

public class PortfolioDynamoDBDataProvider : IPortfolioDataProvider
{
    private AmazonDynamoDBClient _dynamoDbClient;
    private DynamoDBContext _dynamoDbContext;

    public PortfolioDynamoDBDataProvider()
    {
        _dynamoDbClient = new AmazonDynamoDBClient();
        _dynamoDbContext = new DynamoDBContext(_dynamoDbClient);
    }
    
    public async Task<UserPortfolio> GetUserPortfolio(string userId)
    {
        return await _dynamoDbContext.LoadAsync<UserPortfolio>(userId);
    }

    public async Task SaveUserPortfolio(UserPortfolio userPortfolio)
    {
        await _dynamoDbContext.SaveAsync(userPortfolio);
    }
}

Create another file called StockMarketDynamoDBDataProvider.cs and update the code:

using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.DataModel;
using StockBrokerBot.Core.Entities;
using StockBrokerBot.Core.Persistence;

namespace StockBrokerBot.ChatbotLambda.Persistence;

public class StockMarketDynamoDBDataProvider : IStockMarketDataProvider
{
    private AmazonDynamoDBClient _dynamoDbClient;
    private DynamoDBContext _dynamoDbContext;
    
    public StockMarketDynamoDBDataProvider()
    {
        _dynamoDbClient = new AmazonDynamoDBClient();
        _dynamoDbContext = new DynamoDBContext(_dynamoDbClient);
    }
    
    public async Task<decimal> GetStockPrice(string name)
    {
        var stock = await _dynamoDbContext.LoadAsync<Stock>(name);
        return stock.Price;
    }
}

Now you can implement your first concrete intent processor. Create a new file under the IntentProcessors directory called CheckStockPriceIntentProcessor.cs with the following code:

using Amazon.Lambda.Core;
using Amazon.Lambda.LexV2Events;
using StockBrokerBot.ChatbotLambda.Persistence;
using StockBrokerBot.Core.Services;

namespace StockBrokerBot.ChatbotLambda.IntentProcessors;

public class CheckStockPriceIntentProcessor : AbstractIntentProcessor
{
    public override async Task<LexV2Response> Process(LexV2Event lexEvent, ILambdaContext context)
    {
        var slots = lexEvent.SessionState.Intent.Slots;
        var requestedStockName = slots["stockName"].Value.InterpretedValue;

        var stockMarketService = new FluctuatingStockMarketService(new StockMarketDynamoDBDataProvider());
        
        var price = await stockMarketService.GetStockPrice(requestedStockName);
        var responseMessage = $"Current price of {requestedStockName} is ${price:N2}";
        return Close(
            lexEvent.SessionState.Intent.Name,
            lexEvent.SessionState.SessionAttributes,
            IntentStateFulfilled,
            responseMessage
        );
    }
}

Lex sends the stockName in the slots dictionary. After getting that value, you pass it on to the StockMarketService, format the output, and send it back to Lex to deliver to the user.

Next, implement the get user portfolio intent. Create a file named GetPortfolioIntentProcessor.cs under the IntentProcessors directory and update the code to:

using Amazon.Lambda.Core;
using Amazon.Lambda.LexV2Events;
using StockBrokerBot.ChatbotLambda.Persistence;
using StockBrokerBot.Core.Services;

namespace StockBrokerBot.ChatbotLambda.IntentProcessors;

public class GetPortfolioIntentProcessor : AbstractIntentProcessor
{
    public override async Task<LexV2Response> Process(LexV2Event lexEvent, ILambdaContext context)
    {
        var userId = lexEvent.SessionId;
        
        var userPortfolioService = new PortfolioService(
                new FluctuatingStockMarketService(new StockMarketDynamoDBDataProvider()), 
                new PortfolioDynamoDBDataProvider()
        );

        var userPortfolio = await userPortfolioService.GetUserPortfolio(userId);

        return Close(
            lexEvent.SessionState.Intent.Name,
            lexEvent.SessionState.SessionAttributes,
            IntentStateFulfilled,
            userPortfolio.ToString()
        );
    }
}

Twilio sends the user's phone number to Lex and it sends it to your Lambda function in the SessionId field. You use it to fetch the user portfolio and send it back to the user.

Constructing complex objects manually is not ideal. Setting up Dependency Injection is left out as it's not the focus of this project. You can take a look at this article and implement DI as an improvement.

Next, create a new intent processor under the IntentProcessors directory called BuyStocksIntentProcessor.cs with the following code:

using Amazon.Lambda.Core;
using Amazon.Lambda.LexV2Events;
using StockBrokerBot.ChatbotLambda.Persistence;
using StockBrokerBot.Core.Services;

namespace StockBrokerBot.ChatbotLambda.IntentProcessors;

public class BuyStocksIntentProcessor : AbstractIntentProcessor
{
    public override async Task<LexV2Response> Process(LexV2Event lexEvent, ILambdaContext context)
    {
        var slots = lexEvent.SessionState.Intent.Slots;
        var requestedStockName = slots["stockName"].Value.InterpretedValue;
        var numberOfShares =  decimal.Parse(slots["numberOfShares"].Value.InterpretedValue);
        
        var userId = lexEvent.SessionId;
        var userPortfolioService = new PortfolioService(new FluctuatingStockMarketService(new StockMarketDynamoDBDataProvider()), new PortfolioDynamoDBDataProvider());

        try
        {
            var updatedPortfolio = await userPortfolioService.BuyStocks(userId, requestedStockName, numberOfShares);
            var responseMessage = $"Your request has been fulfilled. {updatedPortfolio}";
            return Close(
                lexEvent.SessionState.Intent.Name,
                lexEvent.SessionState.SessionAttributes,
                IntentStateFulfilled,
                responseMessage
            );
        }
        catch (Exception e)
        {
            var responseMessage = $"Error while buying stock: {requestedStockName}. {e.Message}. Call us at +0800 555-555 if the problem persists.";
            return Close(
                lexEvent.SessionState.Intent.Name,
                lexEvent.SessionState.SessionAttributes,
                IntentStateFailed,
                responseMessage
            );
        }
    }
}

And implement the final intent for selling stocks by creating a new file called SellStocksIntentProcessor.cs under the IntentProcessors directory.

Update the code as shown below:

using Amazon.Lambda.Core;
using Amazon.Lambda.LexV2Events;
using StockBrokerBot.ChatbotLambda.Persistence;
using StockBrokerBot.Core.Services;

namespace StockBrokerBot.ChatbotLambda.IntentProcessors;

public class SellStocksIntentProcessor : AbstractIntentProcessor
{
    public override async Task<LexV2Response> Process(LexV2Event lexEvent, ILambdaContext context)
    {
        var slots = lexEvent.SessionState.Intent.Slots;
        var requestedStockName = slots["stockName"].Value.InterpretedValue;
        var numberOfShares =  decimal.Parse(slots["numberOfShares"].Value.InterpretedValue);
        
        var userId = lexEvent.SessionId;
        var userPortfolioService = new PortfolioService(new FluctuatingStockMarketService(new StockMarketDynamoDBDataProvider()), new PortfolioDynamoDBDataProvider());

        try
        {
            var updatedPortfolio = await userPortfolioService.SellStocks(userId, requestedStockName, numberOfShares);
            var responseMessage = $"Your request has been fulfilled. {updatedPortfolio}";
            return Close(
                lexEvent.SessionState.Intent.Name,
                lexEvent.SessionState.SessionAttributes,
                IntentStateFulfilled,
                responseMessage
            );
        }
        catch (Exception e)
        {
            var responseMessage = $"Error while selling stock: {requestedStockName}. {e.Message}. Call us at +0800 555-555 if the problem persists.";
            return Close(
                lexEvent.SessionState.Intent.Name,
                lexEvent.SessionState.SessionAttributes,
                IntentStateFailed,
                responseMessage
            );
        }
    }
}

Most of the business logic is defined in the core library so what these intents do is to collect data from the user (via Lex and Twilio) and call the corresponding method of the services.

Finally, update your Function.cs as shown below to tie them all together:

using Amazon.Lambda.Core;
using Amazon.Lambda.LexV2Events;
using StockBrokerBot.ChatbotLambda.IntentProcessors;

// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class.
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]

namespace StockBrokerBot.ChatbotLambda;

public class Function
{
    public async Task<LexV2Response> FunctionHandler(LexV2Event lexEvent, ILambdaContext context)
    {
        AbstractIntentProcessor process = lexEvent.SessionState.Intent.Name switch
        {
            "CheckStockPrice" => new CheckStockPriceIntentProcessor(),
            "GetPortfolio" => new GetPortfolioIntentProcessor(),
            "BuyStocks" => new BuyStocksIntentProcessor(),
            "SellStocks" => new SellStocksIntentProcessor(),
            _ => throw new Exception($"Intent with name {lexEvent.SessionState.Intent.Name} is not supported")
        };
        
        return await process.Process(lexEvent, context);
    }
}

Now you invoke the correct processor based on the intent name specified in the LexV2Event object.

Now deploy your function to AWS by running the following command in the terminal:

dotnet lambda deploy-function

You have created your bot and backend separately. Now it's the time to bring them together by pointing your bot to your Lambda function, which you will do in the next section.

Connect your Chatbot to Lambda

Go to the Lex Console. Click Bots, then click StockBrokerBot. Click Aliases link, andon the Aliases page, click the Live alias.

In the languages section, click the English (US) link.

Now you should see a page that allows you to select a Lambda function and version of the function. In the Source list, select StockBrokerBot Lambda function and in the Lambda function version or alias list $LATEST should be automatically selected.

Lambda function page showing Source list with StockBrokerBot Lambda function selected and Lambda function version or alias list with $LATEST value selected

Click the Save button.

Now when a request comes to Live alias, Lex will identify the intent and invoke your Lambda function. It will pass all the slot values and user info in a LexV2Event structure that your Lambda expects.

Almost everything is wired up. What's left is to allow users to interact with your bot via SMS. Proceed to the next section to integrate with Twilio SMS.    

Connect your Chatbot to Twilio

On the left menu, right under Aliases, there is a link to Channel integrations. Click that to list the existing integrations.

Channel integrations list showing an empty list and a Add channel button

Click the Add channel button.

Amazon Lex supports 3 integration platforms: Facebook, Slack and Twilio SMS.

Platform list showing Facebook (selected by default), Slack and Twilio SMS

Select Twilio SMS.

In the Integration configuration section, enter TwilioIntegration as the name, select Live in the Alias list and English (US) in the language list.

In the Additional configuration section, you will need your Twilio Account SID and Authentication token.

Open the Twilio Console. On the main page, you should see the Account Info section.

Account info section in Twilio Console showing the AccountSID, Authentication Token and Twilio phone number

Copy your Account SID and Auth Token values and paste them in the corresponding inputs in the AWS console.

Additional configuration section showing the Twilio Account SID and Authentication token fields.

Click the Create button.

The Twilio SMS integration should now appear in the list.

Channel integration list showing the newly created integration in the list

Click the channel name to view the details.

Scroll down to the Callback URL section.

You should see an auto-generated webhook URL that Lex expects Twilio to post data to.

Callback URL showing a webhook URL for Twilio to post data and a Copy button under it

To complete the integration, copy the link and go to the Twilio console. Select your account, and then click Phone Numbers → Manage → Active Numbers on the left pane. (If Phone Numbers isn't on the left pane, click Explore Products and then on Phone Numbers.)

Click the phone number you want to use for your project and scroll down to the Messaging section.

In the "A MESSAGE COMES IN" section, select Webhook and paste the callback URL into the input field. Select HTTP POST in the next dropdown.

Twilio Console showing webhook settings with the Lex callback URL pasted and a Save button at the bottom

Click the Save button at the bottom of the screen.

Test your chatbot via SMS

Finally, it's time to test your chatbot.

From your phone, send an SMS to your Twilio Phone Number with the following message: check price. You should get a response asking for the stock name. Send the name of one of the stocks in your database such as Tesla. It also understands the stock name directly as it's in your utterance list. So you can simplify it by sending the stock name directly and you should still get an answer.

Phone screenshot showing a conversation with the chatbot where the bot returns the stock prices

Now send the following message "buy shares" and your bot should reply by asking the stock name first and then the number of shares. You should see your updated portfolio after the stocks have been bought for you.

You can play around with different utterances and intents.

Conclusion

In this tutorial, you learned how to implement your chatbot from scratch using C#. You also used the Twilio SMS integration with your Amazon Lex chatbot to allow users to interact with your bot via SMS.

A chatbot can be very useful to automate some processes saving time and money for your business. It's also beneficial for the users as they can use your system outside of business hours.

If you'd like to keep learning, I recommend taking a look at these articles:

Volkan Paksoy is a software developer with more than 15 years of experience, focusing mainly on C# and AWS. He’s a home lab and self-hosting fan who loves to spend his personal time developing hobby projects with Raspberry Pi, Arduino, LEGO and everything in-between. You can follow his personal blogs on software development at devpower.co.uk and cloudinternals.net.