Craig Jones

Hi there 👋

My name is Craig. I am a Software Engineer based in the NW of England. This blog will be a dump of my learnings. Expect it to be littered with mainly Tech articles but certainly not limited to that!

tech

Creating a bot using the Bluesky API

Bluesky Logo
Bluesky Logo

What is Bluesky

Bluesky is a new, decentralized social app focused on giving users more control over their online experience. Started by Twitter’s ex-CEO Jack Dorsey, Bluesky is built on the AT Protocol, which lets people move between platforms without losing their followers or data. The goal? A more open, customizable social network where you control your feed and data instead of being at the mercy of big tech.

Introduction

Having recently started using Bluesky, I was interested in how good the API is. It turns out its extremely straightforward. So I decided to build a bot and write a short post on it. The project will be a bot which sends a daily skeet (Bluesky tweet 😂) which lists which horse racing meetings are on (in UK + IRE), along with their first race time.

Prerequisites

To successfully build this bot, you will need 2 things:

Getting Started

So firstly we need to set up a base Node project. Create an empty folder and add an index.ts. Then run an npm init to generate a package.json. There are not many dependencies, so you can install the packages individually or copy/pasta this in, tweak it and run an npm i.

{
  "name": "bluesky-daily-horse-racing-bot",
  "version": "1.0.0",
  "main": "index.js",
  "author": "Craig Jones",
  "dependencies": {
    "@atproto/api": "^0.13.14",
    "@types/lodash": "^4.17.13",
    "axios": "^1.7.7",
    "cron": "^3.1.8",
    "dayjs": "^1.11.13",
    "dotenv": "^16.4.5",
    "lodash": "^4.17.21",
    "process": "^0.11.10"
  }
}

You will also need typescript and ts-node. These can be installed locally or globally. I opted for the global option based on the Bluesky documentation.

npm i -g typescript ts-node

Next we will need to add a tsconfig.json. This can be generated using tsc --init or creating it manually. The contents of this file should look as follows:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "NodeNext",
    "moduleResolution": "nodenext",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noImplicitAny": false,
    "skipLibCheck": true
  },
  "ts-node": {
    "esm": true
  }
}

The final thing we need to do is generate a .env file with our Bluesky credentials and our RapidApi key. It should look like this…

BLUESKY_USERNAME=**********
BLUESKY_PASSWORD=**********
RAPID_API_KEY=**********

That is most the basic setup out the way. We are now ready to start getting the skeet sent.

Setting up the AtpAgent

So next step is to start populating our index.ts. I worked from an example on the Bluesky docs. Its really straightforward.

import { BskyAgent } from '@atproto/api';
import * as dotenv from 'dotenv';
import * as process from 'process';

dotenv.config();

// Create a Bluesky Agent
const agent = new BskyAgent({
  service: 'https://bsky.social'
});

async function sendSkeet() {
  await agent.login({ identifier: process.env.BLUESKY_USERNAME!, password: process.env.BLUESKY_PASSWORD! });
  await agent.post({
    text: '🙂'
  });
  console.log('Skeet Sent!');
}

sendSkeet();

This is the minimum code reqired to send a skeet. Now if you compile your code by running npx tsc, you should see that an index.js is created. Now if you run the following command…

node index.js

You will hopefully see the console log “Skeet Sent!” and if you check your Bluesky timeline, you will hopefully see a skeet containing ”🙂“.

Adding the actual Skeet Content

So in hardly any time, we have got the skeet sending programmatically. So now we just need to build out the actual content. So what I wanted was a list of Horse Racing meetings for the current day, ordered by start time of the first race. So it would look something like this…

Meeting C 13:45 Meeting A 14:00 Meeting D 14:10 Meeting B 17:15

I am not going to go into too much detail on how I achieved this, more just higher level about getting data from the API and parsing it into my desired format.

First step is to get the data from the API…

// Get Daily Racecards
const { data } = await axios
  .get<Race[]>('https://horse-racing.p.rapidapi.com/racecards', {
    params: { date: getTodaysDate() },
    headers: {
      'x-rapidapi-key': process.env.RAPID_API_KEY,
      'x-rapidapi-host': 'horse-racing.p.rapidapi.com'
    }
  })
  .catch((error) => {
    console.error(error);
    console.log(`Post Failed with Error: ${error}`);
    return { data: [] as Race[] };
  });

// Get Skeet Text
const skeetText = getMeetingsWithFirstRaceTime(data);

// Send Skeet with text
agent
  .post({
    text: skeetText
  })
  .then(() => console.log('Skeet Sent'))
  .catch((err) => console.error(err));

The above code code returns an array of the days races. getTodaysDate() is simply a util which returns the date in the format required by the API - YYYY-MM-DD.

I then created a util called getMeetingsWithFirstRaceTime. This does all the heavy lifting in terms of parsing, sorting and spitting out the final text which I need for the Skeet. That file looks like this…

import { Race } from '../types.js';
import _ from 'lodash';
import dayjs from 'dayjs';

export const getMeetingsWithFirstRaceTime = (races: Race[]) => {
  return Object.values(_.groupBy(races, 'course'))
    .sort((a, b) => dayjs(a[0].date).valueOf() - dayjs(b[0].date).valueOf())
    .map(([firstRace]) => {
      const date = dayjs(firstRace.date);
      return `🐎 ${firstRace.course} - ${date.format('HH:mm')}`;
    })
    .join('\r\n');
};

Then thats it really. Run npx tsc again to recompile the code, then node index.js and providing everything has been done correctly, you should see the “Skeet Sent” console log, and when checking your timeline, you should see the skeet as follows…

Next steps

So now we have it sending the correct text via the API, we just need to set it up to do this at a given time every day. 9am feels as good a time as any. So we can again refer to the the Bluesky docs as their example uses a cron job to handle regular posts.

So in the index.ts we need the following code at the bottom…

// Run this on a cron job
const scheduleExpression = '0 9 * * *'; // Run at 9am every day

const job = new CronJob(scheduleExpression, sendSkeet);

job.start();

This will execute the sendSkeet function at 9am everyday when the script is running.

And that is that really. You can change the cron schedule expression to something like every minute to test that aspect of it is working.

Hosting

I guess the final piece of the puzzle is where to host the bot. There are several options which have free tiers or small fee hosting. Vultr, DigitalOcean, Linode, Oracle Cloud, Google Cloud Platform, AWS and probably many more. I haven’t decided on where to do this yet. I will have a play around and try and get it up and running for free and update the post when this is done.

Conclusion

In just a few steps, we’ve built a bot that automatically posts daily horse racing schedules to Bluesky! This project demonstrates how simple it is to interact with Bluesky’s API and bring in external data through a third-party API like RapidAPI. By setting up a daily cron job, we automated the process so the bot posts reliably at 9am every day, delivering timely information without any manual intervention.

This project not only highlights Bluesky’s versatility but also the ease of setting up automated social bots with minimal code. Hopefully, this guide provides a solid starting point for anyone looking to build similar automation on Bluesky! Feel free to experiment with the bot and customize it to post other types of data or messages.

Repository

https://github.com/Jonesatron/horse-racing-bluesky-bot