Illustration to depict a BlueSky bot. A robotic figure with fairy wings. The robot has a blue and silver metallic body, with details suggesting internal mechanics. The wings are large, delicate, and translucent. Above the robot's head floats a glowing blue butterfly with a faint halo.

What can we learn from building a BlueSky web component bot?- Part 1

How to build a web component based application that integrates the BlueSky Social public API? Covered topics: TDD, BlueSky API, BlueSky bot (automation), AtProto SDK, Streaming, Web components.

Introduction

BlueSky is a (relatively) new Social network nowadays. It looks like a Twitter clone, only open source with much more control over the content one consumes. It also has a public API. In this article, we will walk through the building of an A11y love meter bot. We will gamify adding Alt Text to your posts so you will get better at remembering it.

What are we going to build? This

You can view the complete repo here

What’s so hard with Alt Text in BlueSky?

One challenge on BlueSky is that posts are immutable—you can’t edit them once they’re published. Your only options are to publish a new one or delete the old one. There’s no middle ground (at least for now!).

When creating posts, many of us forget to include Alt Text. The excitement of sharing a fresh idea or the perfect image often takes priority, and accessibility features can slip our minds.

So, how do we get better at remembering Alt Text? Practice! Let’s turn this into a fun and practical learning experience with a BlueSky Alt Text training game designed to help you build this habit effortlessly.

How to Build a BlueSky bot?

We will use the AtpAgent SDK to build a BlueSky bot. This typescript library allows you to perform operations on the BlueSky network. 

The Project Setup

We’ll work with the minimal infrastructure that will make our work comfortable. I assume you already have nodejs installed so will skip right ahead to installing packages.

  1. Create a new folder (let’s assume you called it BlueSkyBot)
  2. cd into it: cd BlueSkyBot
  3. Run npm init -y to initialize our project as an npm one (with a package.json file).
  4. Run npm i vite vitest -D && npm i @atproto/api (this will add a package.lock.json file to the project).
  5. Our package.json file has an empty scripts property. We will replace its contents with the following scripts:
"scripts": {
    "test": "vitest",
    "start": "vite"
}

For the full package.json, see this gist.

  1. We’ll use vite and vitest to bundle and test our app, so we’ll add a basic vite.config.ts file:
/// <reference types="vitest/globals" />
import { defineConfig } from 'vitest/config'

export default defineConfig({
 root: './src',
 server: {
   open: true
 },
 test: {
   globals: true,
   environment: 'jsdom',
   coverage: {
     enabled: true,
     provider: 'v8',
     reporter: ['text', 'json', 'html'],
     reportsDirectory: '../coverage',
   },
 },
});

We define the root as the src folder, tell our development server to open the browser with the local URL and setup the tests to ru on jsdom with some coverage options.

  1. Now we can create the src folder. All our files will be there. Our folder structure should look like this:
.
├── src/
│   ├── agent
│   ├── components
│   └── index.html
├── package.lock.json
├── package.json
└── vite.config.ts 

Vite by default is looking for an index.html file, so this will be the app’s source. Once we have that, we can run npm start and see the contents of the index.html file (currently, a blank page).

The Agent

The Agent is going to be our data fetcher from BlueSky.

Inside the agent folder, we will create two files:

  1. find-altless-posts.spec.ts
  2. find-altless-posts.ts

We will start by creating some basic tests to get us going. Inside find-altless-posts.spec.ts we will add the following:

import { AltTextBot } from './find-altless-posts.js';
describe('AltTextBot', () => {
it('should initialize a new instance', async () => {
expect(new AltTextBot()).toBeDefined();
});
});

When we run npm test, it will ask us to install a few packages. Answer yes.

Once everything’s installed, running npm test will fail. That’s because we didn’t implement the class yet. We will create it in find-altless-posts.ts:

export class AltTextBot {
}

And now the tests have passed! Hooray!

Let’s parse the contents of a single BlueSky post.

Check a Single Post

Our first exposed method is going to be: checkSinglePost.

We will build it step-by-step:

Handle Data Fetch Error

Looking at the atproto api docs we can see there’s an agent.getPost method. We’ll use it. First, we will create a mock:

let mockAtpAgent;

const resetAtpAgentMock = () => {
    mockAtpAgent = {
        getPost: vi.fn(),
    };
}

vi.mock('@atproto/api', () => ({
    AtpAgent: vi.fn(() => mockAtpAgent),
}));

and use this mock in our test:

describe('checkSinglePost()', () => {
it('should return the error message if fetch post failed', async () => {
resetAtpAgentMock();
const postUri = 'postUri';
const bot = new AltTextBot();
const error = { message: 'error' };
mockAtpAgent.getPost.mockRejectedValue(error);
const result = await bot.checkSinglePost(postUri);
expect(result).toEqual(error);
});
});

The test follows the Arrange-Act-Assert (AAA) pattern. We first arrange the scenario to fail the fetch post:

  • Reset the agent’s mock
  • Set some consts and initialize the bot
  • Mock a rejection as getPost response

We then commence the action:

 const result = await bot.checkSinglePost(postUri);

Eventually, we assert the result is the error message resolved from getPost:

expect(result).toEqual(error);

This, of course, fails, so we implement the minimal code to make it pass:

import {AtpAgent} from "@atproto/api";
export class AltTextBot {
#agent = new AtpAgent({service: 'https://bsky.social'});
async checkSinglePost(postUri: string) {
try {
await this.#agent.getPost(postUri);
} catch(e) {
return e;
}
}
}

We’ve added a private #agent property, which initializes an AtpAgent.

We then use it in checkSinglePost inside a try/catch phrase and return the error in case of a caught error.

Note that this fails with strict TypeScript enforcement, which we do not have in our project. We will take care of it later.

Refactor

Our tests have some boilerplate we can extract. For instance, we see that instantiating a new bot repeats itself. Let’s make our tests leaner:

describe('AltTextBot', () => {
const postUri = 'postUri';
let bot: AltTextBot;
beforeEach(async () => {
resetAtpAgentMock();
bot = new AltTextBot();
});
it('should initialize a new instance', async () => {
expect(bot).toBeDefined();
});
describe('checkSinglePost()', () => {
it('should return the error message if fetch post failed', async () => {
const error = { message: 'error' };
mockAtpAgent.getPost.mockRejectedValue(error);
const result = await bot.checkSinglePost(postUri);
expect(result).toEqual(error);
});
});
});

Notice that our test case (it) is now three lines of code, and we don’t have to repeat resetting the mock and instantiating the bot again.

Verify Correct Usage of getPost

As mentioned before, TypeScript does not like that we send the postUri as a string to getPost. That’s because it expects something else:

Argument of type ‘string’ is not assignable to parameter of type ‘Omit<QueryParams, “collection”>’.ts(2345)

Because AtProto is written in TypeScript, we can search for the QueryParams interface in their code (simply cmd/ctrl+click on the method will send us there):

export interface QueryParams {
  /** The handle or DID of the repo. */
  repo: string
  /** The NSID of the record collection. */
  collection: string
  /** The Record Key. */
  rkey: string
  /** The CID of the version of the record. If not specified, then return the   most recent version. */
  cid?: string
}

According to the above, we need an object with repo, collection, and rkey. The last two can be inferred from the postUri. The first will require the use of the API. 

Let’s see what we expect to happen:

it('should get the post using atpAgent', async () => {
const postUri = 'https://bsky.app/profile/yonatankra.com/post/3lczalvz7uk2l';
mockAtpAgent.getPost.mockResolvedValueOnce(postUri);
await bot.checkSinglePost(postUri);
expect(mockAtpAgent.getPost).toHaveBeenCalledWith({
"collection": "app.bsky.feed.post",
"repo": "handle-did",
"rkey": "3lczalvz7uk2l",
});
expect(mockAtpAgent.getPost).toHaveBeenCalledTimes(1);
});

Notice we are now using a real postUri. We’d expect our method to use getPost with a QueryParams object derived from our postUri. We also expect it to run only once (expect(mockAtpAgent.getPost).toHaveBeenCalledTimes(1);).

One “GOTCHA” here is the repo, also called the DID. As mentioned before, we need to use the API to get it. For this, we will mock the AtpAgent‘s resolveHandle method, which return the DID:

const resolvedHandle = {
    did: 'handle-did'
};

const resetAtpAgentMock = () => {
    mockAtpAgent = {
        getPost: vi.fn(),
        resolveHandle: vi.fn().mockResolvedValue({
            data: resolvedHandle
        })
    };
}

Now that we have a failing test let’s make it pass. We’ll write a small utility function that converts our Uri to the params needed: 

async function parsePostUri(uri: string, agent: AtpAgent): Promise<{ repo: string; collection: string; rkey: string; } | boolean> {
// Extract handle and post ID
const match = uri.match(/profile\/([^/]+)\/post\/([^/]+)/);
if (!match) {
return false;
}
const [, handle, rkey] = match;
// Get the did
const { data: { did: repo }} = await agent.resolveHandle({handle});
// Use the official bsky app
const collection = 'app.bsky.feed.post';
return {
repo,
collection,
rkey
};
}
view raw parsePostUri.ts hosted with ❤ by GitHub

And use it in our method:

import { AtpAgent } from "@atproto/api";
export class AltTextBot {
#agent = new AtpAgent({ service: 'https://bsky.social' });
async checkSinglePost(postUri: string) {
try {
await this.#agent.getPost(await parsePostUri(postUri, this.#agent));
} catch (e) {
return e;
}
}
}
async function parsePostUri(uri: string, agent: AtpAgent): Promise<{ repo: string; collection: string; rkey: string; } | boolean> {
// Extract handle and post ID
const match = uri.match(/profile\/([^/]+)\/post\/([^/]+)/);
if (!match) {
return false;
}
const [, handle, rkey] = match;
// Get the did
const { data: { did: repo }} = await agent.resolveHandle({handle});
// Use the official bsky app
const collection = 'app.bsky.feed.post';
return {
repo,
collection,
rkey
};
}

Great! We validated that we are using the AtpAgent correctly.

Now, we would like to return the post with a list of Alt-less images. We’d expect the output of the method to be like this:

it('should return the post with altLess images list', async () => {
const imageWithoutAlt = { alt: '' };
const imageWithAlt = { alt: 'I have Alt text!' };
const post = {
uri: 'postUri',
cid: 'postCid',
value: {
embed: {
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt],
$type: 'image'
},
text: '',
createdAt: ''
},
};
mockAtpAgent.getPost.mockResolvedValueOnce(post);
const result = await bot.checkSinglePost(postUri);
expect(result).toEqual({ post, imagesWithoutAlt: [imageWithoutAlt, imageWithoutAlt] })
});

Arrange: we generate a mock post response and tell our agent’s mock to resolve it. We know the value holds the embed according to the AtProto types, so we set it with three images: 1 with alt text and two without.

Act: call bot.checkSinglePost(postUri)

Assert: We expect the value returned from checkSinglePost to be an object with the post as well as an array of the two images without alt text.

The implementation is straightforward:

#returnPostWithAltlessImages(post: { uri: string; cid: string; value: Record; }) {
const images = post.value?.embed?.images || [];
const imagesWithoutAlt = images.filter(img => !img.alt);
return { post, imagesWithoutAlt };
}
async checkSinglePost(postUri: string) {
try {
const post = await this.#agent.getPost(await parsePostUri(postUri, this.#agent));
return this.#returnPostWithAltlessImages(post);
} catch (e) {
return e;
}
}

checkSinglePost uses the agent to get the post (line 8). It then returns an object with the post and imagesWithoutAlt which is the array of alt-less images we expected.

Our code is ready! Let’s check it out in the browser!

Our Bot in the Browser

Remember our index.html file? It’s time to put it to work. We will use our Bot inside the HTML file like this:

<!DOCTYPE html>
<html lang="en">
<head>
<script type="module" defer>
import { AltTextBot } from './agent/find-altless-posts.ts';
async function start() {
const bot = new AltTextBot();
console.log(await bot.checkSinglePost('https://bsky.app/profile/yonatankra.com/post/3lczalvz7uk2l'));
}
start();
</script>
</head>
<body>
</body>
</html>
view raw index.html hosted with ❤ by GitHub

The secret sauce is lines 5 to 10. It should look familiar because it’s just like the tests. Our tests were written as if we were consumers of the interface. It’s fun like that 🙂

Now we can run npm start. It will open a browser serving our app. In the console, you should see the result:

Screenshot of a web browser's developer console, showing a JavaScript object named 'imagesWithoutAlt'. This object contains an array with one entry, indicating an image element is missing alt text. The entry includes properties like 'alt' (which is empty), 'image' (a BlobRef), and 'aspectRatio'. It also shows the associated 'post' with its 'uri' (a long identifier starting with 'at://did:plc:') and 'cid'.

Try to replace the URL we send to our method in the HTML file and see that it also works for your posts.

Now we can go ahead and move to more complex stuff that requires login – so that’d be our next stop.

Login

Some actions require authentication. Actions like posting, replying or even getting all your posts. While we don’t need login to get posts, we will use it in later features, so let’s quickly implement it.

For simplicity’s sake, we will use the simple username (a.k.a. handle) and password login method.

Essentially, it’s supposed to do something straightforward: use the SDK’s login method. Here’s the test and implementation:

const resetAtpAgentMock = () => {
mockAtpAgent = {
getPost: vi.fn(),
resolveHandle: vi.fn().mockResolvedValue({
data: resolvedHandle
}),
login: vi.fn()
};
}
describe('login', () => {
it('should login using AtProto SDK', async () => {
const handle = 'testUser';
const password = 'testPassword';
await bot.login(handle, password);
expect(mockAtpAgent.login).toHaveBeenCalledWith({
identifier: handle,
password: password,
});
});
});
view raw login.spec.ts hosted with ❤ by GitHub
async login(handle: string, password: string): Promise<void> {
await this.#agent.login({
identifier: handle,
password: password
});
}
view raw login.ts hosted with ❤ by GitHub

In the spec file, we add the login method to the AtpAgent mock. We then make sure that when we call bot.login, it will call the agent’s login with the handle and password. The implementation in the `login.ts` file would be just to call login with the handle and password parameters.

For the complete solution with the login and checkSinglePost, click here.

Notice that Bluesky also has an OAuth API which we will not discuss in this (already-very-long) article. Now we can get our posts.

Stream Your Posts

So far, we fetched a single post. When we fetch a user’s posts, the number of posts can be massive. Some people post once a week, some a few times each day. So, if you have been in the system for two years, you might get quite a few posts. When fetching lots of data, it’s always good to “stream” it, which is a fancy name for saying we’ll get it in chunks.

How to Stream BlueSky Posts to the Client?

Part of the AtpAgent SDK is the getAuthorFeed method. It must receive an actor (the user handle). There are also some more properties. We will go over some of them in this example. We can call it like this:

const result: AppBskyFeedGetAuthorFeed.Response = await this.#agent.getAuthorFeed({

   actor: handle,

   limit: 20,

   cursor: cursor,

   filter: 'posts_with_media'

});

In this example, aside from the actor, we also used cursor. The cursor is either undefined, which returns the first batch of posts or a value we receive from a subsequent call. This value signifies that we want to fetch the next batch. I also added a limit of 20 responses in each batch and asked for only posts with media (because we only want to see if we have alt text in media).

Our first step will be to add getAuthorFeed to our AtpAgent mock:

const resetAtpAgentMock = () => {
mockAtpAgent = {

       getPost: vi.fn(),

       resolveHandle: vi.fn().mockResolvedValue({

           data: resolvedHandle

       }),

       login: vi.fn(),

       getAuthorFeed: vi.fn(),

   };

}

Let’s make sure we call it right in our code:

it('should call getAuthorFeed with the correct parameters', async () => {

  queueAgentFeedResponse(emptyFeedResponse);

  await bot.streamPosts(handle);

  expect(mockAtpAgent.getAuthorFeed.mock.calls[0][0]).toEqual({

    actor: handle,

    limit: 20,

    cursor: undefined,

    filter: 'posts_with_media'

  });

});

In the code snippet above, we enqueue an empty feed response from getAuthorFeed mock and call our method. We expect the mock to be called with the expected parameters. 

Simple implementation again:

async streamPosts(handle: string) {

      const result = await this.#agent.getAuthorFeed({

            actor: handle,

            limit: 20,

            cursor: undefined,

            filter: 'posts_with_media'

       });

}

Our next stop is… well… to stop.

Stop Conditions

Because streaming is a kind of an infinite loop, we need “stop” conditions. 

We have two ways of knowing if we have reached the end of the stream.

The first is when we get an empty feed array:

it('should log when there are no more posts and break', async () => {

      queueAgentFeedResponse({

        data: {

            feed: [],

            cursor: ‘somthing’,

        },

      });

      await bot.streamPosts(handle);

      expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(1);

});

As the test description implies, we enqueue an empty response for the getAuthorFeed mock. We then stream the posts. We then assert that getAuthorFeed was called only once, which means we broke out of the loop.

The second stop condition is when we get a response without a new cursor for the next batch:

it('should break if no cursor is provided in the feed response', async () => {

    queueAgentFeedResponse({

        data: {

            feed: [

                { post: { uri: 'postUri1', cid: 'postCid1', embed: {} } },

                { post: { uri: 'postUri2', cid: 'postCid2', embed: {} } },

            ],

            cursor: undefined

        }

    });

    await bot.streamPosts(handle);

    expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(1);

});

In this test, we enqueue a response with a feed – only this time, the cursor is undefined. This simulates the last batch use case.

Implementing those is a breeze. We wrap our request with an endless while loop and add our stop conditions:

async streamPosts(handle: string) {

       while(true) {

           const result = await this.#agent.getAuthorFeed({

            actor: handle,

            limit: 20,

            cursor: undefined,

            filter: 'posts_with_media'

       });

           if (!result.data?.feed?.length) {

               console.log('No more posts');           

                break;

           }

           if (!result.data.cursor) {

               break;

           }

       }

}

Our endless while loop calls getAuthorFeed and checks the feed’s length and the data’s cursor. In the case of an empty feed or undefined cursor, we break from the loop.

Error Handling

We shouldn’t forget the error handling whenever we make a server call. In our case, we decided that if the fetch fails for some reason, we’d like to retry every 5000ms:

it('should retry after 5 seconds if author feed rejected', async () => {

  vi.useFakeTimers();

  const fetchError = new Error('Fetch error');

  mockAtpAgent.getAuthorFeed.mockRejectedValueOnce(fetchError);

  await bot.streamPosts(handle);

  await vi.advanceTimersByTimeAsync(4999);

  const callsBefore5Seconds = mockAtpAgent.getAuthorFeed.mock.calls.length;

  await vi.advanceTimersByTimeAsync(1);

  expect(callsBefore30Seconds).toBe(1);

  expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(2);

  vi.useRealTimers();

});

A retry means we need to use timers. In order to control timers in vitest, we use fake timers (vi.useFakeTimers() on line 2). We make the mock getAuthorFeed reject the request with an error and then call our function. The interesting thing here is that we tell vitest to advance time by 4999ms. We would expect to have only one call to getAuthorFeed. We then advance the time by 1ms, completing the 5000ms cycle. We expect a single call to getAuthorFeed after 4999ms and 2 calls after another 1ms (a total of 5000ms).

Let’s implement this:

async streamPosts(handle: string) {

  while (true) {

    try {

      const result = await this.#agent.getAuthorFeed({

        actor: handle,

        limit: 20,

        cursor: undefined,

        filter: 'posts_with_media'

      });

      if (!result.data?.feed?.length) {

        console.log('No more posts');

        break;

      }

      if (!result.data.cursor) {

        break;

      }

    } catch (e) {

      await new Promise(res => setTimeout(res, 5000));

    }

  }
}

Notice the addition of try/catch and the promise that resolves after 5000ms. Next step – notify the consumer that a chunk has arrived.

The Streaming Callback

To notify the consumer that a new chunk arrived from our fetch request, we will allow the consumer to pass a callback to streamPosts. We will fire this callback on every update and notify the user that the messages have stopped. So, we have three use cases here:

it('should stream posts with done set to false when cursor is truthy', async () => {
const spy = vi.fn();
queueAgentFeedResponse(fullFeedResponse);
queueAgentFeedResponse(fullFeedResponse);
queueAgentFeedResponse(fullFeedLastResponse);
await bot.streamPosts(handle, spy);
expect(spy.mock.calls[0][0])
.toEqual({result: fullFeedResponse.data.feed, done: false});
expect(spy.mock.calls[1][0])
.toEqual({result: fullFeedResponse.data.feed, done: false});
expect(spy.mock.calls[2][0])
.toEqual({result: fullFeedLastResponse.data.feed, done: true});
});
it('should send done true to callback when response cursor is empty', async () => {
const spy = vi.fn();
queueAgentFeedResponse(fullFeedLastResponse);
await bot.streamPosts(handle, spy);
expect(spy.mock.calls[0][0])
.toEqual({result: fullFeedLastResponse.data.feed, done: true});
});
it('should send done true to callback when feed returns empty', async () => {
const spy = vi.fn();
queueAgentFeedResponse(emptyFeedResponse);
await bot.streamPosts(handle, spy);
expect(spy.mock.calls[0][0]).toEqual({ done: true });
});

Let’s go over the test cases:

should stream posts with done set to false when cursor is truthy

Arrange: This is the actual streaming. Our first line creates a spy. A spy is a method we can track. We used it when we mocked the AtpAgent. Here we create a spy that will be the callback we send to streamPosts.

We enqueue three responses – two full responses with a cursor and one last response with and undefined cursor. We must send the last response because this is our stop condition.

Act: When we call streamPosts, we also send our spy as the callback.

Assert: We’d expect to receive an object with result (which will hold the feed) and done (which will be false until we reach the last response and then it should be true).

The other tests should look familiar – they test the stop conditions. See that a final call to our callback receives an object with { done: true }.

Here’s the implementation:

async streamPosts(handle: string, onUpdate: (results: any) => any) {
while (true) {
try {
const result = await this.#agent.getAuthorFeed({
actor: handle,
limit: 20,
cursor: undefined,
filter: 'posts_with_media'
});
if (!result.data?.feed?.length) {
console.log('No more posts');
onUpdate({ done: true });
break;
}
onUpdate({result: result.data.feed, done: !result.data.cursor});
if (!result.data.cursor) {
break;
}
} catch (e) {
await new Promise(res => setTimeout(res, 5000));
}
}
}
view raw streamPosts.ts hosted with ❤ by GitHub

Notice the calls to onUpdate with the results and in our stop conditions.

We have one more thing to do here. Remember the cursor? We need to make sure to send the last result’s cursor in the next request. It looks something like this:

it('should send the last response cursor in the next request', async () => {

  const spy = vi.fn();

  queueAgentFeedResponse({ data: { ...fullFeedResponse.data, cursor: 'next-0' } });

  queueAgentFeedResponse({ data: { ...fullFeedResponse.data, cursor: 'next-1' } });

  queueAgentFeedResponse(fullFeedLastResponse);

  await bot.streamPosts(handle, spy);

  expect(mockAtpAgent.getAuthorFeed.mock.calls[1][0].cursor).toBe('next-0');

  expect(mockAtpAgent.getAuthorFeed.mock.calls[2][0].cursor).toBe('next-1');

  expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(3);

});

We implement it by creating a cursor variable outside the loop. It will be initiated as undefined, but we will populate it with the cursor we receive from the getAuthorFeed response:

async streamPosts(handle: string, onUpdate: (results: any) => any) {
  let cursor: string | undefined = undefined;
  while (true) {
    try {
      const result = await this.#agent.getAuthorFeed({
        actor: handle,
        limit: 20,
        cursor,
        filter: 'posts_with_media'
      });

      if (!result.data?.feed?.length) {
        console.log('No more posts');
        onUpdate({ done: true });
        break;
      }

      onUpdate({ result: result.data.feed, done: !result.data.cursor });

      if (!result.data.cursor) {
        break;
      }

      cursor = result.data.cursor;
      } catch (e) {
      await new Promise(res => setTimeout(res, 5000));
    }
  }
}

For the full solution, click here.

There’s more streaming…

While our streaming solution works, we can keep on enhancing it. 

One way of doing that would be to cache results locally. If we already fetched something, we can keep it in local storage and skip the request.

Another enhancement can be a timed delay of our request. Much like we did with the retry, we can hold the next request by a few hundreds of milliseconds if the request rate is too fast.

One final suggestion would be to add a limit to the number of retries, or gradually increase the retry timeout. 

We will not implement these ideas in this tutorial, but it’s important to note this is not a complete streaming solution.

Running the Bot

We can now stream the posts’ data to our client. The way our bot will work will be as follows:

  1. Stream the posts
  2. For every bunch, process the data

The Run Method

The bot’s entry point will be a run method. If we change our BlueSky service to the public API URL (https://public.api.bsky.app) we will not need to login to get the author feed. We will change it in our AtpAgent initiation:

#agent: AtpAgent = new AtpAgent({ service: 'https://public.api.bsky.app' });

All we need now is a BlueSky handle and a callback for streaming:

describe('run()', () => {
it('should fire the callback on every streaming event', async () => {
queueAgentFeedResponse(fullFeedResponse);
queueAgentFeedResponse(fullFeedLastResponse);
const spy = vi.fn();
await bot.run(handle, 'password', spy);
expect(spy).toHaveBeenCalledTimes(2);
});
});
view raw run.spec.ts hosted with ❤ by GitHub
async run(handle, callback: () => {}) {
       await this.streamPosts(handle, (data) => callback())
   }
}
view raw run.ts hosted with ❤ by GitHub

We use a similar callback mechanism to notify the consumer of newly streamed parsed data.

While this is nice, we want simplify the data structure for the consumer. Our data should look like this:

export interface BotPost {

  imagesWithoutAlt: [];

  text: string;

  createdAt: string;

}

export interface AltlessPosts {

  results: BotPost[],

  done: Boolean;

}

So, we’ll make sure it does:

it('should send parsed output to the callback', async () => {
queueAgentFeedResponse(fullFeedResponseWithAlt);
queueAgentFeedResponse(fullFeedLastResponse);
const spy = vi.fn();
await bot.run(handle, 'password', spy);
expect(spy.mock.calls[0][0]).toEqual({
results: [
{
imagesWithoutAlt: [],
text: 'text1',
createdAt: fullFeedResponseWithAlt.data.feed[0].post.record?.createdAt
},
{
imagesWithoutAlt: [],
text: 'text2',
createdAt: fullFeedResponseWithAlt.data.feed[1].post.record?.createdAt },
],
done: false
});
expect(spy.mock.calls[1][0]).toEqual({
results: [
{
imagesWithoutAlt: [imageWithoutAlt, imageWithoutAlt],
text: 'text1',
createdAt: fullFeedLastResponse.data.feed[0].post.record?.createdAt
},
{
imagesWithoutAlt: [imageWithoutAlt, imageWithoutAlt],
text: 'text2',
createdAt: fullFeedLastResponse.data.feed[1].post.record?.createdAt },
],
done: true
});
});

As usual, we enqueue the messages, call on our bot. This time we expect the callback to receive the expected messages for each chunk of our data.

Implementing it is as easy as:

async run(handle, callback: (results: BotPosts) => { }) {

  await this.streamPosts(handle, ({ result, done }) => {

    const parsedData = {

      results: this.#parseStreamData(result),

      done

    };

    callback(parsedData);

  });

}

Note that #parseStreamData is a private implementation detail. You can view it in the full repository.

If you followed so far, you can be proud. A 100% covered bot:

An image showing test coverage output in the console. It shows all the file `find-altless-posts.ts` has 100% coverage of statments, branches, functions and lines.
A 100% test covered bot

We are ready to use our BlueSky bot for our Alt Text Game of Life!

Summary (of part 1)

We have a well-tested “bot” capable of analyzing BlueSky posts for missing alt text. By leveraging the AtProto SDK and implementing efficient streaming techniques, the bot can handle large amounts of data.

We wrote it in a way that can be used both in a nodejs backend as well as run in the browser. This sets the stage for the next phase of the project: developing a user interface to make this functionality accessible and engaging for BlueSky users. In the upcoming part, we will focus on creating a web component-based UI to gamify the process of adding alt text and encourage wider adoption of accessibility best practices.

If you are eager to see the full code, the complete repository is available on github: https://github.com/YonatanKra/bluesky-alttext-game

5 1 vote
Article Rating
Subscribe
Notify of
guest

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments