iTranslated by AI
Building a Simple Bluesky Bot
It's been about a week since I shifted my focus from Twitter to Bluesky. I've finally gotten used to how Bluesky works, so I decided to try making a bot around here.
To create a Bluesky bot, you just need two things:
- A Bluesky account
- A server to run the bot
Currently, there are no access keys or tokens; it's a simple system where you post using your account ID and password.
Regarding account creation, an invitation code is required. For this, you'll have to manage to get one from someone you know (by the way, invitation codes are not issued until about two weeks after registration, so I don't have one yet...).
The following assumes you already have an account.
Specifications
First, here are the bot's specifications:
- Post every hour
- Post the current temperature in Tokyo at the time of posting (Source: tenki.jp)
- Post content: Text + Link + OGP image
In this case, we need a cron-like mechanism to post at regular intervals. Since I want to run the bot for free as much as possible, I will use Firebase Functions. Although you need to switch to the Blaze plan (requires credit card registration), it should be usable within the free tier for this purpose.
Preparing for Firebase and Bot Development
Registering with Firebase
Make sure to register with Firebase here. You will need to be on the Blaze plan.
Node.js
Install Node.js version 18 so that you can easily use fetch in your bot (Node.js 18 is now available!).
$ nodenv install 18.13.0
I use nodenv, but if you use nvm, that is also fine.
Java
This is required to use the Firebase Emulator (a tool for emulating Firebase in a local environment to run Functions or use Firestore). Currently, Java version 11 or higher is required.
Firebase CLI
Next, you will need the Firebase CLI to configure Firebase locally.
$ npm install -g firebase-tools
Authenticate with Firebase.
$ npx firebase login
Project Creation
Once you have the Firebase CLI, create a Firebase project. In this example, we'll name the project bsky-tenki-bot (you can choose any name you like, but be careful as the Project ID cannot be changed later).
$ mkdir bsky-tenki-bot
$ cd bsky-tenki-bot/
$ npx firebase projects:create
Please specify a unique project id
() bsky-tenki-bot
What would you like to call your project? (defaults to your project ID)
() bsky-tenki-bot
✔ Creating Google Cloud Platform project
✔ Adding Firebase resources to Google Cloud Platform project
🎉🎉🎉 Your Firebase project is ready! 🎉🎉🎉
Project information:
- Project ID: bsky-tenki-bot
- Project Name: bsky-tenki-bot
Firebase console is available at
https://console.firebase.google.com/project/bsky-tenki-bot/overview
Once the project is created, access the URL specified in the Firebase console is available at message above. For now, go to Project Overview > Project Settings > Default GCP resource location and set it to asia-northeast. While this setting is not strictly necessary for this walkthrough, it is better to do it early to avoid potential errors when using Firestore or Storage later.
Creating Template Files
Create template files for Firebase Functions and Emulators. Since I want to manage packages in the root directory, I'll avoid running npm install for now.
Functions
$ npx firebase init functions
=== Project Setup
First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add,
but for now we'll just set up a default project.
? Please select an option: Use an existing project
? Select a default Firebase project for this directory: bsky-tenki-bot (bsky-tenki-bot)
i Using project bsky-tenki-bot (bsky-tenki-bot)
=== Functions Setup
Let's create a new codebase for your functions.
A directory corresponding to the codebase will be created in your project
with sample code pre-configured.
See https://firebase.google.com/docs/functions/organize-functions for
more information on organizing your functions using codebases.
Functions can be deployed with firebase deploy.
? What language would you like to use to write Cloud Functions? TypeScript
? Do you want to use ESLint to catch probable bugs and enforce style? Yes
✔ Wrote functions/package.json
✔ Wrote functions/.eslintrc.js
✔ Wrote functions/tsconfig.json
✔ Wrote functions/tsconfig.dev.json
✔ Wrote functions/src/index.ts
✔ Wrote functions/.gitignore
? Do you want to install dependencies with npm now? n
Emulator
$ npx firebase init emulators
=== Project Setup
First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add,
but for now we'll just set up a default project.
i Using project bsky-tenki-bot (bsky-tenki-bot)
=== Emulators Setup
? Which Firebase emulators do you want to set up? Press Space to select emulators, then Enter to confirm your choices. Functions
Emulator
? Which port do you want to use for the functions emulator? 5001
? Would you like to enable the Emulator UI? Yes
? Which port do you want to use for the Emulator UI (leave empty to use any available port)?
? Would you like to download the emulators now? Yes
i Writing configuration info to firebase.json...
i Writing project information to .firebaserc...
✔ Firebase initialization complete!
Workspace
Thinking about adding Firestore or other features later, I'll set up npm workspaces. Create a package.json in the project root directory (one level above ./functions) and write it as follows. dotenv is included as it will be used later.
{
"name": "bsky-tenki-bot",
"version": "1.0.0",
"description": "",
"main": "index.js",
"workspaces": [
"functions"
],
"scripts": {},
"dependencies": {
"dotenv": "^16.3.1"
}
}
install
Install the npm dependencies.
$ npm i
Installing Required Packages
We will install the necessary packages for posting to Bluesky and the tools for scraping required information.
$ cd functions/
$ npm i @atproto/api node-html-parser
$ npm i -D prettier
Running Functions
Now let's start writing the code. First, write a simple function in functions/src/index.ts and try running it locally. You will need the Emulator installed earlier to run it locally.
For now, write the following code in functions/src/index.ts. This code outputs "Hello World!" to the logs at five minutes past every hour.
import * as functions from "firebase-functions";
export const postJob = functions.pubsub.schedule("05 */1 * * *").onRun((_) => {
console.log("Hello World!");
});
Start the emulator (if the port is already in use, you can change the port in firebase.json from 5001 to a different number).
$ cd functions/
$ npm run serve
Then, just open another terminal tab and execute the function you created.
$ npm run shell
firebase > postJob()
Hello World!
Implementation
Once the preparation is complete, all that's left is to proceed with the bot's implementation.
Logging in to Bluesky
To post to Bluesky, you must first log in to your account. The code looks something like this:
const agent = new BskyAgent({ service: "https://bsky.social" });
await agent.login({
identifier: defineString("BSKY_ID").value(),
password: defineString("BSKY_PASSWORD").value(),
});
By the way, prepare your login ID and password in functions/.env as BSKY_ID and BSKY_PASSWORD. defineString is the official interface for accessing environment variables from Functions. If you use this interface, errors will be caught during CLI deployment if any environment variables are missing in .env.
Getting Weather Information
Access the Tokyo weather information page on tenki.jp and retrieve the "Title", "Temperature", "OGP Image", and "OGP Description". Simply fetch the page and extract the information from the HTML using node-html-parser.
const resp = await fetch(tenkiURL);
const html = parse(await resp.text());
const temp = html
.querySelectorAll(".amedas-current-list li")[0]
.innerText?.replace(/ /, "");
const title = html.querySelector("title")?.innerText;
const ogpText = html
.querySelector("meta[name='description']")
?.getAttribute("content");
const ogpImg = html
.querySelector("meta[property='og:image']")
?.getAttribute("content");
Creating Links
In Bluesky, the app does not automatically turn URL strings in your post into links. You must explicitly specify which part of the text constitutes the link at the time of posting. To achieve this, you use a specification called Rich Text.
The specific code looks like this:
const encoder = new TextEncoder();
const plainText = `Temperature in Tokyo: ${temp} `.slice(0, 299);
const linkStart = encoder.encode(plainText).byteLength;
const linkEnd = byteStart + encoder.encode(tenkiURL).byteLength;
const textParams = `${plainText}${tenkiURL}`;
const facetsParams: AppBskyRichtextFacet.Main[] = [
{
index: {
byteStart: linkStart,
byteEnd: linkEnd,
},
features: [{ $type: "app.bsky.richtext.facet#link", uri: tenkiURL }],
},
];
Creating Embedded Images
When you post a link, you likely want the OGP image to be displayed, just like on Twitter. For this, you use the embedding feature.
Images specified for embedding must be uploaded to IPFS. Therefore, you upload the OGP image you retrieved earlier to IPFS and then create parameters that satisfy the embedding specifications using the obtained CID.
Specifically, the code looks like this:
const blob = await fetch(`${ogpImg}`);
const buffer = await blob.arrayBuffer();
const response = await agent.uploadBlob(new Uint8Array(buffer), {
encoding: "image/jpeg",
});
const embedParams: AppBskyFeedPost.Record["embed"] = {
$type: "app.bsky.embed.external",
external: {
uri: tenkiURL,
thumb: {
$type: "blob",
ref: {
$link: response.data.blob.ref.toString(),
},
mimeType: response.data.blob.mimeType,
size: response.data.blob.size,
},
title: title,
description: ogpText,
},
};
Posting to Bluesky
Finally, post to Bluesky using the data created so far.
Specify plain text in text. Use facets for the Rich Text and embed for the embedded image and link information.
const postParams: AppBskyFeedPost.Record = {
$type: "app.bsky.feed.post",
text: textParams,
facets: facetsParams,
embed: embedParams,
createdAt: new Date().toISOString(),
};
await agent.post(postParams);
If it works correctly, it should be posted as shown below.

Deployment
All that's left is to deploy to production. You might get some ESLint errors; in that case, just fix them one by one.
$ npm run deploy
Full code so far
import { AppBskyFeedPost, AppBskyRichtextFacet, BskyAgent } from "@atproto/api";
import * as functions from "firebase-functions";
import { defineString } from "firebase-functions/params";
import parse from "node-html-parser";
const bskyService = "https://bsky.social";
const tenkiURL = "https://tenki.jp/amedas/3/16/44132.html";
export const postJob = functions.pubsub
.schedule("05 */1 * * *")
.onRun(async (_) => {
// Log in to Bluesky
const agent = new BskyAgent({ service: bskyService });
await agent.login({
identifier: defineString("BSKY_ID").value(),
password: defineString("BSKY_PASSWORD").value(),
});
// Get weather information
const resp = await fetch(tenkiURL);
const html = parse(await resp.text());
const temp = html
.querySelectorAll(".amedas-current-list li")[0]
.innerText?.replace(/ /, "");
const title = html.querySelector("title")?.innerText;
const ogpText = html
.querySelector("meta[name='description']")
?.getAttribute("content");
const ogpImg = html
.querySelector("meta[property='og:image']")
?.getAttribute("content");
// Create Rich Text
// Lexicon: https://atproto.com/lexicons/app-bsky-richtext#appbskyrichtextfacet
// Posting example) Temperature in Tokyo: 29.5℃ https://tenki.jp/amedas/3/16/44132.html
const encoder = new TextEncoder();
const plainText = `[Testing bot] Temperature in Tokyo: ${temp} `.slice(0, 299);
const byteStart = encoder.encode(plainText).byteLength;
const byteEnd = byteStart + encoder.encode(tenkiURL).byteLength;
const textParams = `${plainText}${tenkiURL}`;
const facetsParams: AppBskyRichtextFacet.Main[] = [
{
index: {
byteStart,
byteEnd,
},
features: [{ $type: "app.bsky.richtext.facet#link", uri: tenkiURL }],
},
];
// Create OGP image to embed in the post
// Lexicon: https://atproto.com/lexicons/app-bsky-embed#appbskyembedexternal
const blob = await fetch(`${ogpImg}`);
const buffer = await blob.arrayBuffer();
const response = await agent.uploadBlob(new Uint8Array(buffer), {
encoding: "image/jpeg",
});
const embedParams: AppBskyFeedPost.Record["embed"] = {
$type: "app.bsky.embed.external",
external: {
uri: tenkiURL,
thumb: {
$type: "blob",
ref: {
$link: response.data.blob.ref.toString(),
},
mimeType: response.data.blob.mimeType,
size: response.data.blob.size,
},
title: title,
description: ogpText,
},
};
// Post to Bluesky
// Lexicon: https://atproto.com/lexicons/app-bsky-feed#appbskyfeedpost
const postParams: AppBskyFeedPost.Record = {
$type: "app.bsky.feed.post",
text: textParams,
facets: facetsParams,
embed: embedParams,
createdAt: new Date().toISOString(),
};
await agent.post(postParams);
return null;
});
Summary
I created a simple bot on Bluesky. While posting to Bluesky itself is straightforward, setting up Firebase for periodic execution as a bot was a bit of a hassle.
However, the cost is exceptionally low, so I hope you can bear with the initial setup.
The AT Protocol documentation for Bluesky can be found below.
I recommend starting with the Protocol Overview to understand the overall architecture, and then looking through the Lexicon for the schemas used in posts and other activities.
If jumping straight into the documentation doesn't quite click for you, mattn-san's explanation on gihyo was also very easy to understand and helpful.
There is a Japanese community for Bluesky on Scrapbox, which is also very helpful for reference.
Lastly, the code for the bot used in this article is available here.
I've also built a bot that posts my Hatena Bookmarks by integrating Firestore, so feel free to check that out as well.
Discussion