import type * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; import type * as ComAtprotoRepoApplyWrites from "@atcute/atproto/types/repo/applyWrites"; import type * as ComAtprotoRepoStrongRef from "@atcute/atproto/types/repo/strongRef"; import type * as AppBskyEmbedExternal from "@atcute/bluesky/types/app/embed/external"; import type * as AppBskyEmbedImages from "@atcute/bluesky/types/app/embed/images"; import type * as AppBskyEmbedRecord from "@atcute/bluesky/types/app/embed/record"; import type * as AppBskyEmbedVideo from "@atcute/bluesky/types/app/embed/video"; import type * as AppBskyFeedDefs from "@atcute/bluesky/types/app/feed/defs"; import type * as AppBskyFeedPost from "@atcute/bluesky/types/app/feed/post"; import type * as AppBskyFeedThreadgate from "@atcute/bluesky/types/app/feed/threadgate"; import { ClientResponseError, ok, type Client } from "@atcute/client"; import type { $type, Blob as AtBlob, CanonicalResourceUri, ResourceUri, Did, GenericUri } from "@atcute/lexicons"; import type * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet"; import * as TID from "@atcute/tid"; import { encode } from "@atcute/cbor"; import { create, toString } from "@atcute/cid"; export async function serializeRecordCid(record: { $type: string }): Promise { const bytes = encode(record); const cid = await create(0x71, bytes); const serialized = toString(cid); return serialized; } let lastTimestamp: number = 0; /** * Return the current time, make sure that each call never returns same value * so that posts sent at the same time from different accounts don't end up * colliding with each other potentially causing them to not be shown. */ function getNow(threadSize: number): number { let timestamp = Math.max(Date.now(), lastTimestamp); lastTimestamp = timestamp + 2 + threadSize; return timestamp; } export interface MediaAspectRatio { width: number; height: number; } export interface PostExternalEmbed { type: "external"; uri: GenericUri; title: string; description?: string; thumbnail?: Blob | AtBlob; labels?: string[]; } export interface ComposedImage { blob: Blob | AtBlob; alt?: string; aspectRatio?: MediaAspectRatio; } export interface PostImageEmbed { type: "image"; images: ComposedImage[]; labels?: string[]; } export interface PostVideoEmbed { type: "video"; blob: Blob | AtBlob; alt?: string; aspectRatio?: MediaAspectRatio; labels?: string[]; } export type PostMediaEmbed = PostExternalEmbed | PostImageEmbed | PostVideoEmbed; export interface PostFeedEmbed { type: "feed"; uri: ResourceUri; cid?: string; } export interface PostListEmbed { type: "list"; uri: ResourceUri; cid?: string; } export interface PostQuoteEmbed { type: "quote"; uri: ResourceUri; cid?: string; } export interface PostStarterpackEmbed { type: "starterpack"; uri: ResourceUri; cid?: string; } export type PostRecordEmbed = PostFeedEmbed | PostListEmbed | PostQuoteEmbed | PostStarterpackEmbed; /** Embed in a post, can contain media and links to other records */ export interface PostEmbed { media?: PostMediaEmbed; record?: PostRecordEmbed; } export interface ComposedPost { languages?: string[]; content: { text: string; facets?: AppBskyRichtextFacet.Main[]; }; embed?: PostEmbed; } export interface ComposedThreadgate { follows?: boolean; mentions?: boolean; listUris?: ResourceUri[]; } export interface ComposedThread { client: Client; signal?: AbortSignal; author: Did; createdAt?: string | number | Date; reply?: string | AppBskyFeedDefs.PostView | AppBskyEmbedRecord.ViewRecord; gate?: ComposedThreadgate; languages?: string[]; posts: ComposedPost[]; } /** * Create post records and publish them * @param client An authenticated Bluesky RPC client * @param thread Composed thread * @returns An array of post records that were published */ export async function publishThread( client: Client, thread: Omit ): Promise<$type.enforce[]> { const records = await createThread({ ...thread, client: client }); await ok( client.post("com.atproto.repo.applyWrites", { signal: thread.signal, input: { repo: thread.author, writes: records, }, }) ); return records; } async function createThread(thread: ComposedThread): Promise<$type.enforce[]> { const client = thread.client; const signal = thread.signal; const did = thread.author; const posts = thread.posts; const threadgate = thread.gate; const languages = thread.languages; const writes: $type.enforce[] = []; const now = thread.createdAt !== undefined ? new Date(thread.createdAt) : new Date(getNow(posts.length)); assert(!Number.isNaN(now.getTime()), `provided createdAt value is invalid`); let reply: AppBskyFeedPost.ReplyRef | undefined; let rkey: string | undefined; if (thread.reply) { let post = thread.reply; if (typeof post === "string") { // AT-URI being passed post = await getPost(post as ResourceUri); } let root: ComAtprotoRepoStrongRef.Main | undefined; let ref: ComAtprotoRepoStrongRef.Main; if ("record" in post) { // AppBskyFeedDefs.PostView being passed root = (post.record as AppBskyFeedPost.Main).reply?.root; ref = { uri: post.uri, cid: post.cid }; } else if ("value" in post) { // AppBskyEmbedRecord.ViewRecord being passed root = (post.value as AppBskyFeedPost.Main).reply?.root; ref = { uri: post.uri, cid: post.cid }; } else { assert(false, `Unexpected end of code`); } reply = { root: root ? { uri: root.uri, cid: root.cid } : ref, parent: ref, }; } assert(!reply || !threadgate, `threadgate and reply are mutually exclusive`); for (let idx = 0, len = posts.length; idx < len; idx++) { // Get the record key for this post rkey = TID.createRaw(now.getTime(), Math.floor(Math.random() * 1024)); const post = posts[idx]; const uri: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`; // Resolve embeds let embed: AppBskyFeedPost.Main["embed"]; if (post.embed !== undefined) { embed = await resolveEmbed(post.embed); } // Get the self-labels const labels = getEmbedLabels(post.embed); let selfLabels: $type.enforce | undefined; if (labels?.length) { selfLabels = { $type: "com.atproto.label.defs#selfLabels", values: labels.map((val) => ({ val })), }; } // Now form the record const content = post.content; const record: AppBskyFeedPost.Main = { $type: "app.bsky.feed.post", createdAt: now.toISOString(), text: content.text, facets: content.facets, reply: reply, embed: embed, langs: post.languages ?? languages, labels: selfLabels, tags: ["DuskskyApp"], }; writes.push({ $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.feed.post", rkey: rkey, value: record, }); // If this is the first post, and we have a threadgate set, create one now. if (idx === 0 && threadgate) { const threadgateRecord: AppBskyFeedThreadgate.Main = { $type: "app.bsky.feed.threadgate", createdAt: now.toISOString(), post: uri, allow: resolveThreadgate(threadgate), }; writes.push({ $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.feed.threadgate", rkey: rkey, value: threadgateRecord, }); } if (idx !== len - 1) { // Retrieve the next reply reference const serialized = await serializeRecordCid(record); const ref: ComAtprotoRepoStrongRef.Main = { cid: serialized, uri: uri, }; reply = { root: reply ? reply.root : ref, parent: ref, }; // Posts are not guaranteed to be shown in the correct order if they are // all posted with the same timestamp. now.setMilliseconds(now.getMilliseconds() + 1); } } return writes; async function resolveEmbed(embed: PostEmbed): Promise { const { media, record } = embed; if (media && record) { return { $type: "app.bsky.embed.recordWithMedia", media: await resolveMediaEmbed(media), record: await resolveRecordEmbed(record), }; } else if (media) { return resolveMediaEmbed(media); } else if (record) { return resolveRecordEmbed(record); } return; async function resolveMediaEmbed( embed: PostMediaEmbed ): Promise<$type.enforce> { const type = embed.type; if (type === "external") { const rawThumb = embed.thumbnail; let thumb: AtBlob | undefined; if (rawThumb !== undefined) { if (rawThumb instanceof Blob) { thumb = await uploadBlob(rawThumb); } else { thumb = rawThumb; } } return { $type: "app.bsky.embed.external", external: { uri: embed.uri, title: embed.title, description: embed.description ?? "", thumb: thumb, }, }; } if (type === "image") { const images: AppBskyEmbedImages.Image[] = []; for (const image of embed.images) { const aspectRatio = image.aspectRatio; const rawBlob = image.blob; let blob: AtBlob; if (rawBlob instanceof Blob) { blob = await uploadBlob(rawBlob); } else { blob = rawBlob; } images.push({ image: blob, alt: image.alt ?? "", aspectRatio: aspectRatio ? { width: aspectRatio.width, height: aspectRatio.height } : undefined, }); } return { $type: "app.bsky.embed.images", images: images, }; } if (type === "video") { const aspectRatio = embed.aspectRatio; const rawBlob = embed.blob; let blob: AtBlob | undefined; if (rawBlob instanceof Blob) { blob = await uploadBlob(rawBlob); } else { blob = rawBlob; } return { $type: "app.bsky.embed.video", video: blob, alt: embed.alt ?? "", aspectRatio: aspectRatio ? { width: aspectRatio.width, height: aspectRatio.height } : undefined, }; } assert(false, `Unexpected end of code`); } async function resolveRecordEmbed(embed: PostRecordEmbed): Promise<$type.enforce> { const uri = embed.uri; let cid = embed.cid; if (cid === undefined) { const type = embed.type; if (type === "quote") { const post = await getPost(uri); cid = post.cid; } else if (type === "feed") { const data = await ok( client.get("app.bsky.feed.getFeedGenerator", { signal: signal, params: { feed: uri }, }) ); cid = data.view.cid; } else if (type === "list") { const data = await ok( client.get("app.bsky.graph.getList", { signal: signal, params: { list: uri, limit: 1 }, }) ); cid = data.list.cid; } else if (type === "starterpack") { const data = await ok( client.get("app.bsky.graph.getStarterPack", { signal: signal, params: { starterPack: uri }, }) ); cid = data.starterPack.cid; } else { assert(false, `Unexpected end of code`); } } return { $type: "app.bsky.embed.record", record: { uri: uri, cid: cid, }, }; } } async function uploadBlob(blob: Blob): Promise { const data = await ok( client.post("com.atproto.repo.uploadBlob", { signal: signal, input: blob, }) ); return data.blob; } async function getPost(uri: ResourceUri): Promise { const data = await ok( client.get("app.bsky.feed.getPosts", { signal: signal, params: { uris: [uri], }, }) ); const post = data.posts[0]; if (!post) { throw new ClientResponseError({ status: 400, data: { error: "NotFound", message: `Post not found: ${uri}` }, }); } return post; } } function resolveThreadgate(gate: ComposedThreadgate): AppBskyFeedThreadgate.Main["allow"] { const rules: AppBskyFeedThreadgate.Main["allow"] = []; if (gate.follows) { rules.push({ $type: "app.bsky.feed.threadgate#followingRule" }); } if (gate.mentions) { rules.push({ $type: "app.bsky.feed.threadgate#mentionRule" }); } for (const listUri of gate.listUris ?? []) { rules.push({ $type: "app.bsky.feed.threadgate#listRule", list: listUri }); } return rules; } function getEmbedLabels(embed: PostEmbed | undefined): string[] | undefined { const media = embed?.media; if (media !== undefined) { const type = media.type; if (type === "image" || type === "external") { return media.labels; } } } function assert(condition: boolean, message: string): asserts condition { if (!condition) { throw new Error(message); } }