I thought I’d do a quick guide on how I made the Townsusa reply bot for BlueSky. With Twitter experiencing its current round of exits, I got back on BlueSky and started dusting off some old code. I had a version of this working awhile ago using Dart, which I really didn’t understand, and it didn’t work very well. This new version uses NodeJS with atproto and jetstream, which is a package designed for consuming the bsky Firehose. To see the completed repo, checkout my Github
This also uses the Google Places API, which is a bit dangerous because it could result in people spamming the api using your credentials. Make sure to have quotas set up on the Google Dev platform to prevent overuse (I neglected to do this years ago and made an infinite loop in my code that resulted in a $200 bill, which Google forgave after I begged them!). I am going to use this basic framework to construct other reply projects using free or custom APIs—perhaps an on demand state income tax calculator reply bot, which will be fun for basically just me.
Step 1: Set up project and import modules
Once you’ve installed NodeJS, open a command prompt and navigate to your project’s directory —cd cd:/users/youruser/yourproject
install these packages:
npm install @atproto/api
npm install node-fetch
npm install fs
npm install ws
npm install @skyware/jetstream
import pkg from '@atproto/api';
const { BskyAgent, RichText } = pkg;
var fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
import fs from 'fs';
const agent = new BskyAgent({ service: "https://bsky.social" });
import config from './config.js';
import { Jetstream } from "@skyware/jetstream";
import WebSocket from "ws"
This is what the top of your main js file should look like ^^^. This imports the relevant packages to construct your bot.
One important note. The jetstream package will not work with a “require” statement, which is used by many other Node applications. To use the “import” command it is necessary to change you package.json, adding the key/value pair “type”: “module”. This will properly import your packages.
I also made a config file so I could more easily share my code and make my credentials private. Feel free to hard code your API key and bsky login and password directly into your project if that’s easier—just don’t share it with anyone.
Step 2: Create instance of JetStream to access Bluesky Firehose
First, it might be useful to familiarize yourself with the Jetstream documentation and fiddle with making a simple firehose reader. Below is about the simplest iteration of this. Once you’ve imported your packages, create a file called “firehose.js” or something and navigate to its folder in the command prompt.
import { Jetstream } from "@skyware/jetstream";
import WebSocket from "ws"
const jetstream = new Jetstream({
ws: WebSocket
});
jetstream.onCreate("app.bsky.feed.post", (event) => {
console.log(event.commit.record)
});
jetstream.start()
Start the application with this command —> node firehose.js
this will log every post being published on bluesky in a dizzyingly rapid fashion. Once quick glance at the results will show you that it is definitely Bluesky you’re looking at:
Step 3: Creating search terms and gathering parent post information
Getting the reply information was probably the trickiest part of this as the Jetstream package does not make it very intuitive. The tricky part is constructing the necessary components of the parent post so you can properly reply to it. For this you’ll need the “cid”, “did”, and “URI.” The URI is the actual address of the parent post and consists of a text string combined with the did and a property of the event.commit called the “rkey”. I have modified the code from my working “firehose.js” bot to create and log the necessary components of the parent post you’ll need.
import { Jetstream } from "@skyware/jetstream";
import WebSocket from "ws"
const jetstream = new Jetstream({
ws: WebSocket
});
jetstream.onCreate("app.bsky.feed.post", (event) => {
if(event.commit.record.text.includes("hello") ){
var cid = event.commit.cid
var did = event.did
var eventText = event.commit.record.text
var rkey = event.commit.rkey
console.log(`Text: ${eventText} \nCID: ${cid} \nDID: ${did} \nRKEY: ${rkey} \nURI: at://${did}/app.bsky.feed.post/${rkey}\n`)
}
});
jetstream.start()
notice that in the console.log statement, I’ve concatenated the did and rkey elements in the URI property. I also added a search term: “hello” to limit the number of posts that pop up in the console. Looking at our results, we can see we’re still very much on bluesky:
Search terms such as the example above or in the full code where I use “//location” as the term is also useful for making count data. During the early days of the pandemic I made a bot that used the Twitter firehose and every hour I totaled the occurrences of terms like “I just got laid off” or “I have a fever.” The Twitter firehose was much more limited/locked down back then, and now it’s virtually unusable for regular devs. You could use this method to make a bot that totals the number of people posting “cryptic occult shit.” Be careful though. Last year I made a bot that replied to anyone who mentioned a US State with a picture of that state. I was immediately blocked by George Takei, and my account was flagged as spam. Sorry, captain Sulu!
Step 4: Get image and convert to uint8 array
I don’t want to spend too much time on the Google API portion of the bot because I will probably look elsewhere (free apis) but I did want to devote some time to explaining how to properly grab an image from a URL and convert it to a uint8 array which can then be uploaded to Bluesky, which in turn can be used to post the actual image. I have bolded the part of my getImg function that prepares the image for upload to the bsky api:
var getImg = async (urlString, searchTerms, replyCID, replyDID, replyRev, text) => {
var result = await fetch(urlString);
var json = await result.json();
console.log(json);
if(json.candidates.length == 0){
sendPost('Sorry, there were no results for that location.', null, replyCID, replyDID, replyRev, text);
return;
}
var placeId = json.candidates[0].place_id;
var deets = await fetch(`https://maps.googleapis.com/maps/api/place/details/json?fields=name%2Crating%2Cphotos&place_id=${placeId}&key=${apiKey}`);
var detailsJson = await deets.json();
console.log(detailsJson);
if (detailsJson.result.hasOwnProperty('photos') == false){
sendPost('Sorry, there were no google place photos associated with this place.', null, replyCID, replyDID, replyRev, text);
return;
}
if(detailsJson.result.photos.length ==0){
sendPost('Sorry, there were no google place photos associated with this place.', null, replyCID, replyDID, replyRev, text);
return;
}
var images = []
var photos = detailsJson.result.photos;
var randomNumber = Math.floor(Math.random() * photos.length);
var photoReference = photos[randomNumber].photo_reference;
console.log(photoReference);
const photoResponse = await fetch(`https://maps.googleapis.com/maps/api/place/photo?maxwidth=2000&photoreference=${photoReference}&key=${apiKey}`);
const photoStream = fs.createWriteStream(`./replyBot.jpg`);
photoResponse.body.pipe(photoStream);
photoStream.on('finish', async () => {
var base64 = fs.readFileSync(`./replyBot.jpg`, 'base64');
var buffer = await returnBuffer(base64);
var uint8 = await returnUint8(buffer);
var size = uint8.length;
console.log(size);
if(size > 976000){
sendPost(`The image is too large to upload.`, null, replyCID, replyDID, replyRev, text);
return;
}
sendPost(`Here is a picture of ${searchTerms} for you. Have a good day!`, uint8, replyCID, replyDID, replyRev, text);
});
photoStream.on('error', (err) => {
console.error('Error writing photo to file:', err);
});
}
You’ll see this function calls two additional async functions returnBuffer and returnUint8 after saving the image and piping it with the fs package. Those functions are simple and look like this:
var returnBuffer = async function(base64){
var bufferValue = Buffer.from(base64,"base64");
return bufferValue
}
var returnUint8 = async function(buffer){
var uint8 = new Uint8Array(buffer);
return uint8
}
Notice also that I passed all the relevant parent post information to the getImg function, as well as parameters for the sendPost function. Once you have your image as a uint8 array, you’ll have everything you need to construct your Bluesky reply: your image, the cid, did, rkey of the parent post, and everything after your search terms. In my bot I use “//location” as a flag and then simply grab everything after that term.
Step 5: Upload image to Bluesky and sending your post
Just like Twitter, it’s necessary for any post containing an image to first be uploaded. This way you’re not exactly attaching an image but describing properties of an image that has already been uploaded to the server. Below you’ll see the code for the upload as well as the actual sending of the reply.
var sendPost = async (text, unit8Obj, cid, did, uri, altText) => {
const richText = new RichText({ text });
try{
await agent.login({
identifier: login,
password: password,
});
}
catch(e){
console.log("problem logging in")
console.log(e)
}
try{
var post = {
images: [],
$type: "app.bsky.embed.images",
}
const upl = await agent.uploadBlob(unit8Obj, {encoding: "image/jpg"})
post.images.push({image: upl["data"]["blob"], alt:altText})
console.log(post)
setTimeout(function(){console.log(post)
agent.post({
reply: {
root: {
uri: "at://" + did + "/app.bsky.feed.post/" + uri,
cid: cid
},
parent: {
uri: "at://" + did + "/app.bsky.feed.post/" + uri,
cid: cid
}},
text: richText.text,
facets: richText.facets,
embed: post
});
}, 5000)
}
catch(e){
console.log(e)
}
finally{
console.log('complete')
}
}
In this section I take the uint8 array and upload it to bluesky using the built uploadBlob method from the atproto api. I first create a partially empty object called ‘post’, and then await the response from the upload operation and stuff the blob object that is returned into the images property (which was empty)
var post = {
images: [],
$type: "app.bsky.embed.images",
}
const upl = await agent.uploadBlob(unit8Obj, {encoding: "image/jpg"})
post.images.push({image: upl["data"]["blob"], alt:altText})
console.log(post)
Now our image has been uploaded and is ready to be embedded into our reply post. For this final step, I use the somewhat ugly timeout methodology, as I was having trouble getting this to work as an async function. Whatever—works for now. For the reply property of agent.post to be fully fleshed you, you’ll need the cid and uri, which if you remember from step 3 is a string made from the did and rkey properties of the event.commit jetstream object. Although I called this paremeter “uri” inside the function, it’s actual the “rkey” property from the original event object from step 3.
setTimeout(function(){console.log(post)
agent.post({
reply: {
root: {
uri: "at://" + did + "/app.bsky.feed.post/" + uri,
cid: cid
},
parent: {
uri: "at://" + did + "/app.bsky.feed.post/" + uri,
cid: cid
}},
text: richText.text,
facets: richText.facets,
embed: post
});
}, 5000)
That’s it! Here’s the full code, also available on my github. If you use this to create anything, please let me see! I’m eager to see what cool things people are making on here.
FULL CODE (requires a config file):
import pkg from '@atproto/api';
const { BskyAgent, RichText } = pkg;
var fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
import fs from 'fs';
const agent = new BskyAgent({ service: "https://bsky.social" });
import config from './config.js';
import { Jetstream } from "@skyware/jetstream";
import WebSocket from "ws"
const jetstream = new Jetstream({
ws: WebSocket
});
var apiKey = config.apikey
var login = config.login
var password = config.password
var sendPost = async (text, unit8Obj, cid, did, uri, altText) => {
const richText = new RichText({ text });
try{
await agent.login({
identifier: login,
password: password,
});
}
catch(e){
console.log("problem logging in")
console.log(e)
}
try{
var post = {
images: [],
$type: "app.bsky.embed.images",
}
const upl = await agent.uploadBlob(unit8Obj, {encoding: "image/jpg"})
post.images.push({image: upl["data"]["blob"], alt:altText})
console.log(post)
setTimeout(function(){console.log(post)
agent.post({
reply: {
root: {
uri: "at://" + did + "/app.bsky.feed.post/" + uri,
cid: cid
},
parent: {
uri: "at://" + did + "/app.bsky.feed.post/" + uri,
cid: cid
}},
text: richText.text,
facets: richText.facets,
embed: post
});
}, 5000)
}
catch(e){
console.log(e)
}
finally{
console.log('complete')
}
}
var returnBuffer = async function(base64){
var bufferValue = Buffer.from(base64,"base64");
return bufferValue
}
var returnUint8 = async function(buffer){
var uint8 = new Uint8Array(buffer);
return uint8
}
var getImg = async (urlString, searchTerms, replyCID, replyDID, replyRev, text) => {
var result = await fetch(urlString);
var json = await result.json();
console.log(json);
if(json.candidates.length == 0){
sendPost('Sorry, there were no results for that location.', null, replyCID, replyDID, replyRev, text);
return;
}
var placeId = json.candidates[0].place_id;
var deets = await fetch(`https://maps.googleapis.com/maps/api/place/details/json?fields=name%2Crating%2Cphotos&place_id=${placeId}&key=${apiKey}`);
var detailsJson = await deets.json();
console.log(detailsJson);
if (detailsJson.result.hasOwnProperty('photos') == false){
sendPost('Sorry, there were no google place photos associated with this place.', null, replyCID, replyDID, replyRev, text);
return;
}
if(detailsJson.result.photos.length ==0){
sendPost('Sorry, there were no google place photos associated with this place.', null, replyCID, replyDID, replyRev, text);
return;
}
var images = []
var photos = detailsJson.result.photos;
var randomNumber = Math.floor(Math.random() * photos.length);
var photoReference = photos[randomNumber].photo_reference;
console.log(photoReference);
const photoResponse = await fetch(`https://maps.googleapis.com/maps/api/place/photo?maxwidth=2000&photoreference=${photoReference}&key=${apiKey}`);
const photoStream = fs.createWriteStream(`./replyBot.jpg`);
photoResponse.body.pipe(photoStream);
photoStream.on('finish', async () => {
var base64 = fs.readFileSync(`./replyBot.jpg`, 'base64');
var buffer = await returnBuffer(base64);
var uint8 = await returnUint8(buffer);
var size = uint8.length;
console.log(size);
if(size > 976000){
sendPost(`The image is too large to upload.`, null, replyCID, replyDID, replyRev, text);
return;
}
sendPost(`Here is a picture of ${searchTerms} for you. Have a good day!`, uint8, replyCID, replyDID, replyRev, text);
});
photoStream.on('error', (err) => {
console.error('Error writing photo to file:', err);
});
}
jetstream.onCreate("app.bsky.feed.post", (event) => {
if(event.commit.record.text.toLowerCase().includes('//location')){
var text = event.commit.record.text.toLowerCase();
var splitText = text.split('//location');
var termsAfter = splitText[1];
var url = `https://maps.googleapis.com/maps/api/place/findplacefromtext/json?fields=formatted_address%2Cname%2Cplace_id%2Cphotos%2Cgeometry&input=${termsAfter}&inputtype=textquery&key=${apiKey}`;
getImg(url, termsAfter, event.commit.cid, event.did, event.commit.rkey, text);
}
});
jetstream.start()