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