| … | … | @@ -22,6 +22,7 @@ import process from 'bare-process' |
| 22 | 22 | import os from 'bare-os' |
| 23 | 23 | import { join } from 'bare-path' |
| 24 | 24 | import { planRecord } from './lib/plan.js' |
| 25 | import { planProfile, sameProfile } from './lib/profile.js' | |
| 25 | 26 | import { FavelaPeer } from './lib/peer.js' |
| 26 | 27 | import { BskySink } from './lib/bsky.js' |
| 27 | 28 | import { tidFor } from './lib/tid.js' |
| … | … | @@ -101,6 +102,12 @@ async function cycle (send) { |
| 101 | 102 | } |
| 102 | 103 | if (found === null) console.log(' ⚠ could not read the sink — "already there" is unknown below') |
| 103 | 104 | |
| 105 | // ⛔ THE PROFILE IS PROJECTED TOO, and it never was. favela-bridge carried posts and nothing else, | |
| 106 | // so a display name, bio, avatar or banner reached the fediverse (ap-bridge has projected all four | |
| 107 | // from the start) and stopped — the bsky profile stayed empty with nothing reporting a failure, | |
| 108 | // because there was no failure. The projection had simply never been written. | |
| 109 | await reconcileProfile(peer, bsky, reach, send) | |
| 110 | ||
| 104 | 111 | let sent = 0; let skipped = 0; let withheld = 0; let held = 0 |
| 105 | 112 | for (const row of rows) { |
| 106 | 113 | const plan = planRecord(row, { roomType: ROOM_TYPE, verb: 'post' }) |
| … | … | @@ -141,4 +148,68 @@ async function cycle (send) { |
| 141 | 148 | (held ? ' · held by --limit ' + held : '')) |
| 142 | 149 | } |
| 143 | 150 | |
| 151 | /** | |
| 152 | * Bring the bsky profile into line with favela's, and say what it did. | |
| 153 | * | |
| 154 | * ⛔ RECONCILIATION, not an event. There is no "the profile changed" signal to subscribe to here — | |
| 155 | * the cycle already reads the peer every pass, so the honest shape is to compare what SHOULD be | |
| 156 | * there against what IS and act on the difference. Comparing against the repo rather than a local | |
| 157 | * note of what we last sent is the part that matters: a projector that trusts its own memory drifts | |
| 158 | * from the thing it claims to describe, which is how a follower relationship existed in our file and | |
| 159 | * on no server. | |
| 160 | */ | |
| 161 | const blobCache = new Map() // oid -> blob ref. Content-addressed, so this can never go stale. | |
| 162 | ||
| 163 | async function reconcileProfile (peer, bsky, reach, send) { | |
| 164 | if (!bsky.configured) return | |
| 165 | // ⛔ CONSENT FIRST, exactly as for a post. A profile is published content too. | |
| 166 | if (!reach.networks.includes('bsky')) { console.log(' profile bsky: not enabled for this identity'); return } | |
| 167 | ||
| 168 | let prof = null | |
| 169 | try { prof = await peer.call('profile') } catch { prof = null } | |
| 170 | if (!prof) { console.log(' profile could not be read from the peer — leaving bsky alone'); return } | |
| 171 | ||
| 172 | // An oid names a picture the peer holds; this is where it becomes a file on disk. | |
| 173 | const describe = async (oid) => { | |
| 174 | if (!oid) return null | |
| 175 | try { | |
| 176 | const r = await peer.call('avatar-get', { oid }) | |
| 177 | return (r && r.path) ? { oid, path: r.path, mimeType: r.mimeType, size: r.size } : null | |
| 178 | } catch { return null } | |
| 179 | } | |
| 180 | ||
| 181 | const plan = planProfile({ | |
| 182 | name: prof.name, | |
| 183 | bio: prof.bio, | |
| 184 | avatar: await describe(prof.avatar), | |
| 185 | banner: await describe(prof.banner) | |
| 186 | }) | |
| 187 | for (const l of plan.losses) console.log(' profile ' + l) | |
| 188 | if (!plan.project) { console.log(' profile nothing published on favela — leaving bsky alone'); return } | |
| 189 | ||
| 190 | const actual = await bsky.getProfile() | |
| 191 | // ⛔ "COULD NOT ASK" IS NOT "ABSENT". Writing because a read failed would republish the profile on | |
| 192 | // every transient socket error, and each write is a repo version. | |
| 193 | if (actual && actual.unknown) { console.log(' profile could not read what bsky holds — not writing'); return } | |
| 194 | ||
| 195 | const record = { ...plan.record } | |
| 196 | for (const [field, img] of Object.entries(plan.images)) { | |
| 197 | let ref = blobCache.get(img.oid) | |
| 198 | if (!ref) { | |
| 199 | // ⚠ Only uploaded when we do not already hold its ref: the bytes are content-addressed, so a | |
| 200 | // re-upload is idempotent, but it is still a 512 KiB round trip per cycle if done blindly. | |
| 201 | ref = await bsky.uploadBlob(img.path, img.mimeType) | |
| 202 | if (ref) blobCache.set(img.oid, ref) | |
| 203 | } | |
| 204 | if (ref) record[field] = ref | |
| 205 | else console.log(' profile ' + field + ' could not be uploaded — omitted from this write') | |
| 206 | } | |
| 207 | ||
| 208 | if (sameProfile(record, actual)) { console.log(' profile bsky: already matches'); return } | |
| 209 | if (!send) { console.log(' profile bsky: would update (' + Object.keys(record).filter((k) => k !== '$type').join(', ') + ')'); return } | |
| 210 | ||
| 211 | const r = await bsky.putProfile(record) | |
| 212 | console.log(' profile bsky: ' + (r.sent ? 'UPDATED (' + Object.keys(record).filter((k) => k !== '$type').join(', ') + ')' : 'FAILED — ' + r.error)) | |
| 213 | } | |
| 214 | ||
| 144 | 215 | main().catch((err) => { console.error('favela-bridge: ' + ((err && err.message) || err)); process.exit(1) }) |
| … | … | @@ -12,6 +12,9 @@ |
| 12 | 12 | import { Client } from 'hyper-svc' |
| 13 | 13 | import { tidFor } from './tid.js' |
| 14 | 14 | |
| 15 | export const PROFILE_COLLECTION = 'app.bsky.actor.profile' | |
| 16 | export const PROFILE_RKEY = 'self' | |
| 17 | ||
| 15 | 18 | export class BskySink { |
| 16 | 19 | constructor (sock, opts = {}) { |
| 17 | 20 | this.sock = sock || null |
| … | … | @@ -78,6 +81,78 @@ export class BskySink { |
| 78 | 81 | if (client && client.close) { try { await client.close() } catch {} } |
| 79 | 82 | } |
| 80 | 83 | } |
| 84 | // ── the profile ───────────────────────────────────────────────────────────────────────────── | |
| 85 | // | |
| 86 | // ⛔ READ BEFORE WRITE. The bsky profile is a singleton at rkey `self` and applyWrite upserts it, | |
| 87 | // so writing unconditionally on every cycle would append a repo version every fifteen seconds for | |
| 88 | // a profile nobody changed. Reconciling against the record actually in the repo — rather than a | |
| 89 | // local note of what we last sent — is also the only version that cannot drift from it. | |
| 90 | ||
| 91 | /** The `app.bsky.actor.profile` currently in the repo, or null. Never throws. */ | |
| 92 | async getProfile () { | |
| 93 | if (!this.configured) return null | |
| 94 | let client = null | |
| 95 | try { | |
| 96 | client = await this._open() | |
| 97 | return await client.call('get-record', { collection: PROFILE_COLLECTION, rkey: PROFILE_RKEY }) | |
| 98 | } catch (err) { | |
| 99 | // ⛔ null means "absent"; this means "could not ask", and the caller must not treat the two | |
| 100 | // alike — writing because a read failed would republish on every transient error. | |
| 101 | this._log('profile-read-failed', { err: (err && err.message) || String(err) }) | |
| 102 | return { unknown: true } | |
| 103 | } finally { | |
| 104 | if (client && client.close) { try { await client.close() } catch {} } | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | /** | |
| 109 | * Hand hpp a picture BY PATH and return an atproto blob ref, or null. | |
| 110 | * | |
| 111 | * ⛔ A PATH, never bytes. Both ends are on one machine — it is a unix socket — and favela-peer | |
| 112 | * settled this same question the same way. Hex-encoding a picture to hand it to a process that | |
| 113 | * writes it straight back to disk doubles it in transit, and needs a buffer library this package | |
| 114 | * would otherwise not depend on: b4a resolves here only from a PARENT node_modules, so declaring | |
| 115 | * it would have published a tree that could not find it and crashed the installed app at boot. | |
| 116 | */ | |
| 117 | async uploadBlob (path, mimeType) { | |
| 118 | if (!this.configured || !path) return null | |
| 119 | let client = null | |
| 120 | try { | |
| 121 | client = await this._open() | |
| 122 | const r = await client.call('upload-blob', { path, mimeType }) | |
| 123 | if (!r || !r.cid) return null | |
| 124 | // The atproto blob shape. `size` is the byte count hpp actually stored, not what we sent. | |
| 125 | return { $type: 'blob', ref: { $link: r.cid }, mimeType: r.mimeType || mimeType, size: r.size } | |
| 126 | } catch (err) { | |
| 127 | this._log('blob-upload-failed', { err: (err && err.message) || String(err) }) | |
| 128 | return null | |
| 129 | } finally { | |
| 130 | if (client && client.close) { try { await client.close() } catch {} } | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | /** Write the profile record. `{ sent, error? }`, never rejects. */ | |
| 135 | async putProfile (record) { | |
| 136 | if (!this.configured) return { sent: false, error: 'no bsky sink configured' } | |
| 137 | let client = null | |
| 138 | try { | |
| 139 | client = await this._open() | |
| 140 | await client.call('apply-write', { | |
| 141 | // ⛔ `put`, not `create`: the profile is a singleton and every write after the first is a | |
| 142 | // replacement. applyWrite routes both to putRecord, so this is naming the act correctly | |
| 143 | // rather than relying on that. | |
| 144 | action: 'put', collection: PROFILE_COLLECTION, rkey: PROFILE_RKEY, record | |
| 145 | }) | |
| 146 | this._log('profile-written', {}) | |
| 147 | return { sent: true } | |
| 148 | } catch (err) { | |
| 149 | const msg = (err && err.message) || String(err) | |
| 150 | this._log('profile-write-failed', { err: msg }) | |
| 151 | return { sent: false, error: msg } | |
| 152 | } finally { | |
| 153 | if (client && client.close) { try { await client.close() } catch {} } | |
| 154 | } | |
| 155 | } | |
| 81 | 156 | } |
| 82 | 157 | |
| 83 | 158 | export default { BskySink } |
| … | … | @@ -0,0 +1,107 @@ |
| 1 | // profile.js — what a favela profile becomes on bsky. Pure: no I/O, no clock, no throw. | |
| 2 | // | |
| 3 | // The fediverse got a profile projection from the start (ap-bridge's buildActor reads name, summary, | |
| 4 | // icon and image). bsky never did: favela-bridge projected POSTS and nothing else, so a person could | |
| 5 | // set a display name, a bio, an avatar and a banner, watch all four reach Mastodon, and find their | |
| 6 | // bsky profile still empty with nothing anywhere reporting a failure. There was no bug to find — | |
| 7 | // the projection had simply never been written. | |
| 8 | // | |
| 9 | // ⛔ IT DECIDES, IT DOES NOT UPLOAD. Images are named by oid here and resolved to blob refs by the | |
| 10 | // caller, because uploading is I/O and this stays testable without a repo, a socket or a network. | |
| 11 | // | |
| 12 | // ⛔ AND IT NEVER TRUNCATES. `plan.js` withholds a post rather than cutting it at 300 — publishing | |
| 13 | // something the author did not write, under their name, with nothing to say it was cut. A field is | |
| 14 | // the same: an over-long display name is OMITTED and reported, and the rest of the profile still | |
| 15 | // goes. Dropping one field is recoverable; publishing half a sentence as though it were the whole | |
| 16 | // one is not. | |
| 17 | ||
| 18 | // app.bsky.actor.profile: displayName maxGraphemes 64, description maxGraphemes 256. | |
| 19 | export const MAX_NAME_GRAPHEMES = 64 | |
| 20 | export const MAX_DESCRIPTION_GRAPHEMES = 256 | |
| 21 | ||
| 22 | // ⛔ png and jpeg ONLY. favela accepts gif and webp too (avatars.js types from magic bytes and | |
| 23 | // refuses only SVG), and bsky's lexicon does not — so an avatar this peer holds happily can be one | |
| 24 | // bsky will not take. Reported rather than converted: re-encoding somebody's picture to fit a | |
| 25 | // destination is a decision about their content, not a projection of it. | |
| 26 | export const BSKY_IMAGE_TYPES = ['image/png', 'image/jpeg'] | |
| 27 | ||
| 28 | const graphemesOf = (s) => [...String(s || '')].length | |
| 29 | ||
| 30 | /** | |
| 31 | * planProfile({ name, bio, avatar, banner }) -> { project, record, images, losses } | |
| 32 | * | |
| 33 | * `avatar` and `banner` are `{ oid, mimeType, size }` or null — what the peer holds. | |
| 34 | * `images` names which oids the caller must resolve to blob refs, keyed by the field they fill. | |
| 35 | * | |
| 36 | * TOTAL: never throws. An unreadable profile projects nothing rather than a wrong something. | |
| 37 | */ | |
| 38 | export function planProfile ({ name = null, bio = null, avatar = null, banner = null } = {}) { | |
| 39 | const losses = [] | |
| 40 | const record = { $type: 'app.bsky.actor.profile' } | |
| 41 | const images = {} | |
| 42 | ||
| 43 | const n = typeof name === 'string' ? name.trim() : '' | |
| 44 | if (n) { | |
| 45 | if (graphemesOf(n) > MAX_NAME_GRAPHEMES) { | |
| 46 | losses.push('display name omitted — ' + graphemesOf(n) + ' graphemes against a limit of ' + MAX_NAME_GRAPHEMES) | |
| 47 | } else { | |
| 48 | record.displayName = n | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | const d = typeof bio === 'string' ? bio.trim() : '' | |
| 53 | if (d) { | |
| 54 | if (graphemesOf(d) > MAX_DESCRIPTION_GRAPHEMES) { | |
| 55 | losses.push('description omitted — ' + graphemesOf(d) + ' graphemes against a limit of ' + MAX_DESCRIPTION_GRAPHEMES) | |
| 56 | } else { | |
| 57 | record.description = d | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | for (const [field, img] of [['avatar', avatar], ['banner', banner]]) { | |
| 62 | if (!img || !img.oid) continue | |
| 63 | if (!BSKY_IMAGE_TYPES.includes(img.mimeType)) { | |
| 64 | losses.push(field + ' omitted — bsky takes ' + BSKY_IMAGE_TYPES.join(' or ') + ', this is ' + (img.mimeType || 'untyped')) | |
| 65 | continue | |
| 66 | } | |
| 67 | images[field] = img | |
| 68 | } | |
| 69 | ||
| 70 | // ⛔ AN EMPTY RECORD IS NOT A PROFILE. Writing `{$type}` alone would replace whatever is there | |
| 71 | // with nothing — a projection that erases. If a peer has published no profile, there is nothing | |
| 72 | // to project and the right act is to leave the destination alone. | |
| 73 | const project = !!(record.displayName || record.description || Object.keys(images).length) | |
| 74 | return { project, record, images, losses } | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Is the profile we would write already the one that is there? | |
| 79 | * | |
| 80 | * ⛔ COMPARED AGAINST THE REPO, never against a local note of what we last sent. A projector that | |
| 81 | * trusts its own memory writes when it should not and stays silent when it should not — the shape | |
| 82 | * that let ap-bridge deliver into a follower relationship that existed only in our own file. | |
| 83 | * | |
| 84 | * Blob refs compare by their CID: the bytes are content-addressed, so an identical picture yields an | |
| 85 | * identical ref and a changed one cannot collide with it. | |
| 86 | */ | |
| 87 | export function sameProfile (desired, actual) { | |
| 88 | if (!desired || !actual) return false | |
| 89 | const norm = (r) => JSON.stringify({ | |
| 90 | displayName: r.displayName || null, | |
| 91 | description: r.description || null, | |
| 92 | avatar: cidOf(r.avatar), | |
| 93 | banner: cidOf(r.banner) | |
| 94 | }) | |
| 95 | return norm(desired) === norm(actual) | |
| 96 | } | |
| 97 | ||
| 98 | function cidOf (blob) { | |
| 99 | if (!blob) return null | |
| 100 | if (typeof blob === 'string') return blob | |
| 101 | if (blob.ref && typeof blob.ref === 'object' && blob.ref.$link) return blob.ref.$link | |
| 102 | if (typeof blob.ref === 'string') return blob.ref | |
| 103 | if (blob.cid) return blob.cid | |
| 104 | return null | |
| 105 | } | |
| 106 | ||
| 107 | export default { planProfile, sameProfile } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-bridge", |
| 3 | "version": "0.0.7", | |
| 3 | "version": "0.0.8", | |
| 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", |
| … | … | @@ -11,11 +11,11 @@ |
| 11 | 11 | "test": "brittle-bare test/*.test.js" |
| 12 | 12 | }, |
| 13 | 13 | "dependencies": { |
| 14 | "favela-markdown": "file:../favela-markdown", | |
| 15 | "hyper-svc": "file:../hyper-svc", | |
| 16 | 14 | "bare-net": "^1.3.0", |
| 17 | 15 | "bare-process": "^4.0.0", |
| 18 | "favela-identity": "file:../favela-identity" | |
| 16 | "favela-identity": "file:../favela-identity", | |
| 17 | "favela-markdown": "file:../favela-markdown", | |
| 18 | "hyper-svc": "file:../hyper-svc" | |
| 19 | 19 | }, |
| 20 | 20 | "devDependencies": { |
| 21 | 21 | "brittle": "^3.5.0" |
| … | … | @@ -0,0 +1,67 @@ |
| 1 | // The bsky profile projection. Pure, so all of this runs with no repo, no socket and no network. | |
| 2 | import test from 'brittle' | |
| 3 | import { planProfile, sameProfile, MAX_NAME_GRAPHEMES, MAX_DESCRIPTION_GRAPHEMES } from '../lib/profile.js' | |
| 4 | ||
| 5 | const png = (oid) => ({ oid, mimeType: 'image/png', size: 100 }) | |
| 6 | ||
| 7 | test('a full profile projects every field', (t) => { | |
| 8 | const p = planProfile({ name: 'ash', bio: 'downstairs device', avatar: png('a'), banner: png('b') }) | |
| 9 | t.ok(p.project) | |
| 10 | t.is(p.record.$type, 'app.bsky.actor.profile') | |
| 11 | t.is(p.record.displayName, 'ash') | |
| 12 | t.is(p.record.description, 'downstairs device') | |
| 13 | t.alike(Object.keys(p.images).sort(), ['avatar', 'banner'], 'both pictures named for upload') | |
| 14 | t.is(p.losses.length, 0) | |
| 15 | }) | |
| 16 | ||
| 17 | // ⛔ The same rule plan.js applies to a post: never publish something the author did not write. | |
| 18 | test('an over-long field is OMITTED and reported, and the rest still goes', (t) => { | |
| 19 | const p = planProfile({ name: 'x'.repeat(MAX_NAME_GRAPHEMES + 1), bio: 'fine' }) | |
| 20 | t.absent(p.record.displayName, 'the name is not truncated to fit') | |
| 21 | t.is(p.record.description, 'fine', 'and the rest of the profile is unaffected') | |
| 22 | t.ok(p.losses.some((l) => l.includes('display name omitted')), 'and the loss is named') | |
| 23 | t.ok(p.project, 'a profile with one usable field is still worth writing') | |
| 24 | ||
| 25 | const q = planProfile({ bio: 'y'.repeat(MAX_DESCRIPTION_GRAPHEMES + 1) }) | |
| 26 | t.absent(q.record.description) | |
| 27 | t.ok(q.losses.some((l) => l.includes('description omitted'))) | |
| 28 | }) | |
| 29 | ||
| 30 | test('★ an emoji is ONE grapheme, not four bytes', (t) => { | |
| 31 | const p = planProfile({ name: '🌮'.repeat(MAX_NAME_GRAPHEMES) }) | |
| 32 | t.ok(p.record.displayName, 'counted as graphemes — a byte count would reject this') | |
| 33 | }) | |
| 34 | ||
| 35 | // ⛔ favela accepts gif and webp; bsky's lexicon does not. | |
| 36 | test('a picture bsky will not take is omitted rather than converted', (t) => { | |
| 37 | const p = planProfile({ name: 'ash', avatar: { oid: 'a', mimeType: 'image/webp', size: 10 } }) | |
| 38 | t.absent(p.images.avatar, 'not offered for upload') | |
| 39 | t.ok(p.losses.some((l) => l.includes('avatar omitted')), 'and said so') | |
| 40 | t.is(p.record.displayName, 'ash', 'the rest of the profile still projects') | |
| 41 | }) | |
| 42 | ||
| 43 | // ⛔ A projection must never erase. | |
| 44 | test('⛔ an empty profile projects NOTHING rather than an empty record', (t) => { | |
| 45 | const p = planProfile({}) | |
| 46 | t.absent(p.project, 'writing {$type} alone would replace what is there with nothing') | |
| 47 | const q = planProfile({ name: ' ', bio: '' }) | |
| 48 | t.absent(q.project, 'and whitespace is not content') | |
| 49 | }) | |
| 50 | ||
| 51 | // ⛔ Reconciliation compares against the REPO, not a local note of what we last sent. | |
| 52 | test('sameProfile compares blob refs by CID', (t) => { | |
| 53 | const desired = { displayName: 'ash', avatar: { $type: 'blob', ref: { $link: 'bafk1' }, mimeType: 'image/png', size: 1 } } | |
| 54 | const actual = { displayName: 'ash', avatar: { $type: 'blob', ref: { $link: 'bafk1' }, mimeType: 'image/png', size: 1 } } | |
| 55 | t.ok(sameProfile(desired, actual), 'identical content-addressed picture is the same picture') | |
| 56 | ||
| 57 | const changed = { displayName: 'ash', avatar: { $type: 'blob', ref: { $link: 'bafk2' }, mimeType: 'image/png', size: 1 } } | |
| 58 | t.absent(sameProfile(desired, changed), 'a different CID is a different picture') | |
| 59 | t.absent(sameProfile(desired, { displayName: 'ashatron' }), 'a changed name is a change') | |
| 60 | t.absent(sameProfile(desired, null), 'nothing published yet is not "the same"') | |
| 61 | }) | |
| 62 | ||
| 63 | test('adding a banner to a profile that had none is a change', (t) => { | |
| 64 | const before = { displayName: 'ash' } | |
| 65 | const after = { displayName: 'ash', banner: { ref: { $link: 'bafk9' } } } | |
| 66 | t.absent(sameProfile(after, before), '⛔ or the first banner would never be written') | |
| 67 | }) |