OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge commits / 954a9ea

0.0.10: post images reach bsky
Every post carrying a picture reached the fediverse and none reached bsky, reported as "attachment(s) not projected — bsky needs an uploaded blob ref, which is unbuilt". It was unbuilt because nothing here could upload; hpp has had upload-blob all along. planBsky NAMES the images — off the same parse render uses, never a regex over the stripped text, because strip() removes an image entirely and by then there is no evidence a picture was ever in the post. Uploading is I/O, so the caller resolves the oids and builds app.bsky.embed.images, the same split the profile projection uses. ⛔ WITHHELD, NEVER RESIZED. favela caps an avatar at 512 KiB but a post attachment has NO cap — the overlay chunks — so a photo can exceed the 1,000,000 bytes bsky takes. We do not re-encode it: shrinking someone's picture publishes a degraded copy under their name with nothing to say it was altered, which is the same argument that makes this file withhold a long post rather than cut it. The record stays whole on favela and the loss is named. Same for a non-image embed and for the fifth image onward, since bsky takes four. ⚠ A post whose images ALL fail still goes, as text — withholding a post because a picture would not upload loses more than it protects. Alt text travels: the author wrote it and it is an accessibility obligation. One existing test pinned "an image is a loss", which is precisely the gap this closes; it now pins the capability, and the successful-projection-with-losses case it was really testing moved to a mention, which genuinely still cannot be projected. 28/28. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
954a9ea1378bbf5d52c4f20d9f6dc08d6ddb3525
by plankatron <fleeky@gmail.com> · 2026-09-09T23:12:05.000Z

Changed files (4) +132 -11

index.js +43 -2
……@@ -21,7 +21,7 @@
2121import process from 'bare-process'
2222import os from 'bare-os'
2323import { join } from 'bare-path'
24import { planRecord } from './lib/plan.js'
24import { planRecord, selectImages } from './lib/plan.js'
2525import { planProfile, sameProfile } from './lib/profile.js'
2626import { FavelaPeer } from './lib/peer.js'
2727import { BskySink } from './lib/bsky.js'
……@@ -131,9 +131,13 @@ async function cycle (send) {
131131 console.log(line + ' bsky: held — --limit ' + LIMIT + ' reached this cycle')
132132 } else if (!send) {
133133 console.log(line + ' bsky: would send ' + JSON.stringify(plan.bsky.record.text.slice(0, 48)) +
134 (plan.bsky.images.length ? ' + ' + plan.bsky.images.length + ' image(s)' : '') +
134135 (plan.bsky.losses.length ? ' — losing: ' + plan.bsky.losses.join('; ') : ''))
135136 } else {
136 const r = await bsky.send(row.name, plan.bsky.record)
137 const { embed, losses: imgLosses } = await buildEmbed(peer, bsky, plan.bsky.images)
138 for (const l of imgLosses) console.log(line + ' bsky: ' + l)
139 const record = embed ? { ...plan.bsky.record, embed } : plan.bsky.record
140 const r = await bsky.send(row.name, record)
137141 if (r.sent) { sent++; console.log(line + ' bsky: SENT ' + r.rkey + (plan.bsky.losses.length ? ' — lost: ' + plan.bsky.losses.join('; ') : '')) }
138142 else console.log(line + ' bsky: FAILED — ' + r.error)
139143 }
……@@ -160,6 +164,43 @@ async function cycle (send) {
160164 */
161165const blobCache = new Map() // oid -> blob ref. Content-addressed, so this can never go stale.
162166
167/**
168 * Turn the oids planBsky named into an `app.bsky.embed.images`, uploading what is missing.
169 *
170 * ⛔ THE PICTURE IS WHY THE POST EXISTS, quite often — and until now every one of them reached the
171 * fediverse and none reached bsky, reported as "bsky needs an uploaded blob ref, which is unbuilt".
172 * It was unbuilt because nothing here could upload; hpp has always had upload-blob.
173 *
174 * ⚠ A post whose images ALL fail still goes, as text. The alternative is withholding a post because
175 * a picture would not upload, which loses more than it protects.
176 */
177async function buildEmbed (peer, bsky, imgs) {
178 if (!imgs || !imgs.length) return { embed: null, losses: [] }
179
180 const described = []
181 for (const im of imgs) {
182 let f = null
183 try { f = await peer.call('file-get', { oid: im.oid }) } catch { f = null }
184 described.push({ ...im, path: (f && f.path) || null, mimeType: f && f.mimeType, size: f && f.size })
185 }
186
187 const { images, losses } = selectImages(described)
188 const out = []
189 for (const d of images) {
190 let ref = blobCache.get(d.oid)
191 if (!ref) {
192 ref = await bsky.uploadBlob(d.path, d.mimeType)
193 if (ref) blobCache.set(d.oid, ref)
194 }
195 // ⛔ Alt text travels. It is an accessibility obligation and the author already wrote it.
196 if (ref) out.push({ alt: d.alt || '', image: ref })
197 else losses.push('an image could not be uploaded — omitted from this post')
198 }
199
200 if (!out.length) return { embed: null, losses }
201 return { embed: { $type: 'app.bsky.embed.images', images: out }, losses }
202}
203
163204async function reconcileProfile (peer, bsky, reach, send) {
164205 if (!bsky.configured) return
165206 // ⛔ CONSENT FIRST, exactly as for a post. A profile is published content too.
lib/plan.js +52 -5
……@@ -23,7 +23,7 @@
2323
2424import { maxReach } from 'favela-markdown/lib/reach.js'
2525import { strip } from 'favela-markdown/lib/strip.js'
26import { render } from 'favela-markdown/lib/render.js'
26import { render, embeds } from 'favela-markdown/lib/render.js'
2727
2828// app.bsky.feed.post caps `text` at 300 GRAPHEMES and 3000 bytes. Graphemes, not characters — an
2929// emoji is one grapheme and four bytes, and counting the wrong one rejects valid posts.
……@@ -32,6 +32,37 @@ const MAX_BYTES = 3000
3232
3333const BLOB_RE = /!\[[^\]]*\]\(favela:\/\/blob\//
3434
35// app.bsky.embed.images: at most 4, and each blob at most 1,000,000 bytes.
36export const MAX_IMAGES = 4
37export const MAX_IMAGE_BYTES = 1000000
38
39/**
40 * Which described images may actually be sent. Pure.
41 *
42 * ⛔ WITHHELD, NEVER RESIZED. We do not re-encode anyone's picture: favela's own cap is 512 KiB for
43 * an avatar but a POST attachment has no cap at all (the overlay chunks), so a photo can and will
44 * exceed what bsky takes. Shrinking it would publish a degraded copy under the author's name with
45 * nothing to say it had been altered — the same argument that makes plan.js withhold a long post
46 * rather than cut it. The record stays whole on favela and the loss is named.
47 */
48export function selectImages (described) {
49 const images = []
50 const losses = []
51 for (const d of described || []) {
52 if (!d || !d.path) { losses.push('an image could not be read from the peer'); continue }
53 if (!/^image\//.test(d.mimeType || '')) {
54 losses.push('image omitted — bsky takes image/*, this is ' + (d.mimeType || 'untyped'))
55 continue
56 }
57 if (typeof d.size === 'number' && d.size > MAX_IMAGE_BYTES) {
58 losses.push('image omitted — ' + d.size + ' bytes against bsky\'s limit of ' + MAX_IMAGE_BYTES + ' (not resized: that would publish an altered copy)')
59 continue
60 }
61 images.push(d)
62 }
63 return { images, losses }
64}
65
3566const byteLength = (s) => (typeof Buffer !== 'undefined' && Buffer.byteLength)
3667 ? Buffer.byteLength(s, 'utf8')
3768 : new TextEncoder().encode(s).length
……@@ -91,10 +122,24 @@ function planBsky (body, createdAt) {
91122 return WITHHELD('could not strip the record: ' + ((err && err.message) || String(err)))
92123 }
93124
94 // ⚠ Counted from the SOURCE. strip() removes an image entirely, so by the time we hold `text`
95 // there is no evidence a picture was ever in the post.
96 if (BLOB_RE.test(body)) {
97 losses.push('attachment(s) not projected — bsky needs an uploaded blob ref, which is unbuilt')
125 // ⚠ Named from the SOURCE. strip() removes an image entirely, so by the time we hold `text` there
126 // is no evidence a picture was ever in the post — which is why the oids are collected here, off
127 // the same parse render uses, and never off a regex over the stripped text.
128 //
129 // ⛔ NAMED, NOT UPLOADED. Resolving an oid to a blob ref is I/O; this module is pure. The caller
130 // hands back refs and builds the embed, exactly as the profile projection does.
131 let images = []
132 try {
133 images = (embeds(body) || []).filter((e) => e && e.oid && e.kind === 'image').map((e) => ({ oid: e.oid, alt: e.alt || '' }))
134 } catch { images = [] }
135 if (!images.length && BLOB_RE.test(body)) {
136 // The regex sees a blob embed the parser did not return as an image — a link to a file rather
137 // than a picture. Reported so "no images" and "a file bsky has no form for" stay distinguishable.
138 losses.push('attachment(s) not projected — the embed is not an image')
139 }
140 if (images.length > MAX_IMAGES) {
141 losses.push('only the first ' + MAX_IMAGES + ' of ' + images.length + ' images projected — bsky takes ' + MAX_IMAGES)
142 images = images.slice(0, MAX_IMAGES)
98143 }
99144
100145 if (!text.trim()) return { project: false, losses: losses.concat(['nothing to post — no text survives the strip']) }
……@@ -119,6 +164,8 @@ function planBsky (body, createdAt) {
119164 // ⚠ The AUTHOR's time, unchanged. Stamping projection time would make every backfilled record
120165 // claim to be new; the field is the author's statement about when they wrote it.
121166 record: { $type: 'app.bsky.feed.post', text, createdAt },
167 // The caller uploads these and attaches the embed; an empty list means there was nothing to send.
168 images,
122169 losses
123170 }
124171}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-bridge",
3 "version": "0.0.9",
3 "version": "0.0.10",
44 "description": "Projects favela records onto the networks a room may reach. One reader, one reach decision, one projection per network.",
55 "type": "module",
66 "main": "index.js",
test/plan.test.js +36 -3
……@@ -13,7 +13,7 @@
1313// projection loses what the second one never had.
1414
1515import test from 'brittle'
16import { planRecord } from '../lib/plan.js'
16import { planRecord, selectImages, MAX_IMAGE_BYTES } from '../lib/plan.js'
1717
1818const at = '2026-09-07T23:12:04.600Z'
1919const row = (body, extra = {}) => ({ name: '1788822724600', body, createdAt: at, ...extra })
……@@ -69,8 +69,41 @@ test('⛔ too long for bsky is WITHHELD there and still sent to the fediverse',
6969})
7070
7171test('⛔ losses are reported on a SUCCESSFUL projection too', async (t) => {
72 const p = planRecord(row('look ![p.webp](favela://blob/' + 'cd'.repeat(32) + ')'), { roomType: 'public', verb: 'post' })
73 t.ok(p.bsky.losses.length > 0, 'the picture is named: ' + p.bsky.losses.join('; '))
72 // A mention still cannot be projected — resolving favela:// to a did:plc: is unbuilt — and the
73 // post goes anyway with the loss named beside it.
74 const p = planRecord(row('hello [@alice](favela://' + 'ab'.repeat(32) + ')'), { roomType: 'public', verb: 'post' })
75 t.ok(p.bsky.project, 'the post still goes')
76 t.ok(p.bsky.losses.length > 0, 'and the loss is named: ' + p.bsky.losses.join('; '))
77})
78
79// ⛔ THIS USED TO BE A LOSS. "attachment(s) not projected — bsky needs an uploaded blob ref, which
80// is unbuilt" was reported for every post carrying a picture, and the picture reached the fediverse
81// and never bsky. The oids are named here; the caller uploads them and attaches the embed.
82test('★ a post image is NAMED for projection rather than reported as a loss', async (t) => {
83 const oid = 'cd'.repeat(32)
84 const p = planRecord(row('look ![a cat](favela://blob/' + oid + ')'), { roomType: 'public', verb: 'post' })
85 t.is(p.bsky.images.length, 1, 'the picture is offered for upload')
86 t.is(p.bsky.images[0].oid, oid)
87 t.is(p.bsky.images[0].alt, 'a cat', '⛔ alt text is an accessibility obligation, not a nicety')
88 t.absent(p.bsky.losses.some((l) => l.includes('unbuilt')), 'and is no longer reported as unbuilt')
89})
90
91test('at most four images, and the rest are named as dropped', async (t) => {
92 const imgs = Array.from({ length: 6 }, (_, i) => '![' + i + '](favela://blob/' + String(i).repeat(64) + ')').join(' ')
93 const p = planRecord(row('many ' + imgs), { roomType: 'public', verb: 'post' })
94 t.is(p.bsky.images.length, 4, 'bsky takes four')
95 t.ok(p.bsky.losses.some((l) => l.includes('only the first 4 of 6')), 'and says so')
96})
97
98test('⛔ selectImages WITHHOLDS an oversized picture rather than resizing it', async (t) => {
99 const { images, losses } = selectImages([
100 { oid: 'a', path: '/tmp/a.png', mimeType: 'image/png', size: 500 },
101 { oid: 'b', path: '/tmp/b.png', mimeType: 'image/png', size: MAX_IMAGE_BYTES + 1 },
102 { oid: 'c', path: '/tmp/c.pdf', mimeType: 'application/pdf', size: 10 }
103 ])
104 t.is(images.length, 1, 'only the one bsky will take')
105 t.ok(losses.some((l) => l.includes('against bsky')), 'the oversized one is named, not shrunk')
106 t.ok(losses.some((l) => l.includes('image/*')), 'and so is the non-image')
74107})
75108
76109test('⛔ planRecord NEVER throws — it runs on the projection path', async (t) => {