iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🤖

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...).
https://bsky.app/

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.
https://firebase.google.com/docs/functions?hl=ja

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!

https://firebase.google.com/docs/functions/local-shell?hl=en

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.
https://firebase.google.com/docs/functions/config-env?hl=en

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");

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.
https://atproto.com/lexicons/app-bsky-richtext#appbskyrichtextfacet

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.
https://atproto.com/lexicons/app-bsky-embed#appbskyembedexternal

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.
https://atproto.com/specs/data-model#blob-type

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.
https://atproto.com/lexicons/app-bsky-feed#appbskyfeedpost

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.
https://firebase.google.com/pricing?hl=en

The AT Protocol documentation for Bluesky can be found below.
https://atproto.com/docs

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.
https://gihyo.jp/article/2023/04/bluesky-atprotocol

There is a Japanese community for Bluesky on Scrapbox, which is also very helpful for reference.
https://scrapbox.io/Bluesky/

Lastly, the code for the bot used in this article is available here.
https://github.com/YuheiNakasaka/bsky-tenki-bot

I've also built a bot that posts my Hatena Bookmarks by integrating Firestore, so feel free to check that out as well.
https://github.com/YuheiNakasaka/bsky-hatena-bookmark-bot

Discussion