import Path from "path" import { createSource, createSourceOptions, downloadImages, fetchChannel, getPostIdFromPathname, isUnset } from "../lib.js" // TODO: // Pulling images from cache fails because their images were never added to the annotations class BlueskyError extends Error {} let bluesky = {} bluesky.createEndpoint = (domain, method) => { let endpoint = new URL('https://' + domain) endpoint.pathname = Path.join('xrpc', method) return endpoint } bluesky.createHeaders = (client) => { return { 'Accept': 'application/json', 'Authorization': 'Bearer ' + client.accessJwt } } bluesky.handleRequest = response => { return response .then(response => { response = response.json() if(response.error) { throw new BlueskyError(`${response.error}: ${response.message}`) } return response }) .catch(err => { throw new BlueskyError(err) }) } bluesky.login = async (handle, password, domain = 'bsky.social') => { let body = { identifier: handle, password } let endpoint = bluesky.createEndpoint(domain, 'com.atproto.server.createSession') let { accessJwt, refreshJwt } = await bluesky.handleRequest( fetch(endpoint.href, { method: 'POST', headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) ) return { domain, accessJwt, refreshJwt } } const getPostUriChunks = channel => { let guids = channel.querySelectorAll('item > guid') let storedUris = new Set() let chunks = [] let uris = [] let index = 0 for(let guid of guids) { let uri = guid?.textContent if(!uri || storedUris.has(uri)) continue if(index !== 0 && index % 25 === 0) { chunks.push(uris) uris = [] } uris.push(uri) storedUris.add(uri) index++ } chunks.push(uris) return chunks } bluesky.fetchAnnotations = async (client, channel) => { let chunks = getPostUriChunks(channel) let annotations = new Map() for(let uris of chunks) { let endpoint = bluesky.createEndpoint(client.domain, 'app.bsky.feed.getPosts') for(let uri of uris) endpoint.searchParams.append('uris', uri) let { posts } = await bluesky.handleRequest( fetch(endpoint.href, { headers: bluesky.createHeaders(client) }) ) for(let post of posts) { annotations.set(post.uri, post) } } return annotations } export default bluesky bluesky.createSource = (handle, options = {}, client, postReducerCallback, cache) => { let startOfServerHostname = handle.indexOf('.') let user = handle.slice(0, startOfServerHostname) let serverHostname = handle.slice(startOfServerHostname + 1) let source = { type: 'bluesky', description: `Aggregate feed for @${user} on bluesky`, hostname: 'bsky.app', pathname: 'profile/' + handle + "/rss", name: `${serverHostname}-${user}`, displayName: handle, user, handle, postsToPopulate: new Map(), postsChecked: new Set(), ...createSourceOptions(options) } return createSource( source, isUnset(client) ? fetchChannel : bluesky.fetchAnnotatedChannelFor(client), bluesky.processPostAfterCallback(postReducerCallback), cache ) } bluesky.fetchAnnotatedChannelFor = (client) => async (source) => { let channel = await fetchChannel(source) source.postAnnotations = await bluesky.fetchAnnotations(client, channel) return channel } bluesky.processPostAfterCallback = (postReducerCallback) => async (post) => { post.title = post.item.querySelector('description')?.textContent post = await postReducerCallback(post) return post } bluesky.isRepost = (post) => { let annotation = post.source.postAnnotations.get(post.guid) let beenChecked = post.source.postsChecked.has(post.guid) post.source.postsChecked.add(post.guid) return annotation ? beenChecked && annotation.author.handle === post.source.handle : beenChecked } bluesky.pullImages = async (post, view, discardPostIfNoImages) => { let annotation = post.source.postAnnotations.get(post.guid) let noImages = isUnset(annotation.embed) || annotation.embed.type === 'app.bsky.embed.images#view' if(isUnset(annotation) || noImages) { return discardPostIfNoImages ? null : post } post.images = await downloadImages( annotation.embed.images.map(image => image.fullsize), post.source, getPostIdFromPathname(post), view ) return post }