fancy live pfps

feat: init

dunkirk.sh 2b62b626 2bb5f0a9

verified
+357
+35
.gitignore
··· 1 + # dependencies (bun install) 2 + node_modules 3 + 4 + # output 5 + out 6 + dist 7 + *.tgz 8 + 9 + # code coverage 10 + coverage 11 + *.lcov 12 + 13 + # logs 14 + logs 15 + _.log 16 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 + 18 + # dotenv environment variable files 19 + .env 20 + .env.development.local 21 + .env.test.local 22 + .env.production.local 23 + .env.local 24 + 25 + # caches 26 + .eslintcache 27 + .cache 28 + *.tsbuildinfo 29 + 30 + # IntelliJ based IDEs 31 + .idea 32 + 33 + # Finder (MacOS) folder config 34 + .DS_Store 35 +
+106
CLAUDE.md
··· 1 + 2 + Default to using Bun instead of Node.js. 3 + 4 + - Use `bun <file>` instead of `node <file>` or `ts-node <file>` 5 + - Use `bun test` instead of `jest` or `vitest` 6 + - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` 7 + - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` 8 + - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` 9 + - Use `bunx <package> <command>` instead of `npx <package> <command>` 10 + - Bun automatically loads .env, so don't use dotenv. 11 + 12 + ## APIs 13 + 14 + - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. 15 + - `bun:sqlite` for SQLite. Don't use `better-sqlite3`. 16 + - `Bun.redis` for Redis. Don't use `ioredis`. 17 + - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. 18 + - `WebSocket` is built-in. Don't use `ws`. 19 + - Prefer `Bun.file` over `node:fs`'s readFile/writeFile 20 + - Bun.$`ls` instead of execa. 21 + 22 + ## Testing 23 + 24 + Use `bun test` to run tests. 25 + 26 + ```ts#index.test.ts 27 + import { test, expect } from "bun:test"; 28 + 29 + test("hello world", () => { 30 + expect(1).toBe(1); 31 + }); 32 + ``` 33 + 34 + ## Frontend 35 + 36 + Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. 37 + 38 + Server: 39 + 40 + ```ts#index.ts 41 + import index from "./index.html" 42 + 43 + Bun.serve({ 44 + routes: { 45 + "/": index, 46 + "/api/users/:id": { 47 + GET: (req) => { 48 + return new Response(JSON.stringify({ id: req.params.id })); 49 + }, 50 + }, 51 + }, 52 + // optional websocket support 53 + websocket: { 54 + open: (ws) => { 55 + ws.send("Hello, world!"); 56 + }, 57 + message: (ws, message) => { 58 + ws.send(message); 59 + }, 60 + close: (ws) => { 61 + // handle close 62 + } 63 + }, 64 + development: { 65 + hmr: true, 66 + console: true, 67 + } 68 + }) 69 + ``` 70 + 71 + HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle. 72 + 73 + ```html#index.html 74 + <html> 75 + <body> 76 + <h1>Hello, world!</h1> 77 + <script type="module" src="./frontend.tsx"></script> 78 + </body> 79 + </html> 80 + ``` 81 + 82 + With the following `frontend.tsx`: 83 + 84 + ```tsx#frontend.tsx 85 + import React from "react"; 86 + import { createRoot } from "react-dom/client"; 87 + 88 + // import .css files directly and it works 89 + import './index.css'; 90 + 91 + const root = createRoot(document.body); 92 + 93 + export default function Frontend() { 94 + return <h1>Hello, world!</h1>; 95 + } 96 + 97 + root.render(<Frontend />); 98 + ``` 99 + 100 + Then, run index.ts 101 + 102 + ```sh 103 + bun --hot ./index.ts 104 + ``` 105 + 106 + For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
+26
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "livepfp", 7 + "devDependencies": { 8 + "@types/bun": "latest", 9 + }, 10 + "peerDependencies": { 11 + "typescript": "^5", 12 + }, 13 + }, 14 + }, 15 + "packages": { 16 + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], 17 + 18 + "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], 19 + 20 + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], 21 + 22 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 23 + 24 + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], 25 + } 26 + }
imgs/00.png

This is a binary file and will not be displayed.

imgs/00_headphones.png

This is a binary file and will not be displayed.

imgs/00_zz.png

This is a binary file and will not be displayed.

imgs/01.png

This is a binary file and will not be displayed.

imgs/01_headphones.png

This is a binary file and will not be displayed.

imgs/01_zz.png

This is a binary file and will not be displayed.

imgs/02.png

This is a binary file and will not be displayed.

imgs/02_headphones.png

This is a binary file and will not be displayed.

imgs/02_zz.png

This is a binary file and will not be displayed.

imgs/03.png

This is a binary file and will not be displayed.

imgs/03_headphones.png

This is a binary file and will not be displayed.

imgs/03_zz.png

This is a binary file and will not be displayed.

imgs/04.png

This is a binary file and will not be displayed.

imgs/04_headphones.png

This is a binary file and will not be displayed.

imgs/04_zz.png

This is a binary file and will not be displayed.

imgs/05.png

This is a binary file and will not be displayed.

imgs/05_headphones.png

This is a binary file and will not be displayed.

imgs/05_zz.png

This is a binary file and will not be displayed.

imgs/06.png

This is a binary file and will not be displayed.

imgs/06_headphones.png

This is a binary file and will not be displayed.

imgs/06_zz.png

This is a binary file and will not be displayed.

imgs/07.png

This is a binary file and will not be displayed.

imgs/07_headphones.png

This is a binary file and will not be displayed.

imgs/07_zz.png

This is a binary file and will not be displayed.

imgs/08.png

This is a binary file and will not be displayed.

imgs/08_headphones.png

This is a binary file and will not be displayed.

imgs/08_zz.png

This is a binary file and will not be displayed.

imgs/09.png

This is a binary file and will not be displayed.

imgs/09_headphones.png

This is a binary file and will not be displayed.

imgs/09_zz.png

This is a binary file and will not be displayed.

imgs/10.png

This is a binary file and will not be displayed.

imgs/10_headphones.png

This is a binary file and will not be displayed.

imgs/10_zz.png

This is a binary file and will not be displayed.

imgs/11.png

This is a binary file and will not be displayed.

imgs/11_headphones.png

This is a binary file and will not be displayed.

imgs/11_zz.png

This is a binary file and will not be displayed.

imgs/12.png

This is a binary file and will not be displayed.

imgs/12_headphones.png

This is a binary file and will not be displayed.

imgs/12_zz.png

This is a binary file and will not be displayed.

imgs/13.png

This is a binary file and will not be displayed.

imgs/13_headphones.png

This is a binary file and will not be displayed.

imgs/13_zz.png

This is a binary file and will not be displayed.

imgs/14.png

This is a binary file and will not be displayed.

imgs/14_headphones.png

This is a binary file and will not be displayed.

imgs/14_zz.png

This is a binary file and will not be displayed.

imgs/15.png

This is a binary file and will not be displayed.

imgs/15_headphones.png

This is a binary file and will not be displayed.

imgs/15_zz.png

This is a binary file and will not be displayed.

imgs/16.png

This is a binary file and will not be displayed.

imgs/16_headphones.png

This is a binary file and will not be displayed.

imgs/16_zz.png

This is a binary file and will not be displayed.

imgs/17.png

This is a binary file and will not be displayed.

imgs/17_headphones.png

This is a binary file and will not be displayed.

imgs/17_zz.png

This is a binary file and will not be displayed.

imgs/18.png

This is a binary file and will not be displayed.

imgs/18_headphones.png

This is a binary file and will not be displayed.

imgs/18_zz.png

This is a binary file and will not be displayed.

imgs/19.png

This is a binary file and will not be displayed.

imgs/19_headphones.png

This is a binary file and will not be displayed.

imgs/19_zz.png

This is a binary file and will not be displayed.

imgs/20.png

This is a binary file and will not be displayed.

imgs/20_headphones.png

This is a binary file and will not be displayed.

imgs/20_zz.png

This is a binary file and will not be displayed.

imgs/21.png

This is a binary file and will not be displayed.

imgs/21_headphones.png

This is a binary file and will not be displayed.

imgs/21_zz.png

This is a binary file and will not be displayed.

imgs/22.png

This is a binary file and will not be displayed.

imgs/22_headphones.png

This is a binary file and will not be displayed.

imgs/22_zz.png

This is a binary file and will not be displayed.

imgs/23.png

This is a binary file and will not be displayed.

imgs/23_headphones.png

This is a binary file and will not be displayed.

imgs/23_zz.png

This is a binary file and will not be displayed.

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