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 }) => { 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.captureException(error); 112 if (code === "VALIDATION") { 113 return error.message; 114 } 115 }) 116 .get("/", ({ redirect, headers }) => { 117 // check if its a browser 118 119 if ( 120 headers["user-agent"]?.toLowerCase().includes("mozilla") || 121 headers["user-agent"]?.toLowerCase().includes("chrome") || 122 headers["user-agent"]?.toLowerCase().includes("safari") 123 ) { 124 return redirect("/swagger", 302); 125 } 126 127 return "Hello World from Cachet 😊\n\n---\nSee /swagger for docs\n---"; 128 }) 129 .get( 130 "/health", 131 async ({ error }) => { 132 const slackConnection = await slackApp.testAuth(); 133 134 const databaseConnection = await cache.healthCheck(); 135 136 if (!slackConnection || !databaseConnection) 137 return error(500, { 138 http: false, 139 slack: slackConnection, 140 database: databaseConnection, 141 }); 142 143 return { 144 http: true, 145 slack: true, 146 database: true, 147 }; 148 }, 149 { 150 tags: ["Status"], 151 response: { 152 200: t.Object({ 153 http: t.Boolean(), 154 slack: t.Boolean(), 155 database: t.Boolean(), 156 }), 157 500: t.Object({ 158 http: t.Boolean({ 159 default: false, 160 }), 161 slack: t.Boolean({ 162 default: false, 163 }), 164 database: t.Boolean({ 165 default: false, 166 }), 167 }), 168 }, 169 }, 170 ) 171 .get( 172 "/users/:user", 173 async ({ params, error }) => { 174 const user = await cache.getUser(params.user); 175 176 // if not found then check slack first 177 if (!user || !user.imageUrl) { 178 let slackUser: SlackUser; 179 try { 180 slackUser = await slackApp.getUserInfo(params.user); 181 } catch (e) { 182 if (e instanceof Error && e.message === "user_not_found") 183 return error(404, { message: "User not found" }); 184 185 Sentry.captureException(e); 186 187 if (e instanceof Error) 188 console.warn( 189 `\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`, 190 ); 191 192 return error(500, { 193 message: `Error fetching user from Slack: ${e}`, 194 }); 195 } 196 197 await cache.insertUser(slackUser.id, slackUser.profile.image_512); 198 199 return { 200 id: slackUser.id, 201 expiration: new Date().toISOString(), 202 user: slackUser.id, 203 image: slackUser.profile.image_512, 204 }; 205 } 206 207 return { 208 id: user.id, 209 expiration: user.expiration.toISOString(), 210 user: user.userId, 211 image: user.imageUrl, 212 }; 213 }, 214 { 215 tags: ["The Cache!"], 216 params: t.Object({ 217 user: t.String(), 218 }), 219 response: { 220 404: t.Object({ 221 message: t.String({ 222 default: "User not found", 223 }), 224 }), 225 500: t.Object({ 226 message: t.String({ 227 default: "Error fetching user from Slack", 228 }), 229 }), 230 200: t.Object({ 231 id: t.String({ 232 default: "90750e24-c2f0-4c52-8681-e6176da6e7ab", 233 }), 234 expiration: t.String({ 235 default: new Date().toISOString(), 236 }), 237 user: t.String({ 238 default: "U12345678", 239 }), 240 image: t.String({ 241 default: 242 "https://avatars.slack-edge.com/2024-11-30/8105375749571_53898493372773a01a1f_original.jpg", 243 }), 244 }), 245 }, 246 }, 247 ) 248 .get( 249 "/users/:user/r", 250 async ({ params, error, redirect }) => { 251 const user = await cache.getUser(params.user); 252 253 // if not found then check slack first 254 if (!user || !user.imageUrl) { 255 let slackUser: SlackUser; 256 try { 257 slackUser = await slackApp.getUserInfo(params.user); 258 } catch (e) { 259 if (e instanceof Error && e.message === "user_not_found") 260 return error(404, { message: "User not found" }); 261 262 Sentry.captureException(e); 263 264 if (e instanceof Error) 265 console.warn( 266 `\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`, 267 ); 268 269 return error(500, { 270 message: `Error fetching user from Slack: ${e}`, 271 }); 272 } 273 274 await cache.insertUser(slackUser.id, slackUser.profile.image_512); 275 276 return redirect(slackUser.profile.image_512, 302); 277 } 278 279 return redirect(user.imageUrl, 302); 280 }, 281 { 282 tags: ["The Cache!"], 283 query: t.Object({ 284 r: t.Optional(t.String()), 285 }), 286 params: t.Object({ 287 user: t.String(), 288 }), 289 }, 290 ) 291 .get( 292 "/emojis", 293 async () => { 294 const emojis = await cache.listEmojis(); 295 296 return emojis.map((emoji) => ({ 297 id: emoji.id, 298 expiration: emoji.expiration.toISOString(), 299 name: emoji.name, 300 ...(emoji.alias ? { alias: emoji.alias } : {}), 301 image: emoji.imageUrl, 302 })); 303 }, 304 { 305 tags: ["The Cache!"], 306 response: { 307 200: t.Array( 308 t.Object({ 309 id: t.String({ 310 default: "5427fe70-686f-4684-9da5-95d9ef4c1090", 311 }), 312 expiration: t.String({ 313 default: new Date().toISOString(), 314 }), 315 name: t.String({ 316 default: "blahaj-heart", 317 }), 318 alias: t.Optional( 319 t.String({ 320 default: "blobhaj-heart", 321 }), 322 ), 323 image: t.String({ 324 default: 325 "https://emoji.slack-edge.com/T0266FRGM/blahaj-heart/db9adf8229e9a4fb.png", 326 }), 327 }), 328 ), 329 }, 330 }, 331 ) 332 .get( 333 "/emojis/:emoji", 334 async ({ params, error }) => { 335 const emoji = await cache.getEmoji(params.emoji); 336 337 if (!emoji) return error(404, { message: "Emoji not found" }); 338 339 return { 340 id: emoji.id, 341 expiration: emoji.expiration.toISOString(), 342 name: emoji.name, 343 ...(emoji.alias ? { alias: emoji.alias } : {}), 344 image: emoji.imageUrl, 345 }; 346 }, 347 { 348 tags: ["The Cache!"], 349 params: t.Object({ 350 emoji: t.String(), 351 }), 352 response: { 353 404: t.Object({ 354 message: t.String({ 355 default: "Emoji not found", 356 }), 357 }), 358 200: t.Object({ 359 id: t.String({ 360 default: "9ed0a560-928d-409c-89fc-10fe156299da", 361 }), 362 expiration: t.String({ 363 default: new Date().toISOString(), 364 }), 365 name: t.String({ 366 default: "orphmoji-yay", 367 }), 368 image: t.String({ 369 default: 370 "https://emoji.slack-edge.com/T0266FRGM/orphmoji-yay/23a37f4af47092d3.png", 371 }), 372 }), 373 }, 374 }, 375 ) 376 .get( 377 "/emojis/:emoji/r", 378 async ({ params, error, redirect }) => { 379 const emoji = await cache.getEmoji(params.emoji); 380 381 if (!emoji) return error(404, { message: "Emoji not found" }); 382 383 return redirect(emoji.imageUrl, 302); 384 }, 385 { 386 tags: ["The Cache!"], 387 params: t.Object({ 388 emoji: t.String(), 389 }), 390 }, 391 ) 392 .listen(process.env.PORT ?? 3000); 393 394console.log( 395 `\n---\n\n🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port} on v${version}@${process.env.NODE_ENV}\n\n---\n`, 396);