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