Build a ChatGPT SMS bot with the OpenAI API and ASP.NET Core

March 08, 2023
Written by
Reviewed by

ChatGPT is a powerful language model trained by OpenAI. ChatGPT is a state-of-the-art natural language processing tool that can help you create a chatbot that can provide accurate and helpful responses to user queries.

With its extensive knowledge base and advanced language processing capabilities, ChatGPT can assist you in developing a chatbot that delivers a seamless and engaging user experience. It's worth noting that this introduction was generated using ChatGPT itself, with some minor editing, showcasing the power and versatility of this language model.

In this tutorial, you'll learn how to integrate Twilio SMS, ASP.NET Core, and ChatGPT to to create a robust and intelligent chatbot that you can talk to using SMS. Let's get started!

Prerequisites

Here’s what you will need to follow along:

You can find the source code for this tutorial on GitHub. Use it if you run into any issues, or submit an issue if you run into problems.

Create and set up a Twilio SMS project

Open a shell of your choice and use the .NET CLI to create an empty web project named SmsChatGpt and change into the project directory, using these commands:

dotnet new web -n SmsChatGpt
cd SmsChatGpt

The Twilio .NET SDK lets you communicate with Twilio's APIs without having to manually write the HTTP code. Add the Twilio .NET SDK using the following command:

dotnet add package Twilio

The Twilio helper library for ASP.NET Core helps you integrate Twilio into ASP.NET Core. Add the helper library for ASP.NET Core using the following command:

dotnet add package Twilio.AspNet.Core

Open the project in your preferred .NET IDE and update the Program.cs file to match the following code:

using Twilio.AspNet.Core;
using SmsChatGpt;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDistributedMemoryCache();

builder.Services.AddSession(options =>
{
    options.Cookie.HttpOnly = true;
    options.Cookie.IsEssential = true;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});

builder.Services.AddTwilioClient();

var app = builder.Build();

app.UseSession();

app.MapMessageEndpoint();

app.Run();

Here's an overview of the changes:

  • The application configures server sessions to store the session data  in memory. These sessions will be used to store the chat messages.
  • The Twilio API client is registered as a service in the ASP.NET Core Dependency Injection (DI) container. Now the ITwilioRestClient and TwilioRestClient can be injected into constructors and methods supported by DI.

MapMessageEndpoint is an extension method that doesn't exist yet. Create a new file named MessageEndpoint.cs and add the following code to it:

using Twilio.Clients;
using Twilio.Rest.Api.V2010.Account;

namespace SmsChatGpt;

public static class MessageEndpoint
{
    public static IEndpointRouteBuilder MapMessageEndpoint(this IEndpointRouteBuilder builder)
    {
        builder.MapPost("/message", OnMessage);
        return builder;
    }

    private static async Task<IResult> OnMessage(
        HttpContext context,
        ITwilioRestClient twilioClient,
        CancellationToken cancellationToken
    )
    {
        var request = context.Request;

        var form = await request.ReadFormAsync(cancellationToken);
        var receivedFrom = form["From"].ToString();
        var sentTo = form["To"].ToString();
        var body = form["Body"].ToString().Trim();

        await MessageResource.CreateAsync(
            to: receivedFrom,
            from: sentTo,
            body: $"You said: {body}",
            client: twilioClient
        );

        return Results.Ok();
    }
}

The MessageEndpoint.MapMessageEndpoint extension method maps the /message endpoint for HTTP POST requests to the OnMessage method.

Later, you will configure Twilio's message webhook to send HTTP POST requests to the /message endpoint. Twilio will send data about the incoming message as a form.

 The OnMessage method reads the form data from the request and extracts data from the form. The From parameter is the phone number that sent the message. The To parameter is the phone number that received the message, which will be your Twilio phone number. The Body parameter is the content of the message itself. The MessageResource.CreateAsync method sends a message back to the sender, repeating what the sender said.

Alternatively, you can use TwiML to respond to the incoming message. This is simpler and sufficient for most use cases, however, this application will require sending multiple messages in correct order, which you'll need to call the Twilio API for.

You'll need to configure the Twilio Account SID and Auth Token before you can communicate with the Twilio API using the Twilio REST client. Since the Auth Token is a secret that you should not share, you should avoid hard-coding it such as putting it in your appsettings.json file — or any other way that it could end up in your source control history. Instead, use the Secrets Manager aka user-secrets, environment variables, or a vault service.

Run the following commands to initialize user-secrets:

dotnet user-secrets init

Then, grab the Account SID and Auth Token from the Account Info panel of the Twilio Console and configure them using the following command, replacing [YOUR_ACCOUNT_SID] with your Account SID and [YOUR_AUTH_TOKEN] with your Auth Token:

dotnet user-secrets set Twilio:Client:AccountSid [YOUR_ACCOUNT_SID]
dotnet user-secrets set Twilio:Client:AuthToken [YOUR_AUTH_TOKEN]

Now, run your application using the following command:

dotnet run

In the application's output, you'll see a line similar to Now listening on: http://localhost:5209. Copy the URL and port, as you'll need it shortly.

You can also create a Twilio API key to authenticate to Twilio's APIs instead of an Auth Token. API keys are easier to manage, and you can create one for each application as needed.

Configure the message webhook on your Twilio phone number

Next, you'll need to configure the message webhook on your Twilio phone number. Before you can do that, you'll need to make your locally running application accessible to the internet. You can quickly do this using ngrok which creates a secure tunnel to the internet for you.

Leave your .NET application running, and run the following command in a separate shell, after replacing [YOUR_LOCALHOST_URL] with the URL and port which you copied previously:

ngrok http [YOUR_LOCALHOST_URL]

ngrok print the Forwarding URL to the output, which you'll need to publicly access your local application. Copy it, as you'll need it in just a moment.

Now, go back to the Twilio Console in your browser and use the left navigation to navigate to Phone Numbers > Manage > Active Numbers. Then, click the Twilio Phone Number you want to test with. On the phone number configuration page, locate "A MESSAGE COMES IN" in the Messaging section. Underneath that, set the first dropdown to Webhook. Set the value of the text box next to the dropdown to the ngrok Forwarding URL which you just copied, and add /message at the end of the URL. Then click Save.

Now, text your Twilio phone number something. You should receive an SMS back saying something like "You said: [whatever you said]".

Great job! You have an application that can receive SMS and send back SMS.

Your webhook endpoint has to be publicly accessible, so Twilio can send HTTP requests to them, but this means others can also send HTTP requests to your endpoint. You should add Twilio's request validation to your application to validate that the HTTP requests originated from Twilio, and not a malicious actor.

Integrate OpenAI's ChatGPT

OpenAI has official client libraries for their APIs, but they do not provide one for .NET. Luckily, there are some unofficial libraries developed by the .NET community, such as Betalgo.OpenAI, OpenAI, and OpenAI-DotNet.

You'll be using the Betalgo.OpenAI library in this tutorial. Swap back to the shell that is running your application and press ctrl + c to stop it. Then, add the package for Betalgo.OpenAI using the following command:

dotnet add package Betalgo.OpenAI

Open Program.cs and add the following using statement at the top of the file.

using OpenAI.GPT3.Extensions;

Then add the following line after builder.Services.AddTwilioClient():

builder.Services.AddOpenAIService();

This will add the IOpenAIService to the DI container of ASP.NET Core.

You'll need an API key from OpenAI to communicate with their APIs. Using the OpenAI API is not free, but when you create an account OpenAPI will give you free credit to use. Open a browser and follow this link to the OpenAI API keys page. If you aren't logged in or signed up, it will prompt you to do so now. Once you're logged in, it'll take you to the API keys page.

Click on the Create new secret key button. As the prompt says, save your API key secret somewhere safe, as you won't have another chance to see it!

Image showing the current API keys. User clicked Create new secret key button and a new API key is generated and shown along with a warning to keep it safe.

Use the following command to store the OpenAI API key as a user-secret, replacing [OPENAI_API_KEY] with the API key you just created:

dotnet user-secrets set OpenAIServiceOptions:ApiKey [OPENAI_API_KEY]

Next, update MessageEndpoint.cs with the following code:

using System.Text;
using System.Security.Cryptography;
using OpenAI.GPT3.Interfaces;
using OpenAI.GPT3.ObjectModels;
using OpenAI.GPT3.ObjectModels.RequestModels;
using Twilio.Clients;
using Twilio.Rest.Api.V2010.Account;

namespace SmsChatGpt;

public static class MessageEndpoint
{
    public static IEndpointRouteBuilder MapMessageEndpoint(this IEndpointRouteBuilder builder)
    {
        builder.MapPost("/message", OnMessage);
        return builder;
    }

    private static async Task<IResult> OnMessage(
        HttpContext context,
        IOpenAIService openAiService,
        ITwilioRestClient twilioClient,
        CancellationToken cancellationToken
    )
    {
        var request = context.Request;

        var form = await request.ReadFormAsync(cancellationToken);
        var receivedFrom = form["From"].ToString();
        var sentTo = form["To"].ToString();
        var body = form["Body"].ToString().Trim();

        // ChatGPT doesn't need the phone number, just any string that uniquely identifies the user,
        // hence I'm hashing the phone number to not pass in PII unnecessarily
        var userId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(receivedFrom)));

        var completionResult = await openAiService.ChatCompletion.CreateCompletion(
            new ChatCompletionCreateRequest
            {
                Messages = new List<ChatMessage>()
                {
                    ChatMessage.FromUser(body)
                },
                Model = Models.ChatGpt3_5Turbo,
                User = userId
            },
            cancellationToken: cancellationToken
        );

        if (!completionResult.Successful)
        {
            if (completionResult.Error == null) throw new Exception("An unexpected error occurred.");
            var errorMessage = completionResult.Error.Code ?? "";
            if (errorMessage != "") errorMessage += ": ";
            errorMessage += completionResult.Error.Message;
            throw new Exception(errorMessage);
        }

        var chatResponse = completionResult.Choices[0].Message.Content.Trim();

        await MessageResource.CreateAsync(
            to: receivedFrom,
            from: sentTo,
            body: chatResponse,
            client: twilioClient
        );
        
        return Results.Ok();
    }
}

The IOpenAIService is injected into the openAiService parameter of the OnMessage method. User is one of the optional parameters you can pass to the ChatGPT API.

While it is optional, it helps ChatGPT monitor and detect abuse, so I recommend passing it in. The only data you have access to that uniquely identifies the current use is their phone number. Since a phone number is Personally Identifiable Information (PII), I decided not to hand that to OpenAI, but instead to hash it first using the SHA256 hashing algorithm.

Next, the endpoint will pass the SMS body to the ChatGPT API and retrieve the response. The response is then sent back to the sender using Twilio.

The ChatGPT API has a lot more options than used in this tutorial. I recommend reading up on the chat API reference to customize your chatbot to your needs. If you have access to newer models by OpenAI, you can change the Model property. For example, to use GPT4, configure the property like this: Model = Models.Gpt_4.

Run your application again using the following command:

dotnet run

Then, ask ChatGPT anything by texting a question to your Twilio phone number.

It is possible that the response from ChatGPT is too large and will cause an error or deliverability issues. You'll fix this issue soon, but for now, try asking something simpler.

I asked the SMS ChatGPT bot for a joke about dogs, and it delivered a great joke, however, when I asked it for a follow-up joke about cats, without explicitly mentioning a joke, it just gave me information about cats.

SMS conversation with ChatGPT. Person asks ChatGPT to tell a joke about dogs. ChatGPT responds with a joke about dogs. Person asks for one about cats, but ChatGPT doesn"t retain the context of the previous question and responds with cat facts instead of a joke.

This is because ChatGPT does not maintain the context of the previous messages by default. Let's fix that first.

Persist ChatGPT's chat history as context

Since ChatGPT doesn't persist the history of the chat, it is not able to use the history as context to generate new responses. To solve this problem, you have to persist the history in your application, and pass the entire chat history to ChatGPT's API, for every new query.

In this tutorial, you'll store the history of the chat in ASP.NET Core's server session. Update the MessageEndpoint.cs like this:

using System.Text;
using System.Text.Json;
using System.Security.Cryptography;
using OpenAI.GPT3.Interfaces;
using OpenAI.GPT3.ObjectModels;
using OpenAI.GPT3.ObjectModels.RequestModels;
using Twilio.Clients;
using Twilio.Rest.Api.V2010.Account;

namespace SmsChatGpt;

public static class MessageEndpoint
{
    private const string PreviousMessagesKey = "PreviousMessages";

    public static IEndpointRouteBuilder MapMessageEndpoint(this IEndpointRouteBuilder builder)
    {
        builder.MapPost("/message", OnMessage);
        return builder;
    }

    private static async Task<IResult> OnMessage(
        HttpContext context,
        IOpenAIService openAiService,
        ITwilioRestClient twilioClient,
        CancellationToken cancellationToken
    )
    {
        var request = context.Request;
        var session = context.Session;
        await session.LoadAsync(cancellationToken);

        var form = await request.ReadFormAsync(cancellationToken);
        var receivedFrom = form["From"].ToString();
        var sentTo = form["To"].ToString();
        var body = form["Body"].ToString().Trim();

        // ChatGPT doesn't need the phone number, just any string that uniquely identifies the user,
        // hence I'm hashing the phone number to not pass in PII unnecessarily
        var userId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(receivedFrom)));

        var messages = GetPreviousMessages(session);
        messages.Add(ChatMessage.FromUser(body));

        var completionResult = await openAiService.ChatCompletion.CreateCompletion(
            new ChatCompletionCreateRequest
            {
                Messages = messages,
                Model = Models.ChatGpt3_5Turbo,
                User = userId
            },
            cancellationToken: cancellationToken
        );

        if (!completionResult.Successful)
        {
            if (completionResult.Error == null) throw new Exception("An unexpected error occurred.");
            var errorMessage = completionResult.Error.Code ?? "";
            if (errorMessage != "") errorMessage += ": ";
            errorMessage += completionResult.Error.Message;
            throw new Exception(errorMessage);
        }

        var chatResponse = completionResult.Choices[0].Message.Content.Trim();

        messages.Add(ChatMessage.FromAssistant(chatResponse)); // previously called FromAssistance
        SetPreviousMessages(session, messages);

        await MessageResource.CreateAsync(
            to: receivedFrom,
            from: sentTo,
            body: chatResponse,
            client: twilioClient
        );

        return Results.Ok();
    }

    private static List<ChatMessage> GetPreviousMessages(ISession session)
    {
        var json = session.GetString(PreviousMessagesKey);

        if (string.IsNullOrWhiteSpace(json))
        {
            return new List<ChatMessage>();
        }

        return JsonSerializer.Deserialize<List<ChatMessage>>(json)
               ?? new List<ChatMessage>();
    }

    private static void SetPreviousMessages(ISession session, List<ChatMessage> messages)
    {
        var serializedJson = JsonSerializer.Serialize(messages);
        session.SetString(PreviousMessagesKey, serializedJson);
    }

    private static void RemovePreviousMessages(ISession session)
        => session.Remove(PreviousMessagesKey);
}

Now the endpoint loads any previous chat messages from the session, and then adds it to the new SMS body. The whole conversation is passed to the ChatGPT API and the response is also added to the conversation, which is then persisted back into session.

Stop and start your application to try it out. Now, when I asked for a follow-up joke about cats, it knew I wanted a joke and not facts about cats.

SMS conversation with ChatGPT. Person asks ChatGPT to tell a joke about dogs. ChatGPT responds with a joke about dogs. Person asks for one about cats. ChatGPT tells a joke about cats.

Server sessions will automatically be removed over time, and the cookie that associates the server session with the client will also expire at some point. If a cookie does not have a set expiration date, it'll remove the cookie after 4 hours. If you want the cookie to stay around for longer, you can set the expiration date, however, Twilio may still remove it before the given expiration date.

Check out this document on how Twilio treats your cookies.

Now that the ChatGPT bot has the context of the entire chat history, it is much more useful. However, users may still want to start from a fresh slate. Update the start of the OnMessage method in the MessageEndpoint.cs file:

private static async Task<IResult> OnMessage(
    HttpContext context,
    IOpenAIService openAiService,
    ITwilioRestClient twilioClient,
    CancellationToken cancellationToken
)
{
    var request = context.Request;
    var session = context.Session;
    await session.LoadAsync(cancellationToken);

    var form = await request.ReadFormAsync(cancellationToken);
    var receivedFrom = form["From"].ToString();
    var sentTo = form["To"].ToString();
    var body = form["Body"].ToString().Trim();

    // handle reset
    if (body.Equals("reset", StringComparison.OrdinalIgnoreCase))
    {
        RemovePreviousMessages(session);
        await MessageResource.CreateAsync(
            to: receivedFrom,
            from: sentTo,
            body: "Your conversation is now reset.",
            client: twilioClient
        );
        return Results.Ok();
    }

    // ChatGPT doesn't need the phone number, just any string that uniquely identifies the user,
    // hence I'm hashing the phone number to not pass in PII unnecessarily
    var userId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(receivedFrom)));

Now users can text "reset" and the chat history will be reset.

Maximize message delivery

You can only send messages using Twilio SMS that are shorter or equal to 1600 characters, but sometimes ChatGPT's response will exceed that limit. If so, you'd receive an error that would look something like this:

Unhandled exception. Twilio.Exceptions.ApiException: The concatenated message body exceeds the 1600 character limit.
   at Twilio.Clients.TwilioRestClient.ProcessResponse(Response response)
   at Twilio.Clients.TwilioRestClient.Request(Request request)
   at Twilio.Rest.Api.V2010.Account.MessageResource.Create(CreateMessageOptions options, ITwilioRestClient client)
   at Twilio.Rest.Api.V2010.Account.MessageResource.Create(PhoneNumber to, String pathAccountSid, PhoneNumber from, String messagingServiceSid, String body, List`1 mediaUrl, Uri statusCallback, String applicationSid, Nullable`1 maxPrice, Nullable`1 provideFeedback, Nullable`1 attempt, Nullable`1 validityPeriod, Nullable`1 forceDelivery, ContentRetentionEnum contentRetention, AddressRetentionEnum addressRetention, Nullable`1 smartEncoded, List`1 persistentAction, Nullable`1 shortenUrls, ScheduleTypeEnum scheduleType, Nullable`1 sendAt, Nullable`1 sendAsMms, String contentSid, String contentVariables, ITwilioRestClient client)

While Twilio will accept messages shorter than or equal to 1600 characters, Twilio recommend keeping your messages below or equal to 320 characters to maximize deliverability of your messages.

To ensure ChatGPT does this, update the OnMessage method again:

private static async Task<IResult> OnMessage(
    HttpContext context,
    IOpenAIService openAiService,
    ITwilioRestClient twilioClient,
    CancellationToken cancellationToken
)
{
    var request = context.Request;
    var session = context.Session;
    await session.LoadAsync(cancellationToken);

    var form = await request.ReadFormAsync(cancellationToken);
    var receivedFrom = form["From"].ToString();
    var sentTo = form["To"].ToString();
    var body = form["Body"].ToString().Trim();

    // handle reset
    if (body.Equals("reset", StringComparison.OrdinalIgnoreCase))
    {
        RemovePreviousMessages(session);
        await MessageResource.CreateAsync(
            to: receivedFrom,
            from: sentTo,
            body: "Your conversation is now reset.",
            client: twilioClient
        );
        return Results.Ok();
    }

    // ChatGPT doesn't need the phone number, just any string that uniquely identifies the user,
    // hence I'm hashing the phone number to not pass in PII unnecessarily
    var userId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(receivedFrom)));

    var messages = GetPreviousMessages(session);
    messages.Add(ChatMessage.FromUser(body));

    var completionResult = await openAiService.ChatCompletion.CreateCompletion(
        new ChatCompletionCreateRequest
        {
            Messages = messages,
            Model = Models.ChatGpt3_5Turbo,
            User = userId
        },
        cancellationToken: cancellationToken
    );

    if (!completionResult.Successful)
    {
        if (completionResult.Error == null) throw new Exception("An unexpected error occurred.");
        var errorMessage = completionResult.Error.Code ?? "";
        if (errorMessage != "") errorMessage += ": ";
        errorMessage += completionResult.Error.Message;
        throw new Exception(errorMessage);
    }

    var chatResponse = completionResult.Choices[0].Message.Content.Trim();

    messages.Add(ChatMessage.FromAssistant(chatResponse)); // previously called FromAssistance
    SetPreviousMessages(session, messages);

    // 320 is the recommended message length for maximum deliverability,
    // but you can change this to your preference. The max for a Twilio message is 1600 characters.
    // https://support.twilio.com/hc/en-us/articles/360033806753-Maximum-Message-Length-with-Twilio-Programmable-Messaging
    var responseMessages = SplitTextIntoMessages(chatResponse, maxLength: 320);

    // Twilio webhook expects a response within 10 seconds.
    // we don't need to wait for the SendResponse task to complete, so don't await
    _ = SendResponse(twilioClient, to: receivedFrom, from: sentTo, responseMessages);

    return Results.Ok();
}

Then add these two static functions to the MessageEndpoint class:

/// <summary>
/// Splits the text into multiple strings by splitting it by its paragraphs
/// and adding them back together until the max length is reached.
/// Warning: This assumes each paragraph does not exceed the maxLength already, which may not be the case.
/// </summary>
/// <param name="text"></param>
/// <param name="maxLength"></param>
/// <returns>Returns a list of messages, each not exceeding the maxLength</returns>
private static List<string> SplitTextIntoMessages(string text, int maxLength)
{
    List<string> messages = new();
    var paragraphs = text.Split("\n\n");

    StringBuilder messageBuilder = new();
    for (int paragraphIndex = 0; paragraphIndex < paragraphs.Length - 1; paragraphIndex++)
    {
        string currentParagraph = paragraphs[paragraphIndex];
        string nextParagraph = paragraphs[paragraphIndex + 1];
        messageBuilder.Append(currentParagraph);

        // + 2 for "\n\n"
        if (messageBuilder.Length + nextParagraph.Length > maxLength + 2)
        {
            messages.Add(messageBuilder.ToString());
            messageBuilder.Clear();
        }
        else
        {
            messageBuilder.Append("\n\n");
        }
    }

    messageBuilder.Append(paragraphs.Last());
    messages.Add(messageBuilder.ToString());

    return messages;
}

private static async Task SendResponse(
    ITwilioRestClient twilioClient,
    string to,
    string from,
    List<string> responseMessages
)
{
    foreach (var responseMessage in responseMessages)
    {
        await MessageResource.CreateAsync(
            to: to,
            from: from,
            body: responseMessage,
            client: twilioClient
        );
        // Twilio cannot guarantee order of the messages as it is up to the carrier to deliver the SMS's.
        // by adding a 1s delay between each message, the messages are deliver in the correct order in most cases.
        // alternatively, you could query the status of each message until it is delivered, then send the next.
        await Task.Delay(1000);
    }
}

The SplitTextIntoMessages method will split the text into paragraphs and add the paragraphs while not exceeding the recommended 320-character limit. The method returns a list of strings, each containing 1 or more paragraphs. If a paragraph is larger than 320 characters, the individual string will exceed the 320-character limit. So it doesn't guarantee each string will be less than 320 characters, but in most cases it will be.

The SendResponse method iterates over the list of strings and sends them out one by one with a one-second delay between each. Twilio cannot guarantee that the messages will arrive in the same order as they are created. However, by putting a one-second delay between each message the order is preserved most of the time. Alternatively, you could poll the message status and wait to send the next message until the current message is delivered.

Restart your application and try asking the bot a question that will have a long response. Here's a video that shows how the bot answers the question "How do I become a .NET developer?".

The video shows how the bot responds with multiple messages, with a delay between each message, but the messages are in the correct order.

Next steps

Fantastic job! You integrated OpenAI's APIs with Twilio SMS to create an SMS based ChatGPT bot. 

Want to experiment more with OpenAI's APIs? Here's a tutorial on how to generate AI art with DALL·E 2 by texting a Twilio phone number

We can't wait to see what you build. Let us know!

Niels Swimberghe is a Belgian American software engineer and technical content creator at Twilio. Get in touch with Niels on Twitter @RealSwimburger and follow Niels’ personal blog on .NET, Azure, and web development at swimburger.net.