this string has no description
0
ignore.js
216 lines 7.4 kB view raw
1/** 2 * teal.fm → Bluesky bio updater 3 * Adds "Currently listening to {song} by {artists}" to your Bluesky bio 4 * when a track is playing, and removes it when the status expires. 5 * 6 * Required secrets (wrangler secret put …): 7 * BSKY_IDENTIFIER — your Bluesky handle or DID 8 * BSKY_PASSWORD — your Bluesky app password 9 * 10 * KV binding: TEALFM_BIO_KV 11 */ 12 13const STATUS_URL = 14 "https://rose.madebydanny.uk/xrpc/com.atproto.repo.getRecord" + 15 "?repo=YOUR_DID_HERE" + 16 "&collection=fm.teal.alpha.actor.status" + 17 "&rkey=self"; 18 19const BSKY_PDS = "https://rose.madebydanny.uk"; 20 21// Marker so we can reliably find and strip the now-playing line later 22const NOW_PLAYING_PREFIX = "Currently listening to: "; 23 24// KV keys 25const KV_SESSION_KEY = "bsky_session"; 26const KV_LAST_LINE_KEY = "bio_last_np_line"; // the exact line we injected last time 27 28// ─── Entry point ────────────────────────────────────────────────────────────── 29export default { 30 // Runs every 2 minutes — adjust crons in wrangler.toml as needed 31 async scheduled(event, env, ctx) { 32 ctx.waitUntil(run(env)); 33 }, 34 35 // Manual trigger: GET /run 36 async fetch(request, env, ctx) { 37 const url = new URL(request.url); 38 if (url.pathname === "/run") { 39 ctx.waitUntil(run(env)); 40 return new Response("Triggered", { status: 202 }); 41 } 42 return new Response("teal.fm bio updater", { status: 200 }); 43 }, 44}; 45 46// ─── Core logic ─────────────────────────────────────────────────────────────── 47async function run(env) { 48 // 1. Fetch teal.fm status 49 let nowPlayingLine = null; // null means "not playing" 50 51 try { 52 const res = await fetch(STATUS_URL); 53 if (res.ok) { 54 const body = await res.json(); 55 const record = body.value; 56 const now = new Date(); 57 const expiry = new Date(record.expiry); 58 59 if (now <= expiry && record.item) { 60 const item = record.item; 61 const artistName = item.artists.map((a) => a.artistName).join(", "); 62 nowPlayingLine = `${NOW_PLAYING_PREFIX}${item.trackName} by ${artistName}`; 63 } 64 } 65 } catch (err) { 66 console.error("Failed to fetch teal.fm status:", err); 67 // Treat as not playing — safe to continue and potentially clear the bio 68 } 69 70 // 2. Auth 71 let session; 72 try { 73 session = await getBskySession(env); 74 } catch (err) { 75 console.error("Bluesky auth failed:", err); 76 return; 77 } 78 79 // 3. Fetch the RAW profile record so we get actual blob CID refs for 80 // avatar and banner (getProfile only returns CDN URLs, not blob refs) 81 let rawRecord; 82 try { 83 const res = await fetch( 84 `${BSKY_PDS}/xrpc/com.atproto.repo.getRecord` + 85 `?repo=${encodeURIComponent(session.did)}` + 86 `&collection=app.bsky.actor.profile` + 87 `&rkey=self`, 88 { headers: { Authorization: `Bearer ${session.accessJwt}` } } 89 ); 90 if (!res.ok) throw new Error(`getRecord (profile) returned ${res.status}`); 91 const body = await res.json(); 92 rawRecord = body.value; 93 } catch (err) { 94 console.error("Failed to fetch raw profile record:", err); 95 return; 96 } 97 98 const currentBio = rawRecord.description ?? ""; 99 100 // 4. Work out what the bio should look like 101 const lastLine = await env.TEALFM_BIO_KV.get(KV_LAST_LINE_KEY); 102 const updatedBio = computeNewBio(currentBio, lastLine, nowPlayingLine); 103 104 // 5. Skip the API call if nothing changed 105 if (updatedBio === currentBio) { 106 console.log("Bio unchanged, skipping update."); 107 return; 108 } 109 110 // 6. Push the updated profile — spread the entire raw record so every field 111 // (avatar, banner, labels, joinedViaStarterPack, etc.) is preserved exactly 112 // as-is, and only override description. 113 try { 114 const res = await fetch(`${BSKY_PDS}/xrpc/com.atproto.repo.putRecord`, { 115 method: "POST", 116 headers: { 117 "Content-Type": "application/json", 118 Authorization: `Bearer ${session.accessJwt}`, 119 }, 120 body: JSON.stringify({ 121 repo: session.did, 122 collection: "app.bsky.actor.profile", 123 rkey: "self", 124 record: { 125 ...rawRecord, 126 description: updatedBio, 127 }, 128 }), 129 }); 130 131 if (!res.ok) { 132 const err = await res.text(); 133 throw new Error(`putRecord failed: ${err}`); 134 } 135 console.log("Bio updated:", updatedBio); 136 } catch (err) { 137 console.error("Failed to update profile:", err); 138 return; 139 } 140 141 // 7. Persist what we injected (or clear it) 142 if (nowPlayingLine) { 143 await env.TEALFM_BIO_KV.put(KV_LAST_LINE_KEY, nowPlayingLine, { 144 expirationTtl: 60 * 60, // auto-clean after 1 hour just in case 145 }); 146 } else { 147 await env.TEALFM_BIO_KV.delete(KV_LAST_LINE_KEY); 148 } 149} 150 151// ─── Bio manipulation ───────────────────────────────────────────────────────── 152/** 153 * Given the current bio, the line we injected last time (if any), 154 * and the new now-playing line (or null), returns the desired bio string. 155 * 156 * Strategy: 157 * - Strip the old now-playing line if it's present (exact match or prefix match) 158 * - Append the new now-playing line at the end, separated by a blank line 159 * - If not playing, just return the stripped bio 160 */ 161function computeNewBio(currentBio, lastLine, nowPlayingLine) { 162 let base = currentBio; 163 164 // Remove the line we previously injected 165 if (lastLine) { 166 // Try exact removal (handles trailing newlines gracefully) 167 base = base.replace(`\n\n${lastLine}`, "").replace(`${lastLine}\n\n`, "").replace(lastLine, ""); 168 } 169 170 // Also strip any line starting with the prefix in case KV was cleared manually 171 base = base 172 .split("\n") 173 .filter((line) => !line.startsWith(NOW_PLAYING_PREFIX)) 174 .join("\n") 175 .trimEnd(); 176 177 if (!nowPlayingLine) return base; 178 179 // Append the new line, with a blank line separator if there's existing bio content 180 return base.length > 0 ? `${base}\n\n${nowPlayingLine}` : nowPlayingLine; 181} 182 183// ─── Bluesky session helpers ────────────────────────────────────────────────── 184async function getBskySession(env) { 185 const cached = await env.TEALFM_BIO_KV.get(KV_SESSION_KEY, { type: "json" }); 186 if (cached) { 187 try { 188 const check = await fetch(`${BSKY_PDS}/xrpc/com.atproto.server.getSession`, { 189 headers: { Authorization: `Bearer ${cached.accessJwt}` }, 190 }); 191 if (check.ok) return cached; 192 } catch { 193 // Fall through to re-auth 194 } 195 } 196 197 const res = await fetch(`${BSKY_PDS}/xrpc/com.atproto.server.createSession`, { 198 method: "POST", 199 headers: { "Content-Type": "application/json" }, 200 body: JSON.stringify({ 201 identifier: env.BSKY_IDENTIFIER, 202 password: env.BSKY_PASSWORD, 203 }), 204 }); 205 206 if (!res.ok) { 207 const err = await res.text(); 208 throw new Error(`createSession failed: ${err}`); 209 } 210 211 const session = await res.json(); 212 await env.TEALFM_BIO_KV.put(KV_SESSION_KEY, JSON.stringify(session), { 213 expirationTtl: 90 * 60, 214 }); 215 return session; 216}