a cache for slack profile pictures and emojis
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);