Coding a spicy Twitter bot

This blog definitely wasn’t supposed to be about this, but I got side-tracked and kept on coding, so I let it happen.

Today — whilst I should have been working on a different project — I got a little bit carried away messing around with the Twitter api. As I was trying different requests using their handy postman collection I came up with a funny twitter bot idea. One that might cause a bit of a sh*t storm on the site, so I coded it.

The idea is a bot that tracks the celebrities that other celebrities are following. If — for some reason or another — one of these celebrities decides to perform an unfollow on the other. My handy bot will do the honour of @ing both the victim and the perpetrator to inform them of the occurrence.

Meet Spicy Unfollows, note — at the time of writing this — the only unfollow it’s detected is of me unfollowing for testing purposes:

Followed swiftly with a reply 😂:

How does it work?

I created an Azure Timer Trigger to execute every 16 minutes.

  [FunctionName("FindSpicyUnfollows")]
        public async Task RunAsync([TimerTrigger("0 */16 * * * *")] TimerInfo myTimer, ILogger log)
        {

For the endpoints I’m using Twitter throttles requests in 15 minute chunks, hence the 16 minute delay.

I then fetch the celebrities I’m tracking from table storage, I simply add their username and name then the bot figures out the rest:

    public class Celeb
    {
        [RowKey]
        public string Id { get; set; }
        public string Name { get; set; }
        public string Username { get; set; }
        public string TwitterId { get; set; }
        public IEnumerable<string> Following { get; set; }
    }

Given you’ve got the username of a Twitter account, you can make use of the User by Username endpoint:

GET: https://api.twitter.com/2/users/by/username/:username

public async Task <TwitterUser> GetUserByUsername(string username) {
    var client = new HttpClient();
    client.DefaultRequestHeaders.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("TWITTER_BEARER"));
    var response = await client.GetAsync($"https://api.twitter.com/2/users/by/username/{username}");
    var data = await response.Content.ReadAsStringAsync();
    var twitterUser = JsonConvert.DeserializeObject<TwitterUser> (data);
    return twitterUser;
}

This gives you the following response:

using Newtonsoft.Json;

namespace Codeheir.Spicy.TwitterSdk
{
    public class Data
    {
        [JsonProperty("id")]
        public string Id { get; set; }

        [JsonProperty("name")]
        public string Name { get; set; }

        [JsonProperty("username")]
        public string Username { get; set; }
    }

    public class TwitterUser
    {
        [JsonProperty("data")]
        public Data Data { get; set; }
    }
}

Which provides us with the id, so we’re good to go.

Tip: make use of https://json2csharp.com/ to convert JSON to C# models, whenever I’m consuming an API my approach tends to be the following:

  1. Make the request in Postman
  2. Copy the response of the request
  3. Paste the response into json2csharp
  4. Enable the setting “Use Pascal Case”
  5. Copy the model into code

Next, now we’ve got the ids for all the celebs. If it’s the first time running the bot for a given celebrity then there’s nothing to compare against, so just persist the data.

var celebs = await celebRepo.GetAll("1");

foreach(var celeb in celebs) {
    if (string.IsNullOrEmpty(celeb.TwitterId)) {
        var twitterUser = await twitterService.GetUserByUsername(celeb.Username);
        celeb.TwitterId = twitterUser.Data.Id;
    }

    var currentlyFollowing = await GetCurrentlyFollowing(celeb.TwitterId);
    await CheckIfAnyUnfollows(celeb, currentlyFollowing);
    await UpdateCeleb(celeb, currentlyFollowing);
}

Getting the people a user follows:

GET https://api.twitter.com/2/users/:userId/following

This returns a list of users along with some metadata, telling us whether there’s any more to request.

You can pass a param to determine the size of the batch, I used 1000 as it’s the max allowed — in an attempt to minimise the number of requests I’m making.

GET https://api.twitter.com/2/users/:userId/following?max_results=1000

If a user follows over 1000 people then the response will contain next_token, which you simply append in your next request like so:

GET https://api.twitter.com/2/users/:userId/following?max_results=1000&pagination_token=:next_token

Which in code looks something like this:

public async Task<IEnumerable<Followee>> GetPeopleIFollow(string id) {
  var client = new HttpClient();
  client.DefaultRequestHeaders.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("TWITTER_BEARER"));

  var response = await client.GetAsync($ "https://api.twitter.com/2/users/{id}/following?max_results=1000");
  var data = await response.Content.ReadAsStringAsync();

  var followees = new List<Followee>();

  var following = JsonConvert.DeserializeObject<Following> (data);

  followees.AddRange(following.Data);

  while (!string.IsNullOrEmpty(following.Meta.NextToken)) {
    var newResponse = await client.GetAsync($ "https://api.twitter.com/2/users/{id}/following?max_results=1000&pagination_token={following.Meta.NextToken}");
    var newData = await newResponse.Content.ReadAsStringAsync();
    following = JsonConvert.DeserializeObject<Following> (newData);
    followees.AddRange(following.Data);
  }
  return followees;
}

Now we’ve got this information, I map it to a list of strings just containing the ids of the people a celebrity follows, and perform a comparison against the existing data. If a user from the existing list is not in the new list, they’ve been unfollowed.

   private async Task CheckIfAnyUnfollows(Celeb celeb, IEnumerable<string> currentlyFollowing) {
      if (celeb.Following != null) {
         foreach(var wasFollowing in celeb.Following) {
            if (!currentlyFollowing.Contains(wasFollowing)) {
               var userUnfollowed = await twitterService.GetUser(wasFollowing);

               var output = $ "{celeb.Name} (@{celeb.Username}) just unfollowed {userUnfollowed.Data.Name} (@{userUnfollowed.Data.Username}) 🌶🌶";
               await twitterService.Tweet(output);
            }
         }
      }
   }

And if an unfollow has happened, send a tweet!

For all requests used thus far I’ve utilised a bearer token for the auth, but for sending a tweet oauth 1.0 is required, hence the different setup here:

 public async Task Tweet(string message) {
    var consumerKey = Environment.GetEnvironmentVariable("TWITTER_API_KEY");
    var consumerSecret = Environment.GetEnvironmentVariable("TWITTER_API_KEY_SECRET");
    var accessToken = Environment.GetEnvironmentVariable("TWITTER_ACCESS_TOKEN");
    var accessTokenSecret = Environment.GetEnvironmentVariable("TWITTER_ACCESS_TOKEN_SECRET");

    var client = new HttpClient();
    OAuthHeaderGenerator oAuthHeaderGenerator = new OAuthHeaderGenerator(consumerKey, consumerSecret, accessToken, accessTokenSecret);

    var url = "https://api.twitter.com/2/tweets";
    var oAuth = oAuthHeaderGenerator.GenerateOAuthHeader("POST", url);
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", oAuth);

    var tweet = new Tweet(message);
    var json = JsonConvert.SerializeObject(tweet);
    var data = new StringContent(json, Encoding.UTF8, "application/json");

    await client.PostAsync(url, data);
 }

If you’re interested in OAuth, then you’d like PIN-based Oauth!

So there you have it, I now have a bot that will cause some sh*t for me 😂

It’s currently deployed on my Azure subscription at https://spicyapp.azurewebsites.net, but because it’s a pay-as-you-go subscription, I doubt I’ll have it up for more than a week, unless It really starts kicking off.

If you liked this blog then please sign up for my newsletter and join an awesome community!!

Leave a Reply