Skip to content

Instantly share code, notes, and snippets.

@YonatanKra
Last active December 21, 2024 04:37
Show Gist options
  • Save YonatanKra/8b40bed199d52ed083a7b0446d029081 to your computer and use it in GitHub Desktop.
Save YonatanKra/8b40bed199d52ed083a7b0446d029081 to your computer and use it in GitHub Desktop.
import { AltTextBot } from './find-altless-posts.js';
let mockAtpAgent;
const resolvedHandle = {
did: 'handle-did'
};
const resetAtpAgentMock = () => {
mockAtpAgent = {
getPost: vi.fn(),
resolveHandle: vi.fn().mockResolvedValue({
data: resolvedHandle
}),
login: vi.fn(),
getAuthorFeed: vi.fn(),
};
}
vi.mock('@atproto/api', () => ({
AtpAgent: vi.fn(() => mockAtpAgent),
}));
describe('AltTextBot', () => {
const postUri = 'postUri';
const handle = 'testUser';
const imageWithoutAlt = { alt: '' };
const imageWithAlt = { alt: 'I have Alt text!' };
const emptyFeedResponse = {
data: {
feed: [],
cursor: undefined,
},
};
const fullFeedResponse = {
data: {
feed: [
{
post: {
uri: 'postUri1', cid: 'postCid1',
embed: {
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt],
$type: 'image'
},
record: {
embed: {
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt],
$type: 'image'
}
}
}
},
{
post: {
uri: 'postUri2', cid: 'postCid2',
embed: {
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt],
$type: 'image'
},
record: {
embed: {
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt],
$type: 'image'
}
}
}
},
],
cursor: 'nextCursor',
},
};
const fullFeedLastResponse = {
data: {
feed: [
{
post: {
uri: 'postUri1', cid: 'postCid1', embed: {
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt],
$type: 'image'
},
record: {
embed: {
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt],
$type: 'image'
}
}
}
},
{
post: {
uri: 'postUri2', cid: 'postCid2', embed: {
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt],
$type: 'image'
},
record: {
embed: {
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt],
$type: 'image'
}
}
}
},
]
},
};
let bot: AltTextBot;
function queueAgentFeedResponse(response) {
mockAtpAgent.getAuthorFeed.mockResolvedValueOnce(response);
}
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);
});
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);
});
it('should return the post with altLess images list', async () => {
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] })
});
});
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,
});
});
});
describe('streamPosts', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
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'
});
});
it('should log when there are no more posts and break', async () => {
queueAgentFeedResponse(emptyFeedResponse);
await bot.streamPosts(handle, () => {});
expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(1);
});
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);
});
it('should retry after 5 seconds if author feed rejected', async () => {
vi.useFakeTimers();
const fetchError = new Error('Fetch error');
mockAtpAgent.getAuthorFeed.mockRejectedValueOnce(fetchError);
bot.streamPosts(handle, () => {});
await vi.advanceTimersByTimeAsync(4999);
const callsBefore5Seconds = mockAtpAgent.getAuthorFeed.mock.calls.length;
await vi.advanceTimersByTimeAsync(1);
expect(callsBefore5Seconds).toBe(1);
expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
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 });
});
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);
});
});
});
import { AtpAgent } from "@atproto/api";
import { Record } from "@atproto/api/dist/client/types/app/bsky/feed/post";
export class AltTextBot {
#agent: AtpAgent = new AtpAgent({ service: 'https://bsky.social' });
#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;
}
}
async login(handle: string, password: string): Promise<void> {
await this.#agent.login({
identifier: handle,
password: password
});
}
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));
}
}
}
}
async function parsePostUri(uri: string, agent: AtpAgent): Promise<{ repo: string; collection: string; rkey: string; }> {
// Extract handle and post ID
const match = uri.match(/profile\/([^/]+)\/post\/([^/]+)/);
if (!match) {
return {
repo: '',
collection: '',
rkey: '',
};
}
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
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment