Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ fly.toml
.git
node_modules/
prisma/client/
*.sqlite*
*.db*
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ data/
.DS_Store

mongodata

denokv.sqlite3*

16 changes: 15 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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 \
Expand All @@ -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"]
4 changes: 2 additions & 2 deletions deno.jsonc
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
12 changes: 7 additions & 5 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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'
2 changes: 2 additions & 0 deletions import_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions litestream.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dbs:
- path: /data/denokv.sqlite3
replicas:
- url: ${LITESTREAM_S3_REPLICA_URL}
3 changes: 0 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)

Expand Down
13 changes: 7 additions & 6 deletions src/commands/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<string> {
const forumId = await getSetting("defaultForum")
if (forumId == null) {
throw new ProgressError(
"No TV forum configured, use /settings tv_forum <channel> to set the default TV forum",
)
}

return forumId
}

Expand Down Expand Up @@ -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
Expand Down
30 changes: 15 additions & 15 deletions src/commands/setting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand All @@ -263,7 +266,6 @@ async function updateMorningSummaryChannels(
}
channelList.push({
channelId: channel.id,
forumId: null,
})
} else if (subCommand === "remove_channel") {
if (!hasChannel) {
Expand All @@ -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}`)
Expand Down
3 changes: 0 additions & 3 deletions src/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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()
})

Expand Down
6 changes: 6 additions & 0 deletions src/database/client.ts
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions src/database/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as z from "npm:zod"
import { zDestination } from "schemas.ts"
import kv from "database/client.ts"

const nullToEmptyArray = <T>(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<typeof settingSchema[K]>
}
export type SettingKey = keyof Settings

export async function getSetting<K extends SettingKey>(
key: K,
): Promise<z.infer<(typeof settingSchema)[K]>> {
const kvEntry = await kv.get(["settings", key])
const parsed: z.infer<(typeof settingSchema)[K]> = parseValue(
key,
kvEntry.value,
)
return parsed
}

export async function setSetting<K extends SettingKey>(
key: K,
value: z.infer<(typeof settingSchema)[K]>,
): Promise<Deno.KvCommitResult> {
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
}
13 changes: 11 additions & 2 deletions src/handlers.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}
}
1 change: 1 addition & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions src/lib/episodeNotifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -40,7 +40,7 @@ type PayloadCollection = Collection<string, NotificationPayload>
*/
export async function sendAiringMessages(): Promise<void> {
const discord = getClient()
const globalDestinations = Settings.fetch()?.allEpisodes ?? []
const globalDestinations = await getSetting("allEpisodes") ?? []

const payloadCollection = await getShowPayloads()
for (const payload of payloadCollection.values()) {
Expand Down Expand Up @@ -126,7 +126,7 @@ async function getShowPayloads(
async function sendNotificationPayload(
payload: NotificationPayload,
discord: Client,
globalDestinations: SettingsType["allEpisodes"],
globalDestinations: Settings["allEpisodes"],
): Promise<void> {
const message = getEpisodeMessage(
payload.showName,
Expand Down
Loading