Something went wrong. Try again.
slices images for the bsky.app photo carousel (min 5 images)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199// publish.js — gallery publish flow (plan Step 7 + red-team PLAN-08)//// Sequential uploadBlob x N (paced + backoff), then createRecord with a// hand-constructed app.bsky.embed.gallery embed — @atproto/api 0.13.35 has// no gallery type (M0 pin decision), so the record is built by hand and// validated client-side against the live gallery.json BEFORE sending// (plan §10 lexicon validation; fail fast locally rather than eating a PDS// 400). On any mid-upload failure we ABANDON: orphan blobs are// garbage-collected by the PDS (blob lifetime is tied to record// references) — surface this as "no cleanup needed" (plan Step 7).
import { RichText, lexicons } from '@atproto/api';import { allAltsValid } from './alt';import { sequentialWithPacing } from './rateLimit';import gallerySchema from '../lexicons/gallery.json';
export const VALID_SELF_LABELS = ['sexual', 'nudity', 'porn', 'graphic-media'];
// Register the live gallery schema on the shared lexicons instance (which// already carries app.bsky.embed.defs#aspectRatio) so we can validate the// embed before sending. Idempotent.let galleryRegistered = false;
function ensureGallerySchema() { if (!galleryRegistered) { lexicons.add(gallerySchema); galleryRegistered = true; }}
/** * Validate a hand-constructed gallery embed against the live lexicon. * Throws with a descriptive message when invalid. */export function validateGalleryEmbed(embed) { ensureGallerySchema(); // lexicon 0.4.14: validate() returns {success, error} — it does not // throw for nested failures (only for a non-object top-level value). const result = lexicons.validate('app.bsky.embed.gallery#main', embed);
if (!result.success) { throw new Error(`Gallery embed failed lexicon validation: ${result.error.message}`); }
return embed;}
/** * Build the app.bsky.feed.post record. * * @param {Object} args * @param {string} args.text post text (empty allowed) * @param {BlobRef[]} args.blobRefs uploadBlob results, in slice order * @param {string[]} args.alts per-slice alt text (length === blobRefs) * @param {{width:number,height:number}} args.aspectRatio identical for all * slices (geometry invariant) * @param {string[]} [args.labels=[]] self-labels from * VALID_SELF_LABELS (red-team PLAN-08) * @param {Agent} args.agent for RichText facet detection */export async function buildPostRecord({ text, blobRefs, alts, aspectRatio, labels = [], agent }) { if (!allAltsValid(alts)) { throw new Error('Every slice needs non-empty alt text before publishing (guardrail 14).'); }
if (blobRefs.length !== alts.length) { throw new Error(`Blob count (${blobRefs.length}) does not match alt count (${alts.length}).`); }
for (const label of labels) { if (!VALID_SELF_LABELS.includes(label)) { throw new Error(`Invalid self-label: ${label}. Valid: ${VALID_SELF_LABELS.join(', ')}`); } }
const record = { $type: 'app.bsky.feed.post', text: text || '', createdAt: new Date().toISOString(), embed: { $type: 'app.bsky.embed.gallery', items: blobRefs.map((blob, i) => ({ // Schema discovery 2026-08-19: gallery items are a UNION of // #image refs, so each item REQUIRES the $type discriminator // (the plan's §5a hypothesis omitted it; live gallery.json wins). $type: 'app.bsky.embed.gallery#image', image: blob, alt: alts[i], aspectRatio, })), }, };
if (labels.length > 0) { record.labels = { $type: 'com.atproto.label.defs#selfLabels', values: labels.map((val) => ({ $type: 'com.atproto.label.defs#selfLabel', val, })), }; }
// RichText facet detection so links/mentions in the post text work // (plan §6). Requires the agent for handle resolution. if (record.text && agent) { const rt = new RichText({ text: record.text }); await rt.detectFacets(agent);
if (rt.facets?.length > 0) { record.facets = rt.facets; } }
validateGalleryEmbed(record.embed);
return record;}
/** * Publish the panorama. * * @param {Object} args * @param {Agent} args.agent authenticated session agent * @param {string} args.did the account DID * @param {Blob[]} args.blobs encoded slice blobs (order = slice order) * @param {string[]} args.alts per-slice alt text * @param {{width:number,height:number}} args.aspectRatio * @param {string} [args.text=''] post text * @param {string[]} [args.labels=[]] self-labels * @param {(done:number, total:number) => void} [args.onProgress] * @param {Object} [args.pacing] passed to sequentialWithPacing * @returns {Promise<{uri:string, cid:string, permalink:string}>} */export async function publishGallery({ agent, did, blobs, alts, aspectRatio, text = '', labels = [], onProgress, pacing,}) { if (!agent) throw new Error('No authenticated agent — log in first.');
if (!blobs || blobs.length < 5) { throw new Error('Publishing requires at least 5 encoded slices.'); }
if (!allAltsValid(alts)) { throw new Error('Every slice needs non-empty alt text before publishing (guardrail 14).'); }
// 1. Sequential uploadBlob with pacing + backoff (plan Step 7). // NOTE (verified 2026-08-19): api 0.13.35's generated uploadBlob // signature is uploadBlob(data, opts) — the blob IS the body, not // wrapped in {data}. content-type goes in opts.headers. const blobRefs = [];
await sequentialWithPacing( blobs, async (blob, i) => { const res = await agent.api.com.atproto.repo.uploadBlob(blob, { headers: { 'content-type': blob.type || 'image/webp' }, });
blobRefs.push(res.data.blob); onProgress?.(i + 1, blobs.length);
return res; }, pacing );
// 2. Build + validate the record, then createRecord. const record = await buildPostRecord({ text, blobRefs, alts, aspectRatio, labels, agent, });
const createRes = await agent.api.com.atproto.repo.createRecord({ repo: did, collection: 'app.bsky.feed.post', record, });
const uri = createRes.data.uri; const cid = createRes.data.cid; const rkey = uri.split('/').pop(); const permalink = `https://bsky.app/profile/${did}/post/${rkey}`;
return { uri, cid, permalink };}