fancy live pfps
at main 155 lines 4.4 kB view raw
1const ATPROTO_STATUS_URI = 2 "at://did:plc:krxbvxvis5skq7jj6eot23ul/fm.teal.alpha.actor.status/self"; 3const SLACK_TOKENS = [ 4 process.env.SLACK_TOKEN, 5 process.env.SLACK_TOKEN_2, 6].filter(Boolean) as string[]; 7const POLL_INTERVAL = 30_000; // 30s 8const PORT = parseInt(process.env.PORT || "3000", 10); 9 10// --- AT Protocol helpers --- 11 12async function resolveDidToPds(did: string): Promise<string | null> { 13 if (did.startsWith("did:plc:")) { 14 const res = await fetch(`https://plc.directory/${did}`); 15 const doc = await res.json(); 16 return doc.service?.find((s: any) => s.id === "#atproto_pds") 17 ?.serviceEndpoint; 18 } else if (did.startsWith("did:web:")) { 19 const domain = did.slice(8); 20 const res = await fetch(`https://${domain}/.well-known/did.json`); 21 const doc = await res.json(); 22 return doc.service?.find((s: any) => s.id === "#atproto_pds") 23 ?.serviceEndpoint; 24 } 25 return null; 26} 27 28async function fetchAtUriRecord(atUri: string): Promise<any | null> { 29 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/); 30 if (!match) return null; 31 const [, repo, collection, rkey] = match; 32 const pds = await resolveDidToPds(repo); 33 if (!pds) return null; 34 const url = `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(repo)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`; 35 const res = await fetch(url); 36 return res.ok ? res.json() : null; 37} 38 39// --- State --- 40 41type PfpState = "default" | "headphones" | "zz"; 42 43let currentState: PfpState = "default"; 44const lastSlackUpdate = new Map<string, string>(); 45 46// --- Music detection --- 47 48async function checkNowPlaying(): Promise<boolean> { 49 try { 50 const data = await fetchAtUriRecord(ATPROTO_STATUS_URI); 51 if (!data?.value?.item) return false; 52 const expiry = new Date(data.value.expiry).getTime(); 53 return Date.now() <= expiry + 5 * 60_000; 54 } catch { 55 return false; 56 } 57} 58 59// --- Image selection --- 60 61function getHour(): number { 62 return new Date().getHours(); 63} 64 65function getImagePath(hour: number, state: PfpState): string { 66 const h = hour.toString().padStart(2, "0"); 67 const suffix = state === "default" ? "" : `_${state}`; 68 return `./imgs/${h}${suffix}.png`; 69} 70 71function determineState(isPlaying: boolean): PfpState { 72 if (isPlaying) return "headphones"; 73 const hour = getHour(); 74 if (hour >= 0 && hour < 7) return "zz"; 75 return "default"; 76} 77 78// --- Slack --- 79 80async function updateSlackPfp(token: string, label: string, imagePath: string) { 81 if (lastSlackUpdate.get(token) === imagePath) return; 82 83 const file = Bun.file(imagePath); 84 const blob = await file.arrayBuffer(); 85 86 const form = new FormData(); 87 form.append("image", new Blob([blob], { type: "image/png" }), "pfp.png"); 88 89 const res = await fetch("https://slack.com/api/users.setPhoto", { 90 method: "POST", 91 headers: { Authorization: `Bearer ${token}` }, 92 body: form, 93 }); 94 95 const data = await res.json(); 96 if (data.ok) { 97 lastSlackUpdate.set(token, imagePath); 98 console.log(`[slack:${label}] updated pfp to ${imagePath}`); 99 } else { 100 console.error(`[slack:${label}] failed to update pfp:`, data.error); 101 } 102} 103 104// --- Poll loop --- 105 106async function tick() { 107 const isPlaying = await checkNowPlaying(); 108 const state = determineState(isPlaying); 109 const hour = getHour(); 110 const imagePath = getImagePath(hour, state); 111 112 currentState = state; 113 114 await Promise.all( 115 SLACK_TOKENS.map((token, i) => 116 updateSlackPfp(token, i === 0 ? "primary" : `workspace-${i + 1}`, imagePath) 117 ) 118 ); 119} 120 121tick(); 122setInterval(tick, POLL_INTERVAL); 123 124// --- Server --- 125 126Bun.serve({ 127 port: PORT, 128 routes: { 129 "/pfp": async () => { 130 const hour = getHour(); 131 const imagePath = getImagePath(hour, currentState); 132 const file = Bun.file(imagePath); 133 return new Response(file, { 134 headers: { 135 "Content-Type": "image/png", 136 "Cache-Control": "no-cache, no-store, must-revalidate", 137 }, 138 }); 139 }, 140 "/status": () => { 141 return Response.json({ 142 state: currentState, 143 hour: getHour(), 144 image: getImagePath(getHour(), currentState), 145 }); 146 }, 147 }, 148 fetch() { 149 return new Response("Not found", { status: 404 }); 150 }, 151}); 152 153console.log(`livepfp running on http://localhost:${PORT}`); 154console.log(` GET /pfp → current profile picture`); 155console.log(` GET /status → current state as JSON`);