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";
11
12export default async function upload() {
13 slackApp.anyMessage(async ({ payload, context }) => {
14 try {
15 const user = payload.user as string;
16
17 if (
18 payload.subtype === "bot_message" ||
19 payload.subtype === "thread_broadcast" ||
20 payload.thread_ts ||
21 payload.channel !== process.env.SLACK_LISTEN_CHANNEL
22 )
23 return;
24
25 const userInDB = await db
26 .select()
27 .from(usersTable)
28 .where(eq(usersTable.id, user))
29 .then((users) => users[0] || null);
30
31 if (!userInDB) {
32 await slackClient.chat.postMessage({
33 channel: payload.channel,
34 thread_ts: payload.ts,
35 text: "We don't have a project for you; set one up by clicking the button below or by running `/takes`",
36 blocks: [
37 {
38 type: "section",
39 text: {
40 type: "mrkdwn",
41 text: "We don't have a project for you; set one up by clicking the button below or by running `/takes`",
42 },
43 },
44 {
45 type: "actions",
46 elements: [
47 {
48 type: "button",
49 text: {
50 type: "plain_text",
51 text: "setup your project",
52 },
53 action_id: "takes_setup",
54 },
55 ],
56 },
57 {
58 type: "context",
59 elements: [
60 {
61 type: "plain_text",
62 text: "don't forget to resend your update after setting up your project!",
63 },
64 ],
65 },
66 ],
67 });
68 return;
69 }
70
71 // Convert Slack formatting to markdown
72 const replaceUserMentions = async (text: string) => {
73 const regex = /<@([A-Z0-9]+)>/g;
74 const matches = text.match(regex);
75
76 if (!matches) return text;
77
78 let result = text;
79 for (const match of matches) {
80 const userId = match.match(/[A-Z0-9]+/)?.[0];
81 if (!userId) continue;
82
83 try {
84 const userInfo = await slackClient.users.info({
85 user: userId,
86 });
87 const name =
88 userInfo.user?.profile?.display_name ||
89 userInfo.user?.real_name ||
90 userId;
91 result = result.replace(match, `@${name}`);
92 } catch (e) {
93 result = result.replace(match, `@${userId}`);
94 }
95 }
96 return result;
97 };
98
99 const markdownText = (await replaceUserMentions(payload.text))
100 .replace(/\*(.*?)\*/g, "**$1**") // Bold
101 .replace(/_(.*?)_/g, "*$1*") // Italic
102 .replace(/~(.*?)~/g, "~~$1~~") // Strikethrough
103 .replace(/<(https?:\/\/[^|]+)\|([^>]+)>/g, "[$2]($1)"); // Links
104
105 const mediaUrls = [];
106
107 if (payload.files && payload.files.length > 0) {
108 for (const file of payload.files) {
109 if (
110 file.mimetype &&
111 (file.mimetype.startsWith("image/") ||
112 file.mimetype.startsWith("video/"))
113 ) {
114 const fileres = await slackClient.files.sharedPublicURL(
115 {
116 file: file.id as string,
117 token: process.env.SLACK_USER_TOKEN,
118 },
119 );
120
121 const fetchRes = await fetch(
122 fileres.file?.permalink_public as string,
123 );
124 const html = await fetchRes.text();
125 const match = html.match(
126 /https:\/\/files.slack.com\/files-pri\/[^"]+pub_secret=([^"&]*)/,
127 );
128 const filePublicUrl = match?.[0];
129
130 if (filePublicUrl) {
131 mediaUrls.push(filePublicUrl);
132 }
133 }
134 }
135 }
136
137 // fetch time spent on project via hackatime
138 const timeSpent = await fetchHackatimeSummary(
139 user,
140 userInDB.hackatimeVersion as HackatimeVersion,
141 JSON.parse(userInDB.hackatimeKeys),
142 new Date(userInDB.lastTakeUploadDate),
143 new Date(),
144 ).then((res) => res.total_categories_sum || 0);
145
146 await db.insert(takesTable).values({
147 id: Bun.randomUUIDv7(),
148 userId: user,
149 ts: payload.ts,
150 notes: markdownText,
151 media: JSON.stringify(mediaUrls),
152 elapsedTime: timeSpent,
153 });
154
155 await slackClient.reactions.add({
156 channel: payload.channel,
157 timestamp: payload.ts,
158 name: "fire",
159 });
160
161 await slackClient.chat.postMessage({
162 channel: payload.channel,
163 thread_ts: payload.ts,
164 text: ":inbox_tray: saved! thanks for the upload",
165 blocks: [
166 {
167 type: "section",
168 text: {
169 type: "mrkdwn",
170 text: `:inbox_tray: ${mediaUrls.length > 0 ? "uploaded media and " : ""}saved your notes! That's \`${prettyPrintTime(timeSpent * 1000)}\`!`,
171 },
172 },
173 ],
174 });
175 } catch (error) {
176 console.error("Error handling file message:", error);
177 await slackClient.chat.postMessage({
178 channel: payload.channel,
179 thread_ts: payload.ts,
180 text: ":warning: there was an error processing your upload",
181 });
182
183 Sentry.captureException(error, {
184 extra: {
185 channel: payload.channel,
186 user: payload.user,
187 thread_ts: payload.ts,
188 },
189 tags: {
190 type: "file_upload_error",
191 },
192 });
193 }
194 });
195}