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