This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: migrate to distributed multi file approach

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