a fun bot for the hc slack
at v0.0.1 34 kB view raw
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;