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