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(slackUser.id, slackUser.profile.image_512);
206
207 return {
208 id: slackUser.id,
209 expiration: new Date().toISOString(),
210 user: slackUser.id,
211 image: slackUser.profile.image_512,
212 };
213 }
214
215 return {
216 id: user.id,
217 expiration: user.expiration.toISOString(),
218 user: user.userId,
219 image: user.imageUrl,
220 };
221 },
222 {
223 tags: ["The Cache!"],
224 params: t.Object({
225 user: t.String(),
226 }),
227 response: {
228 404: t.Object({
229 message: t.String({
230 default: "User not found",
231 }),
232 }),
233 500: t.Object({
234 message: t.String({
235 default: "Error fetching user from Slack",
236 }),
237 }),
238 200: t.Object({
239 id: t.String({
240 default: "90750e24-c2f0-4c52-8681-e6176da6e7ab",
241 }),
242 expiration: t.String({
243 default: new Date().toISOString(),
244 }),
245 user: t.String({
246 default: "U12345678",
247 }),
248 image: t.String({
249 default:
250 "https://avatars.slack-edge.com/2024-11-30/8105375749571_53898493372773a01a1f_original.jpg",
251 }),
252 }),
253 },
254 },
255 )
256 .get(
257 "/users/:user/r",
258 async ({ params, error, redirect, request }) => {
259 const user = await cache.getUser(params.user);
260
261 // if not found then check slack first
262 if (!user || !user.imageUrl) {
263 let slackUser: SlackUser;
264 try {
265 slackUser = await slackApp.getUserInfo(params.user);
266 } catch (e) {
267 if (e instanceof Error && e.message === "user_not_found") {
268 console.warn(
269 `\x1b[38;5;214m ⚠️ WARN\x1b[0m user not found: \x1b[38;5;208m${params.user}\x1b[0m`,
270 );
271
272 return redirect(
273 "https://api.dicebear.com/9.x/thumbs/svg?seed={username_hash}",
274 307,
275 );
276 }
277
278 Sentry.withScope((scope) => {
279 scope.setExtra("url", request.url);
280 scope.setExtra("user", params.user);
281 Sentry.captureException(e);
282 });
283
284 if (e instanceof Error)
285 console.warn(
286 `\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`,
287 );
288
289 return error(500, {
290 message: `Error fetching user from Slack: ${e}`,
291 });
292 }
293
294 await cache.insertUser(slackUser.id, slackUser.profile.image_512);
295
296 return redirect(slackUser.profile.image_512, 302);
297 }
298
299 return redirect(user.imageUrl, 302);
300 },
301 {
302 tags: ["The Cache!"],
303 query: t.Object({
304 r: t.Optional(t.String()),
305 }),
306 params: t.Object({
307 user: t.String(),
308 }),
309 },
310 )
311 .get(
312 "/emojis",
313 async () => {
314 const emojis = await cache.listEmojis();
315
316 return emojis.map((emoji) => ({
317 id: emoji.id,
318 expiration: emoji.expiration.toISOString(),
319 name: emoji.name,
320 ...(emoji.alias ? { alias: emoji.alias } : {}),
321 image: emoji.imageUrl,
322 }));
323 },
324 {
325 tags: ["The Cache!"],
326 response: {
327 200: t.Array(
328 t.Object({
329 id: t.String({
330 default: "5427fe70-686f-4684-9da5-95d9ef4c1090",
331 }),
332 expiration: t.String({
333 default: new Date().toISOString(),
334 }),
335 name: t.String({
336 default: "blahaj-heart",
337 }),
338 alias: t.Optional(
339 t.String({
340 default: "blobhaj-heart",
341 }),
342 ),
343 image: t.String({
344 default:
345 "https://emoji.slack-edge.com/T0266FRGM/blahaj-heart/db9adf8229e9a4fb.png",
346 }),
347 }),
348 ),
349 },
350 },
351 )
352 .get(
353 "/emojis/:emoji",
354 async ({ params, error }) => {
355 const emoji = await cache.getEmoji(params.emoji);
356
357 if (!emoji) return error(404, { message: "Emoji not found" });
358
359 return {
360 id: emoji.id,
361 expiration: emoji.expiration.toISOString(),
362 name: emoji.name,
363 ...(emoji.alias ? { alias: emoji.alias } : {}),
364 image: emoji.imageUrl,
365 };
366 },
367 {
368 tags: ["The Cache!"],
369 params: t.Object({
370 emoji: t.String(),
371 }),
372 response: {
373 404: t.Object({
374 message: t.String({
375 default: "Emoji not found",
376 }),
377 }),
378 200: t.Object({
379 id: t.String({
380 default: "9ed0a560-928d-409c-89fc-10fe156299da",
381 }),
382 expiration: t.String({
383 default: new Date().toISOString(),
384 }),
385 name: t.String({
386 default: "orphmoji-yay",
387 }),
388 image: t.String({
389 default:
390 "https://emoji.slack-edge.com/T0266FRGM/orphmoji-yay/23a37f4af47092d3.png",
391 }),
392 }),
393 },
394 },
395 )
396 .get(
397 "/emojis/:emoji/r",
398 async ({ params, error, redirect }) => {
399 const emoji = await cache.getEmoji(params.emoji);
400
401 if (!emoji) return error(404, { message: "Emoji not found" });
402
403 return redirect(emoji.imageUrl, 302);
404 },
405 {
406 tags: ["The Cache!"],
407 params: t.Object({
408 emoji: t.String(),
409 }),
410 },
411 )
412 .listen(process.env.PORT ?? 3000);
413
414console.log(
415 `\n---\n\n🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port} on v${version}@${process.env.NODE_ENV}\n\n---\n`,
416);