a fun bot for the hc slack
at main 8.4 kB view raw
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}