this string has no description
0
ignore.js
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}