a cache for slack profile pictures and emojis
1import { Elysia, t } from "elysia"; 2import { logger } from "@tqman/nice-logger"; 3import { swagger } from "@elysiajs/swagger"; 4import { cors } from "@elysiajs/cors"; 5import { cron } from "@elysiajs/cron"; 6import { version } from "../package.json"; 7import { SlackCache } from "./cache"; 8import { SlackWrapper } from "./slackWrapper"; 9import type { SlackUser } from "./slack"; 10import { getEmojiUrl } from "../utils/emojiHelper"; 11import * as Sentry from "@sentry/bun"; 12 13if (process.env.SENTRY_DSN) { 14 console.log("Sentry DSN provided, error monitoring is enabled"); 15 Sentry.init({ 16 environment: process.env.NODE_ENV, 17 dsn: process.env.SENTRY_DSN, // Replace with your Sentry DSN 18 tracesSampleRate: 1.0, // Adjust this value for performance monitoring 19 }); 20} else { 21 console.warn("Sentry DSN not provided, error monitoring is disabled"); 22} 23 24const slackApp = new SlackWrapper(); 25 26const cache = new SlackCache( 27 process.env.DATABASE_PATH ?? "./data/cachet.db", 28 25, 29 async () => { 30 console.log("Fetching emojis from Slack"); 31 const emojis = await slackApp.getEmojiList(); 32 const emojiEntries = Object.entries(emojis) 33 .map(([name, url]) => { 34 if (typeof url === "string" && url.startsWith("alias:")) { 35 const aliasName = url.substring(6); // Remove 'alias:' prefix 36 const aliasUrl = emojis[aliasName] ?? getEmojiUrl(aliasName) ?? null; 37 38 if (aliasUrl === null) { 39 console.warn(`Could not find alias for ${aliasName}`); 40 return; 41 } 42 43 return { 44 name, 45 imageUrl: aliasUrl === null ? getEmojiUrl(aliasName) : aliasUrl, 46 alias: aliasName, 47 }; 48 } 49 return { 50 name, 51 imageUrl: url, 52 alias: null, 53 }; 54 }) 55 .filter( 56 ( 57 entry, 58 ): entry is { name: string; imageUrl: string; alias: string | null } => 59 entry !== undefined, 60 ); 61 62 console.log("Batch inserting emojis"); 63 64 await cache.batchInsertEmojis(emojiEntries); 65 66 console.log("Finished batch inserting emojis"); 67 }, 68); 69 70const app = new Elysia() 71 .use( 72 logger({ 73 mode: "combined", 74 }), 75 ) 76 .use( 77 cors({ 78 origin: true, 79 }), 80 ) 81 .use( 82 cron({ 83 name: "heartbeat", 84 pattern: "0 0 * * *", 85 async run() { 86 await cache.purgeAll(); 87 }, 88 }), 89 ) 90 .use( 91 swagger({ 92 exclude: ["/", "favicon.ico"], 93 documentation: { 94 info: { 95 version: version, 96 title: "Cachet", 97 description: 98 "Hi 👋\n\nThis is a pretty simple API that acts as a middleman caching layer between slack and the outside world. There may be authentication in the future, but for now, it's just a simple cache.\n\nThe `/r` endpoints are redirects to the actual image URLs, so you can use them as direct image links.", 99 contact: { 100 name: "Kieran Klukas", 101 email: "me@dunkirk.sh", 102 }, 103 license: { 104 name: "AGPL 3.0", 105 url: "https://github.com/taciturnaxoltol/cachet/blob/master/LICENSE.md", 106 }, 107 }, 108 tags: [ 109 { 110 name: "The Cache!", 111 description: "*must be read in an ominous voice*", 112 }, 113 { 114 name: "Status", 115 description: "*Rather boring status endpoints :(*", 116 }, 117 ], 118 }, 119 }), 120 ) 121 .onError(({ code, error, request }) => { 122 if (error instanceof Error) 123 console.error( 124 `\x1b[31m x\x1b[0m unhandled error: \x1b[31m${error.message}\x1b[0m`, 125 ); 126 Sentry.withScope((scope) => { 127 scope.setExtra("url", request.url); 128 scope.setExtra("code", code); 129 Sentry.captureException(error); 130 }); 131 if (code === "VALIDATION") { 132 return error.message; 133 } 134 }) 135 .get("/", ({ redirect, headers }) => { 136 // check if its a browser 137 138 if ( 139 headers["user-agent"]?.toLowerCase().includes("mozilla") || 140 headers["user-agent"]?.toLowerCase().includes("chrome") || 141 headers["user-agent"]?.toLowerCase().includes("safari") 142 ) { 143 return redirect("/swagger", 302); 144 } 145 146 return "Hello World from Cachet 😊\n\n---\nSee /swagger for docs\n---"; 147 }) 148 .get("/favicon.ico", Bun.file("./favicon.ico")) 149 .get( 150 "/health", 151 async ({ error }) => { 152 const slackConnection = await slackApp.testAuth(); 153 154 const databaseConnection = await cache.healthCheck(); 155 156 if (!slackConnection || !databaseConnection) 157 return error(500, { 158 http: false, 159 slack: slackConnection, 160 database: databaseConnection, 161 }); 162 163 return { 164 http: true, 165 slack: true, 166 database: true, 167 }; 168 }, 169 { 170 tags: ["Status"], 171 response: { 172 200: t.Object({ 173 http: t.Boolean(), 174 slack: t.Boolean(), 175 database: t.Boolean(), 176 }), 177 500: t.Object({ 178 http: t.Boolean({ 179 default: false, 180 }), 181 slack: t.Boolean({ 182 default: false, 183 }), 184 database: t.Boolean({ 185 default: false, 186 }), 187 }), 188 }, 189 }, 190 ) 191 .get( 192 "/users/:user", 193 async ({ params, error, request }) => { 194 const user = await cache.getUser(params.user.toLowerCase()); 195 196 // if not found then check slack first 197 if (!user || !user.imageUrl) { 198 let slackUser: SlackUser; 199 try { 200 slackUser = await slackApp.getUserInfo(params.user); 201 } catch (e) { 202 if (e instanceof Error && e.message === "user_not_found") 203 return error(404, { message: "User not found" }); 204 205 Sentry.withScope((scope) => { 206 scope.setExtra("url", request.url); 207 scope.setExtra("user", params.user); 208 Sentry.captureException(e); 209 }); 210 211 if (e instanceof Error) 212 console.warn( 213 `\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`, 214 ); 215 216 return error(500, { 217 message: `Error fetching user from Slack: ${e}`, 218 }); 219 } 220 221 const displayName = 222 slackUser.profile.display_name_normalized || 223 slackUser.profile.real_name_normalized; 224 225 await cache.insertUser( 226 slackUser.id, 227 displayName, 228 slackUser.profile.pronouns, 229 slackUser.profile.image_512, 230 ); 231 232 return { 233 id: slackUser.id, 234 expiration: new Date().toISOString(), 235 user: slackUser.id, 236 displayName: displayName, 237 pronouns: slackUser.profile.pronouns, 238 image: slackUser.profile.image_512, 239 }; 240 } 241 242 return { 243 id: user.id, 244 expiration: user.expiration.toISOString(), 245 user: user.userId, 246 displayName: user.displayName, 247 pronouns: user.pronouns, 248 image: user.imageUrl, 249 }; 250 }, 251 { 252 tags: ["The Cache!"], 253 params: t.Object({ 254 user: t.String(), 255 }), 256 response: { 257 404: t.Object({ 258 message: t.String({ 259 default: "User not found", 260 }), 261 }), 262 500: t.Object({ 263 message: t.String({ 264 default: "Error fetching user from Slack", 265 }), 266 }), 267 200: t.Object({ 268 id: t.String({ 269 default: "90750e24-c2f0-4c52-8681-e6176da6e7ab", 270 }), 271 expiration: t.String({ 272 default: new Date().toISOString(), 273 }), 274 user: t.String({ 275 default: "U12345678", 276 }), 277 displayName: t.String({ 278 default: "krn", 279 }), 280 pronouns: t.Optional(t.String({ default: "possibly/blank" })), 281 image: t.String({ 282 default: 283 "https://avatars.slack-edge.com/2024-11-30/8105375749571_53898493372773a01a1f_original.jpg", 284 }), 285 }), 286 }, 287 }, 288 ) 289 .get( 290 "/users/:user/r", 291 async ({ params, error, redirect, request }) => { 292 const user = await cache.getUser(params.user); 293 294 // if not found then check slack first 295 if (!user || !user.imageUrl) { 296 let slackUser: SlackUser; 297 try { 298 slackUser = await slackApp.getUserInfo(params.user.toUpperCase()); 299 } catch (e) { 300 if (e instanceof Error && e.message === "user_not_found") { 301 console.warn( 302 `\x1b[38;5;214m ⚠️ WARN\x1b[0m user not found: \x1b[38;5;208m${params.user}\x1b[0m`, 303 ); 304 305 return redirect( 306 "https://api.dicebear.com/9.x/thumbs/svg?seed={username_hash}", 307 307, 308 ); 309 } 310 311 Sentry.withScope((scope) => { 312 scope.setExtra("url", request.url); 313 scope.setExtra("user", params.user); 314 Sentry.captureException(e); 315 }); 316 317 if (e instanceof Error) 318 console.warn( 319 `\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`, 320 ); 321 322 return error(500, { 323 message: `Error fetching user from Slack: ${e}`, 324 }); 325 } 326 327 await cache.insertUser( 328 slackUser.id, 329 slackUser.profile.display_name_normalized || 330 slackUser.profile.real_name_normalized, 331 slackUser.profile.pronouns, 332 slackUser.profile.image_512, 333 ); 334 335 return redirect(slackUser.profile.image_512, 302); 336 } 337 338 return redirect(user.imageUrl, 302); 339 }, 340 { 341 tags: ["The Cache!"], 342 query: t.Object({ 343 r: t.Optional(t.String()), 344 }), 345 params: t.Object({ 346 user: t.String(), 347 }), 348 }, 349 ) 350 .get( 351 "/emojis", 352 async () => { 353 const emojis = await cache.listEmojis(); 354 355 return emojis.map((emoji) => ({ 356 id: emoji.id, 357 expiration: emoji.expiration.toISOString(), 358 name: emoji.name, 359 ...(emoji.alias ? { alias: emoji.alias } : {}), 360 image: emoji.imageUrl, 361 })); 362 }, 363 { 364 tags: ["The Cache!"], 365 response: { 366 200: t.Array( 367 t.Object({ 368 id: t.String({ 369 default: "5427fe70-686f-4684-9da5-95d9ef4c1090", 370 }), 371 expiration: t.String({ 372 default: new Date().toISOString(), 373 }), 374 name: t.String({ 375 default: "blahaj-heart", 376 }), 377 alias: t.Optional( 378 t.String({ 379 default: "blobhaj-heart", 380 }), 381 ), 382 image: t.String({ 383 default: 384 "https://emoji.slack-edge.com/T0266FRGM/blahaj-heart/db9adf8229e9a4fb.png", 385 }), 386 }), 387 ), 388 }, 389 }, 390 ) 391 .get( 392 "/emojis/:emoji", 393 async ({ params, error }) => { 394 const emoji = await cache.getEmoji(params.emoji.toLowerCase()); 395 396 if (!emoji) return error(404, { message: "Emoji not found" }); 397 398 return { 399 id: emoji.id, 400 expiration: emoji.expiration.toISOString(), 401 name: emoji.name, 402 ...(emoji.alias ? { alias: emoji.alias } : {}), 403 image: emoji.imageUrl, 404 }; 405 }, 406 { 407 tags: ["The Cache!"], 408 params: t.Object({ 409 emoji: t.String(), 410 }), 411 response: { 412 404: t.Object({ 413 message: t.String({ 414 default: "Emoji not found", 415 }), 416 }), 417 200: t.Object({ 418 id: t.String({ 419 default: "9ed0a560-928d-409c-89fc-10fe156299da", 420 }), 421 expiration: t.String({ 422 default: new Date().toISOString(), 423 }), 424 name: t.String({ 425 default: "orphmoji-yay", 426 }), 427 image: t.String({ 428 default: 429 "https://emoji.slack-edge.com/T0266FRGM/orphmoji-yay/23a37f4af47092d3.png", 430 }), 431 }), 432 }, 433 }, 434 ) 435 .get( 436 "/emojis/:emoji/r", 437 async ({ params, error, redirect }) => { 438 const emoji = await cache.getEmoji(params.emoji.toLowerCase()); 439 440 if (!emoji) return error(404, { message: "Emoji not found" }); 441 442 return redirect(emoji.imageUrl, 302); 443 }, 444 { 445 tags: ["The Cache!"], 446 params: t.Object({ 447 emoji: t.String(), 448 }), 449 }, 450 ) 451 .post( 452 "/reset", 453 async ({ headers, set }) => { 454 if (headers.authorization !== `Bearer ${process.env.BEARER_TOKEN}`) { 455 set.status = 401; 456 return "Unauthorized"; 457 } 458 459 return await cache.purgeAll(); 460 }, 461 { 462 tags: ["The Cache!"], 463 headers: t.Object({ 464 authorization: t.String({ 465 default: "Bearer <token>", 466 }), 467 }), 468 response: { 469 200: t.Object({ 470 message: t.String(), 471 users: t.Number(), 472 emojis: t.Number(), 473 }), 474 401: t.String({ default: "Unauthorized" }), 475 }, 476 }, 477 ) 478 .listen(process.env.PORT ?? 3000); 479 480console.log( 481 `\n---\n\n🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port} on v${version}@${process.env.NODE_ENV}\n\n---\n`, 482);