HomeBlog
todo

Building a Super Simple (and Free!) Twitter Bot

After the changes to the Twitter API Policy, some things are different than they were when I wrote this article. The code still works the same way, but with free API access severely limited you'll need to pay for a developer account to run a bot with a substantial volume of Tweets.

Recently, I had an idea for a Twitter bot account I wanted to make. There are a handful of HTTP status codes I still don't have memorized, and I figured I'd create a fun way to casually learn the remainders. The idea I settled on is a bot account on Twitter that tweets each HTTP status code as it corresponds to the current time. For example, if the time is 4:00, it tweets the 400 status code, a description, and a link to the spec. My book quote clock somewhat inspired this idea since I like making things that use the current time uniquely.

You can check out the finished product here: @whatstatusisit. Below I've embedded an example of a Tweet:

Tweet embeds no longer available

This was my first Twitter bot, so I had to learn a bit about the API. After that, I needed a place to host it. I have a handful of Raspberry Pi computers at home so I was able to easily add the program to one of those. I'll get more into that down below, but I will say it was nice to not have to worry about hosting this anywhere, and really sped up the process.

One final note, I built this bot with NodeJS, and it was pretty hastily thrown together. Normally for something like this I'd write in Typescript but this was simple enough I didn't take the time.

Authenticating With Twitter

First things first, I created my bot's Twitter account and signed up for the developer program, to get API access to my new account. You can join at developer.twitter.com. You're then able to generate consumer keys and authentication tokens to grant the bot access to your account.

To manage credentials and make API calls, I used an npm package called twitter-api-v2. This made all the under-the-hood Twitter logic super simple. I simply had to generate my credentials and create a new client:

const userClient = new TwitterApi({
  accessToken: process.env.ACCESS_TOKEN,
  accessSecret: process.env.ACCESS_SECRET,
  appKey: process.env.TWITTER_API_KEY,
  appSecret: process.env.TWITTER_API_SECRET,
});
javascript

The credentials are stored in a .env file and I used the package dotenv to import them.

Posting a Tweet

Once the API is authenticated, posting the tweet is a piece of cake:

await userClient.v2.tweet('Hello, world!');
javascript

Run on an Interval

For my particular bot, I want it to tweet any time the current hour and minute matches an HTTP status code. The simplest approach is to just run the script every 60 seconds and check the time. This was pretty straightforward: First, the script runs, and then it kicks off an interval using setInterval that runs again every 60 seconds. Here's how that all looks together:

require('dotenv').config();
const { TwitterApi } = require('twitter-api-v2');

(() => {
  const func = async () => {
    const userClient = new TwitterApi({
      accessToken: process.env.ACCESS_TOKEN,
      accessSecret: process.env.ACCESS_SECRET,
      appKey: process.env.TWITTER_API_KEY,
      appSecret: process.env.TWITTER_API_SECRET,
    });

    await userClient.v2.tweet('Hello, world!');
  };

  func();
  setInterval(func, 60000);
})();
javascript

As it stands, this program would tweet "Hello, world!" every 60 seconds (although Twitter won't allow the same text to be repeated in rapid succession like that).

This is the part where you'd need to decide on what your bot does! There are all sorts of interesting bots, like ones that track high-profile jets. You could make some API calls to fetch data, or pull from local information on your machine, like the current time in my case.

To generate the data for my bot, all I need is the local time and a little bit of manipulation:

const def = new Date()
  .toLocaleTimeString('default', {
    hour: '2-digit',
    minute: '2-digit',
    hour12: true,
  })
  .split(' ')[0];

const splits = def.split(':');
let hours = splits[0];
const minutes = splits[1];

/*
        01:10 -> 110
        11:30 -> 1130 (these won't match, intentionally)
        10:30 -> 1030 (these won't match, intentionally)
        01:30 -> 130
        01:00 -> 100
    */
if (hours[0] === '0') {
  hours = hours[1];
}

const code = `${hours}${minutes}`;
javascript

Simple enough! Now I have a code that corresponds to the current time. If the code generated matches a valid status code, a tweet should go out. I don't want my bot to be too dry and boring, so I have a bit of logic to generate a fun message that isn't always the same minute after minute.

The format I settled on for the tweets is: "<random interjection> <short phrase> <code> <description> Read more: <link>." Since tweets can only be 280 characters, the description will be truncated with an ellipsis if the message becomes too long. That logic looks something like this:

const phrases = [
  ', the current status is',
  "! That's a",
  '! The status is',
  ", now it's",
];
const randomInteger = (min, max) =>
  Math.floor(Math.random() * (max - min + 1)) + min;

// Pick a random phrase
const phraseIndex = randomInteger(0, phrases.length - 1);
const startingPhrase = phrases[phraseIndex];

// Pick a random interjection, using fakerjs
const interjection = faker.word.interjection();

const readMoreText = 'Read more:';
const link = codeData.spec_href;
const code = codeData.code;
const message = codeData.message;
const description = codeData.description;

const lengthWithoutDescription =
  interjection.length +
  startingPhrase.length +
  1 +
  code.length +
  2 +
  message.length +
  3 +
  2 +
  readMoreText.length +
  1 +
  link.length;
const remainingCharacters = 280 - lengthWithoutDescription;

let sliceEnd = -1;
if (remainingCharacters < description.length) {
  const newRemaining = remaining - 3; // account for the ellipsis
  sliceEnd = newRemaining;
}

const str = `${interjection}${startingPhrase} ${code}: ${message}! (${
  sliceEnd >= 0 ? description.slice(0, sliceEnd) : description
}${sliceEnd < 0 ? '' : '...'}) ${readMoreText} ${link}`;
javascript

At the end of this, I have a string that will fit in a tweet, that includes all the parts of the message I want! All that's left to do is tweet it as we did above with the "Hello, world."

await userClient.v2.tweet(str);
javascript

Hosting the Bot

As I mentioned at the start, this is not hosted on any public server, because it doesn't need to be! I have a handful of Raspberry Pi's that handle various tasks in my house, and there were plenty of available resources to run a really simple script like this.

I used a program called pm2 to manage the process and make sure my bot survives reboots or power losses, as I do occasionally reboot the system and didn't want to need to restart the service every time. This is a handy and powerful tool that I use extensively in my home network. To use pm2, you simply install it globally using npm: `npm install pm2 -g and then start the program using pm2 the same way you would with node:

pm2 start index.js
bash

This will start the service, and restart it if it crashes, but won't preserve it across reboots. To save the state of your running applications, run:

pm2 save
bash

Once the state is saved, it will be restored on system startup, so if you lose power or need to reboot the Raspberry Pi, it will come back up with no problems! You can also check the status of your running program, using pm2 ls.

pi@raspi-vpn:~ $ pm2 ls
    ┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐
    │ id │ name               │ mode     │ ↺    │ status    │ cpu      │ memory   │
    ├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤
    │ 0  │ index              │ fork     │ 0    │ online    │ 0%       │ 127.4mb  │
    └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘
bash

There's also a command to tail the logs, using pm2 logs to see what's going on and debug any potential issues.

Conclusion

My last step was to leverage DALL·E 2 to generate the cover image and avatar for the account, since I didn't want to take the time to make up any custom graphics. I won't get into that, but if you are curious I've written about the system in the past.

This bot was a fun little project to test out the Twitter api, and I like seeing the tweets go by on my timeline twice a day for each status code! Feel free to give it a follow and brush up on your status codes! You can find the entire project open-sourced on my Github, and I've reproduced the core of the code below. Let me know if you create any fun bots of your own!

require('dotenv').config();
const { TwitterApi } = require('twitter-api-v2');
const codes = require('./codes.json');
const { faker } = require('@faker-js/faker');

const phrases = [
  ', the current status is',
  "! That's a",
  '! The status is',
  ", now it's",
];

const randomInteger = (min, max) => {
  return Math.floor(Math.random() * (max - min + 1)) + min;
};

(() => {
  const func = async () => {
    const def = new Date()
      .toLocaleTimeString('default', {
        hour: '2-digit',
        minute: '2-digit',
        hour12: true,
      })
      .split(' ')[0];

    const splits = def.split(':');

    let hours = splits[0];
    const minutes = splits[1];

    /*
    	  	01:10 -> 110
    		11:30 -> 1130 (these won't match, intentionally)
    		10:30 -> 1030 (these won't match, intentionally)
    		01:30 -> 130
    		01:00 -> 100
    	 */
    if (hours[0] === '0') {
      hours = hours.replace('0', '');
    }

    const code = `${hours}${minutes}`;

    const found = codes[code];

    if (found) {
      const userClient = new TwitterApi({
        accessToken: process.env.ACCESS_TOKEN,
        accessSecret: process.env.ACCESS_SECRET,
        appKey: process.env.TWITTER_API_KEY,
        appSecret: process.env.TWITTER_API_SECRET,
      });

      const phraseIndex = randomInteger(0, phrases.length - 1);
      const startingPhrase = phrases[phraseIndex];
      const interjection = faker.word.interjection();
      const readMoreText = 'Read more:';
      const link = found.spec_href;
      const code = found.code;
      const message = found.message;
      const description = found.description;

      const length =
        interjection.length +
        startingPhrase.length +
        1 +
        code.length +
        2 +
        message.length +
        3 +
        2 +
        readMoreText.length +
        1 +
        link.length;

      const remaining = 280 - length;
      let sliceEnd = -1;

      if (remaining < description.length) {
        const newRemaining = remaining - 3; // account for the ellipsis
        sliceEnd = newRemaining;
      }

      const str = `${interjection}${startingPhrase} ${code}: ${message}! (${
        sliceEnd >= 0 ? description.slice(0, sliceEnd) : description
      }${sliceEnd < 0 ? '' : '...'}) ${readMoreText} ${link}`;

      await userClient.v2.tweet(str);
    } else {
      console.log(code, 'no match');
    }
  };
  func();
  setInterval(func, 60000);
})();
javascript