From caac02546b096f6eb84691705ccc9e5fa62fba55 Mon Sep 17 00:00:00 2001 From: GalvinPython Date: Thu, 12 Feb 2026 18:43:48 +0000 Subject: [PATCH 1/3] chore: squash main into chore/2.0.1 --- bun.lock | 1 + src/utils/youtube/fetchLatestUploads.ts | 121 +++++++++++++++++------- 2 files changed, 88 insertions(+), 34 deletions(-) diff --git a/bun.lock b/bun.lock index 969b6b5..0eda079 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "videonotifier", diff --git a/src/utils/youtube/fetchLatestUploads.ts b/src/utils/youtube/fetchLatestUploads.ts index 954ffd9..2d7e582 100644 --- a/src/utils/youtube/fetchLatestUploads.ts +++ b/src/utils/youtube/fetchLatestUploads.ts @@ -15,6 +15,57 @@ import getSinglePlaylistAndReturnVideoData, { PlaylistType, } from "./getSinglePlaylistAndReturnVideoData"; +/** + * Parse a full ISO 8601 duration string into total seconds. + * Supports formats like "PT1H2M3S", "P1DT2H3M4S", "PT30S", etc. + * Note: YouTube typically returns PT-prefixed durations, but very long + * streams/videos could include a day component (P1DT...). + */ +function parseISO8601Duration(duration: string): number { + const match = duration.match( + /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/, + ); + if (!match) return 0; + const days = parseInt(match[1] || "0", 10); + const hours = parseInt(match[2] || "0", 10); + const minutes = parseInt(match[3] || "0", 10); + const seconds = parseInt(match[4] || "0", 10); + return days * 86400 + hours * 3600 + minutes * 60 + seconds; +} + +/** + * Fetch the duration (in seconds) of a video using the YouTube Videos API. + * Returns 0 if the duration cannot be determined. + */ +async function fetchVideoDuration(videoId: string): Promise { + const res = await fetch( + `https://youtube.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoId}&key=${env.youtubeApiKey}`, + ); + + if (!res.ok) { + console.error( + "Error fetching video duration:", + res.statusText, + ); + return 0; + } + + // TODO: Type the response from YouTube API for better type safety + const data = await res.json(); + if (!data.items || data.items.length === 0) return 0; + + const durationFirstItem = data.items[0]; + if (!durationFirstItem.contentDetails || !durationFirstItem.contentDetails.duration) { + console.error("Duration not found in video details for video ID:", videoId,); + return 0; + } + + const duration = durationFirstItem?.contentDetails?.duration; + if (typeof duration !== "string") return 0; + + return parseISO8601Duration(duration); +} + export const updates = new Map< string, { @@ -99,12 +150,32 @@ export default async function fetchLatestUploads() { "Requires update?", requiresUpdate, ); - const [longVideoId, shortVideoId, streamVideoId] = - await Promise.all([ - getSinglePlaylistAndReturnVideoData( - channelId, - PlaylistType.Video, - ), + // Use duration-based detection to reduce API quota usage + // and avoid UULF which is currently lagging. + // YouTube Shorts are currently limited to 3 minutes (180s). + // Videos at exactly 180s could still be shorts, so we use + // a strict greater-than check. + const durationSeconds = await fetchVideoDuration(videoId); + const SHORTS_DURATION = 180; + + let contentType: PlaylistType | null = null; + + if (durationSeconds > SHORTS_DURATION) { + // Over the shorts limit: cannot be a short, check only if it's a stream + const streamVideoId = await getSinglePlaylistAndReturnVideoData( + channelId, + PlaylistType.Stream, + ); + + if (videoId === streamVideoId.videoId) { + contentType = PlaylistType.Stream; + } else { + // Not a stream and over 3 min; must be a regular video + contentType = PlaylistType.Video; + } + } else { + // Under 3 minutes: could be a short or a video, check UUSH and UULV + const [shortVideoId, streamVideoId] = await Promise.all([ getSinglePlaylistAndReturnVideoData( channelId, PlaylistType.Short, @@ -115,42 +186,24 @@ export default async function fetchLatestUploads() { ), ]); - if (!longVideoId && !shortVideoId && !streamVideoId) { - console.error( - "No video IDs found for channel in fetchLatestUploads", - ); - continue; - } - - let contentType: PlaylistType | null = null; - - if (videoId == longVideoId.videoId) { - contentType = PlaylistType.Video; - } else if (videoId == shortVideoId.videoId) { - contentType = PlaylistType.Short; - } else if (videoId == streamVideoId.videoId) { - contentType = PlaylistType.Stream; - } else { - console.error( - "Video ID does not match any fetched video IDs for channel", - channelId, - ); + if (videoId === shortVideoId.videoId) { + contentType = PlaylistType.Short; + } else if (videoId === streamVideoId.videoId) { + contentType = PlaylistType.Stream; + } else { + // Not in shorts or streams playlist → regular video + contentType = PlaylistType.Video; + } } - const videoIdMap = { - [PlaylistType.Video]: longVideoId, - [PlaylistType.Short]: shortVideoId, - [PlaylistType.Stream]: streamVideoId, - }; - - console.log("Determined content type:", contentType); + console.log("Determined content type:", contentType, `(duration: ${durationSeconds}s)`); if (contentType) { console.log( `Updating ${contentType} video ID for channel`, channelId, "to", - videoIdMap[contentType as keyof typeof videoIdMap], + videoId, ); } else { console.error( From f42d5ba770d859cd26d0436582bfe1a0ff8d1d60 Mon Sep 17 00:00:00 2001 From: GalvinPython Date: Fri, 19 Jun 2026 14:18:09 +0100 Subject: [PATCH 2/3] fix imports - just merge asap --- bun.lock | 36 +- package.json | 100 ++--- src/client.ts | 7 + src/commands.ts | 482 +++++++++++---------- src/events/commandHandler.ts | 2 +- src/events/commandHandlerAuto.ts | 2 +- src/events/guildCreate.ts | 2 +- src/events/guildDelete.ts | 2 +- src/events/ready.ts | 2 +- src/index.ts | 31 +- src/types/youtube.d.ts | 24 + src/utils/cronJobs.ts | 2 +- src/utils/discord/updateGuildsOnStartup.ts | 2 +- src/utils/quickEmbed.ts | 59 ++- src/utils/twitch/checkIfStreamerIsLive.ts | 2 +- src/utils/youtube/fetchLatestUploads.ts | 43 +- src/utils/youtube/sendLatestUploads.ts | 10 +- 17 files changed, 480 insertions(+), 328 deletions(-) create mode 100644 src/client.ts diff --git a/bun.lock b/bun.lock index 0eda079..f358836 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "videonotifier", "dependencies": { "cron": "^4.3.0", - "discord.js": "^14.19.1", + "discord.js": "^14.26.4", "drizzle-orm": "^0.44.2", "hfksdjfskfhsjdfhkasfdhksf": "^1.0.5", "pg": "^8.15.6", @@ -27,22 +27,22 @@ "eslint-plugin-unused-imports": "4.1.4", }, "peerDependencies": { - "typescript": "^5.0.0", + "typescript": "^6.0.3", }, }, }, "packages": { - "@discordjs/builders": ["@discordjs/builders@1.11.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.1", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-2zDAVuoeAkdv0YQzYKO8vZfaDfB+1KZ60ymBKtD7QDpsh6lzAnQSUBLqeRkhlons6BT9+yRctOh9fPy94w6kDA=="], + "@discordjs/builders": ["@discordjs/builders@1.14.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="], "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], - "@discordjs/formatters": ["@discordjs/formatters@0.6.1", "", { "dependencies": { "discord-api-types": "^0.38.1" } }, "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg=="], + "@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="], - "@discordjs/rest": ["@discordjs/rest@2.5.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.1" } }, "sha512-PWhchxTzpn9EV3vvPRpwS0EE2rNYB9pvzDU/eLLW3mByJl0ZHZjHI2/wA8EbH2gRMQV7nu+0FoDF84oiPl8VAQ=="], + "@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.24.1" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="], - "@discordjs/util": ["@discordjs/util@1.1.1", "", {}, "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g=="], + "@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="], - "@discordjs/ws": ["@discordjs/ws@1.2.2", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.0", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-dyfq7yn0wO0IYeYOs3z79I6/HumhmKISzFL0Z+007zQJMtAFGtt3AEoq1nuLXtcunUE5YYYQqgKvybXukAK8/w=="], + "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -246,9 +246,9 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - "discord-api-types": ["discord-api-types@0.38.1", "", {}, "sha512-vsjsqjAuxsPhiwbPjTBeGQaDPlizFmSkU0mTzFGMgRxqCDIRBR7iTY74HacpzrDV0QtERHRKQEk1tq7drZUtHg=="], + "discord-api-types": ["discord-api-types@0.38.49", "", {}, "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg=="], - "discord.js": ["discord.js@14.19.1", "", { "dependencies": { "@discordjs/builders": "^1.11.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.1", "@discordjs/rest": "^2.5.0", "@discordjs/util": "^1.1.1", "@discordjs/ws": "^1.2.2", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.1", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.1" } }, "sha512-r5jsPyaeoCrRGbdse4vQNbHAsoc2zuueyiTFJ2Ce7BiaJak9OldzKZWaWGwKdCFDH3zXlthU1hHXkx1EswKZCA=="], + "discord.js": ["discord.js@14.26.4", "", { "dependencies": { "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.1", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.24.1" } }, "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA=="], "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], @@ -478,7 +478,7 @@ "luxon": ["luxon@3.6.1", "", {}, "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ=="], - "magic-bytes.js": ["magic-bytes.js@1.10.0", "", {}, "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ=="], + "magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -676,7 +676,7 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - "undici": ["undici@6.21.1", "", {}, "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ=="], + "undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -710,10 +710,20 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "@discordjs/formatters/discord-api-types": ["discord-api-types@0.38.38", "", {}, "sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q=="], + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + "@discordjs/rest/@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="], + + "@discordjs/util/discord-api-types": ["discord-api-types@0.38.38", "", {}, "sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q=="], + "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + "@discordjs/ws/@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="], + + "@discordjs/ws/discord-api-types": ["discord-api-types@0.38.38", "", {}, "sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -736,6 +746,10 @@ "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + "@discordjs/ws/@discordjs/rest/magic-bytes.js": ["magic-bytes.js@1.10.0", "", {}, "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ=="], + + "@discordjs/ws/@discordjs/rest/undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], diff --git a/package.json b/package.json index 062f360..8f4cace 100644 --- a/package.json +++ b/package.json @@ -1,51 +1,51 @@ { - "name": "feedr", - "module": "src/index.ts", - "type": "module", - "version": "2.0.0-hotfix.1", - "devDependencies": { - "@types/bun": "1.2.21", - "@types/pg": "^8.11.14", - "@typescript-eslint/eslint-plugin": "8.11.0", - "@typescript-eslint/parser": "8.11.0", - "concurrently": "^9.1.2", - "cross-env": "^7.0.3", - "drizzle-kit": "^0.31.4", - "eslint": "^8.57.0", - "eslint-config-prettier": "9.1.0", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-node": "^11.1.0", - "eslint-plugin-prettier": "5.2.1", - "eslint-plugin-unused-imports": "4.1.4" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "scripts": { - "db:generate": "bun drizzle-kit generate", - "db:push:dev": "cross-env NODE_ENV=development bun drizzle-kit push", - "db:push:staging": "cross-env NODE_ENV=staging bun drizzle-kit push", - "db:push:prod": "bun drizzle-kit push", - "db:migrate:staging": "bun src/db/migratedb.ts --staging", - "db:migrate:prod": "bun src/db/migratedb.ts", - "db:staging:reset": "bun src/db/resetStagingDatabase.ts --staging", - "dev": "concurrently --names \"WEB,API,BOT\" --prefix-colors \"blue,green,magenta\" \"cd web && bun dev\" \"cd api && cargo watch -x \\\"run -- --dev\\\"\" \"bun --watch . --dev\"", - "dev:bot": "bun --watch . --dev", - "test:jetstream": "bun --watch src/utils/bluesky/jetstream.ts", - "lint": "eslint src/ --ext .ts -c .eslintrc.json --debug", - "lint:fix": "eslint . --ext .ts -c .eslintrc.json --fix", - "cargo:api:dev": "cd api && cargo watch -x \"run -- --dev\"", - "web:dev": "concurrently --names \"WEB,API\" --prefix-colors \"blue,green\" \"cd web && bun dev\" \"cd api && cargo watch -x \\\"run -- --dev\\\"\"", - "web:build": "cd web && bun build", - "start:api": "echo 'Cannot start API without cargo. Reserved for future use' && exit 1", - "start:bot": "bun run src/bot.ts", - "start:web": "cd web && bun start" - }, - "dependencies": { - "cron": "^4.3.0", - "discord.js": "^14.19.1", - "drizzle-orm": "^0.44.2", - "hfksdjfskfhsjdfhkasfdhksf": "^1.0.5", - "pg": "^8.15.6" - } -} + "name": "feedr", + "module": "src/index.ts", + "type": "module", + "version": "2.0.0-hotfix.1", + "devDependencies": { + "@types/bun": "1.2.21", + "@types/pg": "^8.11.14", + "@typescript-eslint/eslint-plugin": "8.11.0", + "@typescript-eslint/parser": "8.11.0", + "concurrently": "^9.1.2", + "cross-env": "^7.0.3", + "drizzle-kit": "^0.31.4", + "eslint": "^8.57.0", + "eslint-config-prettier": "9.1.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-prettier": "5.2.1", + "eslint-plugin-unused-imports": "4.1.4" + }, + "peerDependencies": { + "typescript": "^6.0.3" + }, + "scripts": { + "db:generate": "bun drizzle-kit generate", + "db:push:dev": "cross-env NODE_ENV=development bun drizzle-kit push", + "db:push:staging": "cross-env NODE_ENV=staging bun drizzle-kit push", + "db:push:prod": "bun drizzle-kit push", + "db:migrate:staging": "bun src/db/migratedb.ts --staging", + "db:migrate:prod": "bun src/db/migratedb.ts", + "db:staging:reset": "bun src/db/resetStagingDatabase.ts --staging", + "dev": "concurrently --names \"WEB,API,BOT\" --prefix-colors \"blue,green,magenta\" \"cd web && bun dev\" \"cd api && cargo watch -x \\\"run -- --dev\\\"\" \"bun --watch . --dev\"", + "dev:bot": "bun --watch . --dev", + "test:jetstream": "bun --watch src/utils/bluesky/jetstream.ts", + "lint": "eslint src/ --ext .ts -c .eslintrc.json --debug", + "lint:fix": "eslint . --ext .ts -c .eslintrc.json --fix", + "cargo:api:dev": "cd api && cargo watch -x \"run -- --dev\"", + "web:dev": "concurrently --names \"WEB,API\" --prefix-colors \"blue,green\" \"cd web && bun dev\" \"cd api && cargo watch -x \\\"run -- --dev\\\"\"", + "web:build": "cd web && bun build", + "start:api": "echo 'Cannot start API without cargo. Reserved for future use' && exit 1", + "start:bot": "bun run .", + "start:web": "cd web && bun start" + }, + "dependencies": { + "cron": "^4.3.0", + "discord.js": "^14.26.4", + "drizzle-orm": "^0.44.2", + "hfksdjfskfhsjdfhkasfdhksf": "^1.0.5", + "pg": "^8.15.6" + } +} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..b150a9e --- /dev/null +++ b/src/client.ts @@ -0,0 +1,7 @@ +import { Client, GatewayIntentBits } from "discord.js"; + +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], +}); + +export default client; diff --git a/src/commands.ts b/src/commands.ts index 486f4dd..02d3af3 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -54,8 +54,8 @@ import { checkIfStreamerIsAlreadyTracked, } from "./db/twitch"; import { config } from "./config"; - -import client from "."; +import { EmbedType, replyWithQuickEmbed } from "./utils/quickEmbed"; +import client from "./client"; interface Command { data: { @@ -84,12 +84,10 @@ const commands: Record = { contexts: [0, 1, 2], }, execute: async (interaction: CommandInteraction) => { - await interaction - .reply({ - flags: MessageFlags.Ephemeral, - content: `Ping: ${interaction.client.ws.ping}ms`, - }) - .catch(console.error); + await replyWithQuickEmbed( + interaction, + `Ping: ${interaction.client.ws.ping}ms`, + ).catch(console.error); }, }, help: { @@ -108,12 +106,12 @@ const commands: Record = { }, ); - await interaction - .reply({ - flags: MessageFlags.Ephemeral, - content: `Commands:\n${chat_commands?.join("\n")}`, - }) - .catch(console.error); + await replyWithQuickEmbed( + interaction, + `Commands:\n${chat_commands?.join("\n")}`, + EmbedType.Info, + { title: "Available Commands" }, + ).catch(console.error); }, }, sourcecode: { @@ -125,12 +123,12 @@ const commands: Record = { contexts: [0, 1, 2], }, execute: async (interaction: CommandInteraction) => { - await interaction - .reply({ - flags: MessageFlags.Ephemeral, - content: `[Github repository](https://github.com/GalvinPython/feedr)`, - }) - .catch(console.error); + await replyWithQuickEmbed( + interaction, + `[Github repository](https://github.com/GalvinPython/feedr)`, + EmbedType.Info, + { title: "Source Code" }, + ).catch(console.error); }, }, uptime: { @@ -142,15 +140,14 @@ const commands: Record = { contexts: [0, 1, 2], }, execute: async (interaction: CommandInteraction) => { - await interaction - .reply({ - flags: MessageFlags.Ephemeral, - content: `Uptime: ${( - performance.now() / - (86400 * 1000) - ).toFixed(2)} days`, - }) - .catch(console.error); + const uptimeDays = (performance.now() / (86400 * 1000)).toFixed(2); + + await replyWithQuickEmbed( + interaction, + `Uptime: ${uptimeDays} days`, + EmbedType.Info, + { title: "Bot Uptime" }, + ).catch(console.error); }, }, hmm: { @@ -162,10 +159,7 @@ const commands: Record = { contexts: [0, 1, 2], }, execute: async (interaction: CommandInteraction) => { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: hfksdjfskfhsjdfhkasfdhksf(), - }); + await replyWithQuickEmbed(interaction, hfksdjfskfhsjdfhkasfdhksf()); }, }, usage: { @@ -181,22 +175,22 @@ const commands: Record = { const heap = heapStats(); Bun.gc(false); - await interaction - .reply({ - flags: MessageFlags.Ephemeral, - content: [ - `Heap size: ${(heap.heapSize / 1024 / 1024).toFixed(2)} MB / ${( - heap.heapCapacity / - 1024 / - 1024 - ).toFixed( - 2, - )} MB (${(heap.extraMemorySize / 1024 / 1024).toFixed(2)} MB) (${heap.objectCount.toLocaleString()} objects, ${heap.protectedObjectCount.toLocaleString()} protected-objects)`, - ] - .join("\n") - .slice(0, 2000), - }) - .catch(console.error); + await replyWithQuickEmbed( + interaction, + [ + `Heap size: ${(heap.heapSize / 1024 / 1024).toFixed(2)} MB / ${( + heap.heapCapacity / + 1024 / + 1024 + ).toFixed( + 2, + )} MB (${(heap.extraMemorySize / 1024 / 1024).toFixed(2)} MB) (${heap.objectCount.toLocaleString()} objects, ${heap.protectedObjectCount.toLocaleString()} protected-objects)`, + ] + .join("\n") + .slice(0, 2000), + EmbedType.Info, + { title: "Usage Stats" }, + ).catch(console.error); }, }, track: { @@ -296,11 +290,11 @@ const commands: Record = { // Checks if the platform is valid ig if (targetPlatform != "youtube" && targetPlatform != "twitch") { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "Platform not supported! Please select a platform to track!", - }); + await replyWithQuickEmbed( + interaction, + "Platform not supported! Please select a platform to track!", + EmbedType.Error, + ); return; } @@ -312,11 +306,11 @@ const commands: Record = { (platformUserId.length !== 24 || !platformUserId.startsWith("UC")) ) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - 'Invalid YouTube channel ID format! Each channel ID should be 24 characters long and start with "UC". Need to find the channel ID? We have a guide here: https://github.com/GalvinPython/feedr/wiki/Guide:-How-to-get-the-YouTube-Channel-ID. If this was an issue with the autocomplete, please report it!', - }); + await replyWithQuickEmbed( + interaction, + 'Invalid YouTube channel ID format! Each channel ID should be 24 characters long and start with "UC". Need to find the channel ID? We have a guide here: https://github.com/GalvinPython/feedr/wiki/Guide:-How-to-get-the-YouTube-Channel-ID. If this was an issue with the autocomplete, please report it!', + EmbedType.Error, + ); return; } @@ -333,11 +327,11 @@ const commands: Record = { PermissionFlagsBits.ManageChannels, ) ) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "You do not have the permission to manage channels!", - }); + await replyWithQuickEmbed( + interaction, + "You need the Manage Channels permission to use /track in this server.", + EmbedType.Error, + ); return; } @@ -388,20 +382,22 @@ const commands: Record = { .map((permission) => permission.name); if (missingPermissions.length > 0) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: `The bot does not have the required permissions for the target channel! Missing permissions: ${missingPermissions.join(", ")}`, - }); + await replyWithQuickEmbed( + interaction, + `I can't post tracking updates to <#${discordChannelId}>. Missing permissions: ${missingPermissions.join(", ")}.`, + EmbedType.Error, + ); return; } } else if (isDm) { // DM channels don't need permission checks } else { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: "The target channel is not a text channel!", - }); + await replyWithQuickEmbed( + interaction, + `The selected updates channel (<#${discordChannelId}>) is not a text/announcement channel.`, + EmbedType.Error, + ); return; } @@ -423,10 +419,11 @@ const commands: Record = { ?.value as number; if (!contentType) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: "Please specify a valid content type!", - }); + await replyWithQuickEmbed( + interaction, + `Please choose at least one YouTube content type to track for channel ${platformUserId}.`, + EmbedType.Error, + ); return; } @@ -436,21 +433,22 @@ const commands: Record = { platformUserId.length != 24 || !platformUserId.startsWith("UC") ) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - 'Invalid YouTube channel ID format! Each channel ID should be 24 characters long and start with "UC". Handles are currently not supported. Need to find the channel ID? We have a guide here: https://github.com/GalvinPython/feedr/wiki/Guide:-How-to-get-the-YouTube-Channel-ID', - }); + await replyWithQuickEmbed( + interaction, + 'Invalid YouTube channel ID format! Each channel ID should be 24 characters long and start with "UC". Handles are currently not supported. Need to find the channel ID? We have a guide here: https://github.com/GalvinPython/feedr/wiki/Guide:-How-to-get-the-YouTube-Channel-ID', + EmbedType.Error, + ); return; } // Check if the channel is valid if (!(await checkIfChannelIdIsValid(platformUserId))) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: "That channel doesn't exist!", - }); + await replyWithQuickEmbed( + interaction, + `The YouTube channel ID ${platformUserId} was not found.`, + EmbedType.Error, + ); return; } @@ -473,10 +471,11 @@ const commands: Record = { !shouldTrackShorts && !shouldTrackStreams ) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: `You must select at least one type of content to track.`, - }); + await replyWithQuickEmbed( + interaction, + "You must select at least one type of content to track.", + EmbedType.Error, + ); return; } @@ -492,10 +491,11 @@ const commands: Record = { console.log(trackedChannels); if (!trackedChannels || !trackedChannels.success) { // TODO: Embed - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: `An error occurred while trying to check if the channel is already being tracked in this guild! Please report this error!`, - }); + await replyWithQuickEmbed( + interaction, + `Failed to check whether YouTube channel ${platformUserId} is already tracked in this ${isDm ? "DM" : "server"}.`, + EmbedType.Error, + ); return; } else if ( @@ -513,14 +513,21 @@ const commands: Record = { await interaction.reply({ flags: MessageFlags.Ephemeral, - content: `This channel is already being tracked in ${channelList}!`, + embeds: [ + new EmbedBuilder() + .setColor(0xfee75c) + .setTitle("Already Tracked") + .setDescription( + `YouTube channel ${platformUserId} is already being tracked in ${channelList}.`, + ), + ], }); } else { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "This channel is already being tracked, but the data format is invalid.", - }); + await replyWithQuickEmbed( + interaction, + `YouTube channel ${platformUserId} appears to be already tracked, but the stored subscription data is invalid. This is an internal error, please report it to the developer.`, + EmbedType.Error, + ); } return; @@ -536,11 +543,11 @@ const commands: Record = { ); if (!isChannelTracked.success) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "An error occurred while trying to check if the channel is already being tracked globally! Please report this error!", - }); + await replyWithQuickEmbed( + interaction, + `Failed to check global tracking status for YouTube channel ${platformUserId}. This is an internal error, please report it to the developer.`, + EmbedType.Error, + ); } else if ( isChannelTracked.success && isChannelTracked.data.length == 0 @@ -552,11 +559,11 @@ const commands: Record = { await addNewChannelToTrack(platformUserId); if (!channelAdded.success) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "An error occurred while trying to add the channel to track to the main YouTube database. Please report this issue!", - }); + await replyWithQuickEmbed( + interaction, + `Failed to register YouTube channel ${platformUserId} in the global tracking database. This is an internal error, please report it to the developer.`, + EmbedType.Error, + ); return; } @@ -582,14 +589,21 @@ const commands: Record = { await interaction.reply({ flags: MessageFlags.Ephemeral, - content: `Started tracking the channel ${youtubeChannelInfo?.channelName ?? platformUserId} in <#${targetChannel?.id}>!`, + embeds: [ + new EmbedBuilder() + .setColor(0x57f287) + .setTitle("Tracking Started") + .setDescription( + `Started tracking the channel ${youtubeChannelInfo?.channelName ?? platformUserId} in <#${targetChannel?.id}>!`, + ), + ], }); } else { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "An error occurred while trying to add the guild to track the channel! Please report this error!", - }); + await replyWithQuickEmbed( + interaction, + `Failed to create a YouTube subscription for channel ${platformUserId} in <#${discordChannelId}>. This is an internal error, please report it to the developer.`, + EmbedType.Error, + ); } return; @@ -600,11 +614,11 @@ const commands: Record = { const streamerName = await getStreamerName(platformUserId); if (!streamerName) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "That streamer doesn't exist! Please use the autocomplete to find the correct streamer ID as this uses IDs that are not publicly visible on the Twitch site!", - }); + await replyWithQuickEmbed( + interaction, + `Twitch streamer ID ${platformUserId} was not found. Use autocomplete to pick a valid streamer`, + EmbedType.Error, + ); return; } @@ -620,10 +634,11 @@ const commands: Record = { console.log(trackedChannels); if (!trackedChannels || !trackedChannels.success) { // TODO: Embed - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: `An error occurred while trying to check if the channel is already being tracked in this guild! Please report this error!`, - }); + await replyWithQuickEmbed( + interaction, + `Failed to check whether Twitch streamer ${streamerName ?? platformUserId} is already tracked in this ${isDm ? "DM" : "server"}.`, + EmbedType.Error, + ); return; } else if ( @@ -641,14 +656,21 @@ const commands: Record = { await interaction.reply({ flags: MessageFlags.Ephemeral, - content: `This channel is already being tracked in ${channelList}!`, + embeds: [ + new EmbedBuilder() + .setColor(0xfee75c) + .setTitle("Already Tracked") + .setDescription( + `Twitch streamer ${streamerName} is already being tracked in ${channelList}.`, + ), + ], }); } else { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "This channel is already being tracked, but the data format is invalid.", - }); + await replyWithQuickEmbed( + interaction, + `Twitch streamer ${streamerName} appears to be already tracked, but the stored subscription data is invalid.`, + EmbedType.Error, + ); } return; @@ -664,11 +686,11 @@ const commands: Record = { ); if (!isChannelTracked.success) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "An error occurred while trying to check if the channel is already being tracked globally! Please report this error!", - }); + await replyWithQuickEmbed( + interaction, + `Failed to check global tracking status for Twitch streamer ${streamerName}.`, + EmbedType.Error, + ); } else if ( isChannelTracked.success && isChannelTracked.data.length == 0 @@ -685,11 +707,11 @@ const commands: Record = { ); if (!channelAdded.success) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "An error occurred while trying to add the channel to track to the main YouTube database. Please report this issue!", - }); + await replyWithQuickEmbed( + interaction, + `Failed to register Twitch streamer ${streamerName} in the global tracking database.`, + EmbedType.Error, + ); return; } @@ -707,16 +729,18 @@ const commands: Record = { isDm, ) ) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: `Started tracking the streamer ${streamerName} in <#${targetChannel?.id}>!`, - }); + await replyWithQuickEmbed( + interaction, + `Started tracking the streamer ${streamerName} in <#${targetChannel?.id}>!`, + EmbedType.Success, + { title: "Tracking Started" }, + ); } else { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "An error occurred while trying to add the guild to track the streamer! Please report this error!", - }); + await replyWithQuickEmbed( + interaction, + `Failed to create a Twitch subscription for streamer ${streamerName} in <#${discordChannelId}>.`, + EmbedType.Error, + ); } return; @@ -734,9 +758,9 @@ const commands: Record = { const query = platform === "youtube" ? (interaction.options.get("channel_id") - ?.value as string) + ?.value as string) : (interaction.options.get("streamer_id") - ?.value as string); + ?.value as string); // If the query is empty or not a string, return an empty array if (!query || typeof query !== "string") { @@ -835,6 +859,13 @@ const commands: Record = { // Get the YouTube Channel ID const platformUserId = interaction.options.get("user_id") ?.value as string; + const [platform, trackedSubscriptionId] = platformUserId.split("."); + const selectedPlatform = + platform === Platform.YouTube + ? "YouTube" + : platform === Platform.Twitch + ? "Twitch" + : "Unknown"; // Check the permissions of the user if ( @@ -843,11 +874,11 @@ const commands: Record = { PermissionFlagsBits.ManageChannels, ) ) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "You do not have the permission to manage channels!", - }); + await replyWithQuickEmbed( + interaction, + "You need the Manage Channels permission to use /untrack in this server.", + EmbedType.Error, + ); return; } @@ -857,17 +888,20 @@ const commands: Record = { await discordRemoveGuildTrackingChannel(platformUserId); if (!trackingDeleteSuccess || !trackingDeleteSuccess.success) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: "Failed to stop tracking the channel.", - }); + await replyWithQuickEmbed( + interaction, + `Failed to stop tracking ${selectedPlatform} subscription ${trackedSubscriptionId ?? "(unknown id)"}.`, + EmbedType.Error, + ); return; } - await interaction.reply({ - content: "Successfully stopped tracking the channel.", - }); + await replyWithQuickEmbed( + interaction, + `Stopped tracking ${selectedPlatform} subscription ${trackedSubscriptionId ?? "(unknown id)"} in this ${isDm ? "DM" : "server"}.`, + EmbedType.Success, + ); }, autoComplete: async (interaction: AutocompleteInteraction) => { const trackedChannels = await discordGetAllTrackedInGuild( @@ -925,10 +959,11 @@ const commands: Record = { if (isDm) guildId = interaction.channelId; if (!guildId) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: "An error occurred! Please report", - }); + await replyWithQuickEmbed( + interaction, + "Unable to resolve the current server/DM context for /tracked.", + EmbedType.Error, + ); return; } @@ -939,11 +974,11 @@ const commands: Record = { console.error( "An error occurred while trying to get the tracked channels in this guild!", ); - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "An error occurred while trying to get the tracked channels in this guild! Please report this error!", - }); + await replyWithQuickEmbed( + interaction, + `Failed to load tracked subscriptions for ${isDm ? "this DM" : "this server"}.`, + EmbedType.Error, + ); return; } @@ -952,10 +987,11 @@ const commands: Record = { trackedChannels.data.youtubeSubscriptions.length === 0 && trackedChannels.data.twitchSubscriptions.length === 0 ) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: "No channels are being tracked in this guild.", - }); + await replyWithQuickEmbed( + interaction, + `No YouTube or Twitch subscriptions are currently tracked in ${isDm ? "this DM" : "this server"}.`, + EmbedType.Info, + ); return; } @@ -1207,10 +1243,11 @@ const commands: Record = { const guildId = isDm ? channelId : interaction.guildId; if (!isDm && !guildId) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: "An error occurred! Please report", - }); + await replyWithQuickEmbed( + interaction, + "Unable to resolve the current server context for /updates.", + EmbedType.Error, + ); return; } @@ -1230,11 +1267,11 @@ const commands: Record = { ) ) { // Check the permissions of the user - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "You do not have the permission to manage channels!", - }); + await replyWithQuickEmbed( + interaction, + "You need the Manage Channels permission to configure /updates in this server.", + EmbedType.Error, + ); return; } @@ -1251,11 +1288,11 @@ const commands: Record = { .permissionsIn(channelId) .has(PermissionFlagsBits.SendMessages) ) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "I do not have permission to send messages in that channel!", - }); + await replyWithQuickEmbed( + interaction, + `I don't have permission to send messages in <#${channelId}>.`, + EmbedType.Error, + ); return; } @@ -1265,11 +1302,11 @@ const commands: Record = { await discordUpdateSubscriptionCheckGuild(guildId); if (!currentDatabaseState || !currentDatabaseState.success) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "An error occurred while trying to get the current update state from the database! Please report this error!", - }); + await replyWithQuickEmbed( + interaction, + `Failed to load the current updates state for ${isDm ? "this DM" : "this server"}.`, + EmbedType.Error, + ); return; } @@ -1282,12 +1319,12 @@ const commands: Record = { ); if (currentState === desiredState) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: `Updates are already ${ - desiredState ? "enabled" : "disabled" - } in this channel!`, - }); + await replyWithQuickEmbed( + interaction, + `Updates are already ${desiredState ? "enabled" : "disabled" + } for <#${channelId}>.`, + EmbedType.Warning, + ); return; } @@ -1300,15 +1337,17 @@ const commands: Record = { ); if (!updateSuccess || !updateSuccess.success) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "An error occurred while trying to enable updates in this channel! Please report this error!", - }); + await replyWithQuickEmbed( + interaction, + `Failed to enable Feedr updates for <#${channelId}>.`, + EmbedType.Error, + ); return; } + let confirmationSent = false; + await client.channels .fetch(channelId) .then(async (channel) => { @@ -1316,33 +1355,38 @@ const commands: Record = { await (channel as TextChannel).send({ content: `Updates have been successfully enabled in this channel!`, }); + confirmationSent = true; } }) .catch(console.error); - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "If a test message was sent, updates are enabled! If not, please report this as an error!", - }); + await replyWithQuickEmbed( + interaction, + confirmationSent + ? `Enabled Feedr updates for <#${channelId}> and posted a confirmation message there.` + : `Enabled Feedr updates for <#${channelId}>, but I couldn't post the confirmation message there.`, + EmbedType.Success, + ); } else { // Disable updates const updateSuccess = await discordUpdateSubscriptionRemoveChannel(guildId); if (!updateSuccess || !updateSuccess.success) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "An error occurred while trying to disable updates in this channel! Please report this error!", - }); + await replyWithQuickEmbed( + interaction, + `Failed to disable Feedr updates for <#${channelId}>.`, + EmbedType.Error, + ); return; } - await interaction.reply({ - content: `Successfully disabled updates in <#${channelId}>!`, - }); + await replyWithQuickEmbed( + interaction, + `Successfully disabled updates in <#${channelId}>!`, + EmbedType.Success, + ); } }, }, diff --git a/src/events/commandHandler.ts b/src/events/commandHandler.ts index 1c242b9..7c30020 100644 --- a/src/events/commandHandler.ts +++ b/src/events/commandHandler.ts @@ -1,6 +1,6 @@ import { Events } from "discord.js"; -import client from ".."; +import client from "../client"; import commandsMap from "../commands"; client.on(Events.InteractionCreate, async (interaction) => { diff --git a/src/events/commandHandlerAuto.ts b/src/events/commandHandlerAuto.ts index f6dd1b1..5d2d62b 100644 --- a/src/events/commandHandlerAuto.ts +++ b/src/events/commandHandlerAuto.ts @@ -1,6 +1,6 @@ import { Events } from "discord.js"; -import client from ".."; +import client from "../client"; import commandsMap from "../commands"; client.on(Events.InteractionCreate, async (interaction) => { diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index 1a3697b..b1574cc 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -1,6 +1,6 @@ import { Events } from "discord.js"; -import client from ".."; +import client from "../client"; import { discordAddNewGuild } from "../db/discord"; client.on(Events.GuildCreate, async (guild) => { diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index 02d878b..84bce48 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -1,6 +1,6 @@ import { Events } from "discord.js"; -import client from ".."; +import client from "../client"; import { discordRemoveGuildFromTracking } from "../db/discord"; client.on(Events.GuildDelete, async (guild) => { diff --git a/src/events/ready.ts b/src/events/ready.ts index 1d15cb6..58a2adc 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,7 +1,7 @@ import { Events } from "discord.js"; // import { CronJob } from "cron"; -import client from "../index"; +import client from "../client"; import { config } from "../config"; // import { cronUpdateBotInfo } from "../utils/cronJobs"; import sendLatestUploads from "../utils/youtube/sendLatestUploads"; diff --git a/src/index.ts b/src/index.ts index 1f21bb1..882f71d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,14 +3,9 @@ import fs from "fs/promises"; import path from "path"; import Bun from "bun"; -import { - Client, - GatewayIntentBits, - REST, - Routes, - type APIApplicationCommand, -} from "discord.js"; +import { REST, Routes, type APIApplicationCommand } from "discord.js"; +import client from "./client"; import { env } from "./config.ts"; import commandsMap from "./commands.ts"; import { getTwitchToken } from "./utils/twitch/auth.ts"; @@ -35,9 +30,14 @@ if ( throw new Error("You MUST provide a Twitch client secret in .env!"); } -const client = new Client({ - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], -}); +// Import events before logging in so ready handlers are registered in time. +const getEvents = await fs.readdir(path.resolve(__dirname, "./events")); + +await Promise.all( + getEvents.map(async (file) => { + await import("./events/" + file); + }), +); // Update the commands console.log(`Refreshing ${commandsMap.size} commands`); @@ -63,19 +63,10 @@ if (!(await getTwitchToken())) { } // Login to Discord -client.login(env.discordToken); +await client.login(env.discordToken); export default client; -// Import events -const getEvents = await fs.readdir(path.resolve(__dirname, "./events")); - -await Promise.all( - getEvents.map(async (file) => { - await import("./events/" + file); - }), -); - // Update the guilds on startup await updateGuildsOnStartup(); diff --git a/src/types/youtube.d.ts b/src/types/youtube.d.ts index d06bed2..193c713 100644 --- a/src/types/youtube.d.ts +++ b/src/types/youtube.d.ts @@ -57,6 +57,30 @@ export interface YouTubePlaylistResponse { }; } +// YouTube Video API Response Interface (Content Details) +export type YouTubeVideoContentDetailsResponse = { + kind: string; + etag: string; + items: Array<{ + kind: string; + etag: string; + id: string; + contentDetails: { + duration: string; + dimension: string; + definition: string; + caption: string; + licensedContent: boolean; + contentRating: {}; + projection: string; + }; + }>; + pageInfo: { + totalResults: number; + resultsPerPage: number; + }; +}; + // YouTube Channel API Response Interface export interface YouTubeChannelResponse { kind: string; diff --git a/src/utils/cronJobs.ts b/src/utils/cronJobs.ts index 6db87cf..5a4ca88 100644 --- a/src/utils/cronJobs.ts +++ b/src/utils/cronJobs.ts @@ -1,6 +1,6 @@ import { ActivityType, Guild, PresenceUpdateStatus } from "discord.js"; -import client from ".."; +import client from "../client"; import { updateBotInfo } from "../db/botinfo"; export async function cronUpdateBotInfo() { diff --git a/src/utils/discord/updateGuildsOnStartup.ts b/src/utils/discord/updateGuildsOnStartup.ts index 5f7ddd1..570fa84 100644 --- a/src/utils/discord/updateGuildsOnStartup.ts +++ b/src/utils/discord/updateGuildsOnStartup.ts @@ -2,7 +2,7 @@ import { eq } from "drizzle-orm"; import { dbDiscordTable } from "../../db/schema"; -import client from "../.."; +import client from "../../client"; import { db } from "../../db/db"; import { config } from "../../config"; diff --git a/src/utils/quickEmbed.ts b/src/utils/quickEmbed.ts index 18d2d43..7fc5638 100644 --- a/src/utils/quickEmbed.ts +++ b/src/utils/quickEmbed.ts @@ -1,3 +1,9 @@ +import { + EmbedBuilder, + MessageFlags, + type InteractionReplyOptions, +} from "discord.js"; + export enum EmbedType { Success = "success", Error = "error", @@ -5,6 +11,55 @@ export enum EmbedType { Info = "info", } -export default async function (query: string, type: EmbedType) { - // Wow gonna build an embed no way! +const EMBED_COLORS: Record = { + [EmbedType.Success]: 0x57f287, + [EmbedType.Error]: 0xed4245, + [EmbedType.Warning]: 0xfee75c, + [EmbedType.Info]: 0x5865f2, +}; + +const EMBED_TITLES: Record = { + [EmbedType.Success]: "Success", + [EmbedType.Error]: "Error", + [EmbedType.Warning]: "Warning", + [EmbedType.Info]: "Info", +}; + +type QuickEmbedReplyOptions = Omit< + InteractionReplyOptions, + "content" | "embeds" +> & { + title?: string; +}; + +type Replyable = { + reply: (options: InteractionReplyOptions) => Promise; +}; + +export function buildQuickEmbed( + query: string, + type: EmbedType = EmbedType.Info, + title?: string, +) { + return new EmbedBuilder() + .setColor(EMBED_COLORS[type]) + .setTitle(title ?? EMBED_TITLES[type]) + .setDescription(query); } + +export async function replyWithQuickEmbed( + interaction: Replyable, + query: string, + type: EmbedType = EmbedType.Info, + options: QuickEmbedReplyOptions = {}, +) { + const { title, flags, ...restOptions } = options; + + return interaction.reply({ + flags: flags ?? MessageFlags.Ephemeral, + embeds: [buildQuickEmbed(query, type, title)], + ...restOptions, + }); +} + +export default buildQuickEmbed; diff --git a/src/utils/twitch/checkIfStreamerIsLive.ts b/src/utils/twitch/checkIfStreamerIsLive.ts index 207a922..91cc0d6 100644 --- a/src/utils/twitch/checkIfStreamerIsLive.ts +++ b/src/utils/twitch/checkIfStreamerIsLive.ts @@ -1,7 +1,7 @@ import type { TextChannel } from "discord.js"; import { env } from "../../config"; -import client from "../.."; +import client from "../../client"; import { dbTwitchGetAllChannelsToTrack, twitchUpdateIsLive, diff --git a/src/utils/youtube/fetchLatestUploads.ts b/src/utils/youtube/fetchLatestUploads.ts index 2d7e582..4aef1d7 100644 --- a/src/utils/youtube/fetchLatestUploads.ts +++ b/src/utils/youtube/fetchLatestUploads.ts @@ -1,3 +1,5 @@ +import type { YouTubeVideoContentDetailsResponse } from "../../types/youtube"; + import { Platform } from "../../types/types.d"; import { dbGuildYouTubeSubscriptionsTable, @@ -25,11 +27,13 @@ function parseISO8601Duration(duration: string): number { const match = duration.match( /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/, ); + if (!match) return 0; const days = parseInt(match[1] || "0", 10); const hours = parseInt(match[2] || "0", 10); const minutes = parseInt(match[3] || "0", 10); const seconds = parseInt(match[4] || "0", 10); + return days * 86400 + hours * 3600 + minutes * 60 + seconds; } @@ -43,24 +47,32 @@ async function fetchVideoDuration(videoId: string): Promise { ); if (!res.ok) { - console.error( - "Error fetching video duration:", - res.statusText, - ); + console.error("Error fetching video duration:", res.statusText); + return 0; } - // TODO: Type the response from YouTube API for better type safety - const data = await res.json(); + const data = + (await res.json()) as unknown as YouTubeVideoContentDetailsResponse; + if (!data.items || data.items.length === 0) return 0; const durationFirstItem = data.items[0]; - if (!durationFirstItem.contentDetails || !durationFirstItem.contentDetails.duration) { - console.error("Duration not found in video details for video ID:", videoId,); + + if ( + !durationFirstItem.contentDetails || + !durationFirstItem.contentDetails.duration + ) { + console.error( + "Duration not found in video details for video ID:", + videoId, + ); + return 0; } const duration = durationFirstItem?.contentDetails?.duration; + if (typeof duration !== "string") return 0; return parseISO8601Duration(duration); @@ -162,10 +174,11 @@ export default async function fetchLatestUploads() { if (durationSeconds > SHORTS_DURATION) { // Over the shorts limit: cannot be a short, check only if it's a stream - const streamVideoId = await getSinglePlaylistAndReturnVideoData( - channelId, - PlaylistType.Stream, - ); + const streamVideoId = + await getSinglePlaylistAndReturnVideoData( + channelId, + PlaylistType.Stream, + ); if (videoId === streamVideoId.videoId) { contentType = PlaylistType.Stream; @@ -196,7 +209,11 @@ export default async function fetchLatestUploads() { } } - console.log("Determined content type:", contentType, `(duration: ${durationSeconds}s)`); + console.log( + "Determined content type:", + contentType, + `(duration: ${durationSeconds}s)`, + ); if (contentType) { console.log( diff --git a/src/utils/youtube/sendLatestUploads.ts b/src/utils/youtube/sendLatestUploads.ts index 2933bb4..f384358 100644 --- a/src/utils/youtube/sendLatestUploads.ts +++ b/src/utils/youtube/sendLatestUploads.ts @@ -1,6 +1,6 @@ import { ChannelType, TextChannel } from "discord.js"; -import client from "../.."; +import client from "../../client"; import { updates } from "./fetchLatestUploads"; @@ -39,10 +39,10 @@ export default async function sendLatestUploads() { guild.notificationRoleId && channelInfo ? `<@&${guild.notificationRoleId}> New video uploaded for ${channelInfo?.channelName}! https://www.youtube.com/watch?v=${videoId}` : guild.notificationRoleId - ? `<@&${guild.notificationRoleId}> New video uploaded! https://www.youtube.com/watch?v=${videoId}` - : channelInfo - ? `New video uploaded for ${channelInfo.channelName}! https://www.youtube.com/watch?v=${videoId}` - : `New video uploaded! https://www.youtube.com/watch?v=${videoId}`, + ? `<@&${guild.notificationRoleId}> New video uploaded! https://www.youtube.com/watch?v=${videoId}` + : channelInfo + ? `New video uploaded for ${channelInfo.channelName}! https://www.youtube.com/watch?v=${videoId}` + : `New video uploaded! https://www.youtube.com/watch?v=${videoId}`, }); } catch (error) { console.error( From 9047d3f78c4d6dc9482e990bfece6ecb0c7a8d9f Mon Sep 17 00:00:00 2001 From: Galvin Date: Fri, 19 Jun 2026 14:26:20 +0100 Subject: [PATCH 3/3] ok, GitHub Copilot sure Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands.ts b/src/commands.ts index 02d3af3..824e5c9 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -125,7 +125,7 @@ const commands: Record = { execute: async (interaction: CommandInteraction) => { await replyWithQuickEmbed( interaction, - `[Github repository](https://github.com/GalvinPython/feedr)`, + `[GitHub repository](https://github.com/GalvinPython/feedr)`, EmbedType.Info, { title: "Source Code" }, ).catch(console.error);