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 return error(404, { message: "User not found" });
269
270 Sentry.withScope((scope) => {
271 scope.setExtra("url", request.url);
272 scope.setExtra("user", params.user);
273 Sentry.captureException(e);
274 });
275
276 if (e instanceof Error)
277 console.warn(
278 `\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`,
279 );
280
281 return error(500, {
282 message: `Error fetching user from Slack: ${e}`,
283 });
284 }
285
286 await cache.insertUser(slackUser.id, slackUser.profile.image_512);
287
288 return redirect(slackUser.profile.image_512, 302);
289 }
290
291 return redirect(user.imageUrl, 302);
292 },
293 {
294 tags: ["The Cache!"],
295 query: t.Object({
296 r: t.Optional(t.String()),
297 }),
298 params: t.Object({
299 user: t.String(),
300 }),
301 },
302 )
303 .get(
304 "/emojis",
305 async () => {
306 const emojis = await cache.listEmojis();
307
308 return emojis.map((emoji) => ({
309 id: emoji.id,
310 expiration: emoji.expiration.toISOString(),
311 name: emoji.name,
312 ...(emoji.alias ? { alias: emoji.alias } : {}),
313 image: emoji.imageUrl,
314 }));
315 },
316 {
317 tags: ["The Cache!"],
318 response: {
319 200: t.Array(
320 t.Object({
321 id: t.String({
322 default: "5427fe70-686f-4684-9da5-95d9ef4c1090",
323 }),
324 expiration: t.String({
325 default: new Date().toISOString(),
326 }),
327 name: t.String({
328 default: "blahaj-heart",
329 }),
330 alias: t.Optional(
331 t.String({
332 default: "blobhaj-heart",
333 }),
334 ),
335 image: t.String({
336 default:
337 "https://emoji.slack-edge.com/T0266FRGM/blahaj-heart/db9adf8229e9a4fb.png",
338 }),
339 }),
340 ),
341 },
342 },
343 )
344 .get(
345 "/emojis/:emoji",
346 async ({ params, error }) => {
347 const emoji = await cache.getEmoji(params.emoji);
348
349 if (!emoji) return error(404, { message: "Emoji not found" });
350
351 return {
352 id: emoji.id,
353 expiration: emoji.expiration.toISOString(),
354 name: emoji.name,
355 ...(emoji.alias ? { alias: emoji.alias } : {}),
356 image: emoji.imageUrl,
357 };
358 },
359 {
360 tags: ["The Cache!"],
361 params: t.Object({
362 emoji: t.String(),
363 }),
364 response: {
365 404: t.Object({
366 message: t.String({
367 default: "Emoji not found",
368 }),
369 }),
370 200: t.Object({
371 id: t.String({
372 default: "9ed0a560-928d-409c-89fc-10fe156299da",
373 }),
374 expiration: t.String({
375 default: new Date().toISOString(),
376 }),
377 name: t.String({
378 default: "orphmoji-yay",
379 }),
380 image: t.String({
381 default:
382 "https://emoji.slack-edge.com/T0266FRGM/orphmoji-yay/23a37f4af47092d3.png",
383 }),
384 }),
385 },
386 },
387 )
388 .get(
389 "/emojis/:emoji/r",
390 async ({ params, error, redirect }) => {
391 const emoji = await cache.getEmoji(params.emoji);
392
393 if (!emoji) return error(404, { message: "Emoji not found" });
394
395 return redirect(emoji.imageUrl, 302);
396 },
397 {
398 tags: ["The Cache!"],
399 params: t.Object({
400 emoji: t.String(),
401 }),
402 },
403 )
404 .listen(process.env.PORT ?? 3000);
405
406console.log(
407 `\n---\n\n🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port} on v${version}@${process.env.NODE_ENV}\n\n---\n`,
408);