This repository has no description
0

Configure Feed

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

feat: add approval system and uploading videos

+442 -44
local.db

This is a binary file and will not be displayed.

+8 -2
manifest.yaml
··· 18 18 oauth_config: 19 19 scopes: 20 20 bot: 21 + - chat:write 22 + - chat:write.public 21 23 - commands 24 + - im:history 22 25 - users:read 23 - - chat:write.public 24 - - chat:write 26 + - reactions:write 25 27 settings: 28 + event_subscriptions: 29 + request_url: https://casual-renewing-reptile.ngrok-free.app/slack 30 + bot_events: 31 + - message.im 26 32 interactivity: 27 33 is_enabled: true 28 34 request_url: https://casual-renewing-reptile.ngrok-free.app/slack
+1 -1
src/features/index.ts
··· 1 - export { default as example } from "./example"; 1 + export { default as upload } from "./upload"; 2 2 export { default as takes } from "./takes";
+48 -29
src/features/takes.ts
··· 1 1 import type { AnyMessageBlock } from "slack-edge"; 2 - import { slackApp } from "../index"; 2 + import { slackApp, slackClient } from "../index"; 3 3 import { db } from "../libs/db"; 4 4 import { takes as takesTable } from "../libs/schema"; 5 5 import { eq, and, desc } from "drizzle-orm"; ··· 102 102 103 103 // Auto-expire paused sessions that exceed the max pause duration 104 104 if (pausedDuration > TakesConfig.MAX_PAUSE_DURATION) { 105 - await db 106 - .update(takesTable) 107 - .set({ 108 - status: "completed", 109 - completedAt: now, 110 - notes: take.notes 111 - ? `${take.notes} (Automatically completed due to pause timeout)` 112 - : "Automatically completed due to pause timeout", 113 - }) 114 - .where(eq(takesTable.id, take.id)); 115 - 105 + let ts: string | undefined; 116 106 // Notify user that their session was auto-completed 117 107 try { 118 - await slackApp.client.chat.postMessage({ 108 + const res = await slackApp.client.chat.postMessage({ 119 109 channel: take.userId, 120 - text: `⏰ Your paused takes session has been automatically completed because it was paused for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes.`, 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!`, 121 111 }); 112 + ts = res.ts; 122 113 } catch (error) { 123 114 console.error( 124 115 "Failed to notify user of auto-completed session:", 125 116 error, 126 117 ); 127 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)); 128 131 } 129 132 } 130 133 } ··· 172 175 } 173 176 174 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 + 175 193 await db 176 194 .update(takesTable) 177 195 .set({ 178 - status: "completed", 196 + status: "waitingUpload", 179 197 completedAt: now, 198 + ts, 180 199 notes: take.notes 181 200 ? `${take.notes} (Automatically completed - time expired)` 182 201 : "Automatically completed - time expired", 183 202 }) 184 203 .where(eq(takesTable.id, take.id)); 185 - 186 - try { 187 - await slackApp.client.chat.postMessage({ 188 - channel: take.userId, 189 - text: "⏰ Your takes session has automatically completed because the time is up.", 190 - }); 191 - } catch (error) { 192 - console.error( 193 - "Failed to notify user of completed session:", 194 - error, 195 - ); 196 - } 197 204 } 198 205 } 199 206 }; ··· 560 567 notes = args.slice(1).join(" "); 561 568 } 562 569 570 + const res = await slackClient.chat.postMessage({ 571 + channel: userId, 572 + text: "🎬 Your paused takes session has been completed. Please upload your takes video in this thread within the next 24 hours!", 573 + }); 574 + 563 575 await db 564 576 .update(takesTable) 565 577 .set({ 566 - status: "completed", 578 + status: "waitingUpload", 579 + ts: res.ts, 567 580 completedAt: new Date(), 568 581 ...(notes && { notes }), 569 582 }) ··· 581 594 notes = args.slice(1).join(" "); 582 595 } 583 596 597 + const res = await slackClient.chat.postMessage({ 598 + channel: userId, 599 + text: "🎬 Your takes session has been completed. Please upload your takes video in this thread within the next 24 hours!", 600 + }); 601 + 584 602 await db 585 603 .update(takesTable) 586 604 .set({ 587 - status: "completed", 605 + status: "waitingUpload", 606 + ts: res.ts, 588 607 completedAt: new Date(), 589 608 ...(notes && { notes }), 590 609 })
+297
src/features/upload.ts
··· 1 + import { slackApp, slackClient } from "../index"; 2 + import { db } from "../libs/db"; 3 + import { takes as takesTable } from "../libs/schema"; 4 + import { eq, and } from "drizzle-orm"; 5 + import { prettyPrintTime } from "../libs/time"; 6 + 7 + const upload = async () => { 8 + slackApp.anyMessage(async ({ payload }) => { 9 + try { 10 + if (payload.subtype !== "file_share") return; 11 + const user = payload.user; 12 + 13 + if (!user) return; 14 + 15 + const takesNeedUpload = await db 16 + .select() 17 + .from(takesTable) 18 + .where( 19 + and( 20 + eq(takesTable.userId, payload.user as string), 21 + eq(takesTable.ts, payload.thread_ts as string), 22 + eq(takesTable.status, "waitingUpload"), 23 + ), 24 + ); 25 + 26 + if (takesNeedUpload.length === 0) return; 27 + 28 + const take = takesNeedUpload[0]; 29 + 30 + if (!payload.files || !take) return; 31 + 32 + const file = payload.files[0]; 33 + 34 + if (!file || !file.id || !file.thumb_video || !file.mp4) { 35 + await slackClient.reactions.add({ 36 + channel: payload.channel, 37 + timestamp: payload.ts as string, 38 + name: "no", 39 + }); 40 + 41 + slackClient.chat.postMessage({ 42 + channel: payload.channel, 43 + thread_ts: payload.thread_ts, 44 + text: "that's not a video file? 🤔", 45 + }); 46 + return; 47 + } 48 + 49 + const fileres = await slackClient.files.sharedPublicURL({ 50 + file: file.id, 51 + token: process.env.SLACK_USER_TOKEN, 52 + }); 53 + 54 + const fetchRes = await fetch( 55 + fileres.file?.permalink_public as string, 56 + ); 57 + const html = await fetchRes.text(); 58 + const match = html.match(/src="([^"]*\.mp4[^"]*)"/); 59 + const takePublicUrl = match?.[1]; 60 + 61 + await db 62 + .update(takesTable) 63 + .set({ 64 + status: "uploaded", 65 + takeUploadedAt: new Date(), 66 + takeUrl: takePublicUrl, 67 + takeThumbUrl: file?.thumb_video, 68 + }) 69 + .where(eq(takesTable.id, take.id)); 70 + 71 + await slackClient.reactions.add({ 72 + channel: payload.channel, 73 + timestamp: payload.ts as string, 74 + name: "fire", 75 + }); 76 + 77 + await slackClient.chat.postMessage({ 78 + channel: payload.channel, 79 + thread_ts: payload.thread_ts, 80 + text: ":video_camera: uploaded! leme send this to the team for review real quick", 81 + blocks: [ 82 + { 83 + type: "section", 84 + text: { 85 + type: "mrkdwn", 86 + text: ":video_camera: uploaded! leme send this to the team for review real quick", 87 + }, 88 + }, 89 + { 90 + type: "divider", 91 + }, 92 + { 93 + type: "context", 94 + elements: [ 95 + { 96 + type: "mrkdwn", 97 + text: `take by <@${user}> for \`${prettyPrintTime(take.durationMinutes * 60000)}\` working on: *${take.description}*`, 98 + }, 99 + ], 100 + }, 101 + ], 102 + }); 103 + 104 + await slackClient.chat.postMessage({ 105 + channel: process.env.SLACK_REVIEW_CHANNEL || "", 106 + text: "", 107 + blocks: [ 108 + { 109 + type: "section", 110 + text: { 111 + type: "mrkdwn", 112 + text: `:video_camera: new take uploaded by <@${user}> for \`${prettyPrintTime(take.durationMinutes * 60000)}\` working on: *${take.description}*`, 113 + }, 114 + }, 115 + { 116 + type: "divider", 117 + }, 118 + { 119 + type: "video", 120 + video_url: `${process.env.API_URL}/video/${take.id}`, 121 + title_url: `${process.env.API_URL}/video/${take.id}`, 122 + title: { 123 + type: "plain_text", 124 + text: `take on ${take.takeUploadedAt?.toISOString()}`, 125 + }, 126 + thumbnail_url: `https://cachet.dunkirk.sh/users/${payload.user}/r`, 127 + alt_text: `take on ${take.takeUploadedAt?.toISOString()}`, 128 + }, 129 + { 130 + type: "divider", 131 + }, 132 + { 133 + type: "actions", 134 + elements: [ 135 + { 136 + type: "static_select", 137 + placeholder: { 138 + type: "plain_text", 139 + text: "Select multiplier", 140 + }, 141 + options: [ 142 + { 143 + text: { 144 + type: "plain_text", 145 + text: "0.5x", 146 + }, 147 + value: "0.5", 148 + }, 149 + { 150 + text: { 151 + type: "plain_text", 152 + text: "1x", 153 + }, 154 + value: "1", 155 + }, 156 + { 157 + text: { 158 + type: "plain_text", 159 + text: "1.25x", 160 + }, 161 + value: "1.25", 162 + }, 163 + { 164 + text: { 165 + type: "plain_text", 166 + text: "1.5x", 167 + }, 168 + value: "1.5", 169 + }, 170 + { 171 + text: { 172 + type: "plain_text", 173 + text: "2x", 174 + }, 175 + value: "2", 176 + }, 177 + { 178 + text: { 179 + type: "plain_text", 180 + text: "3x", 181 + }, 182 + value: "2.5", 183 + }, 184 + ], 185 + action_id: "select_multiplier", 186 + }, 187 + { 188 + type: "button", 189 + text: { 190 + type: "plain_text", 191 + text: "approve", 192 + }, 193 + style: "primary", 194 + value: take.id, 195 + action_id: "approve", 196 + }, 197 + { 198 + type: "button", 199 + text: { 200 + type: "plain_text", 201 + text: "reject", 202 + }, 203 + style: "danger", 204 + value: take.id, 205 + action_id: "reject", 206 + }, 207 + ], 208 + }, 209 + { 210 + type: "divider", 211 + }, 212 + { 213 + type: "context", 214 + elements: [ 215 + { 216 + type: "mrkdwn", 217 + text: `take by <@${user}> for \`${prettyPrintTime(take.durationMinutes * 60000)}\` working on: *${take.description}*`, 218 + }, 219 + ], 220 + }, 221 + ], 222 + }); 223 + } catch (error) { 224 + console.error("Error handling file message:", error); 225 + } 226 + }); 227 + 228 + slackApp.action("select_multiplier", async () => {}); 229 + 230 + slackApp.action("approve", async ({ payload, context }) => { 231 + const multiplier = Object.values(payload.state.values)[0] 232 + ?.select_multiplier?.selected_option?.value; 233 + // @ts-expect-error 234 + const takeId = payload.actions[0]?.value; 235 + 236 + const take = await db 237 + .select() 238 + .from(takesTable) 239 + .where(eq(takesTable.id, takeId)); 240 + if (take.length === 0) { 241 + return; 242 + } 243 + await db 244 + .update(takesTable) 245 + .set({ 246 + status: "approved", 247 + multiplier: multiplier, 248 + }) 249 + .where(eq(takesTable.id, takeId)); 250 + 251 + await slackClient.chat.postMessage({ 252 + channel: payload.user.id, 253 + thread_ts: take[0]?.ts as string, 254 + text: `take approved with multiplier \`${multiplier}\` so you have earned *${Number(((take[0]?.durationMinutes as number) * Number(multiplier)) / 60).toFixed(1)} takes*!`, 255 + }); 256 + 257 + // delete the message from the review channel 258 + if (context.respond) 259 + await context.respond({ 260 + delete_original: true, 261 + }); 262 + }); 263 + 264 + slackApp.action("reject", async ({ payload, context }) => { 265 + // @ts-expect-error 266 + const takeId = payload.actions[0]?.value; 267 + 268 + const take = await db 269 + .select() 270 + .from(takesTable) 271 + .where(eq(takesTable.id, takeId)); 272 + if (take.length === 0) { 273 + return; 274 + } 275 + await db 276 + .update(takesTable) 277 + .set({ 278 + status: "rejected", 279 + multiplier: "0", 280 + }) 281 + .where(eq(takesTable.id, takeId)); 282 + 283 + await slackClient.chat.postMessage({ 284 + channel: payload.user.id, 285 + thread_ts: take[0]?.ts as string, 286 + text: "take rejected :(", 287 + }); 288 + 289 + // delete the message from the review channel 290 + if (context.respond) 291 + await context.respond({ 292 + delete_original: true, 293 + }); 294 + }); 295 + }; 296 + 297 + export default upload;
+70
src/features/video.ts
··· 1 + import { db } from "../libs/db"; 2 + import { takes as takesTable } from "../libs/schema"; 3 + import { eq, and } from "drizzle-orm"; 4 + 5 + export async function getVideo(url: URL): Promise<Response> { 6 + const videoId = url.pathname.split("/")[2]; 7 + 8 + if (!videoId) { 9 + return new Response("Invalid video id", { status: 400 }); 10 + } 11 + 12 + const video = await db 13 + .select() 14 + .from(takesTable) 15 + .where(eq(takesTable.id, videoId)); 16 + 17 + if (video.length === 0) { 18 + return new Response("Video not found", { status: 404 }); 19 + } 20 + 21 + const videoData = video[0]; 22 + 23 + return new Response( 24 + `<!DOCTYPE html> 25 + <html> 26 + <head> 27 + <title>Video Player</title> 28 + <style> 29 + body, html { 30 + margin: 0; 31 + padding: 0; 32 + height: 100vh; 33 + overflow: hidden; 34 + } 35 + .video-container { 36 + position: fixed; 37 + top: 0; 38 + left: 0; 39 + width: 100vw; 40 + height: 100vh; 41 + display: flex; 42 + flex-direction: column; 43 + justify-content: center; 44 + background: linear-gradient(180deg, #000000 25%, #ffffff 50%, #000000 75%); 45 + } 46 + video { 47 + width: 100vw; 48 + height: 100vh; 49 + object-fit: contain; 50 + position: absolute; 51 + bottom: 0; 52 + } 53 + </style> 54 + </head> 55 + <body> 56 + <div class="video-container"> 57 + <video autoplay controls> 58 + <source src="${videoData?.takeUrl}" type="video/mp4"> 59 + Your browser does not support the video tag. 60 + </video> 61 + </div> 62 + </body> 63 + </html>`, 64 + { 65 + headers: { 66 + "Content-Type": "text/html", 67 + }, 68 + }, 69 + ); 70 + }
+11 -2
src/index.ts
··· 5 5 import { t, t_fetch } 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 9 const environment = process.env.NODE_ENV; 9 10 10 11 // Check required environment variables 11 - const requiredVars = ["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"] as const; 12 + const requiredVars = [ 13 + "SLACK_BOT_TOKEN", 14 + "SLACK_SIGNING_SECRET", 15 + "SLACK_REVIEW_CHANNEL", 16 + "SLACK_USER_TOKEN", 17 + "API_URL", 18 + ] as const; 12 19 const missingVars = requiredVars.filter((varName) => !process.env[varName]); 13 20 14 21 if (missingVars.length > 0) { ··· 46 53 port: process.env.PORT || 3000, 47 54 async fetch(request: Request) { 48 55 const url = new URL(request.url); 49 - const path = url.pathname; 56 + const path = `/${url.pathname.split("/")[1]}`; 50 57 51 58 switch (path) { 52 59 case "/": ··· 55 62 return new Response("OK"); 56 63 case "/slack": 57 64 return slackApp.run(request); 65 + case "/video": 66 + return getVideo(url); 58 67 default: 59 68 return new Response("404 Not Found", { status: 404 }); 60 69 }
+1 -1
src/libs/config.ts
··· 2 2 3 3 export const TakesConfig = { 4 4 // Default takes session length in minutes (should be 90 for production) 5 - DEFAULT_SESSION_LENGTH: 2, 5 + DEFAULT_SESSION_LENGTH: 5, 6 6 7 7 // Maximum time in minutes that a takes session can be paused before automatic expiration 8 8 MAX_PAUSE_DURATION: 3,
+6 -9
src/libs/schema.ts
··· 4 4 export const takes = sqliteTable("takes", { 5 5 id: text("id").primaryKey(), 6 6 userId: text("user_id").notNull(), 7 - channelId: text("channel_id").notNull(), 8 - status: text("status").notNull().default("active"), // active, paused, completed 7 + ts: text("ts"), 8 + status: text("status").notNull().default("active"), // active, paused, waitingUpload, completed 9 9 startedAt: integer("started_at", { mode: "timestamp" }).notNull(), 10 10 pausedAt: integer("paused_at", { mode: "timestamp" }), 11 11 completedAt: integer("completed_at", { mode: "timestamp" }), 12 + takeUploadedAt: integer("take_uploaded_at", { mode: "timestamp" }), 13 + takeUrl: text("take_url"), 14 + takeThumbUrl: text("take_thumb_url"), 15 + multiplier: text("multiplier").notNull().default("1.0"), 12 16 durationMinutes: integer("duration_minutes").notNull().default(5), // 5 minutes for testing (should be 90) 13 17 pausedTimeMs: integer("paused_time_ms").notNull().default(0), // cumulative paused time 14 18 notes: text("notes"), ··· 20 24 mode: "boolean", 21 25 }).default(false), // has user been notified about pause expiration 22 26 }); 23 - 24 - // Define the users table 25 - export const users = sqliteTable("users", { 26 - id: text("id").primaryKey(), 27 - name: text("name").notNull(), 28 - isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), 29 - });