| … | … | @@ -21,10 +21,10 @@ |
| 21 | 21 | import process from 'bare-process' |
| 22 | 22 | import os from 'bare-os' |
| 23 | 23 | import { join } from 'bare-path' |
| 24 | import { planRecord, selectImages } from './lib/plan.js' | |
| 24 | import { planRecord, selectImages, samePost } from './lib/plan.js' | |
| 25 | 25 | import { planProfile, sameProfile } from './lib/profile.js' |
| 26 | 26 | import { FavelaPeer } from './lib/peer.js' |
| 27 | import { BskySink } from './lib/bsky.js' | |
| 27 | import { BskySink, POST_COLLECTION } from './lib/bsky.js' | |
| 28 | 28 | import { tidFor } from './lib/tid.js' |
| 29 | 29 | |
| 30 | 30 | const argv = process.argv.slice(2) |
| … | … | @@ -42,6 +42,12 @@ const LIMIT = (() => { |
| 42 | 42 | // ⛔ --watch implies --send. A watch that only planned would be a process that costs power and |
| 43 | 43 | // changes nothing, and the flag would read as if it were working. |
| 44 | 44 | const WATCH = argv.includes('--watch') |
| 45 | // ⛔ RECONCILIATION, the counterpart to the event path — the same verb ap-bridge has. A post is | |
| 46 | // skipped once its rkey is in the repo, which is right for delivery and wrong the moment the | |
| 47 | // PROJECTION improves: every post made before images worked keeps its text-only version for ever, | |
| 48 | // and no event will ever revisit it. This re-derives each one and updates the ones that CHANGED. | |
| 49 | // Not a --force: it compares against the repo and writes only a real difference. | |
| 50 | const REPROJECT = argv.includes('--reproject') | |
| 45 | 51 | const EVERY = Math.max(5, Number(process.env.FAVELA_BRIDGE_INTERVAL || 15)) * 1000 |
| 46 | 52 | const PEER_SOCK = process.env.FAVELA_PEER_SOCKET || join(os.homedir(), '.config', 'favela-peer', 'favela.sock') |
| 47 | 53 | const BSKY_SOCK = process.env.FAVELA_BSKY_SOCKET || |
| … | … | @@ -122,8 +128,28 @@ async function cycle (send) { |
| 122 | 128 | withheld++ |
| 123 | 129 | console.log(line + ' bsky: withheld — ' + plan.bsky.losses.join('; ')) |
| 124 | 130 | } else if (have.has(tidFor(row.name))) { |
| 125 | skipped++ | |
| 126 | console.log(line + ' bsky: already there') | |
| 131 | if (!REPROJECT) { | |
| 132 | skipped++ | |
| 133 | console.log(line + ' bsky: already there') | |
| 134 | } else { | |
| 135 | const { embed, losses: imgLosses } = await buildEmbed(peer, bsky, plan.bsky.images) | |
| 136 | for (const l of imgLosses) console.log(line + ' bsky: ' + l) | |
| 137 | const desired = embed ? { ...plan.bsky.record, embed } : plan.bsky.record | |
| 138 | const rkey = tidFor(row.name) | |
| 139 | const actual = await bsky.getRecord(POST_COLLECTION, rkey) | |
| 140 | if (actual && actual.unknown) { | |
| 141 | console.log(line + ' bsky: could not read what is there — not touching it') | |
| 142 | } else if (samePost(desired, actual)) { | |
| 143 | skipped++ | |
| 144 | console.log(line + ' bsky: already matches') | |
| 145 | } else if (!send) { | |
| 146 | console.log(line + ' bsky: would REPROJECT ' + rkey + (embed ? ' (+' + embed.images.length + ' image)' : '')) | |
| 147 | } else { | |
| 148 | const r = await bsky.putRecord(POST_COLLECTION, rkey, desired) | |
| 149 | if (r.sent) { sent++; console.log(line + ' bsky: REPROJECTED ' + rkey + (embed ? ' (+' + embed.images.length + ' image)' : '')) } | |
| 150 | else console.log(line + ' bsky: reproject FAILED — ' + r.error) | |
| 151 | } | |
| 152 | } | |
| 127 | 153 | } else if (send && sent >= LIMIT) { |
| 128 | 154 | // Reported per record rather than silently truncated: a run that stopped early and one that |
| 129 | 155 | // had nothing left to do print the same summary otherwise. |
| … | … | @@ -14,6 +14,7 @@ import { tidFor } from './tid.js' |
| 14 | 14 | |
| 15 | 15 | export const PROFILE_COLLECTION = 'app.bsky.actor.profile' |
| 16 | 16 | export const PROFILE_RKEY = 'self' |
| 17 | export const POST_COLLECTION = 'app.bsky.feed.post' | |
| 17 | 18 | |
| 18 | 19 | export class BskySink { |
| 19 | 20 | constructor (sock, opts = {}) { |
| … | … | @@ -88,6 +89,43 @@ export class BskySink { |
| 88 | 89 | // a profile nobody changed. Reconciling against the record actually in the repo — rather than a |
| 89 | 90 | // local note of what we last sent — is also the only version that cannot drift from it. |
| 90 | 91 | |
| 92 | /** | |
| 93 | * One record currently in the repo, or null. `{ unknown: true }` when we could not ask. | |
| 94 | * | |
| 95 | * ⛔ "COULD NOT ASK" IS NOT "ABSENT" and the caller must not treat them alike — writing because a | |
| 96 | * read failed republishes on every transient error, and each write is a repo version. | |
| 97 | */ | |
| 98 | async getRecord (collection, rkey) { | |
| 99 | if (!this.configured) return null | |
| 100 | let client = null | |
| 101 | try { | |
| 102 | client = await this._open() | |
| 103 | return await client.call('get-record', { collection, rkey }) | |
| 104 | } catch (err) { | |
| 105 | this._log('record-read-failed', { collection, rkey, err: (err && err.message) || String(err) }) | |
| 106 | return { unknown: true } | |
| 107 | } finally { | |
| 108 | if (client && client.close) { try { await client.close() } catch {} } | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | /** Replace an existing record — reprojection, not a new post. */ | |
| 113 | async putRecord (collection, rkey, record) { | |
| 114 | if (!this.configured) return { sent: false, error: 'no bsky sink configured' } | |
| 115 | let client = null | |
| 116 | try { | |
| 117 | client = await this._open() | |
| 118 | await client.call('apply-write', { action: 'put', collection, rkey, record }) | |
| 119 | return { sent: true } | |
| 120 | } catch (err) { | |
| 121 | const msg = (err && err.message) || String(err) | |
| 122 | this._log('record-write-failed', { collection, rkey, err: msg }) | |
| 123 | return { sent: false, error: msg } | |
| 124 | } finally { | |
| 125 | if (client && client.close) { try { await client.close() } catch {} } | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 91 | 129 | /** The `app.bsky.actor.profile` currently in the repo, or null. Never throws. */ |
| 92 | 130 | async getProfile () { |
| 93 | 131 | if (!this.configured) return null |
| … | … | @@ -32,9 +32,16 @@ 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. | |
| 35 | // app.bsky.embed.images, quoted from the lexicon rather than recalled: | |
| 36 | // "blob": { "accept": ["image/*"], "maxSize": 2000000 } | |
| 37 | // "The raw image file. May be up to 2 MB, formerly limited to 1 MB." | |
| 38 | // | |
| 39 | // ⛔ I FIRST WROTE 1,000,000 HERE FROM MEMORY, which is the FORMER limit. It would have withheld | |
| 40 | // every picture between 1 and 2 MB — including the one that prompted this work, a 1,044,866-byte | |
| 41 | // screenshot that fits with room to spare. favela-web's resize-plan.js had the right number in a | |
| 42 | // comment the whole time. Read the lexicon, do not remember it. | |
| 36 | 43 | export const MAX_IMAGES = 4 |
| 37 | export const MAX_IMAGE_BYTES = 1000000 | |
| 44 | export const MAX_IMAGE_BYTES = 2000000 | |
| 38 | 45 | |
| 39 | 46 | /** |
| 40 | 47 | * Which described images may actually be sent. Pure. |
| … | … | @@ -45,6 +52,22 @@ export const MAX_IMAGE_BYTES = 1000000 |
| 45 | 52 | * nothing to say it had been altered — the same argument that makes plan.js withhold a long post |
| 46 | 53 | * rather than cut it. The record stays whole on favela and the loss is named. |
| 47 | 54 | */ |
| 55 | /** | |
| 56 | * Is the post we would write already the one that is there? | |
| 57 | * | |
| 58 | * ⛔ Compared against the REPO, like the profile — never a local note of what we last sent. Blob refs | |
| 59 | * compare by CID: the bytes are content-addressed, so an unchanged picture yields an identical ref. | |
| 60 | */ | |
| 61 | export function samePost (desired, actual) { | |
| 62 | if (!desired || !actual) return false | |
| 63 | const cids = (r) => ((r.embed && r.embed.images) || []).map((i) => { | |
| 64 | const b = i && i.image | |
| 65 | return (b && b.ref && b.ref.$link) || (b && b.cid) || null | |
| 66 | }) | |
| 67 | const norm = (r) => JSON.stringify([r.text || '', r.createdAt || '', cids(r), ((r.embed && r.embed.images) || []).map((i) => i.alt || '')]) | |
| 68 | return norm(desired) === norm(actual) | |
| 69 | } | |
| 70 | ||
| 48 | 71 | export function selectImages (described) { |
| 49 | 72 | const images = [] |
| 50 | 73 | const losses = [] |
| … | … | @@ -27,6 +27,12 @@ export const MAX_DESCRIPTION_GRAPHEMES = 256 |
| 27 | 27 | // destination is a decision about their content, not a projection of it. |
| 28 | 28 | export const BSKY_IMAGE_TYPES = ['image/png', 'image/jpeg'] |
| 29 | 29 | |
| 30 | // ⛔ AND A SIZE GATE, which was missing entirely. app.bsky.actor.profile caps avatar and banner at | |
| 31 | // 1,000,000 bytes — HALF the post-embed ceiling, and different in kind: a post image accepts | |
| 32 | // image/*, a profile picture accepts png and jpeg only. Without this an oversized avatar was sent | |
| 33 | // and refused by the PDS, which reads from here as a picture that simply never appears. | |
| 34 | export const MAX_PROFILE_IMAGE_BYTES = 1000000 | |
| 35 | ||
| 30 | 36 | const graphemesOf = (s) => [...String(s || '')].length |
| 31 | 37 | |
| 32 | 38 | // TOTAL, like everything else here: a bio that will not parse degrades to its own text rather than |
| … | … | @@ -81,6 +87,10 @@ export function planProfile ({ name = null, bio = null, avatar = null, banner = |
| 81 | 87 | losses.push(field + ' omitted — bsky takes ' + BSKY_IMAGE_TYPES.join(' or ') + ', this is ' + (img.mimeType || 'untyped')) |
| 82 | 88 | continue |
| 83 | 89 | } |
| 90 | if (typeof img.size === 'number' && img.size > MAX_PROFILE_IMAGE_BYTES) { | |
| 91 | losses.push(field + ' omitted — ' + img.size + ' bytes against a profile limit of ' + MAX_PROFILE_IMAGE_BYTES) | |
| 92 | continue | |
| 93 | } | |
| 84 | 94 | images[field] = img |
| 85 | 95 | } |
| 86 | 96 |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-bridge", |
| 3 | "version": "0.0.10", | |
| 3 | "version": "0.0.11", | |
| 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", |
| … | … | @@ -72,3 +72,12 @@ test('the description is stripped, not handed over raw', (t) => { |
| 72 | 72 | t.absent(p.record.description.includes('**'), '⛔ or the asterisks show on the live profile') |
| 73 | 73 | t.ok(p.record.description.includes('dev'), 'the words survive') |
| 74 | 74 | }) |
| 75 | ||
| 76 | // ⛔ A profile picture caps at 1,000,000 — HALF the post-embed ceiling, and there was no gate at all. | |
| 77 | test('an oversized profile picture is omitted rather than sent to be refused', (t) => { | |
| 78 | const p = planProfile({ name: 'ash', avatar: { oid: 'a', mimeType: 'image/png', size: 1000001 } }) | |
| 79 | t.absent(p.images.avatar, 'not offered for upload') | |
| 80 | t.ok(p.losses.some((l) => l.includes('profile limit')), 'and named') | |
| 81 | const ok = planProfile({ name: 'ash', avatar: { oid: 'a', mimeType: 'image/png', size: 999999 } }) | |
| 82 | t.ok(ok.images.avatar, 'and one that fits still goes') | |
| 83 | }) |