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