a fun bot for the hc slack
1import type { AnyMessageBlock } from "slack-edge";
2import { environment, slackApp, slackClient } from "../index";
3import { db } from "../libs/db";
4import { takes as takesTable } from "../libs/schema";
5import { eq, and, desc } from "drizzle-orm";
6import TakesConfig from "../libs/config";
7import { generateSlackDate, prettyPrintTime } from "../libs/time";
8
9type MessageResponse = {
10 blocks?: AnyMessageBlock[];
11 text: string;
12 response_type: "ephemeral" | "in_channel";
13};
14
15const takes = async () => {
16 // Helper functions for command actions
17 const getActiveTake = async (userId: string) => {
18 return db
19 .select()
20 .from(takesTable)
21 .where(
22 and(
23 eq(takesTable.userId, userId),
24 eq(takesTable.status, "active"),
25 ),
26 )
27 .limit(1);
28 };
29
30 const getPausedTake = async (userId: string) => {
31 return db
32 .select()
33 .from(takesTable)
34 .where(
35 and(
36 eq(takesTable.userId, userId),
37 eq(takesTable.status, "paused"),
38 ),
39 )
40 .limit(1);
41 };
42
43 const getCompletedTakes = async (userId: string, limit = 5) => {
44 return db
45 .select()
46 .from(takesTable)
47 .where(
48 and(
49 eq(takesTable.userId, userId),
50 eq(takesTable.status, "completed"),
51 ),
52 )
53 .orderBy(desc(takesTable.completedAt))
54 .limit(limit);
55 };
56
57 // Check for paused sessions that have exceeded the max pause duration
58 const expirePausedSessions = async () => {
59 const now = new Date();
60 const pausedTakes = await db
61 .select()
62 .from(takesTable)
63 .where(eq(takesTable.status, "paused"));
64
65 for (const take of pausedTakes) {
66 if (take.pausedAt) {
67 const pausedDuration =
68 (now.getTime() - take.pausedAt.getTime()) / (60 * 1000); // Convert to minutes
69
70 // Send warning notification when getting close to expiration
71 if (
72 pausedDuration >
73 TakesConfig.MAX_PAUSE_DURATION -
74 TakesConfig.NOTIFICATIONS
75 .PAUSE_EXPIRATION_WARNING &&
76 !take.notifiedPauseExpiration
77 ) {
78 // Update notification flag
79 await db
80 .update(takesTable)
81 .set({
82 notifiedPauseExpiration: true,
83 })
84 .where(eq(takesTable.id, take.id));
85
86 // Send warning message
87 try {
88 const timeRemaining = Math.round(
89 TakesConfig.MAX_PAUSE_DURATION - pausedDuration,
90 );
91 await slackApp.client.chat.postMessage({
92 channel: take.userId,
93 text: `⚠️ Reminder: Your paused takes session will automatically complete in about ${timeRemaining} minutes if not resumed.`,
94 });
95 } catch (error) {
96 console.error(
97 "Failed to send pause expiration warning:",
98 error,
99 );
100 }
101 }
102
103 // Auto-expire paused sessions that exceed the max pause duration
104 if (pausedDuration > TakesConfig.MAX_PAUSE_DURATION) {
105 let ts: string | undefined;
106 // Notify user that their session was auto-completed
107 try {
108 const res = await slackApp.client.chat.postMessage({
109 channel: take.userId,
110 text: `⏰ Your paused takes session has been automatically completed because it was paused for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes.\n\nPlease upload your takes video in this thread within the next 24 hours!`,
111 });
112 ts = res.ts;
113 } catch (error) {
114 console.error(
115 "Failed to notify user of auto-completed session:",
116 error,
117 );
118 }
119
120 await db
121 .update(takesTable)
122 .set({
123 status: "waitingUpload",
124 completedAt: now,
125 ts,
126 notes: take.notes
127 ? `${take.notes} (Automatically completed due to pause timeout)`
128 : "Automatically completed due to pause timeout",
129 })
130 .where(eq(takesTable.id, take.id));
131 }
132 }
133 }
134 };
135
136 // Check for active sessions that are almost done
137 const checkActiveSessions = async () => {
138 const now = new Date();
139 const activeTakes = await db
140 .select()
141 .from(takesTable)
142 .where(eq(takesTable.status, "active"));
143
144 for (const take of activeTakes) {
145 const endTime = new Date(
146 take.startedAt.getTime() +
147 take.durationMinutes * 60000 +
148 (take.pausedTimeMs || 0),
149 );
150
151 const remainingMs = endTime.getTime() - now.getTime();
152 const remainingMinutes = remainingMs / 60000;
153
154 if (
155 remainingMinutes <=
156 TakesConfig.NOTIFICATIONS.LOW_TIME_WARNING &&
157 remainingMinutes > 0 &&
158 !take.notifiedLowTime
159 ) {
160 await db
161 .update(takesTable)
162 .set({ notifiedLowTime: true })
163 .where(eq(takesTable.id, take.id));
164
165 console.log("Sending low time warning to user");
166
167 try {
168 await slackApp.client.chat.postMessage({
169 channel: take.userId,
170 text: `⏱️ Your takes session has less than ${TakesConfig.NOTIFICATIONS.LOW_TIME_WARNING} minutes remaining.`,
171 });
172 } catch (error) {
173 console.error("Failed to send low time warning:", error);
174 }
175 }
176
177 if (remainingMs <= 0) {
178 let ts: string | undefined;
179 try {
180 const res = await slackApp.client.chat.postMessage({
181 channel: take.userId,
182 text: "⏰ Your takes session has automatically completed because the time is up. Please upload your takes video in this thread within the next 24 hours!",
183 });
184
185 ts = res.ts;
186 } catch (error) {
187 console.error(
188 "Failed to notify user of completed session:",
189 error,
190 );
191 }
192
193 await db
194 .update(takesTable)
195 .set({
196 status: "waitingUpload",
197 completedAt: now,
198 ts,
199 notes: take.notes
200 ? `${take.notes} (Automatically completed - time expired)`
201 : "Automatically completed - time expired",
202 })
203 .where(eq(takesTable.id, take.id));
204 }
205 }
206 };
207
208 // Command action handlers
209 const handleStart = async (
210 userId: string,
211 channelId: string,
212 description?: string,
213 durationMinutes?: number,
214 ): Promise<MessageResponse> => {
215 const activeTake = await getActiveTake(userId);
216 if (activeTake.length > 0) {
217 return {
218 text: "You already have an active takes session! Use `/takes status` to check it.",
219 response_type: "ephemeral",
220 };
221 }
222
223 // Create new takes session
224 const newTake = {
225 id: Bun.randomUUIDv7(),
226 userId,
227 channelId,
228 status: "active",
229 startedAt: new Date(),
230 durationMinutes:
231 durationMinutes || TakesConfig.DEFAULT_SESSION_LENGTH,
232 description: description || null,
233 notifiedLowTime: false,
234 notifiedPauseExpiration: false,
235 };
236
237 await db.insert(takesTable).values(newTake);
238
239 // Calculate end time for message
240 const endTime = new Date(
241 newTake.startedAt.getTime() + newTake.durationMinutes * 60000,
242 );
243
244 const descriptionText = description
245 ? `\n\n*Working on:* ${description}`
246 : "";
247 return {
248 text: `🎬 Takes session started! You have ${prettyPrintTime(newTake.durationMinutes * 60000)} until ${generateSlackDate(endTime)}.${descriptionText}`,
249 response_type: "ephemeral",
250 blocks: [
251 {
252 type: "section",
253 text: {
254 type: "mrkdwn",
255 text: `🎬 Takes session started!${descriptionText}`,
256 },
257 },
258 {
259 type: "divider",
260 },
261 {
262 type: "context",
263 elements: [
264 {
265 type: "mrkdwn",
266 text: `You have ${prettyPrintTime(newTake.durationMinutes * 60000)} left until ${generateSlackDate(endTime)}.`,
267 },
268 ],
269 },
270 {
271 type: "actions",
272 elements: [
273 {
274 type: "button",
275 text: {
276 type: "plain_text",
277 text: "✍️ edit",
278 emoji: true,
279 },
280 value: "edit",
281 action_id: "takes_edit",
282 },
283 {
284 type: "button",
285 text: {
286 type: "plain_text",
287 text: "⏸️ Pause",
288 emoji: true,
289 },
290 value: "pause",
291 action_id: "takes_pause",
292 },
293 {
294 type: "button",
295 text: {
296 type: "plain_text",
297 text: "⏹️ Stop",
298 emoji: true,
299 },
300 value: "stop",
301 action_id: "takes_stop",
302 style: "danger",
303 },
304 {
305 type: "button",
306 text: {
307 type: "plain_text",
308 text: "🔄 Refresh",
309 emoji: true,
310 },
311 value: "status",
312 action_id: "takes_status",
313 },
314 ],
315 },
316 ],
317 };
318 };
319
320 const handlePause = async (
321 userId: string,
322 ): Promise<MessageResponse | undefined> => {
323 const activeTake = await getActiveTake(userId);
324 if (activeTake.length === 0) {
325 return {
326 text: `You don't have an active takes session! Use \`/takes start\` to begin.`,
327 response_type: "ephemeral",
328 };
329 }
330
331 const takeToUpdate = activeTake[0];
332 if (!takeToUpdate) {
333 return;
334 }
335
336 // Update the takes entry to paused status
337 await db
338 .update(takesTable)
339 .set({
340 status: "paused",
341 pausedAt: new Date(),
342 notifiedPauseExpiration: false, // Reset pause expiration notification
343 })
344 .where(eq(takesTable.id, takeToUpdate.id));
345
346 // Calculate when the pause will expire
347 const pauseExpires = new Date();
348 pauseExpires.setMinutes(
349 pauseExpires.getMinutes() + TakesConfig.MAX_PAUSE_DURATION,
350 );
351 const pauseExpiresStr = `<!date^${Math.floor(pauseExpires.getTime() / 1000)}^{date_short_pretty} at {time}|${pauseExpires.toLocaleString()}>`;
352
353 return {
354 text: `⏸️ Session paused! You have ${prettyPrintTime(takeToUpdate.durationMinutes * 60000)} remaining. It will automatically finish in ${TakesConfig.MAX_PAUSE_DURATION} minutes (by ${pauseExpiresStr}) if not resumed.`,
355 response_type: "ephemeral",
356 blocks: [
357 {
358 type: "section",
359 text: {
360 type: "mrkdwn",
361 text: `⏸️ Session paused! You have ${prettyPrintTime(takeToUpdate.durationMinutes * 60000)} remaining.`,
362 },
363 },
364 {
365 type: "divider",
366 },
367 {
368 type: "context",
369 elements: [
370 {
371 type: "mrkdwn",
372 text: `It will automatically finish in ${TakesConfig.MAX_PAUSE_DURATION} minutes (by ${pauseExpiresStr}) if not resumed.`,
373 },
374 ],
375 },
376 {
377 type: "actions",
378 elements: [
379 {
380 type: "button",
381 text: {
382 type: "plain_text",
383 text: "✍️ edit",
384 emoji: true,
385 },
386 value: "edit",
387 action_id: "takes_edit",
388 },
389 {
390 type: "button",
391 text: {
392 type: "plain_text",
393 text: "▶️ Resume",
394 emoji: true,
395 },
396 value: "resume",
397 action_id: "takes_resume",
398 },
399 {
400 type: "button",
401 text: {
402 type: "plain_text",
403 text: "⏹️ Stop",
404 emoji: true,
405 },
406 value: "stop",
407 action_id: "takes_stop",
408 style: "danger",
409 },
410 {
411 type: "button",
412 text: {
413 type: "plain_text",
414 text: "🔄 Refresh",
415 emoji: true,
416 },
417 value: "status",
418 action_id: "takes_status",
419 },
420 ],
421 },
422 ],
423 };
424 };
425
426 const handleResume = async (
427 userId: string,
428 ): Promise<MessageResponse | undefined> => {
429 const pausedTake = await getPausedTake(userId);
430 if (pausedTake.length === 0) {
431 return {
432 text: `You don't have a paused takes session!`,
433 response_type: "ephemeral",
434 };
435 }
436
437 const pausedSession = pausedTake[0];
438 if (!pausedSession) {
439 return;
440 }
441
442 const now = new Date();
443
444 // Calculate paused time
445 if (pausedSession.pausedAt) {
446 const pausedTimeMs =
447 now.getTime() - pausedSession.pausedAt.getTime();
448 const totalPausedTime =
449 (pausedSession.pausedTimeMs || 0) + pausedTimeMs;
450
451 // Update the takes entry to active status
452 await db
453 .update(takesTable)
454 .set({
455 status: "active",
456 pausedAt: null,
457 pausedTimeMs: totalPausedTime,
458 notifiedLowTime: false, // Reset low time notification
459 })
460 .where(eq(takesTable.id, pausedSession.id));
461 }
462
463 const endTime = new Date(
464 new Date(pausedSession.startedAt).getTime() +
465 pausedSession.durationMinutes * 60000 +
466 (pausedSession.pausedTimeMs || 0),
467 );
468
469 return {
470 text: `▶️ Takes session resumed! You have ${prettyPrintTime(pausedSession.durationMinutes * 60000)} remaining in your session.`,
471 response_type: "ephemeral",
472 blocks: [
473 {
474 type: "section",
475 text: {
476 type: "mrkdwn",
477 text: "▶️ Takes session resumed!",
478 },
479 },
480 {
481 type: "divider",
482 },
483 {
484 type: "context",
485 elements: [
486 {
487 type: "mrkdwn",
488 text: `You have ${prettyPrintTime(pausedSession.durationMinutes * 60000)} remaining until ${generateSlackDate(endTime)}.`,
489 },
490 ],
491 },
492 {
493 type: "actions",
494 elements: [
495 {
496 type: "button",
497 text: {
498 type: "plain_text",
499 text: "✍️ edit",
500 emoji: true,
501 },
502 value: "edit",
503 action_id: "takes_edit",
504 },
505 {
506 type: "button",
507 text: {
508 type: "plain_text",
509 text: "⏸️ Pause",
510 emoji: true,
511 },
512 value: "pause",
513 action_id: "takes_pause",
514 },
515 {
516 type: "button",
517 text: {
518 type: "plain_text",
519 text: "⏹️ Stop",
520 emoji: true,
521 },
522 value: "stop",
523 action_id: "takes_stop",
524 style: "danger",
525 },
526 {
527 type: "button",
528 text: {
529 type: "plain_text",
530 text: "🔄 Refresh",
531 emoji: true,
532 },
533 value: "status",
534 action_id: "takes_status",
535 },
536 ],
537 },
538 ],
539 };
540 };
541
542 const handleStop = async (
543 userId: string,
544 args?: string[],
545 ): Promise<MessageResponse | undefined> => {
546 const activeTake = await getActiveTake(userId);
547
548 if (activeTake.length === 0) {
549 const pausedTake = await getPausedTake(userId);
550
551 if (pausedTake.length === 0) {
552 return {
553 text: `You don't have an active or paused takes session!`,
554 response_type: "ephemeral",
555 };
556 }
557
558 // Mark the paused session as completed
559 const pausedTakeToStop = pausedTake[0];
560 if (!pausedTakeToStop) {
561 return;
562 }
563
564 // Extract notes if provided
565 let notes = undefined;
566 if (args && args.length > 1) {
567 notes = args.slice(1).join(" ");
568 }
569
570 const res = await slackClient.chat.postMessage({
571 channel: userId,
572 text: "🎬 Your paused takes session has been completed. Please upload your takes video in this thread within the next 24 hours!",
573 });
574
575 await db
576 .update(takesTable)
577 .set({
578 status: "waitingUpload",
579 ts: res.ts,
580 completedAt: new Date(),
581 ...(notes && { notes }),
582 })
583 .where(eq(takesTable.id, pausedTakeToStop.id));
584 } else {
585 // Mark the active session as completed
586 const activeTakeToStop = activeTake[0];
587 if (!activeTakeToStop) {
588 return;
589 }
590
591 // Extract notes if provided
592 let notes = undefined;
593 if (args && args.length > 1) {
594 notes = args.slice(1).join(" ");
595 }
596
597 const res = await slackClient.chat.postMessage({
598 channel: userId,
599 text: "🎬 Your takes session has been completed. Please upload your takes video in this thread within the next 24 hours!",
600 });
601
602 await db
603 .update(takesTable)
604 .set({
605 status: "waitingUpload",
606 ts: res.ts,
607 completedAt: new Date(),
608 ...(notes && { notes }),
609 })
610 .where(eq(takesTable.id, activeTakeToStop.id));
611 }
612
613 return {
614 text: "✅ Takes session completed! I hope you had fun!",
615 response_type: "ephemeral",
616 blocks: [
617 {
618 type: "section",
619 text: {
620 type: "mrkdwn",
621 text: "✅ Takes session completed! I hope you had fun!",
622 },
623 },
624 {
625 type: "actions",
626 elements: [
627 {
628 type: "button",
629 text: {
630 type: "plain_text",
631 text: "🎬 Start New Session",
632 emoji: true,
633 },
634 value: "start",
635 action_id: "takes_start",
636 },
637 {
638 type: "button",
639 text: {
640 type: "plain_text",
641 text: "📋 History",
642 emoji: true,
643 },
644 value: "history",
645 action_id: "takes_history",
646 },
647 ],
648 },
649 ],
650 };
651 };
652
653 const handleStatus = async (
654 userId: string,
655 ): Promise<MessageResponse | undefined> => {
656 const activeTake = await getActiveTake(userId);
657
658 // First, check for expired paused sessions
659 await expirePausedSessions();
660
661 if (activeTake.length > 0) {
662 const take = activeTake[0];
663 if (!take) {
664 return;
665 }
666
667 const startTime = new Date(take.startedAt);
668 const endTime = new Date(
669 startTime.getTime() + take.durationMinutes * 60000,
670 );
671
672 // Adjust for paused time
673 if (take.pausedTimeMs) {
674 endTime.setTime(endTime.getTime() + take.pausedTimeMs);
675 }
676
677 const now = new Date();
678 const remainingMs = endTime.getTime() - now.getTime();
679
680 // Add description to display if present
681 const descriptionText = take.description
682 ? `\n\n*Working on:* ${take.description}`
683 : "";
684
685 return {
686 text: `🎬 You have an active takes session with ${prettyPrintTime(remainingMs)} remaining.${descriptionText}`,
687 response_type: "ephemeral",
688 blocks: [
689 {
690 type: "section",
691 text: {
692 type: "mrkdwn",
693 text: `🎬 You have an active takes session${descriptionText}`,
694 },
695 },
696 {
697 type: "divider",
698 },
699 {
700 type: "context",
701 elements: [
702 {
703 type: "mrkdwn",
704 text: `You have ${prettyPrintTime(remainingMs)} remaining until ${generateSlackDate(endTime)}.`,
705 },
706 ],
707 },
708 {
709 type: "actions",
710 elements: [
711 {
712 type: "button",
713 text: {
714 type: "plain_text",
715 text: "✍️ edit",
716 emoji: true,
717 },
718 value: "edit",
719 action_id: "takes_edit",
720 },
721 {
722 type: "button",
723 text: {
724 type: "plain_text",
725 text: "⏸️ Pause",
726 emoji: true,
727 },
728 value: "pause",
729 action_id: "takes_pause",
730 },
731 {
732 type: "button",
733 text: {
734 type: "plain_text",
735 text: "⏹️ Stop",
736 emoji: true,
737 },
738 value: "stop",
739 action_id: "takes_stop",
740 style: "danger",
741 },
742
743 {
744 type: "button",
745 text: {
746 type: "plain_text",
747 text: "🔄 Refresh",
748 emoji: true,
749 },
750 value: "status",
751 action_id: "takes_status",
752 },
753 ],
754 },
755 ],
756 };
757 }
758
759 // Check for paused session
760 const pausedTakeStatus = await getPausedTake(userId);
761
762 if (pausedTakeStatus.length > 0) {
763 const pausedTake = pausedTakeStatus[0];
764 if (!pausedTake || !pausedTake.pausedAt) {
765 return;
766 }
767
768 // Calculate how much time remains before auto-completion
769 const now = new Date();
770 const pausedDuration =
771 (now.getTime() - pausedTake.pausedAt.getTime()) / (60 * 1000); // In minutes
772 const remainingPauseTime = Math.max(
773 0,
774 TakesConfig.MAX_PAUSE_DURATION - pausedDuration,
775 );
776
777 // Format the pause timeout
778 const pauseExpires = new Date(pausedTake.pausedAt);
779 pauseExpires.setMinutes(
780 pauseExpires.getMinutes() + TakesConfig.MAX_PAUSE_DURATION,
781 );
782 const pauseExpiresStr = `<!date^${Math.floor(pauseExpires.getTime() / 1000)}^{date_short_pretty} at {time}|${pauseExpires.toLocaleString()}>`;
783
784 // Add notes to display if present
785 const noteText = pausedTake.notes
786 ? `\n\n*Working on:* ${pausedTake.notes}`
787 : "";
788
789 return {
790 text: `⏸️ You have a paused takes session. It will auto-complete in ${remainingPauseTime.toFixed(1)} minutes if not resumed.`,
791 response_type: "ephemeral",
792 blocks: [
793 {
794 type: "section",
795 text: {
796 type: "mrkdwn",
797 text: `⏸️ Session paused! You have ${prettyPrintTime(pausedTake.durationMinutes * 60000)} remaining.`,
798 },
799 },
800 {
801 type: "divider",
802 },
803 {
804 type: "context",
805 elements: [
806 {
807 type: "mrkdwn",
808 text: `It will automatically finish in ${TakesConfig.MAX_PAUSE_DURATION} minutes (by ${pauseExpiresStr}) if not resumed.`,
809 },
810 ],
811 },
812 {
813 type: "actions",
814 elements: [
815 {
816 type: "button",
817 text: {
818 type: "plain_text",
819 text: "▶️ Resume",
820 emoji: true,
821 },
822 value: "resume",
823 action_id: "takes_resume",
824 },
825 {
826 type: "button",
827 text: {
828 type: "plain_text",
829 text: "⏹️ Stop",
830 emoji: true,
831 },
832 value: "stop",
833 action_id: "takes_stop",
834 style: "danger",
835 },
836 {
837 type: "button",
838 text: {
839 type: "plain_text",
840 text: "🔄 Refresh",
841 emoji: true,
842 },
843 value: "status",
844 action_id: "takes_status",
845 },
846 ],
847 },
848 ],
849 };
850 }
851
852 // Check history of completed sessions
853 const completedSessions = await getCompletedTakes(userId);
854 const takeTime = completedSessions.length
855 ? (() => {
856 const diffMs =
857 new Date().getTime() -
858 // @ts-expect-error - TS doesn't know that we are checking the length
859 completedSessions[
860 completedSessions.length - 1
861 ].startedAt.getTime();
862
863 const hours = Math.ceil(diffMs / (1000 * 60 * 60));
864 if (hours < 24) return `${hours} hours`;
865
866 const weeks = Math.floor(
867 diffMs / (1000 * 60 * 60 * 24 * 7),
868 );
869 if (weeks > 0 && weeks < 4) return `${weeks} weeks`;
870
871 const months = Math.floor(
872 diffMs / (1000 * 60 * 60 * 24 * 30),
873 );
874 return `${months} months`;
875 })()
876 : 0;
877
878 return {
879 text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the last ${takeTime}.`,
880 response_type: "ephemeral",
881 blocks: [
882 {
883 type: "section",
884 text: {
885 type: "mrkdwn",
886 text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the last ${takeTime}.`,
887 },
888 },
889 {
890 type: "actions",
891 elements: [
892 {
893 type: "button",
894 text: {
895 type: "plain_text",
896 text: "🎬 Start New Session",
897 emoji: true,
898 },
899 value: "start",
900 action_id: "takes_start",
901 },
902 {
903 type: "button",
904 text: {
905 type: "plain_text",
906 text: "📋 History",
907 emoji: true,
908 },
909 value: "history",
910 action_id: "takes_history",
911 },
912 ],
913 },
914 ],
915 };
916 };
917
918 const handleHistory = async (userId: string): Promise<MessageResponse> => {
919 // Get completed takes for the user
920 const completedTakes = (
921 await getCompletedTakes(userId, TakesConfig.MAX_HISTORY_ITEMS)
922 ).sort(
923 (a, b) =>
924 (b.completedAt?.getTime() ?? 0) -
925 (a.completedAt?.getTime() ?? 0),
926 );
927
928 if (completedTakes.length === 0) {
929 return {
930 text: "You haven't completed any takes sessions yet.",
931 response_type: "ephemeral",
932 };
933 }
934
935 // Create blocks for each completed take
936 const historyBlocks: AnyMessageBlock[] = [
937 {
938 type: "header",
939 text: {
940 type: "plain_text",
941 text: `📋 Your most recent ${completedTakes.length} Takes Sessions`,
942 emoji: true,
943 },
944 },
945 ];
946
947 for (const take of completedTakes) {
948 const startTime = new Date(take.startedAt);
949 const endTime = take.completedAt || startTime;
950
951 // Calculate duration in minutes
952 const durationMs = endTime.getTime() - startTime.getTime();
953 const pausedMs = take.pausedTimeMs || 0;
954 const activeDuration = Math.round((durationMs - pausedMs) / 60000);
955
956 // Format dates
957 const startDate = `<!date^${Math.floor(startTime.getTime() / 1000)}^{date_short_pretty} at {time}|${startTime.toLocaleString()}>`;
958 const endDate = `<!date^${Math.floor(endTime.getTime() / 1000)}^{date_short_pretty} at {time}|${endTime.toLocaleString()}>`;
959
960 const notes = take.notes ? `\n• Notes: ${take.notes}` : "";
961 const description = take.description
962 ? `\n• Description: ${take.description}\n`
963 : "";
964
965 historyBlocks.push({
966 type: "section",
967 text: {
968 type: "mrkdwn",
969 text: `*Take on ${startDate}*\n${description}• Duration: ${activeDuration} minutes${
970 pausedMs > 0
971 ? ` (+ ${Math.round(pausedMs / 60000)} minutes paused)`
972 : ""
973 }\n• Started: ${startDate}\n• Completed: ${endDate}${notes}`,
974 },
975 });
976
977 // Add a divider between entries
978 if (take !== completedTakes[completedTakes.length - 1]) {
979 historyBlocks.push({
980 type: "divider",
981 });
982 }
983 }
984
985 // Add actions block
986 historyBlocks.push({
987 type: "actions",
988 elements: [
989 {
990 type: "button",
991 text: {
992 type: "plain_text",
993 text: "🎬 Start New Session",
994 emoji: true,
995 },
996 value: "start",
997 action_id: "takes_start",
998 },
999 {
1000 type: "button",
1001 text: {
1002 type: "plain_text",
1003 text: "👁️ Status",
1004 emoji: true,
1005 },
1006 value: "status",
1007 action_id: "takes_status",
1008 },
1009 {
1010 type: "button",
1011 text: {
1012 type: "plain_text",
1013 text: "🔄 Refresh",
1014 emoji: true,
1015 },
1016 value: "status",
1017 action_id: "takes_history",
1018 },
1019 ],
1020 });
1021
1022 return {
1023 text: `Your recent takes history (${completedTakes.length} sessions)`,
1024 response_type: "ephemeral",
1025 blocks: historyBlocks,
1026 };
1027 };
1028
1029 const handleHelp = async (): Promise<MessageResponse> => {
1030 return {
1031 text: `*Takes Commands*\n\n• \`/takes start [minutes]\` - Start a new takes session, optionally specifying duration\n• \`/takes pause\` - Pause your current session (max ${TakesConfig.MAX_PAUSE_DURATION} min)\n• \`/takes resume\` - Resume your paused session\n• \`/takes stop [notes]\` - End your current session with optional notes\n• \`/takes status\` - Check the status of your session\n• \`/takes history\` - View your past takes sessions`,
1032 response_type: "ephemeral",
1033 blocks: [
1034 {
1035 type: "section",
1036 text: {
1037 type: "mrkdwn",
1038 text: "*Takes Commands*",
1039 },
1040 },
1041 {
1042 type: "section",
1043 text: {
1044 type: "mrkdwn",
1045 text: `• \`/takes start [minutes]\` - Start a new session (default: ${TakesConfig.DEFAULT_SESSION_LENGTH} min)\n• \`/takes pause\` - Pause your session (max ${TakesConfig.MAX_PAUSE_DURATION} min)\n• \`/takes resume\` - Resume your paused session\n• \`/takes stop [notes]\` - End session with optional notes\n• \`/takes status\` - Check status\n• \`/takes history\` - View past sessions`,
1046 },
1047 },
1048 {
1049 type: "actions",
1050 elements: [
1051 {
1052 type: "button",
1053 text: {
1054 type: "plain_text",
1055 text: "🎬 Start New Session",
1056 emoji: true,
1057 },
1058 value: "start",
1059 action_id: "takes_start",
1060 },
1061 {
1062 type: "button",
1063 text: {
1064 type: "plain_text",
1065 text: "📋 History",
1066 emoji: true,
1067 },
1068 value: "history",
1069 action_id: "takes_history",
1070 },
1071 ],
1072 },
1073 ],
1074 };
1075 };
1076 const getDescriptionBlocks = (error?: string): MessageResponse => {
1077 const blocks: AnyMessageBlock[] = [
1078 {
1079 type: "input",
1080 block_id: "note_block",
1081 element: {
1082 type: "plain_text_input",
1083 action_id: "note_input",
1084 placeholder: {
1085 type: "plain_text",
1086 text: "Enter a note for your session",
1087 },
1088 multiline: true,
1089 },
1090 label: {
1091 type: "plain_text",
1092 text: "Note",
1093 },
1094 },
1095 {
1096 type: "actions",
1097 elements: [
1098 {
1099 type: "button",
1100 text: {
1101 type: "plain_text",
1102 text: "🎬 Start Session",
1103 emoji: true,
1104 },
1105 value: "start",
1106 action_id: "takes_start",
1107 },
1108 {
1109 type: "button",
1110 text: {
1111 type: "plain_text",
1112 text: "⛔ Cancel",
1113 emoji: true,
1114 },
1115 value: "cancel",
1116 action_id: "takes_status",
1117 style: "danger",
1118 },
1119 ],
1120 },
1121 ];
1122
1123 if (error) {
1124 blocks.push(
1125 {
1126 type: "divider",
1127 },
1128 {
1129 type: "context",
1130 elements: [
1131 {
1132 type: "mrkdwn",
1133 text: `⚠️ ${error}`,
1134 },
1135 ],
1136 },
1137 );
1138 }
1139
1140 return {
1141 text: "Please enter a note for your session:",
1142 response_type: "ephemeral",
1143 blocks,
1144 };
1145 };
1146
1147 const getEditDescriptionBlocks = (
1148 description: string,
1149 error?: string,
1150 ): MessageResponse => {
1151 const blocks: AnyMessageBlock[] = [
1152 {
1153 type: "input",
1154 block_id: "note_block",
1155 element: {
1156 type: "plain_text_input",
1157 action_id: "note_input",
1158 placeholder: {
1159 type: "plain_text",
1160 text: "Enter a note for your session",
1161 },
1162 multiline: true,
1163 initial_value: description,
1164 },
1165 label: {
1166 type: "plain_text",
1167 text: "Note",
1168 },
1169 },
1170 {
1171 type: "actions",
1172 elements: [
1173 {
1174 type: "button",
1175 text: {
1176 type: "plain_text",
1177 text: "✍️ Update Note",
1178 emoji: true,
1179 },
1180 value: "start",
1181 action_id: "takes_edit",
1182 },
1183 {
1184 type: "button",
1185 text: {
1186 type: "plain_text",
1187 text: "⛔ Cancel",
1188 emoji: true,
1189 },
1190 value: "cancel",
1191 action_id: "takes_status",
1192 style: "danger",
1193 },
1194 ],
1195 },
1196 ];
1197
1198 if (error) {
1199 blocks.push(
1200 {
1201 type: "divider",
1202 },
1203 {
1204 type: "context",
1205 elements: [
1206 {
1207 type: "mrkdwn",
1208 text: `⚠️ ${error}`,
1209 },
1210 ],
1211 },
1212 );
1213 }
1214
1215 return {
1216 text: "Please enter a note for your session:",
1217 response_type: "ephemeral",
1218 blocks,
1219 };
1220 };
1221
1222 // Main command handler
1223 slackApp.command(
1224 environment === "dev" ? "/takes-dev" : "/takes",
1225 async ({ payload, context }): Promise<void> => {
1226 const userId = payload.user_id;
1227 const channelId = payload.channel_id;
1228 const text = payload.text || "";
1229 const args = text.trim().split(/\s+/);
1230 let subcommand = args[0]?.toLowerCase() || "";
1231
1232 // Check for active takes session
1233 const activeTake = await getActiveTake(userId);
1234
1235 // Check for paused session if no active one
1236 const pausedTakeCheck =
1237 activeTake.length === 0 ? await getPausedTake(userId) : [];
1238
1239 // Run checks for expired or about-to-expire sessions
1240 await expirePausedSessions();
1241 await checkActiveSessions();
1242
1243 // Default to status if we have an active or paused session and no command specified
1244 if (
1245 subcommand === "" &&
1246 (activeTake.length > 0 || pausedTakeCheck.length > 0)
1247 ) {
1248 subcommand = "status";
1249 } else if (subcommand === "") {
1250 subcommand = "help";
1251 }
1252
1253 let response: MessageResponse | undefined;
1254
1255 // Special handling for start command to show modal
1256 if (subcommand === "start" && !activeTake.length) {
1257 response = getDescriptionBlocks();
1258 }
1259
1260 // Route to the appropriate handler function
1261 switch (subcommand) {
1262 case "start":
1263 response = await handleStart(userId, channelId);
1264 break;
1265 case "pause":
1266 response = await handlePause(userId);
1267 break;
1268 case "resume":
1269 response = await handleResume(userId);
1270 break;
1271 case "stop":
1272 response = await handleStop(userId, args);
1273 break;
1274 case "edit":
1275 response = getEditDescriptionBlocks(
1276 activeTake[0]?.description || "",
1277 );
1278 break;
1279 case "status":
1280 response = await handleStatus(userId);
1281 break;
1282 case "history":
1283 response = await handleHistory(userId);
1284 break;
1285 case "help":
1286 response = await handleHelp();
1287 break;
1288 default:
1289 response = await handleHelp();
1290 break;
1291 }
1292
1293 if (context.respond)
1294 await context.respond(
1295 response || {
1296 text: "An error occurred while processing your request.",
1297 response_type: "ephemeral",
1298 },
1299 );
1300 },
1301 );
1302
1303 // Handle button actions
1304 slackApp.action(/^takes_(\w+)$/, async ({ payload, context }) => {
1305 const userId = payload.user.id;
1306 const channelId = context.channelId || "";
1307 const actionId = payload.actions[0]?.action_id as string;
1308 const command = actionId.replace("takes_", "");
1309 const descriptionInput = payload.state.values.note_block?.note_input;
1310
1311 let response: MessageResponse | undefined;
1312
1313 const activeTake = await getActiveTake(userId);
1314
1315 // Route to the appropriate handler function
1316 switch (command) {
1317 case "start": {
1318 if (activeTake.length > 0) {
1319 if (context.respond) {
1320 response = await handleStatus(userId);
1321 }
1322 } else {
1323 if (!descriptionInput?.value?.trim()) {
1324 response = getDescriptionBlocks(
1325 "Please enter a note for your session.",
1326 );
1327 } else {
1328 response = await handleStart(
1329 userId,
1330 channelId,
1331 descriptionInput?.value?.trim(),
1332 );
1333 }
1334 }
1335 break;
1336 }
1337 case "pause":
1338 response = await handlePause(userId);
1339 break;
1340 case "resume":
1341 response = await handleResume(userId);
1342 break;
1343 case "stop":
1344 response = await handleStop(userId);
1345 break;
1346 case "edit": {
1347 if (!activeTake.length && context.respond) {
1348 await context.respond({
1349 text: "You don't have an active takes session to edit!",
1350 response_type: "ephemeral",
1351 });
1352 return;
1353 }
1354
1355 if (!descriptionInput) {
1356 response = getEditDescriptionBlocks(
1357 activeTake[0]?.description || "",
1358 );
1359 } else if (descriptionInput.value?.trim()) {
1360 const takeToUpdate = activeTake[0];
1361 if (!takeToUpdate) return;
1362
1363 // Update the note for the active session
1364 await db.update(takesTable).set({
1365 description: descriptionInput.value.trim(),
1366 });
1367
1368 response = await handleStatus(userId);
1369 } else {
1370 response = getEditDescriptionBlocks(
1371 "",
1372 "Please enter a note for your session.",
1373 );
1374 }
1375 break;
1376 }
1377
1378 case "status":
1379 response = await handleStatus(userId);
1380 break;
1381 case "history":
1382 response = await handleHistory(userId);
1383 break;
1384 default:
1385 response = await handleHelp();
1386 break;
1387 }
1388
1389 // Send the response
1390 if (response && context.respond) {
1391 await context.respond(response);
1392 }
1393 });
1394
1395 // Setup scheduled tasks
1396 const notificationInterval = TakesConfig.NOTIFICATIONS.CHECK_INTERVAL;
1397 setInterval(async () => {
1398 await checkActiveSessions();
1399 await expirePausedSessions();
1400 }, notificationInterval);
1401};
1402
1403export default takes;