| … | … | @@ -21,7 +21,7 @@ |
| 21 | 21 | import process from 'bare-process' |
| 22 | 22 | import os from 'bare-os' |
| 23 | 23 | import { join } from 'bare-path' |
| 24 | import { planRecord } from './lib/plan.js' | |
| 24 | import { planRecord, selectImages } from './lib/plan.js' | |
| 25 | 25 | import { planProfile, sameProfile } from './lib/profile.js' |
| 26 | 26 | import { FavelaPeer } from './lib/peer.js' |
| 27 | 27 | import { BskySink } from './lib/bsky.js' |
| … | … | @@ -131,9 +131,13 @@ async function cycle (send) { |
| 131 | 131 | console.log(line + ' bsky: held — --limit ' + LIMIT + ' reached this cycle') |
| 132 | 132 | } else if (!send) { |
| 133 | 133 | 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)' : '') + | |
| 134 | 135 | (plan.bsky.losses.length ? ' — losing: ' + plan.bsky.losses.join('; ') : '')) |
| 135 | 136 | } 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) | |
| 137 | 141 | if (r.sent) { sent++; console.log(line + ' bsky: SENT ' + r.rkey + (plan.bsky.losses.length ? ' — lost: ' + plan.bsky.losses.join('; ') : '')) } |
| 138 | 142 | else console.log(line + ' bsky: FAILED — ' + r.error) |
| 139 | 143 | } |
| … | … | @@ -160,6 +164,43 @@ async function cycle (send) { |
| 160 | 164 | */ |
| 161 | 165 | const blobCache = new Map() // oid -> blob ref. Content-addressed, so this can never go stale. |
| 162 | 166 | |
| 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 | */ | |
| 177 | async 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 | ||
| 163 | 204 | async function reconcileProfile (peer, bsky, reach, send) { |
| 164 | 205 | if (!bsky.configured) return |
| 165 | 206 | // ⛔ CONSENT FIRST, exactly as for a post. A profile is published content too. |
| … | … | @@ -23,7 +23,7 @@ |
| 23 | 23 | |
| 24 | 24 | import { maxReach } from 'favela-markdown/lib/reach.js' |
| 25 | 25 | import { strip } from 'favela-markdown/lib/strip.js' |
| 26 | import { render } from 'favela-markdown/lib/render.js' | |
| 26 | import { render, embeds } from 'favela-markdown/lib/render.js' | |
| 27 | 27 | |
| 28 | 28 | // app.bsky.feed.post caps `text` at 300 GRAPHEMES and 3000 bytes. Graphemes, not characters — an |
| 29 | 29 | // emoji is one grapheme and four bytes, and counting the wrong one rejects valid posts. |
| … | … | @@ -32,6 +32,37 @@ const MAX_BYTES = 3000 |
| 32 | 32 | |
| 33 | 33 | const BLOB_RE = /!\[[^\]]*\]\(favela:\/\/blob\// |
| 34 | 34 | |
| 35 | // app.bsky.embed.images: at most 4, and each blob at most 1,000,000 bytes. | |
| 36 | export const MAX_IMAGES = 4 | |
| 37 | export 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 | */ | |
| 48 | export 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 | ||
| 35 | 66 | const byteLength = (s) => (typeof Buffer !== 'undefined' && Buffer.byteLength) |
| 36 | 67 | ? Buffer.byteLength(s, 'utf8') |
| 37 | 68 | : new TextEncoder().encode(s).length |
| … | … | @@ -91,10 +122,24 @@ function planBsky (body, createdAt) { |
| 91 | 122 | return WITHHELD('could not strip the record: ' + ((err && err.message) || String(err))) |
| 92 | 123 | } |
| 93 | 124 | |
| 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) | |
| 98 | 143 | } |
| 99 | 144 | |
| 100 | 145 | 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) { |
| 119 | 164 | // ⚠ The AUTHOR's time, unchanged. Stamping projection time would make every backfilled record |
| 120 | 165 | // claim to be new; the field is the author's statement about when they wrote it. |
| 121 | 166 | 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, | |
| 122 | 169 | losses |
| 123 | 170 | } |
| 124 | 171 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-bridge", |
| 3 | "version": "0.0.9", | |
| 3 | "version": "0.0.10", | |
| 4 | 4 | "description": "Projects favela records onto the networks a room may reach. One reader, one reach decision, one projection per network.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -13,7 +13,7 @@ |
| 13 | 13 | // projection loses what the second one never had. |
| 14 | 14 | |
| 15 | 15 | import test from 'brittle' |
| 16 | import { planRecord } from '../lib/plan.js' | |
| 16 | import { planRecord, selectImages, MAX_IMAGE_BYTES } from '../lib/plan.js' | |
| 17 | 17 | |
| 18 | 18 | const at = '2026-09-07T23:12:04.600Z' |
| 19 | 19 | const 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', |
| 69 | 69 | }) |
| 70 | 70 | |
| 71 | 71 | test('⛔ losses are reported on a SUCCESSFUL projection too', async (t) => { |
| 72 | const p = planRecord(row('look  + ')'), { 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. | |
| 82 | test('★ 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 '), { 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 | ||
| 91 | test('at most four images, and the rest are named as dropped', async (t) => { | |
| 92 | const imgs = Array.from({ length: 6 }, (_, 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 | ||
| 98 | test('⛔ 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') | |
| 74 | 107 | }) |
| 75 | 108 | |
| 76 | 109 | test('⛔ planRecord NEVER throws — it runs on the projection path', async (t) => { |