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