a fun bot for the hc slack
1import { eq } from "drizzle-orm";
2import { slackApp, slackClient } from "../../../index";
3import { db } from "../../../libs/db";
4import { takes as takesTable, users as usersTable } from "../../../libs/schema";
5import * as Sentry from "@sentry/bun";
6import {
7 fetchHackatimeSummary,
8 type HackatimeVersion,
9} from "../../../libs/hackatime";
10import { prettyPrintTime } from "../../../libs/time";
11import { deployToHackClubCDN } from "../../../libs/cdn";
12
13export default async function upload() {
14 slackApp.anyMessage(async ({ payload, context }) => {
15 // Track user ID at the top level so it's available in catch block
16 const user = payload.user as string;
17 try {
18
19 if (
20 payload.subtype === "bot_message" ||
21 payload.subtype === "thread_broadcast" ||
22 payload.thread_ts ||
23 payload.channel !== process.env.SLACK_LISTEN_CHANNEL
24 )
25 return;
26
27 const userInDB = await db
28 .select()
29 .from(usersTable)
30 .where(eq(usersTable.id, user))
31 .then((users) => users[0] || null);
32
33 if (!userInDB) {
34 await slackClient.chat.postMessage({
35 channel: payload.channel,
36 thread_ts: payload.ts,
37 text: "We don't have a project for you; set one up by clicking the button below or by running `/takes`",
38 blocks: [
39 {
40 type: "section",
41 text: {
42 type: "mrkdwn",
43 text: "We don't have a project for you; set one up by clicking the button below or by running `/takes`",
44 },
45 },
46 {
47 type: "actions",
48 elements: [
49 {
50 type: "button",
51 text: {
52 type: "plain_text",
53 text: "setup your project",
54 },
55 action_id: "takes_setup",
56 },
57 ],
58 },
59 {
60 type: "context",
61 elements: [
62 {
63 type: "plain_text",
64 text: "don't forget to resend your update after setting up your project!",
65 },
66 ],
67 },
68 ],
69 });
70 return;
71 }
72
73 // Check if the user is already uploading - prevent multiple simultaneous uploads
74 if (userInDB.isUploading) {
75 await slackClient.chat.postMessage({
76 channel: payload.channel,
77 thread_ts: payload.ts,
78 text: "You already have an upload in progress. Please wait for it to complete before sending another one.",
79 });
80
81 await slackClient.reactions.add({
82 channel: payload.channel,
83 timestamp: payload.ts,
84 name: "hourglass_flowing_sand",
85 });
86
87 return;
88 }
89
90 // Set the upload lock
91 await db.update(usersTable)
92 .set({ isUploading: true })
93 .where(eq(usersTable.id, user));
94
95 // Add initial 'loading' reaction to indicate processing
96 await slackClient.reactions.add({
97 channel: payload.channel,
98 timestamp: payload.ts,
99 name: "spin-loading",
100 });
101
102 // fetch time spent on project via hackatime
103 const timeSpent = await fetchHackatimeSummary(
104 user,
105 userInDB.hackatimeVersion as HackatimeVersion,
106 JSON.parse(userInDB.hackatimeKeys),
107 new Date(userInDB.lastTakeUploadDate),
108 new Date(),
109 ).then((res) => res.total_projects_sum || 0);
110
111 if (timeSpent < 360) {
112 await slackClient.chat.postMessage({
113 channel: payload.channel,
114 thread_ts: payload.ts,
115 text: "You haven't spent enough time on your project yet! Spend a few more minutes hacking then come back :)",
116 });
117
118 await slackClient.reactions.remove({
119 channel: payload.channel,
120 timestamp: payload.ts,
121 name: "spin-loading",
122 });
123
124 await slackClient.reactions.add({
125 channel: payload.channel,
126 timestamp: payload.ts,
127 name: "tw_timer_clock",
128 });
129
130 return;
131 }
132
133 // Convert Slack formatting to markdown
134 const replaceUserMentions = async (text: string) => {
135 const regex = /<@([A-Z0-9]+)>/g;
136 const matches = text.match(regex);
137
138 if (!matches) return text;
139
140 let result = text;
141 for (const match of matches) {
142 const userId = match.match(/[A-Z0-9]+/)?.[0];
143 if (!userId) continue;
144
145 try {
146 const userInfo = await slackClient.users.info({
147 user: userId,
148 });
149 const name =
150 userInfo.user?.profile?.display_name ||
151 userInfo.user?.real_name ||
152 userId;
153 result = result.replace(match, `@${name}`);
154 } catch (e) {
155 result = result.replace(match, `@${userId}`);
156 }
157 }
158 return result;
159 };
160
161 const markdownText = (await replaceUserMentions(payload.text))
162 .replace(/\*(.*?)\*/g, "**$1**") // Bold
163 .replace(/_(.*?)_/g, "*$1*") // Italic
164 .replace(/~(.*?)~/g, "~~$1~~") // Strikethrough
165 .replace(/<(https?:\/\/[^|]+)\|([^>]+)>/g, "[$2]($1)"); // Links
166
167 const mediaUrls = [];
168 // Check if the message is from a private channel
169 if (
170 payload.channel_type === "im" ||
171 payload.channel_type === "mpim" ||
172 payload.channel_type === "group"
173 ) {
174 // Process all files in one batch for private channels
175 if (payload.files && payload.files.length > 0) {
176 const cdnUrls = await deployToHackClubCDN(
177 payload.files.map((file) => file.url_private),
178 );
179 mediaUrls.push(
180 ...cdnUrls.files.map((file) => file.deployedUrl),
181 );
182 }
183 } else if (payload.files && payload.files.length > 0) {
184 // For public channels, process media files in parallel
185 const mediaFiles = payload.files.filter(
186 (file) =>
187 file.mimetype &&
188 (file.mimetype.startsWith("image/") ||
189 file.mimetype.startsWith("video/")),
190 );
191
192 if (mediaFiles.length > 0) {
193 const results = await Promise.all(
194 mediaFiles.map(async (file) => {
195 try {
196 const fileres =
197 await slackClient.files.sharedPublicURL({
198 file: file.id as string,
199 token: process.env.SLACK_USER_TOKEN,
200 });
201
202 const fetchRes = await fetch(
203 fileres.file?.permalink_public as string,
204 );
205 const html = await fetchRes.text();
206 const match = html.match(
207 /https:\/\/files.slack.com\/files-pri\/[^"]+pub_secret=([^"&]*)/,
208 );
209
210 return match?.[0] || null;
211 } catch (error) {
212 console.error(
213 "Error processing file:",
214 file.id,
215 error,
216 );
217 return null;
218 }
219 }),
220 );
221
222 // Filter out null results and add to mediaUrls
223 mediaUrls.push(...results.filter(Boolean));
224 }
225 }
226
227 await db.insert(takesTable).values({
228 id: Bun.randomUUIDv7(),
229 userId: user,
230 ts: payload.ts,
231 notes: markdownText,
232 media: JSON.stringify(mediaUrls),
233 elapsedTime: timeSpent,
234 });
235
236 await slackClient.reactions.remove({
237 channel: payload.channel,
238 timestamp: payload.ts,
239 name: "spin-loading",
240 });
241
242 await slackClient.reactions.add({
243 channel: payload.channel,
244 timestamp: payload.ts,
245 name: "fire",
246 });
247
248 await slackClient.chat.postMessage({
249 channel: payload.channel,
250 thread_ts: payload.ts,
251 text: ":inbox_tray: saved! thanks for the upload",
252 blocks: [
253 {
254 type: "section",
255 text: {
256 type: "mrkdwn",
257 text: `:inbox_tray: ${mediaUrls.length > 0 ? "uploaded media and " : ""}saved your notes! That's \`${prettyPrintTime(timeSpent * 1000)}\`!`,
258 },
259 },
260 ],
261 });
262
263 // Release the upload lock after successful processing
264 await db.update(usersTable)
265 .set({ isUploading: false })
266 .where(eq(usersTable.id, user));
267 } catch (error) {
268 console.error("Error handling file message:", error);
269 await slackClient.chat.postMessage({
270 channel: payload.channel,
271 thread_ts: payload.ts,
272 text: ":warning: there was an error processing your upload",
273 });
274
275 await slackClient.reactions.remove({
276 channel: payload.channel,
277 timestamp: payload.ts,
278 name: "fire",
279 });
280
281 await slackClient.reactions.remove({
282 channel: payload.channel,
283 timestamp: payload.ts,
284 name: "spin-loading",
285 });
286
287 await slackClient.reactions.add({
288 channel: payload.channel,
289 timestamp: payload.ts,
290 name: "nukeboom",
291 });
292
293 // Release the upload lock in case of error
294 try {
295 await db.update(usersTable)
296 .set({ isUploading: false })
297 .where(eq(usersTable.id, user)); // Now user is in scope
298 } catch (lockError) {
299 console.error("Error releasing upload lock:", lockError);
300 }
301
302 Sentry.captureException(error, {
303 extra: {
304 channel: payload.channel,
305 user: payload.user,
306 thread_ts: payload.ts,
307 },
308 tags: {
309 type: "file_upload_error",
310 },
311 });
312 }
313 });
314}