In my previous post, which I'd highly recommend reading before diving into this one, I discussed the process of building my first bot using the Microsoft Bot Framework. The post covered getting everything set up, registered, and deploying the bot out into the wild.
This post will focus on several pieces of functionality that were added to the bot to make it what it is today, namely:
- Determining whose turn it is to pick a restaurant.
- Responding to queries about specific restaurants.
- Talking with third-party APIs.
- Configuring SMS/Text Support
Where were we?
If you last recall, we had just configured the bot to simply list out all of the available restaurants that had been visited, the dates that were visited, and who chose them via the show all
command:
So let's extend this by determining whose turn it is to make the next selection. This is actually an extremely trivial operation, but it does add some useful functionality (as we often lose track of such things).
First, let's make a command to request this from the bot within the Post()
method of the MessageController.cs
file to accept a rough request about that type of question:
if Regex.IsMatch(message, "who's next|who is next|whose (pick|turn) is it", RegexOptions.IgnoreCase))
{
await ReplyWithNextMemberToChoose(activity, connector);
}
So with that addition, the Post()
method now looks as follows:
public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
{
var connector = new ConnectorClient(new Uri(activity.ServiceUrl));
if (activity.Type == ActivityTypes.Message)
{
var message = activity.Text;
if (Regex.IsMatch(message, "show|all|list all", RegexOptions.IgnoreCase))
{
await ReplyWithRestaurantListingAsync(activity, connector);
}
else if Regex.IsMatch(message, "who's next|who is next|whose (pick|turn) is it", RegexOptions.IgnoreCase))
{
await ReplyWithNextMemberToChoose(activity, connector);
}
else
{
await ReplyWithDefaultMessageAsync(activity, connector);
}
}
return Request.CreateResponse(HttpStatusCode.OK);
}
Next, we will want to configure the basic logic to handle this, which just uses a bit of basic math (i.e. number of users modulo current user, etc.) :
private async Task<ResourceResponse> ReplyWithNextMemberToChoose(Activity activity, ConnectorClient connector)
{
try
{
var lastRestaurantVisited = await GetLastVisitedRestaurantAsync();
var members = await GetAllMembers();
var currentMember = Array.IndexOf(members, lastRestaurantVisited?.PickedBy ?? "");
var nextMember = members[(currentMember + 1) % members.Length];
var nextMonth = lastRestaurantVisited?.Date.AddMonths(1) ?? DateTime.Now.AddMonths(1);
var replyMessage = string.Format(Constants.NextChooserFormattingMessage, nextMember, nextMonth.ToString("MMMM"));
var reply = activity.CreateReply(replyMessage);
return await connector.Conversations.ReplyToActivityAsync(reply);
}
catch
{
var reply = activity.CreateReply("I'm not sure who has the next pick. Try again later.");
return await connector.Conversations.ReplyToActivityAsync(reply);
}
}
At that point, we can just ask the bot who has the next pick:
Since we already know who in the group is picking next, let's allow users to ask questions about where the group has previously dined, to ensure we don't repeat any earlier choices. We can do this by just adding another phrase to the bot in the form of "have we been to {restaurant}" within the PostMessage()
method as seen below:
if (Regex.IsMatch(message, "(?<=have we been to )(?<restaurant>[^?]+)", RegexOptions.IgnoreCase))
{
var restaurant = Regex.Match(message, @"(?<=have we been to )(?<restaurant>[^?]+)", RegexOptions.IgnoreCase)?.Groups["restaurant"]?.Value ?? "";
if (!string.IsNullOrWhiteSpace(restaurant))
{
var vistedRestaurants = await _service.GetAllVisitedRestaurantsAsync();
var visitedRestaurant = vistedRestaurants.FirstOrDefault(r => string.Equals(r.Location, restaurant, StringComparison.OrdinalIgnoreCase));
if (visitedRestaurant != null)
{
await ReplyWithVisitedRestaurantAsync(visitedRestaurant, activity, connector);
}
else
{
await ReplyWithUnchosenRestaurantAsync(restaurant, activity, connector);
}
}
else
{
await ReplyWithUnrecognizableRestaurantAsync(activity, connector);
}
}
Pretty basic right? All this does is:
- Checks if the restaurant requested has been previously visited.
- If it has, indicate when it was visited and who chose it.
- If it hasn't, let the user know that.
- Otherwise, let the user know there was an issue recognizing the restaurant.
You can see this demonstrated below:
So now that we know who gets to pick the next restaurant, we need to help them make an informed decision on how to do this, and that's where some external APIs come in.
Yelp! I've fallen and I can't get up!
As an avid foodie and traveler, I use sites like Yelp all of the time, so it's no surprise here that we are going to take advantage of their API in order to get recommendations for where our group should go eat.
There are a few things that we will need in order to be able to call Yelp from our existing bot, which we will need to sign up through Yelp's Developer Program to access, namely:
- A Client ID - used to uniquely identify our requests against the Yelp API and ensure that we aren't doing any funny business
- A Client Secret - used to prove that we are who we say we are when making a request to Yelp
Both of these are pretty standard when working with most third-party APIs, so your mileage may vary if you are using something else.
So after creating your account on Yelp, you'll need to register an application, which is going to be your bot. This is a fairly straight-forward process and generally just requires filling out a few fields, and afterwards, you should see something like this within the portal:
This is mainly all that we are going to need on the Yelp end of things, but next we'll need to set-up our application so that we can actually use this information.
Storing Your Yelp Credentials
Normally, you could simply place your Yelp credentials within a simple configuration file and just read from it at run-time without any issues, and that's a perfectly valid solution in most use cases as seen in the previous post:
<appSettings>
<add key="BotId" value="ThirdThursdayBot" />
<add key="MicrosoftAppId" value="foo" />
<add key="MicrosoftAppPassword" value="bar" />
<add key="YelpClientId" value="******************" />
<add key="YelpClientSecret" value="*************************" />
</appSettings>
However, since I'm going to be posting this on GitHub and I don't want to receive some really nasty e-mails from Yelp about the millions of requests that they are getting from me, we will go another route and take advantage of using environmental variables in Azure.
You can think of them just as you would when working with any other type of settings or configuration files, except, you can access them from the Azure portal and they are not actually stored within your code itself:
Now that we have these in place, let's build a service that we can actually use to go talk to Yelp.
Mapping Yelp Classes
After taking a look at the Yelp API, we will first need a few classes to bind our responses to that will make the code a bit easier to digest:
- YelpBusiness - This represents a business within Yelp (as not everything is a restaurant)
- YelpLocation - This represents an address for a given business, which we will use to provide users with contact information (for making reservations, etc.)
- YelpAuthenticationResponse - This is simply a token that we will need to store so that we don't have to authenticate each time we make a request to the Yelp API.
Each of these can be seen below and map directly to properties that we will be returned from the Yelp API based on their documentation:
public class YelpBusiness
{
[JsonProperty("rating")]
public double Rating { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("image_url")]
public string Image { get; set; }
[JsonProperty("phone")]
public string PhoneNumber { get; set; }
[JsonProperty("location")]
public YelpLocation Location { get; set; }
}
public class YelpLocation
{
[JsonProperty("city")]
public string City { get; set; }
[JsonProperty("address1")]
public string Address { get; set; }
[JsonProperty("state")]
public string State { get; set; }
[JsonProperty("zip_code")]
public string ZipCode { get; set; }
public string FullAddress => $"{Address}, {City}, {State} {ZipCode}";
}
public class YelpAuthenticationResponse
{
[JsonProperty("access_token")]
public string AccessToken { get; set; }
}
At present, we really only need Yelp to do one thing: provide recommendations for where my friends and I should go eat. To do this we'll create a service that handles authenticating with Yelp and making the request that we need.
public class YelpService : IYelpService
{
private const string YelpSearchUrl = "https://api.yelp.com/v3/businesses/search?";
private readonly string _clientId;
private readonly string _clientSecret;
private readonly string _preferredLocation;
private string _authToken;
public YelpService(string clientId, string clientSecret, string preferredLocation = "Lake Charles")
{
_clientId = clientId;
_clientSecret = clientSecret;
_preferredLocation = preferredLocation;
}
/// <summary>
/// Gets a random, unvisited Restauraunt from Yelp's API
/// </summary>
public async Task<YelpBusiness> GetRandomUnvisitedRestaurantAsync(Restaurant[] restaurantsToExclude)
{
try
{
using (var yelpClient = new HttpClient())
{
await EnsureYelpAuthenticationAsync(yelpClient);
if (string.IsNullOrWhiteSpace(_authToken))
{
// Yelp failed to authenticate properly, you should probably check the Client ID and Secret to ensure they are correct
// or you could throw an exception and log it here (i.e. YelpAuthenticationException, etc.)
return null;
}
var response = await GetYelpSearchQueryAsync(yelpClient);
var recommendation = response.Restaurants
.OrderBy(r => Guid.NewGuid())
.First(r => restaurantsToExclude.All(v => !v.Location.Contains(r.Name) && !r.Name.Contains(v.Location)));
return recommendation;
}
}
catch
{
// Something else bad happened when communicating with Yelp; If you like logging, you should probably do that here
return null;
}
}
/// <summary>
/// Ensures that the Yelp API has been authenticated for the current request
/// </summary>
private async Task EnsureYelpAuthenticationAsync(HttpClient yelpClient)
{
if (string.IsNullOrWhiteSpace(_authToken))
{
var authenticationResponse = await yelpClient.PostAsync($"https://api.yelp.com/oauth2/token?client_id={_clientId}&client_secret={_clientSecret}&grant_type=client_credentials", null);
if (authenticationResponse.IsSuccessStatusCode)
{
var authResponse = JsonConvert.DeserializeObject<YelpAuthenticationResponse>(await authenticationResponse.Content.ReadAsStringAsync());
_authToken = authResponse.AccessToken;
}
}
}
/// <summary>
/// Sets the headers and search terms for the Yelp search query
/// </summary>
private async Task<YelpSearchResponse> GetYelpSearchQueryAsync(HttpClient yelpClient)
{
yelpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {_authToken}");
var searchTerms = new[]
{
$"term=food",
$"location={_preferredLocation}",
$"limit=50"
};
var searchRequest = await yelpClient.GetStringAsync($"{YelpSearchUrl}{string.Join("&", searchTerms)}");
return JsonConvert.DeserializeObject<YelpSearchResponse>(searchRequest);
}
}
The real meat of this is contained within the GetRandomUnvisitedRestaurantAsync()
method, which does the following:
- Ensures that the Yelp API can be reached and that we can authenticate against it.
- Grabs 50 "food-related" businesses for our preferred location (configured in application settings)
- Chooses a random one that is not currently found within our list of visited restaurants.
Now in order to actually call this service, we'll spin up instances of the service within the MessagesController
:
[BotAuthentication]
public class MessagesController : ApiController
{
private readonly IFirebaseService _service;
private readonly IYelpService _yelpService;
public MessagesController()
{
_service = new FirebaseService(Environment.GetEnvironmentVariable("DatabaseEndpoint"));
_yelpService = new YelpService(
Environment.GetEnvironmentVariable("YelpClientId"),
Environment.GetEnvironmentVariable("YelpClientSecret"),
Environment.GetEnvironmentVariable("YelpPreferredLocation")
);
}
// Omitted for brevity
}
The Environment.GetEnvironmentalVariable()
method can read the settings we previously configured in Azure earlier and ensure that your services get configured properly. Once this is set up, you should be able to spin up the application and figure out where to go eat next month:
Extending Beyond the Web
While having an online bot on a web page can be useful, the real power of the Microsoft Bot Framework comes in the form of extensibility.
By visiting the Channels area of the Bot Framework, you can see all of the previously created extension points that you can leverage with your new bot:
Since this is a bot that I'd like to be able to potentially share with non tech-savvy folks, I think that this would be a perfect use-case for integrating Twilio, so that my friends could simply "text" the bot.
Configuring Twilio
For those that are unfamiliar, Twilio has basically been the gold-standard as far as sending messages as a service and they have made the process extremely easy to do and the best part is: it's free*.
If you haven't skipped to the following section, then I'll assume that you want to follow along and make your bot "textable". If so, then go visit Twilio's site to get started.
After registering, we are going to need the following information in order to effectively route SMS messages to/from our bot:
- A Phone Number - Twilio will allow you to create a new number that can be used as an endpoint to send and receive text messages, which will be routed to your bot.
- Account ID - Very similar to the previous Yelp API example, we need a unique identifier for our account.
- Authentication Token - See above example with Yelp API, but this basically lets Twilio know that we are who we say we are.
Let's start with the first part: a phone number. Twilio thankfully will provide you with a single phone number to use for your account for free:
NOTE: Although Twilio will provide free phone numbers for their services, it's worth noting that every text message sent using them will be prepended with "This was sent from a Twilio Trial Account", so you'll actually have to bite the bullet if you don't want this.
One you have a phone number, you'll need a Twilio "App" to associate it to as well. You can easily create one of these through Twilio's web interface under the Phone Numbers > Tools > TwiMLApps section:
Since this will just be used for routing text messages, you'll need to ensure that you set the Request URL to use the Bot Framework API address of "https://sms.botframework.com/api/sms", which will allow Twilio to talk with your bot as expected.
Now, you'll just need to associate your newly created "App" with the phone number that you previously created, which can be done within the configuration section of the Phone Number. You'll just need to ensure that the Messaging section of the number is configured to point to your new TwiML App:
At this point, you should be set. You have all of the information that you need from Twilio, and your actual application doesn't need to know about it (as the Bot Framework handles all of that for you). So let's finishing wiring up the final pieces.
Food Recommendation via Text
For the last section, we will just need to revisit the Bot Framework portal and go wire up the Twilio channel to begin using it. You'll just need to fill out a few fields, which are readily present from the Twilio portal as seen below:
At this point, you should now be able to pull out your phone, enter in the phone number for the bot, and interact with it, just as you would from any other interface:
Now, just go share this number among all of your friends that you want to eat dinner within let the machines decide where you'll be dining from now on.
Make It Your Own
Now obviously my city and the names of my friends are going to vary wildly from you own, but there's no reason that you can't have your own Third Thursday as well!
Which is why I'm going to publish all of this code (and any other related code) out onto GitHub so that you can play around and do whatever you see fit with it. Keep it the same, make it different, do whatever you like!