Skip to content
Merged
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 .github/workflows/fly-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
permissions:
contents: read
environment: tvbot-prod
concurrency: deploy-group
steps:
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/semantic-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ on:
required: false
type: boolean
default: false
permissions:
contents: read
jobs:
release:
name: Semantic Release
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/tags.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,4 +36,7 @@ jobs:

deploy:
needs: build_and_push
permissions:
contents: read
uses: ./.github/workflows/fly-deploy.yml
secrets: inherit
7 changes: 2 additions & 5 deletions deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,10 +21,6 @@
"include": [
"src/"
],
"exclude": [
"src/testdata/",
"src/fixtures/**/*.ts"
],
"rules": {
"tags": [
"recommended"
Expand Down
16 changes: 11 additions & 5 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion import_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
53 changes: 53 additions & 0 deletions scripts/get-sonarr-shows.ts
Original file line number Diff line number Diff line change
@@ -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(","))
}
172 changes: 41 additions & 131 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<boolean> => 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<boolean> => discordClient
export const getClientUser = (): ClientUser => {
assert(discordClient.user != null, "Client user is null")
return discordClient.user
}
Loading