/** * teal.fm → Bluesky bio updater * Adds "Currently listening to {song} by {artists}" to your Bluesky bio * when a track is playing, and removes it when the status expires. * * Required secrets (wrangler secret put …): * BSKY_IDENTIFIER — your Bluesky handle or DID * BSKY_PASSWORD — your Bluesky app password * * KV binding: TEALFM_BIO_KV */ const STATUS_URL = "https://rose.madebydanny.uk/xrpc/com.atproto.repo.getRecord" + "?repo=YOUR_DID_HERE" + "&collection=fm.teal.alpha.actor.status" + "&rkey=self"; const BSKY_PDS = "https://rose.madebydanny.uk"; // Marker so we can reliably find and strip the now-playing line later const NOW_PLAYING_PREFIX = "Currently listening to: "; // KV keys const KV_SESSION_KEY = "bsky_session"; const KV_LAST_LINE_KEY = "bio_last_np_line"; // the exact line we injected last time // ─── Entry point ────────────────────────────────────────────────────────────── export default { // Runs every 2 minutes — adjust crons in wrangler.toml as needed async scheduled(event, env, ctx) { ctx.waitUntil(run(env)); }, // Manual trigger: GET /run async fetch(request, env, ctx) { const url = new URL(request.url); if (url.pathname === "/run") { ctx.waitUntil(run(env)); return new Response("Triggered", { status: 202 }); } return new Response("teal.fm bio updater", { status: 200 }); }, }; // ─── Core logic ─────────────────────────────────────────────────────────────── async function run(env) { // 1. Fetch teal.fm status let nowPlayingLine = null; // null means "not playing" try { const res = await fetch(STATUS_URL); if (res.ok) { const body = await res.json(); const record = body.value; const now = new Date(); const expiry = new Date(record.expiry); if (now <= expiry && record.item) { const item = record.item; const artistName = item.artists.map((a) => a.artistName).join(", "); nowPlayingLine = `${NOW_PLAYING_PREFIX}${item.trackName} by ${artistName}`; } } } catch (err) { console.error("Failed to fetch teal.fm status:", err); // Treat as not playing — safe to continue and potentially clear the bio } // 2. Auth let session; try { session = await getBskySession(env); } catch (err) { console.error("Bluesky auth failed:", err); return; } // 3. Fetch the RAW profile record so we get actual blob CID refs for // avatar and banner (getProfile only returns CDN URLs, not blob refs) let rawRecord; try { const res = await fetch( `${BSKY_PDS}/xrpc/com.atproto.repo.getRecord` + `?repo=${encodeURIComponent(session.did)}` + `&collection=app.bsky.actor.profile` + `&rkey=self`, { headers: { Authorization: `Bearer ${session.accessJwt}` } } ); if (!res.ok) throw new Error(`getRecord (profile) returned ${res.status}`); const body = await res.json(); rawRecord = body.value; } catch (err) { console.error("Failed to fetch raw profile record:", err); return; } const currentBio = rawRecord.description ?? ""; // 4. Work out what the bio should look like const lastLine = await env.TEALFM_BIO_KV.get(KV_LAST_LINE_KEY); const updatedBio = computeNewBio(currentBio, lastLine, nowPlayingLine); // 5. Skip the API call if nothing changed if (updatedBio === currentBio) { console.log("Bio unchanged, skipping update."); return; } // 6. Push the updated profile — spread the entire raw record so every field // (avatar, banner, labels, joinedViaStarterPack, etc.) is preserved exactly // as-is, and only override description. try { const res = await fetch(`${BSKY_PDS}/xrpc/com.atproto.repo.putRecord`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.accessJwt}`, }, body: JSON.stringify({ repo: session.did, collection: "app.bsky.actor.profile", rkey: "self", record: { ...rawRecord, description: updatedBio, }, }), }); if (!res.ok) { const err = await res.text(); throw new Error(`putRecord failed: ${err}`); } console.log("Bio updated:", updatedBio); } catch (err) { console.error("Failed to update profile:", err); return; } // 7. Persist what we injected (or clear it) if (nowPlayingLine) { await env.TEALFM_BIO_KV.put(KV_LAST_LINE_KEY, nowPlayingLine, { expirationTtl: 60 * 60, // auto-clean after 1 hour just in case }); } else { await env.TEALFM_BIO_KV.delete(KV_LAST_LINE_KEY); } } // ─── Bio manipulation ───────────────────────────────────────────────────────── /** * Given the current bio, the line we injected last time (if any), * and the new now-playing line (or null), returns the desired bio string. * * Strategy: * - Strip the old now-playing line if it's present (exact match or prefix match) * - Append the new now-playing line at the end, separated by a blank line * - If not playing, just return the stripped bio */ function computeNewBio(currentBio, lastLine, nowPlayingLine) { let base = currentBio; // Remove the line we previously injected if (lastLine) { // Try exact removal (handles trailing newlines gracefully) base = base.replace(`\n\n${lastLine}`, "").replace(`${lastLine}\n\n`, "").replace(lastLine, ""); } // Also strip any line starting with the prefix in case KV was cleared manually base = base .split("\n") .filter((line) => !line.startsWith(NOW_PLAYING_PREFIX)) .join("\n") .trimEnd(); if (!nowPlayingLine) return base; // Append the new line, with a blank line separator if there's existing bio content return base.length > 0 ? `${base}\n\n${nowPlayingLine}` : nowPlayingLine; } // ─── Bluesky session helpers ────────────────────────────────────────────────── async function getBskySession(env) { const cached = await env.TEALFM_BIO_KV.get(KV_SESSION_KEY, { type: "json" }); if (cached) { try { const check = await fetch(`${BSKY_PDS}/xrpc/com.atproto.server.getSession`, { headers: { Authorization: `Bearer ${cached.accessJwt}` }, }); if (check.ok) return cached; } catch { // Fall through to re-auth } } const res = await fetch(`${BSKY_PDS}/xrpc/com.atproto.server.createSession`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identifier: env.BSKY_IDENTIFIER, password: env.BSKY_PASSWORD, }), }); if (!res.ok) { const err = await res.text(); throw new Error(`createSession failed: ${err}`); } const session = await res.json(); await env.TEALFM_BIO_KV.put(KV_SESSION_KEY, JSON.stringify(session), { expirationTtl: 90 * 60, }); return session; }