Building a Twitch Quote Generator

This article is part of the C# Advent Calendar 2020 series. Be sure to check out all the articles published by some really amazing community members.


Three nights a week, I broadcast coding adventures on Twitch (be sure to tune in Monday, Wednesday, and Fridays at 8:00 pm Eastern by going to TaleLearnCode.tv). With so many events this year being canceled, this has been a great way for me to keep connected with the software development community. Plus, I have really learned a lot by having folks watch along while I write code.

But one thing about Twitch is the need to do more in order to keep up engagement not only between you and your viewers but also your viewers with each other. As such, I have implemented features that help with these engagements. One of the new ways I have been working is to have quotes randomly appear in the chat. These should give folks a chuckle and hopefully will start a conversation about something within the quote.

What follows is a description of how I have built rQuote so far using C#, Azure Functions, and Azure Storage.

Requirements

First and foremost (event with hobby projects), we need to define at least a base set of requirements. Here are the initial requirements I had set in place for rQuote:

  • Handle multiple Twitch channels – Along with the TaleLearnCode channel, I also have the BricksWithChad Twitch channel where I broadcast Lego builds (Saturday evenings at 9:00 pm Eastern at Twitch.tv/BricksWithChad). I wanted to be able to use one service to be able to serve both channels. Plus in the future, I will probably open this up to at least streamer friends if not streamers in general.
  • Add Channel – Endpoint allowing users to add a Twitch channel to the system.
  • Add Quote – Endpoint allowing users to add quotes for their channel.
  • Get Random Quote – Endpoint returning a random quote from the list of quotes submitted by the channel.
  • Random Quotes Sent to Channel Chat – At specified intervals specific to a particular channel, quotes from that channel’s bank quotes would be randomly sent to the channel’s chat via the rQuote channel.
  • Low Cost – Probably an obvious requirement, but this needs to be done as cheaply as possible while providing a great service.

At this point, I have the above-listed requirements satisfied. There is still work to be done like the ability to ate or remove a quote, an actual way to sign up for the service, and a user interface for managing all this.

Technologies and Services

Let’s take a quick look at what technologies and services I used to develop the tool.

Azure Functions

Ever since I first saw Azure Functions, I have been looking for ways to implement them into different solutions. From the Azure documentation:

Azure Functions is a serverless compute service that lets you run event-triggered code without having to explicitly provision or manage infrastructure. Its “compute on-demand” capabilities enable lightweight integration and one-off supporting functionality. You can also use it to provide web APIs, respond to database changes, process IoT data streams, and manage message queues.

I am using Azure Functions because it provide a way to have small, concise, event-driven methods to handle the needs of rQuote. In particular, I am using HTTP triggered Azure Functions to handle the CRUD operations and a timer-triggered Azure Function to handle the sending random quotes to a channel’s chat at specified intervals.

Another benefit of using Azure Functions is its ability to bind to different elements for input and output. This help reduced some of the plumbing code for such tasks as storing and retrieving data from Azure Storage.

Azure Storage

Storage is one of the older services within the Azure portfolio. But even as simple as it is, it is still an amazing piece of technology that makes storing data quick, easy, and cheap. Azure Storage includes many different types of storage; but for rQuote, we are using blob and table storage.

TwitchLib

TwitchLib is a community-supported project to allow C# code to easily interact with the Twitch APIs. We barely scratch the surface as to what this set of libraries can do, but it is indispensable as the sending of Twitch chat messages is so easy because of the library.

You can get complete details about TwitchLib by going to TwitchLib (github.com).

Azure Storage Setup

Before you try to use any of the code we are about to walkthrough, we need to perform the following steps in Azure:

  • Create an Azure Storage account (or use an existing one if you prefer)
  • Create a Blob container where we will store the maximum quote identifiers for each channel – this is done to implement an auto-incremental identifier for new quotes
  • Add a Table for storing the channels
  • Add a Table for storing the quotes

Note that all of this could be done using the Azure Storage emulator if you are just wanting to work on everything on your machine.

Code Walkthrough

Note: All of the code is available at on GitHub.

When you open the rQuote solution, you will see there are two projects – a class library and an Azure Functions app. I separated the entities used by rQuote as in the future I plan on having a web UI that will also need those elements. We are going to focus on the Azure Functions app project.

Settings

There are several settings that we need throughout this Azure Function app. Here is the contents of my local.settings.json file. Obviously, you will need to add these settings in the Azure Function app when deploying to Azure.

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "TableStorageKey": "<Insert Your Azure Storage primary key>",
    "TableStorageUrl": "<Insert the URL for your Azure Storage account>",
    "QuoteTableName": "Quotes",
    "ChannelTableName": "Channels",
    "AccountName": "<Insert the name of the Azure Storage account>",
    "AccountKey": "<Insert the account key for the Azure Storage account>",
    "BlobConnectionString": "<Insert the Blob connection string>",
    "TwitchChannelName": "rQuote",
    "TwitchAccessToken": "<Insert your Twitch access token>"
  }
}
  • AzureWebJobStorage: This is used for local development of Azure Functions and should be there by default
  • FUNCTIONS_WORKER_RUNTIME: Also used by the Azure Function engine and should be there by default
  • TableStorageKey: This is the connection string for the Azure Storage account where the tables are located
  • TableStorageUrl: The URL for the Azure Storage Table account
  • QuoteTableName: The name of the Azure Storage Table where the quotes are stored
  • ChannelTableName: The name of the Azure Storage Table where the channels are stored
  • AccountName: The name of the Azure Storage account used by rQuote
  • AccountKey: The key for the Azure Storage account used by rQuote
  • BlobConnectionString: The connection string for the Blob container where the quote identifiers are stored
  • TwitchChannelName: Name of the Twitch channel that will be sending the random quotes
  • TwitchAccessToken: The OAuth access token to use when connecting to Twitch (you can go to TwitchTokenGenerator.com to generate your access token)
AddQuote

This is an HTTP-triggered Function that adds the specified channel to the “database” along with the channel settings.

public static class AddChannel
{
	[FunctionName("AddChannel")]
	public static async Task<IActionResultRun(
			[HttpTrigger(AuthorizationLevel.Function, "post", Route = "AddChannel/{channelName}")] HttpRequest request,
			[Blob("quoteids/{channelName}"FileAccess.Write, Connection = "TableStorageKey")] Stream writeQuoteId,
			string channelName,
			ILogger log)
	{
 
		string requestBody = await new StreamReader(request.Body).ReadToEndAsync();
		Channel channel = JsonConvert.DeserializeObject<Channel>(requestBody);
		if (channel == null)
			channel = new Channel()
			{
				ChannelName = channelName
			};
		channel.LastRandomMessage = DateTime.UtcNow;
		Common.GetTableClient(Environment.GetEnvironmentVariable("ChannelTableName")).UpsertEntity(channel.ToChannelTableRow());
 
		writeQuoteId.Write(Encoding.ASCII.GetBytes(JsonConvert.SerializeObject(new QuoteId(channelName))));
 
		return new OkObjectResult(channel);
 
	}
 
}

Noticed that I have taken away the default get HTTP command from the allowed commands. This is done to be consistent with REST protocols – since we are adding a record to a datastore, the user will perform a post. I have also customized the Route in order to define how the URL route will look like and to allow the channel name to be supplied within the endpoint URL.

Below the HttpTrigger you will see a Blob output binding. We are storing the value representing the largest identifier value for the channel’s quotes so that we can “auto-increment” when another quote is added. This binding makes the creation of the blob very simple. Also, note that we are using the channelName parameter from the route in the binding (this is the primary reason for customizing the route).

The code itself is pretty straighforward:

  • Retrieve the channel input (channel name and message frequency) from the request body
  • Save the channel to the Azure Storage channel’s Table
  • Create the QuoteId blob seeded with a maximum identifier value of 0
  • Return a 200 response message along with resulting channel details as confirmation
AddQuote

Here is another HTTP-triggered Azure Function, this time for adding quotes to the channel’s bank of quotes. Let’s take a look at what it is doing:

public static class AddQuote
{
	[FunctionName("AddQuote")]
	public static async Task<IActionResultRunAsync(
			[HttpTrigger(AuthorizationLevel.Function, "post", Route = "AddQuote/{channelName}")] HttpRequest request,
			[Blob("quoteids/{channelName}"FileAccess.Read, Connection = "TableStorageKey")] Stream readQuoteId,
			[Blob("quoteids/{channelName}"FileAccess.Write, Connection = "TableStorageKey")] Stream writeQuoteId,
			string channelName,
			ILogger log)
	{
		QuoteTableRow quoteTableRow = null;
		string requestBody = await new StreamReader(request.Body).ReadToEndAsync();
		Quote input = JsonConvert.DeserializeObject<Quote>(requestBody);
		if (input == nullthrow new ArgumentNullException();
 
		QuoteId quoteId = JsonConvert.DeserializeObject<QuoteId>(await new StreamReader(readQuoteId).ReadToEndAsync());
		if (quoteId == nullquoteId = new QuoteId(channelName);
		quoteId.MaxId = ++quoteId.MaxId;
 
		quoteTableRow = new QuoteTableRow()
		{
			PartitionKey = channelName,
			RowKey = quoteId.MaxId.ToString(),
			Text = input.Text,
			Author = input.Author
		};
 
		Common.GetTableClient(Environment.GetEnvironmentVariable("QuoteTableName")).UpsertEntity(quoteTableRow);
 
		writeQuoteId.Write(Encoding.ASCII.GetBytes(JsonConvert.SerializeObject(quoteId)));
 
		return new OkObjectResult(new Quote(quoteTableRow));
 
	}
 
}

Just like with AddChannel, we are only allowing POST HTTP requests and have customized the route.

This time we are defining both a Blob input and output binding. This allows us to retrieve that maximum identifier value and then set the new maximum identifier.

Then in the method body, we are performing the following tasks:

  • Retrieving the quote input from the HTTP request body
  • Reading in the current maximum quote identifier for the channel and incrementing that by one for the quote we are just about to add
  • Convert the inputted quote information into a type used for saving the Azure Storage Table
  • Save the quote in the Azure Storage Table account
  • Save the new maximum identifier
  • Return a 200 response with the resulting quote included in the response body
GetRandomQuote

I am not using this Function at this point, but at worst case it served as a way to determine how to get random quotes for the timer Function will talk about next.

public static class GetRandomQuote
{
	[FunctionName("GetRandomQuote")]
	public static async Task<IActionResultRun(
			[HttpTrigger(AuthorizationLevel.Function, "get", Route = "GetRandom/{channelName}")] HttpRequest request,
			[Blob("quoteids/{channelName}"FileAccess.Read, Connection = "TableStorageKey")] Stream readQuoteId,
			string channelName,
			ILogger log)
	{
 
		QuoteId quoteId = JsonConvert.DeserializeObject<QuoteId>(await new StreamReader(readQuoteId).ReadToEndAsync());
		int randomId = new Random().Next(1quoteId.MaxId);
 
		return new OkObjectResult(new Quote(Common.GetQuote(channelNamerandomId)));
 
	}
 
}

Not much different here: we have customized the HttpTrigger attribute for a custom route and (this time) to only allow GET HTTP requests. Following that, we have included a Blob input binding so can the maximum quote identifier.

In this method, here is what we are doing:

  • Retrieve the maximum identifier for the channel’s quotes
  • Get a random value between 1 and the maximum identifier
  • Return the random quote from the Table storage (see the break down of the Common class below)
RandomQuoteTimer

Alright, here is where all the fun is. Unlike the other Azure Functions, this is a timer-triggered Function. Every minute it fires and looks for the channels due to have a random quote sent to their chat.

public static class RandomQuoteTimer
{
 
	[FunctionName("RandomQuoteTimer")]
	public static async Task RunAsync([TimerTrigger("0 */1 * * * *")] TimerInfo myTimerILogger log)
	{
 
		List<ChannelTableRowchannels = Common.GetTableClient(Environment.GetEnvironmentVariable("ChannelTableName")).Query<ChannelTableRow>(s => s.PartitionKey == "rQuote").ToList();
		foreach (ChannelTableRow channel in channels)
		{
			if (DateTime.UtcNow >= channel.LastRandomMessage.AddMinutes(channel.MessageFrequency))
			{
				await SendRandomQuoteAsync(channel.RowKey, log);
				channel.LastRandomMessage = DateTime.UtcNow;
				Common.GetTableClient(Environment.GetEnvironmentVariable("ChannelTableName")).UpsertEntity(channel);
			}
		}
 
	}
 
	private static async Task SendRandomQuoteAsync(string channelNameILogger log)
	{
		TwitchBot twitchBot = new TwitchBot(channelName);
		QuoteTableRow quoteTableRow = await Common.GetRandomQuoteAsync(channelName);
		string message = $"\"{quoteTableRow.Text}\" — {quoteTableRow.Author}";
		twitchBot.SendMessage(message);
		log.LogInformation($"Quote sent to {channelName}{message}");
 
	}
 
}

In the Function itself, we are getting the list of managed channels and enumerating through each of those. If it is time to send a random quote, we call the SendRandomQuoteAsync method.

But the real tricky work is done in the in the TwitchClient class.

public class TwitchBot
{
 
	private TwitchClient _twitchClient = new TwitchClient();
	private string _destinationChannel;
 
	public TwitchBot(string destinationChannel)
	{
		_destinationChannel = destinationChannel;
		ConnectionCredentials credentials = new ConnectionCredentials(Environment.GetEnvironmentVariable("TwitchChannelName"), Environment.GetEnvironmentVariable("TwitchAccessToken"));
		_twitchClient.OnMessageSent += TwitchClient_OnMessageSent;
		_twitchClient.Initialize(credentials, _destinationChannel);
		_twitchClient.Connect();
	}
 
	public void SendMessage(string message)
	{
		bool sendMessage = true;
		Stopwatch stopwatch = Stopwatch.StartNew();
		// Need to wait for the connection to be made; waiting for up to 5 seconds for that to happen
		while (!_twitchClient.IsConnected && sendMessage)
			if (stopwatch.ElapsedMilliseconds >= 5000sendMessage = false;
		if (sendMessage) _twitchClient.SendMessage(_destinationChannel, message);
	}
 
	private void TwitchClient_OnMessageSent(object senderOnMessageSentArgs e)
	{
		if (_twitchClient.IsConnected) _twitchClient.Disconnect();
	}
 
}

Couple of important things to note in the TwitchBot class:

  • When the type is constructed, we are starting the connection process
  • When the type consumer calls the SendMessage method, the first thing we do is wait for the TwitchBot to actually connect to the Twitch channel. This could take a moment, so I have a while look checking for connection and allowing up to 5 seconds for the connection to happen.
  • We then send the message (which also could take a second or two)
  • We are listening for the OnMessageSent event and disconnecting from Twitch at that point; trying to do so before the message is sent will stop that process

Here you can see the results, with a random quote being sent to the TaleLearnCode channel.

Common

This class contains three methods used throughout the Azure Functions.

public static TableClient GetTableClient(string tableName)
{
	return new TableClient(
		new Uri(Environment.GetEnvironmentVariable("TableStorageUrl")),
		tableName,
		new TableSharedKeyCredential(
			Environment.GetEnvironmentVariable("AccountName"),
			Environment.GetEnvironmentVariable("AccountKey")));
}

The GetTableClient is to simplify the process of getting the TableClient which is needed for performing actions against the Table account.

public static QuoteTableRow GetQuote(string channelNameint quoteId)
{
	return GetTableClient(Environment.GetEnvironmentVariable("QuoteTableName")).Query<QuoteTableRow>(s => s.PartitionKey == channelName && s.RowKey == quoteId.ToString()).FirstOrDefault();
}

The GetQuote method does exactly what it sounds like, the caller specifies the channel and quote identifier and the method returns the requested quote. This is done by connecting to the Table account and querying against the partition key (the channel name) and the row key (the quote identifier).

public static async Task<QuoteTableRowGetRandomQuoteAsync(string channelName)
{
 
	BlobContainerClient container = new BlobContainerClient(Environment.GetEnvironmentVariable("BlobConnectionString"), "quoteids");
	BlobClient blob = container.GetBlobClient(channelName);
	BlobDownloadInfo download = blob.Download();
	Stream stream = new MemoryStream();
	QuoteId quoteId;
	using (MemoryStream memoryStream = new MemoryStream())
	{
		download.Content.CopyTo(memoryStream);
		memoryStream.Position = 0;
		quoteId = JsonConvert.DeserializeObject<QuoteId>(await new StreamReader(memoryStream).ReadToEndAsync());
	}
 
	int randomId = new Random().Next(1quoteId.MaxId);
	return GetQuote(channelNamerandomId);
}

Finally we have the GetRandomQuoteAsync method which is responsible for getting a random quote for the specified channel. To do this, the following happens:

  • Connect to the blob container where we store the maximum quote identifiers for each channel
  • Read the blob to get that maximum identifier (note the memoryStream.Position = 0 — that took a while to figure out why we not able to read anything)
  • Get a random identifier between the start and end of the valid quote identifiers for the channel
  • Get and return the quote at that random identifier

Conclusion

All in all, it wasn’t that hard to get this up and running; but like so many software projects, this is just the start. In the near future I would like to consolidate some more of the code and implement features like updating/removing quotes. I’ll be sure to do more posts about those changes.

Be sure to check out rQuote in action by tuning into the TaleLearnCode stream on Monday, Wednesday, and Friday evenings starting at 8:00 pm Eastern or the BricksWithChad stream on Saturdays at 9:00 pm Eastern.

Leave a Reply

Your email address will not be published. Required fields are marked *