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