draw things doodl.waow.tech
draw atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

doodl / bot / bot.js
8.7 kB 257 lines
1// doodl bot — features new drawings on @doodl.waow.tech, in real time. 2// 3// a single Durable Object holds an OPEN websocket to jetstream.waow.tech 4// (filtered to tech.waow.doodl.drawing) and reacts the instant a drawing is 5// created. it keeps itself warm + reconnects via a self-scheduled DO alarm 6// (NOT a cron — there is no polling; events are processed live as they arrive), 7// and persists a cursor so a dropped socket replays without missing anything. 8// outbound WS from a Worker/DO uses the https scheme + Upgrade header (NOT wss) 9const JETSTREAM = "https://jetstream.waow.tech/subscribe"; 10const SLINGSHOT = "https://slingshot.microcosm.blue"; 11const DRAWING = "tech.waow.doodl.drawing"; 12const PREFERENCES = "tech.waow.doodl.preferences"; 13const APP = "https://doodl.waow.tech"; 14const BOT_HANDLE = "doodl.waow.tech"; 15const HEARTBEAT_MS = 10_000; // keep the DO warm + reconnect if the socket dropped 16 17export class Sub2 { 18 constructor(state, env) { 19 this.state = state; 20 this.env = env; 21 this.ws = null; 22 this.seen = new Set(); // sync in-memory dedup claim (per instance) 23 this.stats = { 24 connects: 0, 25 lastConnect: null, 26 msgs: 0, 27 drawings: 0, 28 posts: 0, 29 errors: [], 30 }; 31 } 32 33 // kick + status: ensure the alarm loop is running and report subscriber stats 34 async fetch(req) { 35 const a = await this.state.storage.getAlarm(); 36 if (a === null) await this.state.storage.setAlarm(Date.now() + HEARTBEAT_MS); 37 await this.ensureConnected(); 38 return Response.json({ 39 wsReadyState: this.ws ? this.ws.readyState : null, 40 cursor: await this.state.storage.get("cursor"), 41 ...this.stats, 42 }); 43 } 44 45 async alarm() { 46 // reschedule first so the loop can never break, then heal the connection 47 await this.state.storage.setAlarm(Date.now() + HEARTBEAT_MS); 48 await this.ensureConnected(); 49 } 50 51 async ensureConnected() { 52 if (this.ws && this.ws.readyState === 1) return; // 1 = OPEN 53 await this.connect(); 54 } 55 56 async connect() { 57 // resume from the last seen event; live (no cursor) on a cold start 58 const cursor = await this.state.storage.get("cursor"); 59 const url = 60 `${JETSTREAM}?wantedCollections=${DRAWING}` + 61 (cursor ? `&cursor=${cursor}` : ""); 62 this.stats.connects++; 63 try { 64 const resp = await fetch(url, { headers: { Upgrade: "websocket" } }); 65 const ws = resp.webSocket; 66 this.stats.lastConnect = `status=${resp.status} ws=${ws ? "ok" : "null"}`; 67 if (!ws) return; 68 ws.accept(); 69 this.ws = ws; 70 ws.addEventListener("message", (e) => this.onMessage(e.data)); 71 ws.addEventListener("close", () => { 72 if (this.ws === ws) this.ws = null; 73 }); 74 ws.addEventListener("error", () => { 75 if (this.ws === ws) this.ws = null; 76 }); 77 } catch (e) { 78 this.stats.lastConnect = `error: ${e.message}`; 79 this.ws = null; 80 } 81 } 82 83 async onMessage(data) { 84 let m; 85 try { 86 m = JSON.parse(data); 87 } catch { 88 return; 89 } 90 this.stats.msgs++; 91 if (m.time_us) await this.state.storage.put("cursor", String(m.time_us)); 92 const c = m.commit; 93 if (m.kind !== "commit" || c?.operation !== "create" || c.collection !== DRAWING) 94 return; 95 const did = m.did; 96 const rkey = c.rkey; 97 const cid = c.record?.image?.ref?.["$link"]; 98 this.stats.drawings++; 99 if (!cid) return; 100 101 // dedup: claim the rkey synchronously (before any await) so concurrent 102 // replays of the same create can't all slip past the check, then back it 103 // with a persistent key that survives reconnects/restarts. 104 const key = `posted:${did}/${rkey}`; 105 if (this.seen.has(key)) return; 106 this.seen.add(key); 107 try { 108 if (await this.state.storage.get(key)) return; 109 if (!this.env.BOT_APP_PASSWORD) { 110 this.stats.errors.push("no BOT_APP_PASSWORD"); 111 return; 112 } 113 if (!(await shouldFeature(did, c.record))) return; // not opted in — silent skip 114 await postDrawing(this.env, did, rkey, cid); 115 await this.state.storage.put(key, 1); 116 this.stats.posts++; 117 } catch (e) { 118 this.seen.delete(key); // allow a retry on transient failure 119 this.stats.errors.push(`post ${rkey}: ${e.message}`.slice(0, 200)); 120 } 121 } 122} 123 124export default { 125 // hitting the worker bootstraps / re-kicks the singleton subscriber. 126 async fetch(req, env) { 127 const name = new URL(req.url).searchParams.get("do") || "v1"; 128 return env.SUB.get(env.SUB.idFromName(name)).fetch(req); 129 }, 130}; 131 132// whether to feature this drawing: its per-drawing override ?? the author's 133// global default ?? off. mirrors src/prefs.ts's cascade (the bot can't import 134// it). default is off — featuring is strictly opt-in. 135async function shouldFeature(did, record) { 136 const override = record?.prefs?.botFeature; 137 if (override) return override === "on"; 138 try { 139 const r = await fetch( 140 `${SLINGSHOT}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${PREFERENCES}&rkey=self`, 141 ); 142 if (!r.ok) return false; // no preferences record → default off 143 const d = await r.json(); 144 return d?.value?.defaults?.botFeature === "on"; 145 } catch { 146 return false; 147 } 148} 149 150async function miniDoc(idOrDid) { 151 const r = await fetch( 152 `${SLINGSHOT}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${idOrDid}`, 153 ); 154 if (!r.ok) throw new Error(`resolveMiniDoc ${r.status}`); 155 return r.json(); 156} 157 158async function postDrawing(env, did, rkey, cid) { 159 const [author, bot] = await Promise.all([miniDoc(did), miniDoc(BOT_HANDLE)]); 160 161 // post the white-backed image (the worker flattens the transparent drawing 162 // onto white), not the raw blob — otherwise the transparency shows the dark 163 // background in Bluesky's dark mode. fall back to the raw blob if /img fails. 164 // upload with the ACTUAL returned content-type (the flattener may emit jpeg). 165 let imgRes = await fetch(`${APP}/img/${did}/${cid}?w=1024&bg=ffffff&fmt=png`, { 166 headers: { accept: "image/png" }, 167 }); 168 let mime = "image/png"; 169 if (imgRes.ok) { 170 mime = imgRes.headers.get("content-type") || "image/png"; 171 } else { 172 imgRes = await fetch(`${author.pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`); 173 } 174 if (!imgRes.ok) throw new Error(`image fetch ${imgRes.status}`); 175 const bytes = new Uint8Array(await imgRes.arrayBuffer()); 176 177 const sess = await jpost(bot.pds, "com.atproto.server.createSession", { 178 identifier: BOT_HANDLE, 179 password: env.BOT_APP_PASSWORD, 180 }); 181 const auth = sess.accessJwt; 182 183 const up = await rawPost( 184 bot.pds, 185 "com.atproto.repo.uploadBlob", 186 bytes, 187 mime, 188 auth, 189 ); 190 const detail = `${APP}/l/${did}/${rkey}`; 191 const text = `a doodl by @${author.handle}\n${detail}`; 192 await jpost( 193 bot.pds, 194 "com.atproto.repo.createRecord", 195 { 196 repo: bot.did, 197 collection: "app.bsky.feed.post", 198 record: { 199 $type: "app.bsky.feed.post", 200 text, 201 createdAt: new Date().toISOString(), 202 facets: postFacets(text, did, author.handle, detail), 203 embed: { 204 $type: "app.bsky.embed.images", 205 images: [{ image: up.blob, alt: `a drawing by @${author.handle}` }], 206 }, 207 }, 208 }, 209 auth, 210 ); 211} 212 213async function jpost(pds, nsid, body, auth) { 214 const r = await fetch(`${pds}/xrpc/${nsid}`, { 215 method: "POST", 216 headers: { 217 "content-type": "application/json", 218 ...(auth ? { authorization: `Bearer ${auth}` } : {}), 219 }, 220 body: JSON.stringify(body), 221 }); 222 if (!r.ok) throw new Error(`${nsid} ${r.status} ${await r.text()}`); 223 return r.json(); 224} 225 226async function rawPost(pds, nsid, bytes, mime, auth) { 227 const r = await fetch(`${pds}/xrpc/${nsid}`, { 228 method: "POST", 229 headers: { "content-type": mime, authorization: `Bearer ${auth}` }, 230 body: bytes, 231 }); 232 if (!r.ok) throw new Error(`${nsid} ${r.status}`); 233 return r.json(); 234} 235 236// facets index into the post text by UTF-8 BYTE offsets, not JS char offsets, 237// so measure each substring with TextEncoder. 238function facet(text, sub, feature) { 239 const enc = new TextEncoder(); 240 const i = text.indexOf(sub); 241 if (i < 0) return null; 242 return { 243 index: { 244 byteStart: enc.encode(text.slice(0, i)).length, 245 byteEnd: enc.encode(text.slice(0, i + sub.length)).length, 246 }, 247 features: [feature], 248 }; 249} 250 251// a mention (clickable @handle → profile, via the author's DID) plus the link 252function postFacets(text, did, handle, url) { 253 return [ 254 facet(text, `@${handle}`, { $type: "app.bsky.richtext.facet#mention", did }), 255 facet(text, url, { $type: "app.bsky.richtext.facet#link", uri: url }), 256 ].filter(Boolean); 257}