Build a Voicemail Inbox using Twilio Voice and Blazor (Part 2)

June 05, 2023
Written by
Volkan Paksoy
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Build a Voicemail Inbox using Twilio Voice and Blazor (Part 2)

In Part 1 of this series, you implemented a voicemail service that you can manage by calling your own Twilio number. You could then listen to the voice messages and choose to save or delete them. In this article, you will improve your voicemail service by adding a GUI so that you can carry out the same operations via the web and also see more information about the call, such as the caller number, duration and even the transcript of the message. If this sounds interesting to you, let's get started!

Prerequisites

You'll need the following things in this tutorial:

Project Overview

The project will start where Part 1 ended. The older version relies on filenames to keep the state of the recordings (new vs saved). In this version, you will introduce a SQLite database to store the metadata such as caller number, call duration, etc.

Project setup

The easiest way to set up the starter project is by cloning the sample GitHub repository.

Open a terminal, change to the directory you want to download the project (on the starter-project branch) and run the following command:

git clone https://github.com/Dev-Power/voicemail-service-with-gui-using-twilio-and-blazor --branch starter-project

Before making any changes, ensure the current version is in working order.

Run the following command to start tunneling:

ngrok http http://localhost:5096

Update the Twilio incoming call webhook URL with the Forwarding URL assigned to you by ngrok, and add the /IncomingCall path. Leave the ngrok tunnel running and open a separate shell for the upcoming commands.

In a new shell, change directories to the web API project.

cd voicemail-service-with-gui-using-twilio-and-blazor/src/VoicemailDirectory.WebApi

To test leaving a voicemail, remove your phone number from the Voicemail:Owners array in

 appsettings.json and run the application with the following command:

dotnet run

Call your Twilio phone number, and you should be able to record a message. The saved message should appear under the wwwroot/Voicemails directory.

Update the appsettings.json by adding your number to the owner's list (using E. 164 formatting) and call your Twilio phone number again.

This time you should be greeted with a message telling you have one new message and no saved messages, followed by the recorded message.

If your project works so far, you're ready to move on to the next section to refactor and improve. If not, please refer to the original article and make sure your setup works.

Use EF Core and a SQLite database to store metadata

In the first version, you used the filename to store very limited metadata about the recording: Call status, which can be "New" or "Saved". This was used to order the recordings to play the new ones first.

In this version, you will use a local SQLite database instead. First, start by adding the EF Core SQLite NuGet package to your API project:

dotnet add package Microsoft.EntityFrameworkCore.Sqlite

Create a new directory called Data and add a new C# file under it called RecordingContext.cs. Update the contents as below:

using Microsoft.EntityFrameworkCore;

namespace VoicemailDirectory.WebApi.Data;

public class RecordingContext : DbContext
{
    public DbSet<Recording>? Recordings { get; set; }

    public RecordingContext(DbContextOptions<RecordingContext> options) : base(options)
    {
    }
}

The context class you created above refers to an entity class called Recording which you will use to save the recording metadata. Next, create a new file under the Data directory called Recording.cs and update it as shown below:

namespace VoicemailDirectory.WebApi.Data;

public class Recording
{
    public int Id { get; set; }
    public string RecordingSID { get; set; }
    public DateTime Date { get; set; }
    public TimeSpan Duration { get; set; }
    public string CallerNumber { get; set; }
    public string Transcription { get; set; }
    public RecordingStatus Status { get; set; }
}

public enum RecordingStatus
{
    New,
    Saved
}

SQLite is a file-based SQL database engine. EF Core is going to create the database for you, as you will see later, but first, you have to specify the path for the database file.

Open Program.cs file and add the highlighted lines as shown below:

builder.Services.AddControllers();

var folder = Environment.SpecialFolder.LocalApplicationData;
var path = Environment.GetFolderPath(folder);
var dbPath = Path.Join(path, "recordings.db");
builder.Services.AddDbContext<RecordingContext>(options => options.UseSqlite($"Data Source={dbPath}"));

// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();

Also, add the using statements at the top:

using Microsoft.EntityFrameworkCore;
using VoicemailDirectory.WebApi.Data;

You can use any path or file name you like. The above example uses the local data path and uses "recordings.db" as the filename.

You can now inject the RecordingContext class wherever you want to carry out database operations. Alternatively, you can create a separate class to encapsulate all the database operations. This way your business logic doesn't become tied to the implementation. To achieve this, create an interface under the Data directory called IRecordingRepository.cs with the following code:

namespace VoicemailDirectory.WebApi.Data;

public interface IRecordingRepository
{
    Task NewRecording(Recording recording);
    Task UpdateCallerAndTranscription(string recordingSID, string caller, string transcriptionText);
    Task ChangeStatusToSaved(string recordingSID);
    Task Delete(string recordingSID);
    List<Recording> GetAll();
}

This interface defines all the database operations you will need:

  • NewRecording: Creates and inserts a new Recording object when a new message comes in.
  • UpdateCallerAndTranscription: It takes a while to get the transcription, and it's handled in a different endpoint. When Twilio calls your endpoint with the transcript info, this method is called to update the record. It also updates the caller number as it's not present in the recording status callback.
  • ChangeStatusToSaved: Updates the status column to Saved for the specified recording.
  • Delete: Removes the recording metadata from the database.
  • GetAll: Returns all recordings in the database.

To implement this interface, create a new class called RecordingRepository under the Data directory:

using VoicemailDirectory.WebApi.Data;

public class RecordingRepository : IRecordingRepository
{
    private readonly RecordingContext _recordingContext;
    
    public RecordingRepository(RecordingContext recordingContext)
    {
        _recordingContext = recordingContext;
    }

    public async Task NewRecording(Recording recording)
    {
        await _recordingContext.Recordings.AddAsync(recording);
        await _recordingContext.SaveChangesAsync();
    }
    
    public async Task UpdateCallerAndTranscription(string recordingSID, string caller, string transcriptionText)
    {
        var recording = _recordingContext.Recordings.Single(rec => rec.RecordingSID == recordingSID);
        recording.CallerNumber = caller;
        recording.Transcription = transcriptionText;
        await _recordingContext.SaveChangesAsync();
    }
    
    public async Task ChangeStatusToSaved(string recordingSID)
    {
        var recording = _recordingContext.Recordings.Single(rec => rec.RecordingSID == recordingSID);
        recording.Status = RecordingStatus.Saved;
        await _recordingContext.SaveChangesAsync();
    }

    public async Task Delete(string recordingSID)
    {
        var recording = _recordingContext.Recordings.Single(rec => rec.RecordingSID == recordingSID);
        _recordingContext.Recordings.Remove(recording);
        await _recordingContext.SaveChangesAsync();
    }

    public List<Recording> GetAll()
    {
        return _recordingContext.Recordings.ToList();
    }
}

The repository class encapsulates RecordingContext and carries out the DB operations via EF Core.

You also have to register it to the IoC container by updating Program.cs as shown below:

builder.Services.AddTransient<FileService>();
builder.Services.AddTransient<IRecordingRepository, RecordingRepository>();

builder.Services.Configure<VoicemailOptions>(builder.Configuration.GetSection("Voicemail"));

Finally, it's time to create the database. Run the following commands to scaffold the migrations and create the database:

dotnet tool install --global dotnet-ef
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet ef migrations add InitialCreate
dotnet ef database update

You should now see a directory inside your project called Migrations and the recordings.db file in your local data path.

Migrations directory showing auto-generated migration classes

Refactor Controllers and Services

Now that you have a database layer, it's time to refactor the controllers to make use of it.

Open RecordController.cs under the Controllers directory and set up IRecordingRepository as shown below:

    private readonly ILogger<RecordController> _logger;
    private readonly FileService _fileService;
    private readonly IRecordingRepository _recordingRepository;
    
    public RecordController(
        ILogger<RecordController> logger,
        FileService fileService,
        IRecordingRepository recordingRepository
    )
    {
        _logger = logger;
        _fileService = fileService;
        _recordingRepository = recordingRepository;
    }

Also add this required using statement:

using VoicemailDirectory.WebApi.Data;

Replace the RecordingStatus action with the code below:

    [HttpPost]
    public async Task RecordingStatus(
        [FromForm] string callSid,
        [FromForm] string recordingUrl,
        [FromForm] string recordingSid,
        [FromForm] string recordingStatus,
        [FromForm] string recordingStartTime,
        [FromForm] string recordingDuration
    )
    {
        _logger.LogInformation(
            "Recording status changed to {recordingStatus} for call {callSid}. Recording is available at {recordingUrl}",
            recordingStatus, callSid, recordingUrl
        );

        if (recordingStatus == "completed")
        {
            await _recordingRepository.NewRecording(new Recording
            {
                RecordingSID = recordingSid,
                Status = Data.RecordingStatus.New,
                Date = DateTime.Parse(recordingStartTime),
                Duration = TimeSpan.FromSeconds(double.Parse(recordingDuration)),
                CallerNumber = string.Empty,
                Transcription = string.Empty
            });
            
            await _fileService.DownloadRecording(recordingUrl, recordingSid);
        }
    }

The previous version only downloaded the file. Now, in addition to that, you also insert the metadata into the database.

Another change in the controller needs to be done in the Index method. You will instruct Twilio to transcribe the call and send you the transcription by updating the Record call as below:

response.Record(
    timeout: 10,
    action: new Uri(Url.Action("Bye")!, UriKind.Relative),
    method: Twilio.Http.HttpMethod.Post,
    recordingStatusCallback: new Uri(Url.Action("RecordingStatus")!, UriKind.Relative),
    recordingStatusCallbackMethod: Twilio.Http.HttpMethod.Post,
    transcribe: true,
    transcribeCallback: new Uri(Url.Action("TranscribeCallback")!, UriKind.Relative)
);

And add the method to handle the transcription callback:

HttpPost]
public async Task TranscribeCallback(
    [FromForm] string recordingSid

Update your settings and remove your phone from the owner list to test recording a new message again. Repeat the previous test. This time, you should also see a new record in the recordings.db database.

If you don't have an application to manage SQLite databases already installed, you can download DB Browser for SQLite for free.

After you've saved your message, you should see a new call to your API:

ngrok logs showing successful RecordingStatus and TranscribeCallback HTTP POST requests in the terminal

The record is first inserted in the /Record/RecordingStatus endpoint. Then updated with the caller number and the call transcription in the /Record/TranscribeCallback endpoint.

Your recording row should look something like this in your database:

DB Browser for SQLite window showing the newly inserted recording details

Next step is refactoring the DirectoryController class to use the database. Like RecordController, you need to inject the repository to handle the database operations.

Open DirectoryController.cs and set up IRecordingRepository as shown below:

    private readonly ILogger<DirectoryController> _logger;
    private readonly FileService _fileService;
    private readonly IRecordingRepository _recordingRepository;
    
    public DirectoryController(
        ILogger<DirectoryController> logger,
        FileService fileService,
        IRecordingRepository recordingRepository
    )
    {
        _logger = logger;
        _fileService = fileService;
        _recordingRepository = recordingRepository;
    }

Then, add the required using statement:

using VoicemailDirectory.WebApi.Data;

Current version gets the recordings from the FileService. In this version, you will get that data from the database.

Update the first 3 lines of the Index method as shown below:

var allRecordings = _recordingRepository.GetAll();
var newMessages = allRecordings.Where(rec => rec.Status == RecordingStatus.New).ToList();
var savedMessages = allRecordings.Where(rec => rec.Status == RecordingStatus.Saved).ToList();

Another change in this method is at the bottom, where you filter the recordings. Again, you will now use the RecordingRepository instead of the FileService. Replace the var allMessages = _fileService.GetRecordingSids; call with the code below:

var allMessages = allRecordings
    .OrderBy(rec => rec.Status) // So the new ones come on top
    .ThenByDescending(rec => rec.Date) // Descending so that the newest ones come on top
    .Select(rec => rec.RecordingSID)
    .ToList();

Final modification in this controller will be in the Gather method. Update the actions as shown below:

case 2: // Save
    await _recordingRepository.ChangeStatusToSaved(currentMessage);
    queuedMessages.Remove(currentMessage);
    break;

case 3: // Delete
    _fileService.DeleteRecording(currentMessage);
    await _recordingRepository.Delete(currentMessage);
    queuedMessages.Remove(currentMessage);
    break;

This way, in addition to managing the actual MP3 files, you keep the database accurate.

For this update to work, you have to convert the Gather method to async by making the following change in the method signature:

   public async Task<TwiMLResult> Gather(
        [FromQuery] List<string> queuedMessages,
        [FromForm] int digits
    )

Due to all these changes, the functionality of the FileService has also changed. You don't have to prefix the file name with its status anymore. Therefore, you don't need a save method. Update the current service with the simplified version as shown below:

namespace VoicemailDirectory.WebApi.Services;

public class FileService
{
    private readonly HttpClient _httpClient;
    private readonly string _rootVoicemailPath;

    public FileService(IHttpClientFactory httpClientFactory, IWebHostEnvironment webHostEnvironment)
    {
        _rootVoicemailPath = $"{webHostEnvironment.WebRootPath}/Voicemails";
        _httpClient = httpClientFactory.CreateClient();
    }

    public async Task DownloadRecording(string recordingUrl, string recordingSid)
    {
        using HttpResponseMessage response = await _httpClient.GetAsync($"{recordingUrl}.mp3");
        response.EnsureSuccessStatusCode();
        await using var fs = new FileStream(
            $"{_rootVoicemailPath}/{recordingSid}.mp3",
            FileMode.CreateNew
        );
        await response.Content.CopyToAsync(fs);
    }

    public void DeleteRecording(string recordingSid) => File.Delete(GetRecordingPathBySid(recordingSid));

    private string GetRecordingPathBySid(string recordingSid)
        => Directory.GetFiles($"{_rootVoicemailPath}/", "*.mp3")
            .Single(s => s.Contains(recordingSid));
}

Leave another message for yourself and test the existing functionality by adding your number to the owner list and calling back. Once you've confirmed everything is still working as expected, move on to the next section to implement a new controller for your future front-end.

Implement the new controller

Currently, you have a voice-based user interface where you're prompted with options when you call your own number. The GUI you will implement will need to have a new API endpoint that it can talk to. To achieve this, create a new controller under the Controllers directory called RecordingManagementController and update its code as shown below:

using Microsoft.AspNetCore.Mvc;
using VoicemailDirectory.WebApi.Data;
using VoicemailDirectory.WebApi.Services;

namespace VoicemailDirectory.WebApi.Controllers;

[ApiController]
[Route("[controller]/[action]")]
public class RecordingManagementController : ControllerBase
{
    private readonly FileService _fileService;
    private readonly IRecordingRepository _recordingRepository;
    
    public RecordingManagementController(IRecordingRepository recordingRepository, FileService fileService)
    {
        _recordingRepository = recordingRepository;
        _fileService = fileService;
    }

    [HttpGet]
    public ActionResult Index()
    {
        var recordings = _recordingRepository.GetAll();
        return Ok(recordings.OrderByDescending(m => m.Date));
    }
    
    [HttpPatch("{recordingSid}")]
    public async Task<ActionResult> Save(string recordingSid)
    {
        await _recordingRepository.ChangeStatusToSaved(recordingSid);
        return NoContent();
    }
    
    [HttpDelete("{recordingSid}")]
    public async Task<ActionResult> Delete(string recordingSid)
    {
        await _recordingRepository.Delete(recordingSid);
        _fileService.DeleteRecording(recordingSid);
        return NoContent();
    }
}

As you can see, the goal here is to have the same functionality via the GUI.

Now that you have your API ready for the front-end, proceed to implement it.

Implement the front-end

In the terminal, navigate to the solution level and run the following command:

dotnet new blazorwasm -o VoicemailDirectory.Console
dotnet sln add ./VoicemailDirectory.Console/VoicemailDirectory.Console.csproj

This should create a new Blazor WebAssembly project and add it to your solution.

The Blazor application sets up and HTTP client to resolve HTTP requests to its own app URL, but you'll need to make HTTP requests to the API project running on a separate URL. The web API runs on http://localhost:5096, so update your VoicemailDirectory.Console/Program.cs as shown below to reflect this:

builder.RootComponents.Add<HeadOutlet>("head::after");

builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://localhost:5096") });

await builder.Build().RunAsync();

To get rid of the default navigation and sample pages, update the MainLayout.razor as shown below:

@inherits LayoutComponentBase

<div class="page">
    <main>
        <article class="content px-4">
            @Body
        </article>
    </main>
</div>

Also remove the following files to keep things clean:

  • Shared/SurveyPrompt.razor
  • Shared/NavMenu.razor
  • Shared/NavMenu.razor.cs
  • Pages/Counter.razor
  • Pages/FetchData.razor

Replace the contents of Index.razor with the code below:

@page "/"
@inject HttpClient Http

<PageTitle>Voicemail Management Console</PageTitle>
<h1>Voicemail Management Console</h1>

@if (recordingMetadata == null)
{
    <p>No recording found.</p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Caller</th>
                <th>Duration</th>
                <th>Transcription</th>
                <th>Status</th>
                <th>Recording</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var recording in recordingMetadata)
            {
                <tr>
                    <td>@recording.Date.ToShortDateString() @recording.Date.ToShortTimeString()</td>
                    <td>@recording.CallerNumber</td>
                    <td>@recording.Duration</td>
                    <td>@recording.Transcription</td>
                    <td>@recording.Status</td>
                    <td>
                        <audio controls>
                            <source src=@($"{Http.BaseAddress}/Voicemails/{recording.RecordingSID}.mp3") type="audio/mpeg">
                        </audio>
                    </td>
                    <td>
                        <button type="button" class="btn btn-primary" @onclick="() => SaveRecording(recording.RecordingSID)">Save</button>
                        <button type="button" class="btn btn-danger" @onclick="() => DeleteRecording(recording.RecordingSID)">Delete</button>
                    </td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private Recording[]? recordingMetadata;

    protected override async Task OnInitializedAsync() => await UpdateTable();
    
    private async Task SaveRecording(string recordingSid)
    {
        await Http.PatchAsync($"/RecordingManagement/Save/{recordingSid}", null);
        await UpdateTable();
    }
    
    private async Task DeleteRecording(string recordingSid)
    {
        await Http.DeleteAsync($"/RecordingManagement/Delete/{recordingSid}");
        await UpdateTable();
    }

    private async Task UpdateTable() => recordingMetadata = await Http.GetFromJsonAsync<Recording[]>("/RecordingManagement/Index");
}

This page calls the newly created API endpoints to get the list of Recording objects and save/delete operations.

Create a new class called Recording inside the Blazor project as well, and paste the same code as the API:

namespace VoicemailDirectory.Console;

public class Recording
{
    public int Id { get; set; }
    public string RecordingSID { get; set; }
    public DateTime Date { get; set; }
    public TimeSpan Duration { get; set; }
    public string CallerNumber { get; set; }
    public string Transcription { get; set; }
    public RecordingStatus Status { get; set; }
}

public enum RecordingStatus
{
    New,
    Saved
}

Instead of duplicating the code this way, you can create a class library with the data classes and share between the API and the Blazor app. This tutorial creates a copy of the recording class to keep things simple.

For this setup to work, you also need to update the CORS policy of your API. Even though both applications run locally, they run on different ports and thus on different origins. For security reasons, browsers don't allow one origin to send HTTP requests to other origins. You can provide Cross-Origin Resource Sharing (CORS) headers from your API project to tell the browser which origins are allowed to send HTTP requests to the API project. So in order for Blazor to call the API, you need to enable a permissive CORS policy in the API project.

Sometimes in development environments, and especially in production, a reverse proxy is used to host the front-end and the back-end on a single origin, thus avoiding the need for CORS. Here's a tutorial that shows you how to host a client and API on a single origin using YARP, Microsoft's ASP.NET Core based reverse proxy.

Open the Program.cs file in the API project and the highlighted lines as shown below:

builder.Services.AddDbContext<RecordingContext>(options => options.UseSqlite($"Data Source={dbPath}"));

builder.Services.AddCors(policy =>
{
    policy.AddPolicy("CorsPolicy", opt => opt
        .AllowAnyOrigin()
        .AllowAnyHeader()
        .AllowAnyMethod());
});

// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();

Do not allow any origin (AllowAnyOrigin()) in production. Instead, explicitly configure the individual origins you'd like to allow.

And the following line to enable the policy:

app.MapControllers();

app.UseCors("CorsPolicy");

app.Run();

Then, restart your API for your changes to take effect.

So the final step is to run the front-end test the functionality using the Blazor WebAssembly front-end.

Test via the front-end

While your API is still running, open another terminal inside the console application and run it with the following command:

dotnet run

Open a browser and go to your application at http://localhost:{YOUR APPLICATION'S PORT}.

It should look like this:

Blazor application showing the recording details: Date, Caller, Duration, Transcription, Status, playable recording and Save and Delete buttons

If you have lots of messages, you can play them very quickly as the play button for all of them will be directly accessible to you, whereas if you tried to achieve the same via the voice UI, you would have to go through all the messages one-by-one. Also, you can see the transcriptions, so you may not even want to play the messages in the first place.

Conclusion

In this tutorial, you implemented using a SQLite database with EF Core, and also implemented your Blazor WebAssembly front-end to add more functionality to your voicemail service. I hope you enjoyed following this tutorial as much as I enjoyed writing it.

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.