···
import { slackApp } from "../index";
import { db } from "../libs/db";
import { takes as takesTable } from "../libs/schema";
5
-
import { eq, and, isNull } from "drizzle-orm";
5
+
import { eq, and, desc } from "drizzle-orm";
6
+
import TakesConfig from "../libs/config";
blocks?: AnyMessageBlock[];
···
const takes = async () => {
15
+
// Helper function for pretty-printing time
16
+
const prettyPrintTime = (ms: number): string => {
17
+
const minutes = Math.round(ms / 60000);
19
+
const seconds = Math.max(0, Math.round(ms / 1000));
20
+
return `${seconds} seconds`;
22
+
return `${minutes} minutes`;
// Helper functions for command actions
const getActiveTake = async (userId: string) => {
···
41
-
const getCompletedTakes = async (userId: string) => {
52
+
const getCompletedTakes = async (userId: string, limit = 5) => {
···
eq(takesTable.userId, userId),
eq(takesTable.status, "completed"),
62
+
.orderBy(desc(takesTable.completedAt))
66
+
// Check for paused sessions that have exceeded the max pause duration
67
+
const expirePausedSessions = async () => {
68
+
const now = new Date();
69
+
const pausedTakes = await db
72
+
.where(eq(takesTable.status, "paused"));
74
+
for (const take of pausedTakes) {
75
+
if (take.pausedAt) {
76
+
const pausedDuration =
77
+
(now.getTime() - take.pausedAt.getTime()) / (60 * 1000); // Convert to minutes
79
+
// Send warning notification when getting close to expiration
82
+
TakesConfig.MAX_PAUSE_DURATION -
83
+
TakesConfig.NOTIFICATIONS
84
+
.PAUSE_EXPIRATION_WARNING &&
85
+
!take.notifiedPauseExpiration
87
+
// Update notification flag
91
+
notifiedPauseExpiration: true,
93
+
.where(eq(takesTable.id, take.id));
95
+
// Send warning message
97
+
const timeRemaining = Math.round(
98
+
TakesConfig.MAX_PAUSE_DURATION - pausedDuration,
100
+
await slackApp.client.chat.postMessage({
101
+
channel: take.userId,
102
+
text: `⚠️ Reminder: Your paused takes session will automatically complete in about ${timeRemaining} minutes if not resumed.`,
106
+
"Failed to send pause expiration warning:",
112
+
// Auto-expire paused sessions that exceed the max pause duration
113
+
if (pausedDuration > TakesConfig.MAX_PAUSE_DURATION) {
115
+
.update(takesTable)
117
+
status: "completed",
120
+
? `${take.notes} (Automatically completed due to pause timeout)`
121
+
: "Automatically completed due to pause timeout",
123
+
.where(eq(takesTable.id, take.id));
125
+
// Notify user that their session was auto-completed
127
+
await slackApp.client.chat.postMessage({
128
+
channel: take.userId,
129
+
text: `⏰ Your paused takes session has been automatically completed because it was paused for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes.`,
133
+
"Failed to notify user of auto-completed session:",
142
+
// Check for active sessions that are almost done
143
+
const checkActiveSessions = async () => {
144
+
const now = new Date();
145
+
const activeTakes = await db
148
+
.where(eq(takesTable.status, "active"));
150
+
for (const take of activeTakes) {
151
+
const endTime = new Date(
152
+
take.startedAt.getTime() +
153
+
take.durationMinutes * 60000 +
154
+
(take.pausedTimeMs || 0),
157
+
const remainingMs = endTime.getTime() - now.getTime();
158
+
const remainingMinutes = remainingMs / 60000;
161
+
remainingMinutes <=
162
+
TakesConfig.NOTIFICATIONS.LOW_TIME_WARNING &&
163
+
remainingMinutes > 0 &&
164
+
!take.notifiedLowTime
167
+
.update(takesTable)
168
+
.set({ notifiedLowTime: true })
169
+
.where(eq(takesTable.id, take.id));
171
+
console.log("Sending low time warning to user");
174
+
await slackApp.client.chat.postMessage({
175
+
channel: take.userId,
176
+
text: `⏱️ Your takes session has less than ${TakesConfig.NOTIFICATIONS.LOW_TIME_WARNING} minutes remaining.`,
179
+
console.error("Failed to send low time warning:", error);
183
+
if (remainingMs <= 0) {
185
+
.update(takesTable)
187
+
status: "completed",
190
+
? `${take.notes} (Automatically completed - time expired)`
191
+
: "Automatically completed - time expired",
193
+
.where(eq(takesTable.id, take.id));
196
+
await slackApp.client.chat.postMessage({
197
+
channel: take.userId,
198
+
text: "⏰ Your takes session has automatically completed because the time is up.",
202
+
"Failed to notify user of completed session:",
// Command action handlers
const handleStart = async (
214
+
description?: string,
215
+
durationMinutes?: number,
): Promise<MessageResponse> => {
const activeTake = await getActiveTake(userId);
if (activeTake.length > 0) {
61
-
text: `You already have an active takes session! Use \`/takes status\` to check it.`,
220
+
text: "You already have an active takes session! Use `/takes status` to check it.",
response_type: "ephemeral",
···
73
-
durationMinutes: 5, // 5 minutes for testing (should be 90)
233
+
durationMinutes || TakesConfig.DEFAULT_SESSION_LENGTH,
234
+
description: description || null,
235
+
notifiedLowTime: false,
236
+
notifiedPauseExpiration: false,
await db.insert(takesTable).values(newTake);
···
const endTimeStr = `<!date^${Math.floor(endTime.getTime() / 1000)}^{time}|${endTime.toLocaleTimeString()}>`;
247
+
const descriptionText = description
248
+
? `\n\n*Working on:* ${description}`
85
-
text: `🎬 Takes session started! You have ${newTake.durationMinutes} minutes until ${endTimeStr}.`,
86
-
response_type: "in_channel",
251
+
text: `🎬 Takes session started! You have ${prettyPrintTime(newTake.durationMinutes * 60000)} until ${endTimeStr}.${descriptionText}`,
252
+
response_type: "ephemeral",
92
-
text: `🎬 Takes session started! You have ${newTake.durationMinutes} minutes until ${endTimeStr}.`,
258
+
text: `🎬 Takes session started!${descriptionText}`,
269
+
text: `You have ${prettyPrintTime(newTake.durationMinutes * 60000)} left until ${endTimeStr}.`,
284
+
action_id: "takes_edit",
289
+
type: "plain_text",
···
310
+
type: "plain_text",
315
+
action_id: "takes_status",
···
345
+
notifiedPauseExpiration: false, // Reset pause expiration notification
.where(eq(takesTable.id, takeToUpdate.id));
349
+
// Calculate when the pause will expire
350
+
const pauseExpires = new Date();
351
+
pauseExpires.setMinutes(
352
+
pauseExpires.getMinutes() + TakesConfig.MAX_PAUSE_DURATION,
354
+
const pauseExpiresStr = `<!date^${Math.floor(pauseExpires.getTime() / 1000)}^{date_short_pretty} at {time}|${pauseExpires.toLocaleString()}>`;
151
-
text: `⏸️ Takes session paused! Use \`/takes resume\` to continue.`,
152
-
response_type: "in_channel",
357
+
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.`,
358
+
response_type: "ephemeral",
158
-
text: `⏸️ Takes session paused!`,
364
+
text: `⏸️ Session paused! You have ${prettyPrintTime(takeToUpdate.durationMinutes * 60000)} remaining.`,
375
+
text: `It will automatically finish in ${TakesConfig.MAX_PAUSE_DURATION} minutes (by ${pauseExpiresStr}) if not resumed.`,
390
+
action_id: "takes_edit",
395
+
type: "plain_text",
···
416
+
type: "plain_text",
421
+
action_id: "takes_status",
···
pausedTimeMs: totalPausedTime,
461
+
notifiedLowTime: false, // Reset low time notification
.where(eq(takesTable.id, pausedSession.id));
466
+
const endTime = new Date(
467
+
new Date(pausedSession.startedAt).getTime() +
468
+
pausedSession.durationMinutes * 60000 +
469
+
(pausedSession.pausedTimeMs || 0),
471
+
const endTimeStr = `<!date^${Math.floor(endTime.getTime() / 1000)}^{time}|${endTime.toLocaleTimeString()}>`;
228
-
text: `▶️ Takes session resumed!`,
229
-
response_type: "in_channel",
474
+
text: `▶️ Takes session resumed! You have ${prettyPrintTime(pausedSession.durationMinutes * 60000)} remaining in your session.`,
475
+
response_type: "ephemeral",
235
-
text: `▶️ Takes session resumed!`,
481
+
text: "▶️ Takes session resumed!",
492
+
text: `You have ${prettyPrintTime(pausedSession.durationMinutes * 60000)} remaining until ${endTimeStr}.`,
502
+
type: "plain_text",
507
+
action_id: "takes_edit",
···
533
+
type: "plain_text",
538
+
action_id: "takes_status",
···
const handleStop = async (
): Promise<MessageResponse | undefined> => {
const activeTake = await getActiveTake(userId);
···
568
+
// Extract notes if provided
569
+
let notes = undefined;
570
+
if (args && args.length > 1) {
571
+
notes = args.slice(1).join(" ");
579
+
...(notes && { notes }),
.where(eq(takesTable.id, pausedTakeToStop.id));
···
589
+
// Extract notes if provided
590
+
let notes = undefined;
591
+
if (args && args.length > 1) {
592
+
notes = args.slice(1).join(" ");
600
+
...(notes && { notes }),
.where(eq(takesTable.id, activeTakeToStop.id));
313
-
text: `✅ Takes session completed! Thanks for your contribution.`,
314
-
response_type: "in_channel",
606
+
text: "✅ Takes session completed! I hope you had fun!",
607
+
response_type: "ephemeral",
320
-
text: `✅ Takes session completed! Thanks for your contribution.`,
613
+
text: "✅ Takes session completed! I hope you had fun!",
···
action_id: "takes_start",
632
+
type: "plain_text",
637
+
action_id: "takes_history",
···
): Promise<MessageResponse | undefined> => {
const activeTake = await getActiveTake(userId);
650
+
// First, check for expired paused sessions
651
+
await expirePausedSessions();
if (activeTake.length > 0) {
const take = activeTake[0];
···
endTime.setTime(endTime.getTime() + take.pausedTimeMs);
669
+
const endTimeStr = `<!date^${Math.floor(endTime.getTime() / 1000)}^{time}|${endTime.toLocaleTimeString()}>`;
const remainingMs = endTime.getTime() - now.getTime();
365
-
let remaining: string;
366
-
if (remainingMs < 120000) {
367
-
// Less than 2 minutes
368
-
remaining = `${Math.max(0, Math.floor(remainingMs / 1000))} seconds`;
370
-
remaining = `${Math.max(0, Math.floor(remainingMs / 60000))} minutes`;
674
+
// Add description to display if present
675
+
const descriptionText = take.description
676
+
? `\n\n*Working on:* ${take.description}`
374
-
text: `You have an active takes session with ${remaining} minutes remaining.`,
680
+
text: `🎬 You have an active takes session with ${prettyPrintTime(remainingMs)} remaining.${descriptionText}`,
response_type: "ephemeral",
381
-
text: `You have an active takes session with *${remaining}* remaining.`,
687
+
text: `🎬 You have an active takes session${descriptionText}`,
698
+
text: `You have ${prettyPrintTime(remainingMs)} remaining until ${endTimeStr}.`,
713
+
action_id: "takes_edit",
718
+
type: "plain_text",
···
740
+
type: "plain_text",
745
+
action_id: "takes_status",
···
const pausedTakeStatus = await getPausedTake(userId);
if (pausedTakeStatus.length > 0) {
757
+
const pausedTake = pausedTakeStatus[0];
758
+
if (!pausedTake || !pausedTake.pausedAt) {
762
+
// Calculate how much time remains before auto-completion
763
+
const now = new Date();
764
+
const pausedDuration =
765
+
(now.getTime() - pausedTake.pausedAt.getTime()) / (60 * 1000); // In minutes
766
+
const remainingPauseTime = Math.max(
768
+
TakesConfig.MAX_PAUSE_DURATION - pausedDuration,
771
+
// Format the pause timeout
772
+
const pauseExpires = new Date(pausedTake.pausedAt);
773
+
pauseExpires.setMinutes(
774
+
pauseExpires.getMinutes() + TakesConfig.MAX_PAUSE_DURATION,
776
+
const pauseExpiresStr = `<!date^${Math.floor(pauseExpires.getTime() / 1000)}^{date_short_pretty} at {time}|${pauseExpires.toLocaleString()}>`;
778
+
// Add notes to display if present
779
+
const noteText = pausedTake.notes
780
+
? `\n\n*Working on:* ${pausedTake.notes}`
419
-
text: `You have a paused takes session. Use \`/takes resume\` to continue.`,
784
+
text: `⏸️ You have a paused takes session. It will auto-complete in ${remainingPauseTime.toFixed(1)} minutes if not resumed.`,
response_type: "ephemeral",
426
-
text: `You have a paused takes session.`,
791
+
text: `⏸️ Session paused! You have ${prettyPrintTime(pausedTake.durationMinutes * 60000)} remaining.`,
802
+
text: `It will automatically finish in ${TakesConfig.MAX_PAUSE_DURATION} minutes (by ${pauseExpiresStr}) if not resumed.`,
···
833
+
type: "plain_text",
838
+
action_id: "takes_status",
···
// Check history of completed sessions
const completedSessions = await getCompletedTakes(userId);
848
+
const takeTime = completedSessions.length
851
+
new Date().getTime() -
852
+
// @ts-expect-error - TS doesn't know that we are checking the length
854
+
completedSessions.length - 1
855
+
].startedAt.getTime();
857
+
const hours = Math.ceil(diffMs / (1000 * 60 * 60));
858
+
if (hours < 24) return `${hours} hours`;
860
+
const weeks = Math.floor(
861
+
diffMs / (1000 * 60 * 60 * 24 * 7),
863
+
if (weeks > 0 && weeks < 4) return `${weeks} weeks`;
865
+
const months = Math.floor(
866
+
diffMs / (1000 * 60 * 60 * 24 * 30),
868
+
return `${months} months`;
463
-
text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the past.`,
873
+
text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the last ${takeTime}.`,
response_type: "ephemeral",
470
-
text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the past.`,
880
+
text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the last ${takeTime}.`,
···
action_id: "takes_start",
899
+
type: "plain_text",
904
+
action_id: "takes_history",
912
+
const handleHistory = async (userId: string): Promise<MessageResponse> => {
913
+
// Get completed takes for the user
914
+
const completedTakes = (
915
+
await getCompletedTakes(userId, TakesConfig.MAX_HISTORY_ITEMS)
918
+
(b.completedAt?.getTime() ?? 0) -
919
+
(a.completedAt?.getTime() ?? 0),
922
+
if (completedTakes.length === 0) {
924
+
text: "You haven't completed any takes sessions yet.",
925
+
response_type: "ephemeral",
929
+
// Create blocks for each completed take
930
+
const historyBlocks: AnyMessageBlock[] = [
934
+
type: "plain_text",
935
+
text: `📋 Your most recent ${completedTakes.length} Takes Sessions`,
941
+
for (const take of completedTakes) {
942
+
const startTime = new Date(take.startedAt);
943
+
const endTime = take.completedAt || startTime;
945
+
// Calculate duration in minutes
946
+
const durationMs = endTime.getTime() - startTime.getTime();
947
+
const pausedMs = take.pausedTimeMs || 0;
948
+
const activeDuration = Math.round((durationMs - pausedMs) / 60000);
951
+
const startDate = `<!date^${Math.floor(startTime.getTime() / 1000)}^{date_short_pretty} at {time}|${startTime.toLocaleString()}>`;
952
+
const endDate = `<!date^${Math.floor(endTime.getTime() / 1000)}^{date_short_pretty} at {time}|${endTime.toLocaleString()}>`;
954
+
const notes = take.notes ? `\n• Notes: ${take.notes}` : "";
955
+
const description = take.description
956
+
? `\n• Description: ${take.description}\n`
959
+
historyBlocks.push({
963
+
text: `*Take on ${startDate}*\n${description}• Duration: ${activeDuration} minutes${
965
+
? ` (+ ${Math.round(pausedMs / 60000)} minutes paused)`
967
+
}\n• Started: ${startDate}\n• Completed: ${endDate}${notes}`,
971
+
// Add a divider between entries
972
+
if (take !== completedTakes[completedTakes.length - 1]) {
973
+
historyBlocks.push({
979
+
// Add actions block
980
+
historyBlocks.push({
986
+
type: "plain_text",
987
+
text: "🎬 Start New Session",
991
+
action_id: "takes_start",
996
+
type: "plain_text",
1001
+
action_id: "takes_status",
1006
+
type: "plain_text",
1007
+
text: "🔄 Refresh",
1011
+
action_id: "takes_history",
1017
+
text: `Your recent takes history (${completedTakes.length} sessions)`,
1018
+
response_type: "ephemeral",
1019
+
blocks: historyBlocks,
const handleHelp = async (): Promise<MessageResponse> => {
494
-
text: `*Takes Commands*\n\n• \`/takes start\` - Start a new takes session\n• \`/takes pause\` - Pause your current session\n• \`/takes resume\` - Resume your paused session\n• \`/takes stop\` - End your current session\n• \`/takes status\` - Check the status of your session`,
1025
+
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`,
response_type: "ephemeral",
···
508
-
text: "• `/takes start` - Start a new takes session\n• `/takes pause` - Pause your current session\n• `/takes resume` - Resume your paused session\n• `/takes stop` - End your current session\n• `/takes status` - Check the status of your session",
1039
+
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`,
···
action_id: "takes_start",
1058
+
type: "plain_text",
1059
+
text: "📋 History",
1063
+
action_id: "takes_history",
1070
+
const getDescriptionBlocks = (error?: string): MessageResponse => {
1071
+
const blocks: AnyMessageBlock[] = [
1074
+
block_id: "note_block",
1076
+
type: "plain_text_input",
1077
+
action_id: "note_input",
1079
+
type: "plain_text",
1080
+
text: "Enter a note for your session",
1085
+
type: "plain_text",
1095
+
type: "plain_text",
1096
+
text: "🎬 Start Session",
1100
+
action_id: "takes_start",
1105
+
type: "plain_text",
1110
+
action_id: "takes_status",
1127
+
text: `⚠️ ${error}`,
1135
+
text: "Please enter a note for your session:",
1136
+
response_type: "ephemeral",
1141
+
const getEditDescriptionBlocks = (
1142
+
description: string,
1144
+
): MessageResponse => {
1145
+
const blocks: AnyMessageBlock[] = [
1148
+
block_id: "note_block",
1150
+
type: "plain_text_input",
1151
+
action_id: "note_input",
1153
+
type: "plain_text",
1154
+
text: "Enter a note for your session",
1157
+
initial_value: description,
1160
+
type: "plain_text",
1170
+
type: "plain_text",
1171
+
text: "✍️ Update Note",
1175
+
action_id: "takes_edit",
1180
+
type: "plain_text",
1185
+
action_id: "takes_status",
1202
+
text: `⚠️ ${error}`,
1210
+
text: "Please enter a note for your session:",
1211
+
response_type: "ephemeral",
slackApp.command("/takes", async ({ payload, context }): Promise<void> => {
···
activeTake.length === 0 ? await getPausedTake(userId) : [];
1231
+
// Run checks for expired or about-to-expire sessions
1232
+
await expirePausedSessions();
1233
+
await checkActiveSessions();
// Default to status if we have an active or paused session and no command specified
···
let response: MessageResponse | undefined;
1247
+
// Special handling for start command to show modal
1248
+
if (subcommand === "start" && !activeTake.length) {
1249
+
response = getDescriptionBlocks();
// Route to the appropriate handler function
···
response = await handleResume(userId);
569
-
response = await handleStop(userId);
1264
+
response = await handleStop(userId, args);
1267
+
response = getEditDescriptionBlocks(
1268
+
activeTake[0]?.description || "",
response = await handleStatus(userId);
1275
+
response = await handleHistory(userId);
1278
+
response = await handleHelp();
response = await handleHelp();
···
590
-
slackApp.action(/^takes_(\w+)$/, async ({ body, context }) => {
591
-
const userId = body.user.id;
592
-
const channelId = body.channel?.id || "";
593
-
const actionId = body.actions[0].action_id;
1295
+
slackApp.action(/^takes_(\w+)$/, async ({ payload, context }) => {
1296
+
const userId = payload.user.id;
1297
+
const channelId = context.channelId || "";
1298
+
const actionId = payload.actions[0]?.action_id as string;
const command = actionId.replace("takes_", "");
1300
+
const descriptionInput = payload.state.values.note_block?.note_input;
let response: MessageResponse | undefined;
1304
+
const activeTake = await getActiveTake(userId);
// Route to the appropriate handler function
601
-
response = await handleStart(userId, channelId);
1309
+
if (activeTake.length > 0) {
1310
+
if (context.respond) {
1311
+
response = await handleStatus(userId);
1314
+
if (!descriptionInput?.value?.trim()) {
1315
+
response = getDescriptionBlocks(
1316
+
"Please enter a note for your session.",
1319
+
response = await handleStart(
1322
+
descriptionInput?.value?.trim(),
response = await handlePause(userId);
···
response = await handleStop(userId);
1338
+
if (!activeTake.length && context.respond) {
1339
+
await context.respond({
1340
+
text: "You don't have an active takes session to edit!",
1341
+
response_type: "ephemeral",
1346
+
if (!descriptionInput) {
1347
+
response = getEditDescriptionBlocks(
1348
+
activeTake[0]?.description || "",
1350
+
} else if (descriptionInput.value?.trim()) {
1351
+
const takeToUpdate = activeTake[0];
1352
+
if (!takeToUpdate) return;
1354
+
// Update the note for the active session
1355
+
await db.update(takesTable).set({
1356
+
description: descriptionInput.value.trim(),
1359
+
response = await handleStatus(userId);
1361
+
response = getEditDescriptionBlocks(
1363
+
"Please enter a note for your session.",
response = await handleStatus(userId);
1373
+
response = await handleHistory(userId);
response = await handleHelp();
620
-
if (context.respond)
621
-
await context.respond(
623
-
text: "An error occurred while processing your request.",
1380
+
// Send the response
1381
+
if (response && context.respond) {
1382
+
await context.respond(response);
1386
+
// Setup scheduled tasks
1387
+
const notificationInterval = TakesConfig.NOTIFICATIONS.CHECK_INTERVAL;
1388
+
setInterval(async () => {
1389
+
await checkActiveSessions();
1390
+
await expirePausedSessions();
1391
+
}, notificationInterval);