draw things
doodl.waow.tech
draw
atproto
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}