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