diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml index 6e3c6d9..b2c9748 100644 --- a/.github/workflows/fly-deploy.yml +++ b/.github/workflows/fly-deploy.yml @@ -6,6 +6,8 @@ jobs: deploy: name: Deploy app runs-on: ubuntu-latest + permissions: + contents: read environment: tvbot-prod concurrency: deploy-group steps: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c6391c1..c87604e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,6 +10,9 @@ on: jobs: pr-title: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read steps: - name: Check PR Title uses: deepakputhraya/action-pr-title@master @@ -18,16 +21,32 @@ jobs: prefix_case_sensitive: true lint: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4 - uses: denoland/setup-deno@v2 - run: deno lint + typecheck: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v2 + - run: deno install --allow-scripts=npm:prisma,npm:@prisma/engines,npm:@prisma/client + - run: deno task typecheck test-semantic-release: uses: ./.github/workflows/semantic-release.yml + permissions: + contents: read with: dry_run: true build_docker: runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 diff --git a/.github/workflows/semantic-release.yml b/.github/workflows/semantic-release.yml index 75ac33e..59fa6a0 100644 --- a/.github/workflows/semantic-release.yml +++ b/.github/workflows/semantic-release.yml @@ -7,6 +7,8 @@ on: required: false type: boolean default: false +permissions: + contents: read jobs: release: name: Semantic Release diff --git a/.github/workflows/tags.yml b/.github/workflows/tags.yml index 29cdf00..18c31e6 100644 --- a/.github/workflows/tags.yml +++ b/.github/workflows/tags.yml @@ -7,6 +7,9 @@ on: jobs: build_and_push: runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - uses: actions/checkout@v4 - uses: docker/metadata-action@v5 @@ -33,4 +36,7 @@ jobs: deploy: needs: build_and_push + permissions: + contents: read uses: ./.github/workflows/fly-deploy.yml + secrets: inherit diff --git a/deno.jsonc b/deno.jsonc index 2f68599..a0621b1 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -6,7 +6,8 @@ "prisma": "deno run -A npm:prisma", "prisma:generate": "deno task prisma generate --schema=./prisma/schema.prisma", "db:migrate": "deno run -A npm:prisma migrate dev", - "db:studio": "deno run -A npm:prisma studio" + "db:studio": "deno run -A npm:prisma studio", + "typecheck": "deno check src/**/*.ts" }, "compilerOptions": { "strict": true, @@ -20,10 +21,6 @@ "include": [ "src/" ], - "exclude": [ - "src/testdata/", - "src/fixtures/**/*.ts" - ], "rules": { "tags": [ "recommended" diff --git a/deno.lock b/deno.lock index e4cc02b..796244a 100644 --- a/deno.lock +++ b/deno.lock @@ -16,7 +16,7 @@ "npm:prisma@^6.6.0": "6.6.0", "npm:prisma@latest": "6.6.0", "npm:semantic-release@*": "24.2.3_marked@12.0.2", - "npm:sqlite@^4.2.1": "4.2.1" + "npm:zod@^4.0.0-beta": "4.0.0-beta.20250420T053007" }, "jsr": { "@std/dotenv@0.225.2": { @@ -781,6 +781,9 @@ "@vladfrangu/async_event_emitter@2.4.6": { "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==" }, + "@zod/core@0.8.1": { + "integrity": "sha512-djj8hPhxIHcG8ptxITaw/Bout5HJZ9NyRbKr95Eilqwt9R0kvITwUQGDU+n+MVdsBIka5KwztmZSLti22F+P0A==" + }, "abbrev@3.0.1": { "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==" }, @@ -2875,9 +2878,6 @@ "sprintf-js@1.1.3": { "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" }, - "sqlite@4.2.1": { - "integrity": "sha512-Tll0Ndvnwkuv5Hn6WIbh26rZiYQORuH1t5m/or9LUpSmDmmyFG89G9fKrSeugMPxwmEIXoVxqTun4LbizTs4uw==" - }, "ssri@12.0.0": { "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", "dependencies": [ @@ -3239,6 +3239,12 @@ }, "yoctocolors@2.1.1": { "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==" + }, + "zod@4.0.0-beta.20250420T053007": { + "integrity": "sha512-5pp8Q0PNDaNcUptGiBE9akyioJh3RJpagIxrLtAVMR9IxwcSZiOsJD/1/98CyhItdTlI2H91MfhhLzRlU+fifA==", + "dependencies": [ + "@zod/core" + ] } }, "workspace": { @@ -3251,7 +3257,7 @@ "npm:moment-timezone@~0.5.43", "npm:parse-url@^8.1.0", "npm:prisma@^6.6.0", - "npm:sqlite@^4.2.1" + "npm:zod@^4.0.0-beta" ] } } diff --git a/import_map.json b/import_map.json index 7c71f6c..ca86444 100644 --- a/import_map.json +++ b/import_map.json @@ -13,6 +13,6 @@ "npm:city-timezones": "npm:city-timezones@^1.2.1", "npm:moment-timezone": "npm:moment-timezone@^0.5.43", "npm:parse-url": "npm:parse-url@^8.1.0", - "npm:sqlite": "npm:sqlite@^4.2.1" + "npm:zod": "npm:zod@^4.0.0-beta" } } diff --git a/scripts/get-sonarr-shows.ts b/scripts/get-sonarr-shows.ts new file mode 100644 index 0000000..4c16d8f --- /dev/null +++ b/scripts/get-sonarr-shows.ts @@ -0,0 +1,53 @@ +const SONARR_URL = Deno.env.get("SONARR_URL") || "http://localhost:8989" +const API_KEY = Deno.env.get("SONARR_API_KEY") + +interface SonarrCalendarItem { + seriesId: number + title: string + seasonNumber: number + episodeNumber: number + airDateUtc: string + series: { + title: string + imdbId: string + tvdbId: number + } +} + +async function getUpcomingShows() { + if (!API_KEY) throw new Error("SONARR_API_KEY environment variable not set") + + const now = new Date() + const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) + + const url = new URL(`${SONARR_URL}/api/v3/calendar`) + url.searchParams.set("start", now.toISOString()) + url.searchParams.set("end", nextWeek.toISOString()) + url.searchParams.set("includeSeries", "true") + + const response = await fetch(url.toString(), { + headers: { "X-Api-Key": API_KEY }, + }) + + if (!response.ok) { + throw new Error( + `Sonarr API error: ${response.status} - ${await response.text()}`, + ) + } + + const data: SonarrCalendarItem[] = await response.json() + + return data.map((item) => ({ + title: item.series.title, + imdbId: item.series.imdbId, + airDate: item.airDateUtc, + season: item.seasonNumber, + episode: item.episodeNumber, + })) +} + +const shows = (await getUpcomingShows()).map((show) => show.imdbId) +for (let i = 0; i < shows.length; i += 10) { + const chunk = shows.slice(i, i + 10) + console.log(chunk.join(",")) +} diff --git a/src/app.ts b/src/app.ts index d5bf218..9d5b7fa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,144 +1,54 @@ import "jsr:@std/dotenv/load" -import process from "node:process" -import { ChannelType, Client, Events, GatewayIntentBits } from "npm:discord.js" +import { Client, ClientUser, Events, GatewayIntentBits } from "npm:discord.js" import { CommandManager } from "lib/commandManager.ts" -import { - checkForAiringEpisodes, - pruneUnsubscribedShows, - removeAllSubscriptions, -} from "lib/shows.ts" +import { checkForAiringEpisodes, pruneUnsubscribedShows } from "lib/shows.ts" import { sendAiringMessages } from "lib/episodeNotifier.ts" -import { type Settings, SettingsManager } from "lib/settingsManager.ts" -import { sendMorningSummary } from "lib/morningSummary.ts" +import { Settings } from "lib/settingsManager.ts" import { setRandomShowActivity, setTVDBLoadingActivity, } from "lib/discordActivities.ts" +import { getEnv } from "lib/env.ts" +import { scheduleCronJobs } from "./cron.ts" +import assert from "node:assert" +import { handleChannelDelete, handleThreadDelete } from "./handlers.ts" -/** - * The main bot application - */ -export class App { - private readonly client: Client - private readonly commands: CommandManager - private readonly settings: SettingsManager - - private readonly token: string - private readonly clientId: string - private readonly guildId: string +const token = getEnv("DISCORD_TOKEN") +const clientId = getEnv("DISCORD_CLIENT_ID") +const guildId = getEnv("DISCORD_GUILD_ID") - constructor() { - if (process.env.DISCORD_TOKEN === undefined) { - throw new Error("DISCORD_TOKEN is not defined") - } - if (process.env.DISCORD_CLIENT_ID === undefined) { - throw new Error("DISCORD_CLIENT_ID is not defined") - } - if (process.env.DISCORD_GUILD_ID === undefined) { - throw new Error("DISCORD_GUILD_ID is not defined") - } - if (process.env.TZ === undefined) throw new Error("TZ is not defined") +await Settings.refresh() - this.token = process.env.DISCORD_TOKEN - this.clientId = process.env.DISCORD_CLIENT_ID - this.guildId = process.env.DISCORD_GUILD_ID +const commandManager = new CommandManager() +await commandManager.registerCommands(clientId, token, guildId) - this.client = new Client({ intents: [GatewayIntentBits.Guilds] }) - this.commands = new CommandManager( - this, - this.clientId, - this.token, - this.guildId, - ) - this.settings = new SettingsManager() +const discordClient = new Client({ intents: [GatewayIntentBits.Guilds] }) - void this.init() +discordClient.on(Events.ClientReady, async (client) => { + if (client.user == null) { + throw new Error("Fatal: Client user is null") } - - /** - * Async init function for app - */ - private readonly init = async (): Promise => { - await this.settings.refresh() - await this.commands.registerCommands() - this.startBot() - } - - /** - * Start the bot and register listeners - */ - private readonly startBot = (): void => { - this.client.on(Events.ClientReady, async () => { - const { user } = this.client - if (user == null) throw new Error("User is null") - console.log(`Logged in as ${user.tag}!`) - - // run initial scheduled activities - setTVDBLoadingActivity(user) - await pruneUnsubscribedShows() - if (process.env.UPDATE_SHOWS !== "false") await checkForAiringEpisodes() - void sendAiringMessages(this) - void setRandomShowActivity(user) - - Deno.cron("Announcements", { minute: { every: 10, start: 8 } }, () => { - void sendAiringMessages(this) - void setRandomShowActivity(user) - }) - - Deno.cron("Fetch Episode Data", { hour: { every: 4 } }, async () => { - setTVDBLoadingActivity(user) - await pruneUnsubscribedShows() - await checkForAiringEpisodes() - }) - - Deno.cron("Morning Summary", { hour: 8, minute: 0 }, async () => { - const settings = this.getSettings() - if (settings == null) throw new Error("Settings not found") - - await sendMorningSummary(settings, this.client) - }) - - const healthcheckUrl = process.env.HEALTHCHECK_URL - if (healthcheckUrl != null) { - Deno.cron("Healthcheck", { minute: { every: 1 } }, async () => { - await fetch(healthcheckUrl) - console.debug("[Healthcheck] Healthcheck ping sent") - }) - } - }) - - this.client.on(Events.InteractionCreate, this.commands.interactionHandler) - - /** - * When a thread (forum post) is deleted, remove all subscriptions for that post - */ - this.client.on(Events.ThreadDelete, async (thread) => { - await removeAllSubscriptions(thread.id, "channelId") - await pruneUnsubscribedShows() - }) - - /** - * When a forum is deleted, remove all subscriptions for post in that forum - */ - this.client.on(Events.ChannelDelete, async (channel) => { - if (channel.type === ChannelType.GuildForum) { - await removeAllSubscriptions(channel.id, "forumId") - await pruneUnsubscribedShows() - } - - if (channel.type === ChannelType.GuildText) { - await removeAllSubscriptions(channel.id, "channelId") - await pruneUnsubscribedShows() - await this.settings.removeGlobalDestination(channel.id) - } - }) - - void this.client.login(this.token) - } - - public getClient = (): Client => this.client - public getSettings = (): Settings | undefined => this.settings.fetch() - public getSettingsManager = (): SettingsManager => this.settings -} // make an instance of the application class - -;(() => new App())() + console.info(`Logged in as ${client.user.tag}!`) + + // run initial scheduled activities + setTVDBLoadingActivity() + await pruneUnsubscribedShows() + if (getEnv("UPDATE_SHOWS")) await checkForAiringEpisodes() + void sendAiringMessages() + void setRandomShowActivity() + + scheduleCronJobs() +}) + +discordClient.on(Events.InteractionCreate, commandManager.interactionHandler) +discordClient.on(Events.ThreadDelete, handleThreadDelete) +discordClient.on(Events.ChannelDelete, handleChannelDelete) + +// start the bot +await discordClient.login(token) + +export const getClient = (): Client => discordClient +export const getClientUser = (): ClientUser => { + assert(discordClient.user != null, "Client user is null") + return discordClient.user +} diff --git a/src/commands/example.ts b/src/commands/example.ts index 7183b5c..cbcc598 100644 --- a/src/commands/example.ts +++ b/src/commands/example.ts @@ -3,7 +3,6 @@ import { type ChatInputCommandInteraction, SlashCommandBuilder, } from "npm:discord.js" -import { type App } from "app.ts" import { type CommandV2 } from "interfaces/command.ts" const slashCommand = new SlashCommandBuilder() @@ -11,7 +10,6 @@ const slashCommand = new SlashCommandBuilder() .setDescription("Replies with Pong!") const executeCommand = async ( - app: App, interaction: ChatInputCommandInteraction, ): Promise => { await interaction.reply("Pong!") diff --git a/src/commands/link.ts b/src/commands/link.ts index d81540f..d6844f4 100644 --- a/src/commands/link.ts +++ b/src/commands/link.ts @@ -2,18 +2,16 @@ import { ChannelType, type ChatInputCommandInteraction, Collection, - type GuildTextChannelType, + InteractionContextType, PermissionFlagsBits, SlashCommandBuilder, type SlashCommandStringOption, SlashCommandSubcommandBuilder, type TextBasedChannel, - type TextChannel, } from "npm:discord.js" import client from "lib/prisma.ts" import { type CommandV2 } from "interfaces/command.ts" import { ProgressMessageBuilder } from "lib/progressMessages.ts" -import { type App } from "app.ts" import { getSeriesByImdbId } from "lib/tvdb.ts" import { createNewSubscription, updateEpisodes } from "lib/shows.ts" import { ProgressError } from "interfaces/error.ts" @@ -40,7 +38,7 @@ export const command: CommandV2 = { main: new SlashCommandBuilder() .setName("link") .setDescription("Link a show to a channel for notifications.") - .setDMPermission(false) + .setContexts(InteractionContextType.Guild) .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), subCommands: [ new SlashCommandSubcommandBuilder() @@ -59,7 +57,7 @@ export const command: CommandV2 = { .addStringOption(imdbOption), ], }, - async executeCommand(app: App, interaction: ChatInputCommandInteraction) { + async executeCommand(interaction: ChatInputCommandInteraction) { const imdbIds = parseIMDBIds(interaction.options.getString("imdb_id", true)) if (imdbIds.length > 10) { diff --git a/src/commands/list.ts b/src/commands/list.ts index 5085420..ce4edd2 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,6 +1,7 @@ import { ChannelType, type ChatInputCommandInteraction, + InteractionContextType, PermissionFlagsBits, SlashCommandBuilder, SlashCommandSubcommandBuilder, @@ -9,7 +10,6 @@ import { } from "npm:discord.js" import client from "lib/prisma.ts" import { type CommandV2 } from "interfaces/command.ts" -import { type App } from "app.ts" import { type Show } from "prisma-client/client.ts" const subCommands = { @@ -23,7 +23,7 @@ export const command: CommandV2 = { main: new SlashCommandBuilder() .setName("list") .setDescription("List various things from the bot's DB") - .setDMPermission(false) + .setContexts(InteractionContextType.Guild) .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), subGroups: [{ main: new SlashCommandSubcommandGroupBuilder() @@ -45,7 +45,7 @@ export const command: CommandV2 = { ], }], }, - async executeCommand(app: App, interaction: ChatInputCommandInteraction) { + async executeCommand(interaction: ChatInputCommandInteraction) { if (interaction.options.getSubcommandGroup() !== "shows") { return await interaction.editReply("Invalid subcommand group") } diff --git a/src/commands/post.ts b/src/commands/post.ts index db1f863..ceab622 100644 --- a/src/commands/post.ts +++ b/src/commands/post.ts @@ -4,6 +4,7 @@ import { ChannelType, type ChatInputCommandInteraction, Collection, + InteractionContextType, PermissionFlagsBits, SlashCommandBuilder, type TextBasedChannel, @@ -12,7 +13,6 @@ import { import client from "lib/prisma.ts" import { type CommandV2 } from "interfaces/command.ts" import { ProgressMessageBuilder } from "lib/progressMessages.ts" -import { type App } from "app.ts" import { getSeriesByImdbId } from "lib/tvdb.ts" import { createNewSubscription, updateEpisodes } from "lib/shows.ts" import { ProgressError } from "interfaces/error.ts" @@ -21,6 +21,7 @@ import { buildShowEmbed } from "lib/messages.ts" import { type SeriesExtendedRecord } from "interfaces/tvdb.generated.ts" import { type Destination, type Show } from "prisma-client/client.ts" import { parseIMDBIds } from "lib/util.ts" +import { Settings } from "lib/settingsManager.ts" interface SeriesWrapper { series: SeriesExtendedRecord @@ -34,7 +35,7 @@ export const command: CommandV2 = { .setDescription( 'Create a forum post for a show. Require "Manage Channels" permission.', ) - .setDMPermission(false) + .setContexts(InteractionContextType.Guild) .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) .addStringOption((option) => option.setName("imdb_id") @@ -52,7 +53,7 @@ export const command: CommandV2 = { ), }, - async executeCommand(app: App, interaction: ChatInputCommandInteraction) { + async executeCommand(interaction: ChatInputCommandInteraction) { let imdbIds = parseIMDBIds(interaction.options.getString("imdb_id", true)) if (imdbIds.length > 10) { @@ -76,7 +77,7 @@ export const command: CommandV2 = { // if the user passed in a forum then send the post to that forum const useInputForum = forumInput !== null && isForumChannel(forumInput as Channel) - const tvForum = useInputForum ? forumInput.id : getDefaultTVForumId(app) + const tvForum = useInputForum ? forumInput.id : getDefaultTVForumId() await progress.sendNextStep() // start step 1 @@ -183,9 +184,8 @@ export const command: CommandV2 = { * @param app main application object instance * @returns ID of the default TV forum */ -function getDefaultTVForumId(app: App): string { - const forumId = app.getSettings()?.defaultForum - +function getDefaultTVForumId(): string { + const forumId = Settings.fetch()?.defaultForum if (forumId == null) { throw new ProgressError( "No TV forum configured, use /settings tv_forum to set the default TV forum", diff --git a/src/commands/search.ts b/src/commands/search.ts index 78160c7..cf64c44 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,11 +1,11 @@ import { type AutocompleteInteraction, type ChatInputCommandInteraction, + InteractionContextType, SlashCommandBuilder, } from "npm:discord.js" import client from "lib/prisma.ts" import { type CommandV2 } from "interfaces/command.ts" -import { type App } from "app.ts" import { getSeriesByImdbId, getSeriesByName } from "lib/tvdb.ts" import { buildShowEmbed } from "lib/messages.ts" import { showSearchAutocomplete } from "lib/autocomplete.ts" @@ -15,7 +15,7 @@ export const command: CommandV2 = { main: new SlashCommandBuilder() .setName("search") .setDescription("Link a show to a channel for notifications.") - .setDMPermission(false) + .setContexts(InteractionContextType.Guild) .addStringOption((option) => option.setName("query") .setDescription( @@ -26,7 +26,7 @@ export const command: CommandV2 = { .setRequired(true) ), }, - async executeCommand(app: App, interaction: ChatInputCommandInteraction) { + async executeCommand(interaction: ChatInputCommandInteraction) { const query = interaction.options.getString("query", true) let imdbId = query.toLowerCase().startsWith("tt") ? query : undefined @@ -66,7 +66,7 @@ export const command: CommandV2 = { embeds: [buildShowEmbed(imdbId, series, [])], }) }, - async executeAutoComplate(app: App, interaction: AutocompleteInteraction) { + async executeAutoComplete(interaction: AutocompleteInteraction) { await showSearchAutocomplete(interaction) }, } diff --git a/src/commands/setting.ts b/src/commands/setting.ts index 0eb5df3..1785618 100644 --- a/src/commands/setting.ts +++ b/src/commands/setting.ts @@ -2,6 +2,7 @@ import { type Channel, ChannelType, type ChatInputCommandInteraction, + InteractionContextType, type Message, PermissionFlagsBits, SlashCommandBuilder, @@ -10,8 +11,7 @@ import { } from "npm:discord.js" import { type CommandV2 } from "interfaces/command.ts" import { ProgressMessageBuilder } from "lib/progressMessages.ts" -import { type App } from "app.ts" -import { type SettingsManager } from "lib/settingsManager.ts" +import { Settings } from "lib/settingsManager.ts" import { type Destination } from "prisma-client/client.ts" export const command: CommandV2 = { @@ -19,7 +19,7 @@ export const command: CommandV2 = { main: new SlashCommandBuilder() .setName("setting") .setDescription("Configure various bot settings") - .setDMPermission(false) + .setContexts(InteractionContextType.Guild) .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), subCommands: [ new SlashCommandSubcommandBuilder() @@ -117,18 +117,16 @@ export const command: CommandV2 = { }, ], }, - async executeCommand(app: App, interaction: ChatInputCommandInteraction) { + async executeCommand(interaction: ChatInputCommandInteraction) { const subCommand = interaction.options.getSubcommand() const subcommandGroup = interaction.options.getSubcommandGroup() const channel = interaction.options.getChannel("channel", true) as Channel - const settingsManager = app.getSettingsManager() - /** * Handle the TV forum setting */ if (subcommandGroup === null && subCommand === "tv_forum") { - await setTVForum(settingsManager, interaction, channel) + await setTVForum(interaction, channel) return } @@ -137,7 +135,6 @@ export const command: CommandV2 = { */ if (subcommandGroup === "all_episodes") { return await updateGlobalChannels( - settingsManager, interaction, channel, subCommand, @@ -148,7 +145,7 @@ export const command: CommandV2 = { * Handle all the morning summary settings */ if (subcommandGroup === "morning_summary") { - return await updateMorningSummaryChannels(settingsManager, interaction) + return await updateMorningSummaryChannels(interaction) } }, } @@ -159,7 +156,6 @@ export const command: CommandV2 = { * @param channel channel to set as the TV forum */ async function setTVForum( - settingsManager: SettingsManager, interaction: ChatInputCommandInteraction, channel: Channel, ): Promise | void> { @@ -173,7 +169,7 @@ async function setTVForum( await interaction.editReply(progressMessage.nextStep()) // update the db with the new value - await settingsManager.update({ + await Settings.update({ defaultForum: channel.id, }) @@ -187,7 +183,6 @@ async function setTVForum( * @param mode `add` or `remove` */ async function updateGlobalChannels( - settingsManager: SettingsManager, interaction: ChatInputCommandInteraction, channel: Channel, mode: string, @@ -215,9 +210,9 @@ async function updateGlobalChannels( // add or remove the channel from the list if (mode === "add") { - destinations = await settingsManager.addGlobalDestination(channel.id) + destinations = await Settings.addGlobalDestination(channel.id) } else if (mode === "remove") { - destinations = await settingsManager.removeGlobalDestination(channel.id) + destinations = await Settings.removeGlobalDestination(channel.id) } const destinationsString = destinations.map((d) => `<#${d.channelId}>`).join( @@ -235,7 +230,6 @@ async function updateGlobalChannels( * @returns nothin */ async function updateMorningSummaryChannels( - settingsManager: SettingsManager, interaction: ChatInputCommandInteraction, ): Promise> { const subCommand = interaction.options.getSubcommand() // add_channel or remove_channel @@ -259,7 +253,7 @@ async function updateMorningSummaryChannels( await progress.sendNextStep() - let channelList = settingsManager.fetch()?.morningSummaryDestinations ?? [] + let channelList = Settings.fetch()?.morningSummaryDestinations ?? [] const hasChannel = channelList.some((d) => d.channelId === channel.id) // add or remove the channel from the list @@ -278,7 +272,7 @@ async function updateMorningSummaryChannels( channelList = channelList.filter((d) => d.channelId !== channel.id) } - await settingsManager.update({ + await Settings.update({ morningSummaryDestinations: channelList, }) diff --git a/src/commands/unlink.ts b/src/commands/unlink.ts index 0799d04..4857d05 100644 --- a/src/commands/unlink.ts +++ b/src/commands/unlink.ts @@ -3,6 +3,7 @@ import { type AnySelectMenuInteraction, ChannelType, type ChatInputCommandInteraction, + InteractionContextType, PermissionFlagsBits, SlashCommandBuilder, SlashCommandSubcommandBuilder, @@ -11,7 +12,6 @@ import { } from "npm:discord.js" import client from "lib/prisma.ts" import { type CommandV2 } from "interfaces/command.ts" -import { type App } from "app.ts" import { ProgressError } from "interfaces/error.ts" import { ProgressMessageBuilder } from "lib/progressMessages.ts" import { pruneUnsubscribedShows } from "lib/shows.ts" @@ -21,7 +21,7 @@ export const command: CommandV2 = { main: new SlashCommandBuilder() .setName("unlink") .setDescription("Unlink shows from a channel for notifications.") - .setDMPermission(false) + .setContexts(InteractionContextType.Guild) .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), subCommands: [ new SlashCommandSubcommandBuilder() @@ -41,7 +41,7 @@ export const command: CommandV2 = { ], }, selectMenuIds: ["unlink_shows_menu"], - async executeCommand(app: App, interaction: ChatInputCommandInteraction) { + async executeCommand(interaction: ChatInputCommandInteraction) { const subCommand = interaction.options.getSubcommand() const progress = new ProgressMessageBuilder() @@ -115,7 +115,7 @@ export const command: CommandV2 = { throw error } }, - async executeSelectMenu(_app, interaction: AnySelectMenuInteraction) { + async executeSelectMenu(interaction: AnySelectMenuInteraction) { const channelId = interaction.message.content.match(/<#([0-9]+)>/)?.at(1) if (channelId === undefined) { diff --git a/src/commands/upcoming.ts b/src/commands/upcoming.ts index 3ff94ad..3616c8e 100644 --- a/src/commands/upcoming.ts +++ b/src/commands/upcoming.ts @@ -1,12 +1,12 @@ import { type AutocompleteInteraction, type ChatInputCommandInteraction, + InteractionContextType, SlashCommandBuilder, SlashCommandSubcommandBuilder, } from "npm:discord.js" import client from "lib/prisma.ts" import { type CommandV2 } from "interfaces/command.ts" -import { type App } from "app.ts" import { getSeriesByImdbId } from "lib/tvdb.ts" import { showSearchAutocomplete } from "lib/autocomplete.ts" import { type Show } from "prisma-client/client.ts" @@ -18,7 +18,7 @@ export const command: CommandV2 = { main: new SlashCommandBuilder() .setName("upcoming") .setDescription("Get upcoming episodes") - .setDMPermission(false), + .setContexts(InteractionContextType.Guild), subCommands: [ new SlashCommandSubcommandBuilder() .setName("all") @@ -40,7 +40,7 @@ export const command: CommandV2 = { ), ], }, - async executeCommand(_app: App, interaction: ChatInputCommandInteraction) { + async executeCommand(interaction: ChatInputCommandInteraction) { const subCommand = interaction.options.getSubcommand() let s: Show[] = [] @@ -76,7 +76,7 @@ export const command: CommandV2 = { embeds: [embed], }) }, - async executeAutoComplate(_app: App, interaction: AutocompleteInteraction) { + async executeAutoComplete(interaction: AutocompleteInteraction) { await showSearchAutocomplete(interaction) }, } @@ -115,7 +115,7 @@ async function getShowsHere(channelId: string): Promise { } /** - * Get the show by IMDB ID in the querys + * Get the show by IMDB ID in the queries */ async function getShowByImdbId(query: string): Promise { // check that the query is an IMDB ID diff --git a/src/cron.ts b/src/cron.ts new file mode 100644 index 0000000..61fc034 --- /dev/null +++ b/src/cron.ts @@ -0,0 +1,40 @@ +import { + setRandomShowActivity, + setTVDBLoadingActivity, +} from "lib/discordActivities.ts" +import { getEnv } from "lib/env.ts" +import { sendAiringMessages } from "lib/episodeNotifier.ts" +import { sendMorningSummary } from "lib/morningSummary.ts" +import { Settings } from "lib/settingsManager.ts" +import { checkForAiringEpisodes, pruneUnsubscribedShows } from "lib/shows.ts" + +export function scheduleCronJobs() { + Deno.cron("Announcements", { minute: { every: 10, start: 8 } }, () => { + void sendAiringMessages() + void setRandomShowActivity() + }) + + Deno.cron("Fetch Episode Data", { hour: { every: 4 } }, async () => { + setTVDBLoadingActivity() + await pruneUnsubscribedShows() + await checkForAiringEpisodes() + }) + + Deno.cron("Morning Summary", { hour: 8, minute: 0 }, async () => { + const settings = Settings.fetch() + if (settings == null) throw new Error("Settings not found") + await sendMorningSummary() + }) + + const healthcheckUrl = getEnv("HEALTHCHECK_URL") + if (healthcheckUrl != null) { + Deno.cron("Healthcheck", { minute: { every: 1 } }, async () => { + try { + await fetch(healthcheckUrl) + console.debug("[Healthcheck] Healthcheck ping sent") + } catch (error) { + console.error("[Healthcheck] Healthcheck ping failed", error) + } + }) + } +} diff --git a/src/handlers.ts b/src/handlers.ts new file mode 100644 index 0000000..5c3bc63 --- /dev/null +++ b/src/handlers.ts @@ -0,0 +1,33 @@ +import { ChannelType, ClientEvents } from "npm:discord.js" +import { Settings } from "lib/settingsManager.ts" +import { pruneUnsubscribedShows, removeAllSubscriptions } from "lib/shows.ts" + +/** + * When a thread (forum post) is deleted, remove all subscriptions for that post + */ +export async function handleThreadDelete( + ...args: ClientEvents["threadDelete"] +) { + const [thread] = args + await removeAllSubscriptions(thread.id, "channelId") + await pruneUnsubscribedShows() +} + +/** + * When a forum is deleted, remove all subscriptions for post in that forum + */ +export async function handleChannelDelete( + ...args: ClientEvents["channelDelete"] +) { + const [channel] = args + if (channel.type === ChannelType.GuildForum) { + await removeAllSubscriptions(channel.id, "forumId") + await pruneUnsubscribedShows() + } + + if (channel.type === ChannelType.GuildText) { + await removeAllSubscriptions(channel.id, "channelId") + await pruneUnsubscribedShows() + await Settings.removeGlobalDestination(channel.id) + } +} diff --git a/src/interfaces/command.ts b/src/interfaces/command.ts index be19314..554443c 100644 --- a/src/interfaces/command.ts +++ b/src/interfaces/command.ts @@ -10,7 +10,6 @@ import { type SlashCommandSubcommandGroupBuilder, type SlashCommandSubcommandsOnlyBuilder, } from "npm:discord.js" -import { type App } from "app.ts" type ExecuteFunction = void | Message | InteractionResponse @@ -29,15 +28,12 @@ export interface CommandV2 { } selectMenuIds?: string[] executeCommand: ( - app: App, interaction: ChatInputCommandInteraction, ) => Promise - executeAutoComplate?: ( - app: App, + executeAutoComplete?: ( interaction: AutocompleteInteraction, ) => Promise executeSelectMenu?: ( - app: App, interaction: AnySelectMenuInteraction, ) => Promise } diff --git a/src/lib/commandManager.ts b/src/lib/commandManager.ts index 782fcbb..9abba4c 100644 --- a/src/lib/commandManager.ts +++ b/src/lib/commandManager.ts @@ -1,4 +1,3 @@ -import process from "node:process" import { type AnySelectMenuInteraction, type AutocompleteInteraction, @@ -11,8 +10,8 @@ import { Routes, type SlashCommandBuilder, } from "npm:discord.js" -import { type App } from "app.ts" import { type CommandV2 } from "interfaces/command.ts" +import { getEnv } from "lib/env.ts" interface Getter { command: TInput @@ -34,22 +33,14 @@ const commandModules: Array> = [ export class CommandManager { private readonly commands = new Collection() - private readonly app: App - private readonly clientId: string - private readonly token: string - private readonly guildId: string - - constructor(app: App, clientId: string, token: string, guildId: string) { - this.app = app - this.clientId = clientId - this.token = token - this.guildId = guildId - } - /** * Register all commands with Discord */ - public registerCommands = async (): Promise => { + public registerCommands = async ( + clientId: string, + token: string, + guildId: string, + ): Promise => { type SlashCommandData = | RESTPostAPIChatInputApplicationCommandsJSONBody | RESTPostAPIContextMenuApplicationCommandsJSONBody @@ -65,13 +56,13 @@ export class CommandManager { } // when testing locally you dont always need to register commands - if (process.env.REGISTER_COMMANDS === "false") return + if (getEnv("REGISTER_COMMANDS") === false) return try { console.log("Starting to register slash commands") - const rest = new REST({ version: "10" }).setToken(this.token) + const rest = new REST({ version: "10" }).setToken(token) await rest.put( - Routes.applicationGuildCommands(this.clientId, this.guildId), + Routes.applicationGuildCommands(clientId, guildId), { body: slashCommandData }, ) console.log( @@ -115,7 +106,7 @@ export class CommandManager { await interaction.deferReply({ ephemeral: true }) try { - await command.executeCommand(this.app, interaction) + await command.executeCommand(interaction) } catch (error) { console.error(error) await interaction.editReply( @@ -135,7 +126,7 @@ export class CommandManager { await interaction.deferUpdate() try { - await command.executeSelectMenu(this.app, interaction) + await command.executeSelectMenu(interaction) } catch (error) { console.error(error) await interaction.editReply( @@ -148,10 +139,10 @@ export class CommandManager { interaction: AutocompleteInteraction, ): Promise => { const command = this.commands.get(interaction.commandName) - if (command == null || command.executeAutoComplate == null) return + if (command == null || command.executeAutoComplete == null) return try { - await command.executeAutoComplate(this.app, interaction) + await command.executeAutoComplete(interaction) } catch (e) { console.error(e) } diff --git a/src/lib/discordActivities.ts b/src/lib/discordActivities.ts index 9e6f376..0c49c61 100644 --- a/src/lib/discordActivities.ts +++ b/src/lib/discordActivities.ts @@ -1,13 +1,12 @@ -import { ActivityType, type ClientUser } from "npm:discord.js" +import { ActivityType } from "npm:discord.js" import client from "lib/prisma.ts" +import { getClientUser } from "app.ts" /** * sets the bots activity to a random show from the bot db * @param clientUser the discord user to set the activity for */ -export async function setRandomShowActivity( - clientUser: ClientUser, -): Promise { +export async function setRandomShowActivity(): Promise { const showCount = await client.show.count() const randomIndex = Math.floor(Math.random() * showCount) const show = await client.show.findMany({ @@ -16,11 +15,11 @@ export async function setRandomShowActivity( }) if (show.length !== 1) { - clearActivity(clientUser) + clearActivity() return } - setWatchingActivity(clientUser, show[0].name) + setWatchingActivity(show[0].name) } /** @@ -29,9 +28,9 @@ export async function setRandomShowActivity( * @param show show name to put in the activity */ export function setWatchingActivity( - clientUser: ClientUser, show: string, ): void { + const clientUser = getClientUser() console.info(`Setting activity to watching ${show}`) clientUser.setActivity(show, { type: ActivityType.Watching }) } @@ -40,7 +39,8 @@ export function setWatchingActivity( * this clears the bots activity * @param clientUser the discord user to set the activity for */ -export function clearActivity(clientUser: ClientUser): void { +export function clearActivity(): void { + const clientUser = getClientUser() console.info("Clearing activity") clientUser.setActivity() } @@ -49,7 +49,8 @@ export function clearActivity(clientUser: ClientUser): void { * this sets a dumb 'loading' activity for while the bot is fetching stuff from the TVDB * @param clientUser the discord user to set the activity for */ -export function setTVDBLoadingActivity(clientUser: ClientUser): void { +export function setTVDBLoadingActivity(): void { + const clientUser = getClientUser() clientUser.setActivity("episode data from the TVDB", { type: ActivityType.Playing, }) diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..f3ba6b1 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,39 @@ +import * as z from "npm:zod" + +const envKeys = { + "REGISTER_COMMANDS": z.stringbool().optional().default(true), + "TZ": z.string().optional().default("America/Chicago"), + "DISCORD_TOKEN": z.string(), + "DISCORD_CLIENT_ID": z.string(), + "DISCORD_GUILD_ID": z.string(), + "UPDATE_SHOWS": z.stringbool().optional().default(true), + "HEALTHCHECK_URL": z.string().optional(), + "TVDB_API_KEY": z.string(), + "TVDB_USER_PIN": z.string(), + "NODE_ENV": z.enum(["development", "production"]).optional().default( + "development", + ), +} as const +export type EnvKey = keyof typeof envKeys + +/** + * @throws Error if the environment variable doesn't match the zod schema + */ +export function getEnv(key: K): z.infer<(typeof envKeys)[K]> { + const value = Deno.env.get(key) + const parsed = parseEnv(key, value) + return parsed !== undefined ? parsed : undefined +} + +/** + * @throws Error if the environment variable is not valid + */ +function parseEnv(key: EnvKey, value: unknown) { + const parsedValue = envKeys[key].safeParse(value) + if (parsedValue.error != null) { + throw new Error( + `Environment variable ${key} is not valid: ${parsedValue.error.toString()}`, + ) + } + return parsedValue.data +} diff --git a/src/lib/episodeNotifier.ts b/src/lib/episodeNotifier.ts index f171d0e..801c9a0 100644 --- a/src/lib/episodeNotifier.ts +++ b/src/lib/episodeNotifier.ts @@ -8,11 +8,11 @@ import { type TextChannel, } from "npm:discord.js" import moment from "npm:moment-timezone" -import { type App } from "app.ts" import { markMessageSent } from "lib/shows.ts" import client from "lib/prisma.ts" -import { type Settings } from "lib/settingsManager.ts" +import { Settings, type SettingsType } from "lib/settingsManager.ts" import { addLeadingZeros, toRanges } from "lib/util.ts" +import { getClient } from "app.ts" export function isTextChannel( channel: Channel, @@ -38,9 +38,9 @@ type PayloadCollection = Collection * @param app the app instance * @returns a promise that resolves when all the messages have been sent */ -export async function sendAiringMessages(app: App): Promise { - const discord = app.getClient() - const globalDestinations = app.getSettingsManager().fetch()?.allEpisodes ?? [] +export async function sendAiringMessages(): Promise { + const discord = getClient() + const globalDestinations = Settings.fetch()?.allEpisodes ?? [] const payloadCollection = await getShowPayloads() for (const payload of payloadCollection.values()) { @@ -126,7 +126,7 @@ async function getShowPayloads( async function sendNotificationPayload( payload: NotificationPayload, discord: Client, - globalDestinations: Settings["allEpisodes"], + globalDestinations: SettingsType["allEpisodes"], ): Promise { const message = getEpisodeMessage( payload.showName, diff --git a/src/lib/morningSummary.ts b/src/lib/morningSummary.ts index 1483163..7aaff0a 100644 --- a/src/lib/morningSummary.ts +++ b/src/lib/morningSummary.ts @@ -1,14 +1,12 @@ -import { type APIEmbed, type Client } from "npm:discord.js" -import { type Settings } from "lib/settingsManager.ts" +import { type APIEmbed } from "npm:discord.js" +import { Settings } from "lib/settingsManager.ts" import { getUpcomingEpisodesEmbed } from "lib/upcoming.ts" import client from "lib/prisma.ts" import { type Show } from "prisma-client/client.ts" import { isTextChannel } from "lib/episodeNotifier.ts" +import { getClient } from "app.ts" -export async function sendMorningSummary( - settings: Settings, - c: Client, -): Promise { +export async function sendMorningSummary(): Promise { const shows: Show[] = await client.show.findMany({ where: { episodes: { @@ -21,8 +19,10 @@ export async function sendMorningSummary( const embed: APIEmbed = getUpcomingEpisodesEmbed(shows, 1) - for (const dest of settings.morningSummaryDestinations) { - const channel = await c.channels.fetch(dest.channelId) + const discordClient = getClient() + const destinations = Settings.fetch()?.morningSummaryDestinations ?? [] + for (const dest of destinations) { + const channel = await discordClient.channels.fetch(dest.channelId) if (channel == null || !isTextChannel(channel) || !channel.isSendable()) { console.warn( `Found channel ${dest.channelId} in the morning summary destinations but it is not a text channel or is not sendable`, diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 2dfb9ce..8214168 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,5 +1,5 @@ -import process from "node:process" import { PrismaClient } from "prisma-client/client.ts" +import { getEnv } from "lib/env.ts" declare global { var prisma: PrismaClient | undefined @@ -11,6 +11,6 @@ export const DBChannelType = { } as const const client = globalThis.prisma ?? new PrismaClient() -if (process.env.NODE_ENV !== "production") globalThis.prisma = client +if (getEnv("NODE_ENV") !== "production") globalThis.prisma = client export default client diff --git a/src/lib/settingsManager.ts b/src/lib/settingsManager.ts index f60585b..dde5ce0 100644 --- a/src/lib/settingsManager.ts +++ b/src/lib/settingsManager.ts @@ -5,14 +5,23 @@ import { type Settings as DBSettings, } from "prisma-client/client.ts" -export type Settings = Omit +export type SettingsType = Omit /** * Manager to handle fetching and saving settings in the DB */ -export class SettingsManager { +export class Settings { + private static instance: Settings + + public static getInstance(): Settings { + if (!Settings.instance) { + Settings.instance = new Settings() + } + return Settings.instance + } + // set the defaults for the settings - private settings?: Settings + private settings?: SettingsType /** * Save initial settings data to the DB @@ -34,7 +43,7 @@ export class SettingsManager { /** * Fetches the settings from the DB and updates the SettingsManager instance with the latest values */ - refresh = async (): Promise => { + refresh = async (): Promise => { try { // fetch the settings from the DB const settings = await client.settings.findUniqueOrThrow({ @@ -50,11 +59,19 @@ export class SettingsManager { } } + public static refresh = async (): Promise => { + const instance = Settings.getInstance() + const settings = await instance.refresh() + return settings + } + /** * Update settings in the DB * @param inputData settings data to update */ - update = async (inputData: Partial): Promise => { + public static update = async ( + inputData: Partial, + ): Promise => { const data = Prisma.validator()(inputData) await client.settings.update({ @@ -64,7 +81,7 @@ export class SettingsManager { data, }) - await this.refresh() + await Settings.getInstance().refresh() } /** @@ -89,9 +106,12 @@ export class SettingsManager { return matchingChannels > 0 } - addGlobalDestination = async (channelId: string): Promise => { - if (await this.channelIsAlreadyGlobal(channelId)) { - return this.settings?.allEpisodes ?? [] + public static addGlobalDestination = async ( + channelId: string, + ): Promise => { + const instance = Settings.getInstance() + if (await instance.channelIsAlreadyGlobal(channelId)) { + return instance.settings?.allEpisodes ?? [] } const settings = await client.settings.update({ @@ -112,15 +132,16 @@ export class SettingsManager { console.info(`Added ${channelId} to global destinations`) - await this.refresh() + await instance.refresh() return settings.allEpisodes } - removeGlobalDestination = async ( + public static removeGlobalDestination = async ( channelId: string, ): Promise => { - if (!await this.channelIsAlreadyGlobal(channelId)) { - return this.settings?.allEpisodes ?? [] + const instance = Settings.getInstance() + if (!await instance.channelIsAlreadyGlobal(channelId)) { + return instance.settings?.allEpisodes ?? [] } const settings = await client.settings.update({ @@ -140,9 +161,10 @@ export class SettingsManager { console.info(`Removed ${channelId} from global destinations`) - await this.refresh() + await instance.refresh() return settings.allEpisodes } - fetch = (): Settings | undefined => this.settings + public static fetch = (): SettingsType | undefined => + Settings.getInstance().settings } diff --git a/src/lib/tvdb.ts b/src/lib/tvdb.ts index a05e0c3..bdfd617 100644 --- a/src/lib/tvdb.ts +++ b/src/lib/tvdb.ts @@ -1,17 +1,10 @@ -import process from "node:process" import axios, { AxiosError, type AxiosRequestConfig } from "npm:axios" import { type SearchByRemoteIdResult, type SearchResult, type SeriesExtendedRecord, } from "interfaces/tvdb.generated.ts" - -if (process.env.TVDB_API_KEY === undefined) { - throw new Error("TVDB_API_KEY is not defined") -} -if (process.env.TVDB_USER_PIN === undefined) { - throw new Error("TVDB_USER_PIN is not defined") -} +import { getEnv } from "lib/env.ts" let token: string | undefined @@ -20,8 +13,8 @@ async function getToken(): Promise { try { const response = await axios.post("https://api4.thetvdb.com/v4/login", { - apikey: process.env.TVDB_API_KEY, - pin: process.env.TVDB_USER_PIN, + apikey: getEnv("TVDB_API_KEY"), + pin: getEnv("TVDB_USER_PIN"), }) token = response.data.data.token diff --git a/src/lib/upcoming.ts b/src/lib/upcoming.ts index f12a822..bfd05ad 100644 --- a/src/lib/upcoming.ts +++ b/src/lib/upcoming.ts @@ -1,9 +1,9 @@ -import process from "node:process" import { type Show } from "prisma-client/client.ts" import { type APIEmbed, type APIEmbedField, Collection } from "npm:discord.js" import moment from "npm:moment-timezone" import { type NotificationPayload } from "lib/episodeNotifier.ts" import { addLeadingZeros, toRanges } from "lib/util.ts" +import { getEnv } from "lib/env.ts" interface UpcomingEpisodeMessages { prefix: string @@ -43,7 +43,7 @@ function getShowMessages( episodeNumbers.join(",") } - ` const airDate = moment.unix(payload.timestamp).tz( - process.env.TZ ?? "America/Chicago", + getEnv("TZ"), ) acc.ensure(airDate.format("dddd - Do of MMMM"), () => []).push(message) return acc @@ -140,7 +140,7 @@ function reduceEpisodes( if (!inTimeWindow) continue const airDateString = moment.utc(e.airDate) - .tz(process.env.TZ ?? "America/Chicago") + .tz(getEnv("TZ")) .format("YYYY-MM-DD@HH:mm") const key = `announceEpisodes:${airDateString}:${show.imdbId}:S${