diff --git a/.dockerignore b/.dockerignore index f05b4c3..42432ba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,5 @@ fly.toml .git node_modules/ prisma/client/ +*.sqlite* +*.db* diff --git a/.gitignore b/.gitignore index fd89284..f249b8d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ data/ .DS_Store mongodata + +denokv.sqlite3* + diff --git a/Dockerfile b/Dockerfile index 4a7cef0..d294c4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ -FROM denoland/deno:debian +FROM litestream/litestream AS litestream +FROM denoland/deno:debian AS build WORKDIR /app RUN apt-get update && \ apt-get install -y --no-install-recommends \ @@ -8,5 +9,18 @@ RUN apt-get update && \ COPY . . RUN deno install -r --allow-scripts=npm:@prisma/client,npm:prisma,npm:@prisma/engines RUN deno task prisma:generate +RUN deno task build + +FROM debian:latest +COPY --from=litestream /usr/local/bin/litestream /usr/local/bin/litestream +COPY litestream.yml /etc/litestream.yml + +COPY entrypoint.sh /app/entrypoint.sh + +COPY --from=build /app/dist/tvbot /usr/local/bin/tvbot + +ENV DENO_KV_SQLITE_PATH="/data/denokv.sqlite3" ENV TZ="America/Chicago" + +WORKDIR /app ENTRYPOINT ["sh", "entrypoint.sh"] diff --git a/deno.jsonc b/deno.jsonc index a0621b1..3ab6e43 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,8 +1,8 @@ { "$schema": "https://raw.githubusercontent.com/denoland/deno/refs/heads/main/cli/schemas/config-file.v1.json", "tasks": { - "dev": "deno --watch --no-clear-screen -A --unstable-cron --allow-scripts=npm:prisma,npm:@prisma/client,npm:@prisma/engines ./src/app.ts", - "build": "deno compile --unstable-cron -o ./dist/tvbot ./src/app.ts", + "dev": "deno --watch --no-clear-screen -ERN --unstable-cron --unstable-kv --allow-scripts=npm:prisma,npm:@prisma/client,npm:@prisma/engines ./src/app.ts", + "build": "deno compile --unstable-cron --unstable-kv -EN -o ./dist/tvbot ./src/app.ts", "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", diff --git a/entrypoint.sh b/entrypoint.sh index 1738925..54323c4 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,8 +1,10 @@ #!/bin/bash -set -e -# migrate the database -deno run -A --allow-scripts=npm:prisma,npm:@prisma/engines npm:prisma db push --skip-generate -set +e +if ! test -d /data; then + mkdir /data +fi +if ! test -f "$DENO_KV_SQLITE_PATH"; then + litestream restore --if-replica-exists -o "$DENO_KV_SQLITE_PATH" +fi -deno -A --unstable-cron ./src/app.ts +litestream replicate -exec 'tvbot' diff --git a/import_map.json b/import_map.json index b7d4e73..a4cc9dd 100644 --- a/import_map.json +++ b/import_map.json @@ -2,8 +2,10 @@ "imports": { "app.ts": "./src/app.ts", "lib/": "./src/lib/", + "database/": "./src/database/", "prisma-client/": "./prisma/client/", "interfaces/": "./src/interfaces/", + "schemas.ts": "./src/schemas.ts", "commands/": "./src/commands/", "npm:discord.js": "npm:discord.js@^14.18.0", "@prisma/client/runtime/library": "npm:@prisma/client/runtime/library", diff --git a/litestream.yml b/litestream.yml new file mode 100644 index 0000000..00891cb --- /dev/null +++ b/litestream.yml @@ -0,0 +1,4 @@ +dbs: + - path: /data/denokv.sqlite3 + replicas: + - url: ${LITESTREAM_S3_REPLICA_URL} diff --git a/src/app.ts b/src/app.ts index 9d5b7fa..deaa97f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,7 +3,6 @@ import { Client, ClientUser, Events, GatewayIntentBits } from "npm:discord.js" import { CommandManager } from "lib/commandManager.ts" import { checkForAiringEpisodes, pruneUnsubscribedShows } from "lib/shows.ts" import { sendAiringMessages } from "lib/episodeNotifier.ts" -import { Settings } from "lib/settingsManager.ts" import { setRandomShowActivity, setTVDBLoadingActivity, @@ -17,8 +16,6 @@ const token = getEnv("DISCORD_TOKEN") const clientId = getEnv("DISCORD_CLIENT_ID") const guildId = getEnv("DISCORD_GUILD_ID") -await Settings.refresh() - const commandManager = new CommandManager() await commandManager.registerCommands(clientId, token, guildId) diff --git a/src/commands/post.ts b/src/commands/post.ts index ceab622..485a187 100644 --- a/src/commands/post.ts +++ b/src/commands/post.ts @@ -21,7 +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" +import { getSetting } from "database/settings.ts" interface SeriesWrapper { series: SeriesExtendedRecord @@ -77,7 +77,9 @@ 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() + const tvForum = useInputForum + ? forumInput.id + : await getDefaultTVForumId() await progress.sendNextStep() // start step 1 @@ -184,14 +186,13 @@ export const command: CommandV2 = { * @param app main application object instance * @returns ID of the default TV forum */ -function getDefaultTVForumId(): string { - const forumId = Settings.fetch()?.defaultForum +async function getDefaultTVForumId(): Promise { + const forumId = await getSetting("defaultForum") if (forumId == null) { throw new ProgressError( "No TV forum configured, use /settings tv_forum to set the default TV forum", ) } - return forumId } @@ -219,7 +220,7 @@ async function checkForExistingPosts( }, }) - // if the show isnt in the DB then we can just return + // if the show isn't in the DB then we can just return if (show === null) return undefined // if the show is in the DB but has no destinations then we can just return if (show.destinations.length === 0) return undefined diff --git a/src/commands/setting.ts b/src/commands/setting.ts index 1785618..5119c86 100644 --- a/src/commands/setting.ts +++ b/src/commands/setting.ts @@ -11,8 +11,7 @@ import { } from "npm:discord.js" import { type CommandV2 } from "interfaces/command.ts" import { ProgressMessageBuilder } from "lib/progressMessages.ts" -import { Settings } from "lib/settingsManager.ts" -import { type Destination } from "prisma-client/client.ts" +import { getSetting, setSetting, Settings } from "database/settings.ts" export const command: CommandV2 = { slashCommand: { @@ -169,9 +168,7 @@ async function setTVForum( await interaction.editReply(progressMessage.nextStep()) // update the db with the new value - await Settings.update({ - defaultForum: channel.id, - }) + await setSetting("defaultForum", channel.id) await interaction.editReply(progressMessage.nextStep()) } @@ -206,26 +203,30 @@ async function updateGlobalChannels( await progress.sendNextStep() - let destinations: Destination[] = [] + const destinations: Settings["allEpisodes"] = await getSetting("allEpisodes") // add or remove the channel from the list if (mode === "add") { - destinations = await Settings.addGlobalDestination(channel.id) + destinations.push({ + channelId: channel.id, + }) } else if (mode === "remove") { - destinations = await Settings.removeGlobalDestination(channel.id) + const index = destinations.findIndex((d) => d.channelId === channel.id) + if (index >= 0) { + destinations.splice(index, 1) + } } const destinationsString = destinations.map((d) => `<#${d.channelId}>`).join( "\n", ) - return await progress.sendNextStep(`__New List__:\nn${destinationsString}`) + return await progress.sendNextStep(`__New List__:\n${destinationsString}`) } /** * handle the morning_sumarry commands * allows adding and removing channels from the list of channels that receive the morning summary message * todo allow setting the time of the morning summary - * @param settingsManager pass the settingsManager to avoid having to fetch it multiple times * @param interaction the chat interaction that got us here * @returns nothin */ @@ -253,7 +254,9 @@ async function updateMorningSummaryChannels( await progress.sendNextStep() - let channelList = Settings.fetch()?.morningSummaryDestinations ?? [] + let channelList: Settings["morningSumarryDestinations"] = await getSetting( + "morningSumarryDestinations", + ) const hasChannel = channelList.some((d) => d.channelId === channel.id) // add or remove the channel from the list @@ -263,7 +266,6 @@ async function updateMorningSummaryChannels( } channelList.push({ channelId: channel.id, - forumId: null, }) } else if (subCommand === "remove_channel") { if (!hasChannel) { @@ -272,9 +274,7 @@ async function updateMorningSummaryChannels( channelList = channelList.filter((d) => d.channelId !== channel.id) } - await Settings.update({ - morningSummaryDestinations: channelList, - }) + await setSetting("morningSumarryDestinations", channelList) const channelsString = channelList.map((d) => `<#${d.channelId}>`).join("\n") return await progress.sendNextStep(`__New List__:\n${channelsString}`) diff --git a/src/cron.ts b/src/cron.ts index 61fc034..25d6b9c 100644 --- a/src/cron.ts +++ b/src/cron.ts @@ -5,7 +5,6 @@ import { 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() { @@ -21,8 +20,6 @@ export function scheduleCronJobs() { }) Deno.cron("Morning Summary", { hour: 8, minute: 0 }, async () => { - const settings = Settings.fetch() - if (settings == null) throw new Error("Settings not found") await sendMorningSummary() }) diff --git a/src/database/client.ts b/src/database/client.ts new file mode 100644 index 0000000..739f9dd --- /dev/null +++ b/src/database/client.ts @@ -0,0 +1,6 @@ +import { getEnv } from "lib/env.ts" + +const dbLocation = getEnv("DENO_KV_SQLITE_PATH") +const kv = await Deno.openKv(dbLocation) + +export default kv diff --git a/src/database/settings.ts b/src/database/settings.ts new file mode 100644 index 0000000..dc8fb9b --- /dev/null +++ b/src/database/settings.ts @@ -0,0 +1,54 @@ +import * as z from "npm:zod" +import { zDestination } from "schemas.ts" +import kv from "database/client.ts" + +const nullToEmptyArray = (val: T[] | null): T[] => { + if (val == null) { + return [] + } + return val +} + +const settingSchema = { + defaultForum: z.string().nullable(), + allEpisodes: z.array(zDestination).nullable().transform(nullToEmptyArray), + morningSumarryDestinations: z.array(zDestination).nullable().transform( + nullToEmptyArray, + ), +} +export type Settings = { + [K in keyof typeof settingSchema]: z.infer +} +export type SettingKey = keyof Settings + +export async function getSetting( + key: K, +): Promise> { + const kvEntry = await kv.get(["settings", key]) + const parsed: z.infer<(typeof settingSchema)[K]> = parseValue( + key, + kvEntry.value, + ) + return parsed +} + +export async function setSetting( + key: K, + value: z.infer<(typeof settingSchema)[K]>, +): Promise { + const parsedValue = parseValue(key, value) + return await kv.set(["settings", key], parsedValue) +} + +/** + * @throws Error if the setting variable is not valid + */ +function parseValue(key: SettingKey, value: unknown) { + const parsedValue = settingSchema[key].safeParse(value) + if (parsedValue.error != null) { + throw new Error( + `Setting ${key} is not valid: ${parsedValue.error.toString()}`, + ) + } + return parsedValue.data +} diff --git a/src/handlers.ts b/src/handlers.ts index 5c3bc63..e42732c 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -1,6 +1,6 @@ import { ChannelType, ClientEvents } from "npm:discord.js" -import { Settings } from "lib/settingsManager.ts" import { pruneUnsubscribedShows, removeAllSubscriptions } from "lib/shows.ts" +import { getSetting, setSetting, type Settings } from "database/settings.ts" /** * When a thread (forum post) is deleted, remove all subscriptions for that post @@ -28,6 +28,15 @@ export async function handleChannelDelete( if (channel.type === ChannelType.GuildText) { await removeAllSubscriptions(channel.id, "channelId") await pruneUnsubscribedShows() - await Settings.removeGlobalDestination(channel.id) + + const destinations: Settings["allEpisodes"] = await getSetting( + "allEpisodes", + ) + const filteredDestinations: Settings["allEpisodes"] = destinations.filter(( + d: Settings["allEpisodes"][number], + ) => d.channelId !== channel.id) + if (filteredDestinations.length !== destinations.length) { + await setSetting("allEpisodes", filteredDestinations) + } } } diff --git a/src/lib/env.ts b/src/lib/env.ts index f3ba6b1..43eb07b 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -13,6 +13,7 @@ const envKeys = { "NODE_ENV": z.enum(["development", "production"]).optional().default( "development", ), + "DENO_KV_SQLITE_PATH": z.string().optional().default("./denokv.sqlite3"), } as const export type EnvKey = keyof typeof envKeys diff --git a/src/lib/episodeNotifier.ts b/src/lib/episodeNotifier.ts index 801c9a0..2f5e402 100644 --- a/src/lib/episodeNotifier.ts +++ b/src/lib/episodeNotifier.ts @@ -10,9 +10,9 @@ import { import moment from "npm:moment-timezone" import { markMessageSent } from "lib/shows.ts" import client from "lib/prisma.ts" -import { Settings, type SettingsType } from "lib/settingsManager.ts" import { addLeadingZeros, toRanges } from "lib/util.ts" import { getClient } from "app.ts" +import { getSetting, type Settings } from "database/settings.ts" export function isTextChannel( channel: Channel, @@ -40,7 +40,7 @@ type PayloadCollection = Collection */ export async function sendAiringMessages(): Promise { const discord = getClient() - const globalDestinations = Settings.fetch()?.allEpisodes ?? [] + const globalDestinations = await getSetting("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: SettingsType["allEpisodes"], + globalDestinations: Settings["allEpisodes"], ): Promise { const message = getEpisodeMessage( payload.showName, diff --git a/src/lib/morningSummary.ts b/src/lib/morningSummary.ts index 7aaff0a..e28e145 100644 --- a/src/lib/morningSummary.ts +++ b/src/lib/morningSummary.ts @@ -1,12 +1,18 @@ 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" +import { getSetting } from "database/settings.ts" export async function sendMorningSummary(): Promise { + const destinations = await getSetting("morningSumarryDestinations") + if (destinations.length === 0) { + console.info("Morning summary destinations not set, skipping") + return + } + const shows: Show[] = await client.show.findMany({ where: { episodes: { @@ -20,7 +26,6 @@ export async function sendMorningSummary(): Promise { const embed: APIEmbed = getUpcomingEpisodesEmbed(shows, 1) 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()) { diff --git a/src/lib/settingsManager.ts b/src/lib/settingsManager.ts deleted file mode 100644 index dde5ce0..0000000 --- a/src/lib/settingsManager.ts +++ /dev/null @@ -1,170 +0,0 @@ -import client from "lib/prisma.ts" -import { - type Destination, - Prisma, - type Settings as DBSettings, -} from "prisma-client/client.ts" - -export type SettingsType = Omit - -/** - * Manager to handle fetching and saving settings in the DB - */ -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?: SettingsType - - /** - * Save initial settings data to the DB - */ - private readonly initData = async (): Promise => { - try { - await client.settings.create({ - data: { - id: 0, - allEpisodes: [], - }, - }) - } catch (error) { - if (!(error instanceof Prisma.PrismaClientKnownRequestError)) throw error - if (error.code !== "P2002") throw error - } - } - - /** - * Fetches the settings from the DB and updates the SettingsManager instance with the latest values - */ - refresh = async (): Promise => { - try { - // fetch the settings from the DB - const settings = await client.settings.findUniqueOrThrow({ - where: { - id: 0, - }, - }) - this.settings = settings - return settings - } catch (error) { - if (!(error instanceof Prisma.PrismaClientKnownRequestError)) throw error - if (error.code === "P2025") await this.initData() - } - } - - 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 - */ - public static update = async ( - inputData: Partial, - ): Promise => { - const data = Prisma.validator()(inputData) - - await client.settings.update({ - where: { - id: 0, - }, - data, - }) - - await Settings.getInstance().refresh() - } - - /** - * check if a channel is already in settings 'allEpisodes' list global destiations list - * @param channelId channel to check - * @returns true if channel is in list, false if not - */ - private readonly channelIsAlreadyGlobal = async ( - channelId: string, - ): Promise => { - const matchingChannels = await client.settings.count({ - where: { - id: 0, - allEpisodes: { - some: { - channelId, - }, - }, - }, - }) - - return matchingChannels > 0 - } - - 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({ - where: { - id: 0, - }, - data: { - allEpisodes: { - push: { - channelId, - }, - }, - }, - select: { - allEpisodes: true, - }, - }) - - console.info(`Added ${channelId} to global destinations`) - - await instance.refresh() - return settings.allEpisodes - } - - public static removeGlobalDestination = async ( - channelId: string, - ): Promise => { - const instance = Settings.getInstance() - if (!await instance.channelIsAlreadyGlobal(channelId)) { - return instance.settings?.allEpisodes ?? [] - } - - const settings = await client.settings.update({ - where: { - id: 0, - }, - data: { - allEpisodes: { - deleteMany: { - where: { - channelId, - }, - }, - }, - }, - }) - - console.info(`Removed ${channelId} from global destinations`) - - await instance.refresh() - return settings.allEpisodes - } - - public static fetch = (): SettingsType | undefined => - Settings.getInstance().settings -} diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..2228f2c --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,32 @@ +import * as z from "npm:zod" + +export const zDestination = z.object({ + channelId: z.string(), + forumId: z.string().optional(), +}) +export type Destination = z.infer + +export const zEpisode = z.object({ + season: z.number(), + nuumber: z.number(), + title: z.string(), + airDate: z.iso.datetime(), + messageSent: z.boolean(), +}) +export type Episode = z.infer + +export const zShow = z.object({ + name: z.string(), + imdbId: z.string(), + tvdbId: z.string(), + episodes: z.array(zEpisode), + destinations: z.array(zDestination), +}) +export type Show = z.infer + +// export const zSettings = z.object({ +// defaultForum: z.string().nullable(), +// allEpisodes: z.array(zDestination), +// morningSumarryDestinations: z.array(zDestination), +// }) +// export type Settings = z.infer