diff --git a/.env.example b/.env.example index 561ac8d..6cc5ccf 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ DISCORD_DEV_TOKEN='YOUR_DISCORD_DEV_TOKEN' # YouTube API Key YOUTUBE_API_KEY='YOUR_YOUTUBE_API_KEY' +YOUTUBE_INNERTUBE_PROXY_URL='YOUR_OPTIONAL_YOUTUBE_INNERTUBE_PROXY_URL' # Twitch TWITCH_CLIENT_ID='YOUR_TWITCH_CLIENT_ID' diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b6cf540..3608b02 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,16 +1,14 @@ -# Moving back to npm for package management rather than bun -# because wow dependabot is so broken for it 😭 # TODO: Labels on package updates after project restructure version: 2 updates: - - package-ecosystem: "npm" + - package-ecosystem: "bun" directory: "/" schedule: interval: "weekly" target-branch: "dev" - - package-ecosystem: "npm" + - package-ecosystem: "bun" directory: "/web" schedule: interval: "weekly" diff --git a/.gitignore b/.gitignore index 9810e77..4c354da 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ target/ *.sql dbtests.ts perftesting.ts -drizzle/ \ No newline at end of file +drizzle/ +fetchTest.ts \ No newline at end of file diff --git a/README.md b/README.md index a8c64da..c202fc1 100644 --- a/README.md +++ b/README.md @@ -96,17 +96,26 @@ These guidelines ensure predictable behavior and simplify error handling across > [!NOTE] > WIP update! -### Bot +### Fixes - Fixed the double notification bug + +### Changes + - Moved to Postgres as our database engine + +### Features + - Improved flow of `/track` command - Autocomplete for YouTube - Filter by videos, shorts and streams for YouTube! +- `/tracked` is now improved and is an interactive embed! +- Can now use search/autocomplete for `/track` for both YouTube and Twitch -### API +### Known Issues -### Site +- Twitch channel username doesn't show up in `/track` +- Unable to subscribe to updates via the bot ## V1 @@ -115,7 +124,7 @@ These guidelines ensure predictable behavior and simplify error handling across - Added a new command! `/tracked` ([#50](https://github.com/GalvinPython/feedr/issues/50)) - See all the tracked channels in your server - The channel you ran the command in will appear first as there is no option to only see the current channel for now -- Locale improvments ([#43](https://github.com/GalvinPython/feedr/issues/43)) +- Locale improvements ([#43](https://github.com/GalvinPython/feedr/issues/43)) ### 1.3.0 diff --git a/bun.lock b/bun.lock index c9e7f16..969b6b5 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ "pg": "^8.15.6", }, "devDependencies": { - "@types/bun": "1.2.10", + "@types/bun": "1.2.21", "@types/pg": "^8.11.14", "@typescript-eslint/eslint-plugin": "8.11.0", "@typescript-eslint/parser": "8.11.0", @@ -131,7 +131,7 @@ "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], - "@types/bun": ["@types/bun@1.2.10", "", { "dependencies": { "bun-types": "1.2.10" } }, "sha512-eilv6WFM3M0c9ztJt7/g80BDusK98z/FrFwseZgT4bXCq2vPhXD4z8R3oddmAn+R/Nmz9vBn4kweJKmGTZj+lg=="], + "@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="], "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], @@ -141,6 +141,8 @@ "@types/pg": ["@types/pg@8.11.14", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-qyD11E5R3u0eJmd1lB0WnWKXJGA7s015nyARWljfz5DcX83TKAIlY+QrmvzQTsbIe+hkiFtkyL2gHC6qwF6Fbg=="], + "@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="], + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.11.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.11.0", "@typescript-eslint/type-utils": "8.11.0", "@typescript-eslint/utils": "8.11.0", "@typescript-eslint/visitor-keys": "8.11.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA=="], @@ -199,7 +201,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.2.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-b5ITZMnVdf3m1gMvJHG+gIfeJHiQPJak0f7925Hxu6ZN5VKA8AGy4GZ4lM+Xkn6jtWxg5S3ldWvfmXdvnkp3GQ=="], + "bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], @@ -227,6 +229,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], diff --git a/package.json b/package.json index 3668ee2..5b10c71 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "version": "2.0.0-dev", "devDependencies": { - "@types/bun": "1.2.10", + "@types/bun": "1.2.21", "@types/pg": "^8.11.14", "@typescript-eslint/eslint-plugin": "8.11.0", "@typescript-eslint/parser": "8.11.0", diff --git a/src/commands.ts b/src/commands.ts index 8c55786..8cebbd3 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -4,6 +4,7 @@ import { ActionRowBuilder, ApplicationCommandOptionType, ApplicationCommandType, + ApplicationIntegrationType, AutocompleteInteraction, ButtonBuilder, ButtonStyle, @@ -12,6 +13,7 @@ import { ComponentType, EmbedBuilder, GuildMember, + InteractionContextType, MessageFlags, type ApplicationCommandOptionData, type CacheType, @@ -31,6 +33,8 @@ import search from "./utils/youtube/search"; import { checkIfGuildIsTrackingUserAlready, discordAddGuildTrackingUser, + discordAddNewGuild, + discordCheckIfDmChannelExists, discordGetAllTrackedInGuild, discordRemoveGuildTrackingChannel, } from "./db/discord"; @@ -54,8 +58,8 @@ interface Command { name: string; description: string; options?: ApplicationCommandOptionData[]; - integration_types?: number[]; - contexts?: number[]; + integration_types?: ApplicationIntegrationType[]; + contexts?: InteractionContextType[]; type?: ApplicationCommandType; }; execute: (interaction: ChatInputCommandInteraction) => Promise; @@ -64,6 +68,8 @@ interface Command { ) => Promise; } +// Context 2: Interaction can be used within Group DMs and DMs other than the app's bot user +// /track, /tracked and /untracked can't be used in these contexts const commands: Record = { ping: { data: { @@ -76,7 +82,7 @@ const commands: Record = { execute: async (interaction: CommandInteraction) => { await interaction .reply({ - ephemeral: false, + flags: MessageFlags.Ephemeral, content: `Ping: ${interaction.client.ws.ping}ms`, }) .catch(console.error); @@ -134,7 +140,7 @@ const commands: Record = { execute: async (interaction: CommandInteraction) => { await interaction .reply({ - ephemeral: false, + flags: MessageFlags.Ephemeral, content: `Uptime: ${( performance.now() / (86400 * 1000) @@ -149,7 +155,7 @@ const commands: Record = { name: "hmm", description: "What does this command do?", integration_types: [0, 1], - contexts: [0, 1], + contexts: [0, 1, 2], }, execute: async (interaction: CommandInteraction) => { await interaction.reply({ @@ -173,7 +179,7 @@ const commands: Record = { Bun.gc(false); await interaction .reply({ - ephemeral: false, + flags: MessageFlags.Ephemeral, content: [ `Heap size: ${(heap.heapSize / 1024 / 1024).toFixed(2)} MB / ${( heap.heapCapacity / @@ -266,9 +272,11 @@ const commands: Record = { description: "Track a channel to get notified when they upload a video!", integration_types: [0, 1], - contexts: [0, 1, 2], + contexts: [0, 1], }, execute: async (interaction: CommandInteraction) => { + const isDm = !interaction.inGuild(); + // Get the YouTube Channel ID const targetPlatform = ( interaction as ChatInputCommandInteraction @@ -280,7 +288,7 @@ const commands: Record = { const discordChannelId = (interaction.options.get("updates_channel")?.value as string) ?? interaction.channelId; - const guildId = interaction.guildId; + const guildId = isDm ? discordChannelId : interaction.guildId; // Log the autocomplete value console.log(`Autocomplete value: ${platformUserId}`); @@ -312,33 +320,26 @@ const commands: Record = { return; } - // TODO: Enable DMs :) - const isDm = interaction.channel?.isDMBased(); - - if (!guildId || isDm || isDm === undefined) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "This command is not supported in DMs currently!\nNot a DM? Then the bot failed to get the guild info", - }); + console.log(interaction.channelId); - return; - } + if (isDm) console.log("DM"); // TODO: Embed // Check the permissions of the user - if ( - !interaction.memberPermissions?.has( - PermissionFlagsBits.ManageChannels, - ) - ) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "You do not have the permission to manage channels!", - }); + if (!isDm) { + if ( + !interaction.memberPermissions?.has( + PermissionFlagsBits.ManageChannels, + ) + ) { + await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: + "You do not have the permission to manage channels!", + }); - return; + return; + } } // TODO: Embed @@ -393,6 +394,8 @@ const commands: Record = { return; } + } else if (isDm) { + // DM channels don't need permission checks } else { await interaction.reply({ flags: MessageFlags.Ephemeral, @@ -402,6 +405,17 @@ const commands: Record = { return; } + // Before attempting to add the subscription, if it's a DM, check if it's already in the database. If not add it + if (isDm) { + const data = ( + await discordCheckIfDmChannelExists(discordChannelId) + ).data; + + if (!data.length) { + await discordAddNewGuild(discordChannelId, true); + } + } + switch (targetPlatform) { case "youtube": { const contentType = interaction.options.get("content_type") @@ -567,7 +581,7 @@ const commands: Record = { await interaction.reply({ flags: MessageFlags.Ephemeral, - content: `Started tracking the channel ${youtubeChannelInfo?.channelName ?? platformUserId} in ${targetChannel.name}!`, + content: `Started tracking the channel ${youtubeChannelInfo?.channelName ?? platformUserId} in <#${targetChannel?.id}>!`, }); } else { await interaction.reply({ @@ -694,7 +708,7 @@ const commands: Record = { ) { await interaction.reply({ flags: MessageFlags.Ephemeral, - content: `Started tracking the streamer ${platformUserId} (${platformUserId}) in ${targetChannel.name}!`, + content: `Started tracking the streamer ${platformUserId} (${platformUserId}) in <#${targetChannel?.id}>!`, }); } else { await interaction.reply({ @@ -815,24 +829,15 @@ const commands: Record = { contexts: [0, 1], }, execute: async (interaction: CommandInteraction) => { + const isDm = !interaction.inGuild(); + // Get the YouTube Channel ID const platformUserId = interaction.options.get("user_id") ?.value as string; - const guildId = interaction.guildId; - - // DMs are currently not supported, so throw back an error - if (!guildId || interaction.channel?.isDMBased()) { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - "This command is not supported in DMs currently!\nNot a DM? Then an error has occurred :(", - }); - - return; - } // Check the permissions of the user if ( + !isDm && !interaction.memberPermissions?.has( PermissionFlagsBits.ManageChannels, ) @@ -865,7 +870,7 @@ const commands: Record = { }, autoComplete: async (interaction: AutocompleteInteraction) => { const trackedChannels = await discordGetAllTrackedInGuild( - interaction.guildId as string, + interaction.guildId ?? (interaction.channelId as string), ); console.dir( @@ -912,14 +917,16 @@ const commands: Record = { contexts: [0, 1], }, execute: async (interaction: CommandInteraction) => { - const guildId = interaction.guildId; - const channelId = interaction.channelId; + let guildId = interaction.guildId; - if (!guildId || !channelId) { + const isDm = !interaction.inGuild(); + + if (isDm) guildId = interaction.channelId; + + if (!guildId) { await interaction.reply({ flags: MessageFlags.Ephemeral, - content: - "You are likely in a DM, this command is not supported in DMs!", + content: "An error occurred! Please report", }); return; diff --git a/src/config.ts b/src/config.ts index c2df64f..b1def11 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ // FILL IN THIS INFORMATION IN .ENV export const runningInDevMode: boolean = process.argv.includes("--dev"); export interface Config { + youtubeInnertubeProxyUrl: string | null; updateIntervalYouTube: number; updateIntervalTwitch: number; databaseUrl: string | undefined; @@ -10,6 +11,7 @@ export interface Config { } export const config: Config = { + youtubeInnertubeProxyUrl: process.env?.YOUTUBE_INNERTUBE_PROXY_URL ?? null, updateIntervalYouTube: process.env?.CONFIG_UPDATE_INTERVAL_YOUTUBE ? parseInt(process.env?.CONFIG_UPDATE_INTERVAL_YOUTUBE) * 1000 : 60_000, diff --git a/src/db/discord.ts b/src/db/discord.ts index 02b25fe..9b2fc64 100644 --- a/src/db/discord.ts +++ b/src/db/discord.ts @@ -11,6 +11,7 @@ import { dbTwitchTable, } from "./schema"; +// Check if the guild is tracking the user already export async function checkIfGuildIsTrackingUserAlready( platform: Platform, userId: string, @@ -334,6 +335,7 @@ export async function discordRemoveGuildTrackingChannel( // Add a new guild to track export async function discordAddNewGuild( guildId: string, + isDm?: boolean, ): Promise<{ success: boolean; data: [] }> { console.log(`Adding new guild to track: ${guildId}`); @@ -343,6 +345,7 @@ export async function discordAddNewGuild( allowedPublicSharing: false, isInServer: true, memberCount: 0, + isDm: isDm ?? false, }); return { success: true, data: [] }; @@ -373,3 +376,28 @@ export async function discordRemoveGuildFromTracking( return { success: false, data: [] }; } } + +// Check if the DM channel is already in the "guilds" table +export async function discordCheckIfDmChannelExists( + channelId: string, +): Promise<{ success: boolean; data: (typeof dbDiscordTable.$inferSelect)[] }> { + console.log(`Checking if DM channel exists: ${channelId}`); + + try { + const result = await db + .select() + .from(dbDiscordTable) + .where( + and( + eq(dbDiscordTable.guildId, channelId), + eq(dbDiscordTable.isDm, true), + ), + ); + + return { success: true, data: result }; + } catch (error) { + console.error("Error checking if DM channel exists:", error); + + return { success: false, data: [] }; + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 5607e0a..b9a01cb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -17,7 +17,7 @@ export const dbDiscordTable = pgTable("discord", { ${table.allowedPublicSharing} = false AND ${table.feedrUpdatesChannelId} = ${table.guildId} AND ${table.isInServer} = true AND - ${table.memberCount} = 1 + ${table.memberCount} = 0 )` ) ]); diff --git a/src/utils/discord/updateGuildsOnStartup.ts b/src/utils/discord/updateGuildsOnStartup.ts index ba0c37a..5f7ddd1 100644 --- a/src/utils/discord/updateGuildsOnStartup.ts +++ b/src/utils/discord/updateGuildsOnStartup.ts @@ -31,7 +31,7 @@ export default async function () { // Find any guilds that are in the database but not in the current guilds const missingGuilds = data.filter( - (guild) => !currentGuilds.includes(guild.guildId), + (guild) => !currentGuilds.includes(guild.guildId) && !guild.isDm, ); // Find any guilds that are in the current guilds but not in the database diff --git a/src/utils/youtube/search.ts b/src/utils/youtube/search.ts index 8122d99..1fb97b2 100644 --- a/src/utils/youtube/search.ts +++ b/src/utils/youtube/search.ts @@ -1,12 +1,16 @@ import type { InnertubeSearchRequest } from "../../types/youtube"; +import { config } from "../../config"; import formatLargeNumber from "../formatLargeNumber"; export default async function (query: string) { try { + // This will NOT work without Bun due to proxy not being in NodeJS + // Unfortunately theres no type for this that will make Typescript happy so this is a TODO: thing const response = await fetch( "https://www.youtube.com/youtubei/v1/search?prettyPrint=false", { + proxy: config.youtubeInnertubeProxyUrl, headers: { "X-Goog-Fieldmask": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.itemSectionRenderer.contents", @@ -22,7 +26,7 @@ export default async function (query: string) { query: query, }), method: "POST", - }, + } as any, ); const data = (