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