diff --git a/.github/workflows/fly.yml b/.github/workflows/fly.yml deleted file mode 100644 index debb473..0000000 --- a/.github/workflows/fly.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Fly Deploy -on: - push: - branches: - - main -jobs: - deploy: - name: Deploy app - runs-on: ubuntu-latest - concurrency: deploy-group - steps: - - uses: actions/checkout@v4 - - uses: superfly/flyctl-actions/setup-flyctl@master - - run: flyctl deploy --remote-only - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/docker-compose.yml b/docker-compose.yml index c6d5a1b..c1e3feb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,34 +1,9 @@ services: - app: - build: - context: . - dockerfile: Dockerfile - container_name: wack-hacker-app - restart: always - depends_on: - - s3 - env_file: - - .env - networks: - - app_network - - s3: - image: minio/minio - container_name: wack-hacker-kv - restart: always - env_file: - - .env - ports: - - "9000:9000" - - "9001:9001" - volumes: - - s3_data:/data - command: server --console-address ":9001" /data - networks: - - app_network - -volumes: - s3_data: - -networks: - app_network: + app: + build: + context: . + dockerfile: Dockerfile + container_name: wack-hacker-app + restart: always + env_file: + - .env diff --git a/fly.toml b/fly.toml deleted file mode 100644 index 7937f1f..0000000 --- a/fly.toml +++ /dev/null @@ -1,22 +0,0 @@ -# fly.toml app configuration file generated for phack-discord-summarizer on 2024-12-21T14:52:22-06:00 -# -# See https://fly.io/docs/reference/configuration/ for information about how to use this file. -# - -app = 'phack-discord-summarizer' -primary_region = 'dfw' - -[build] - -[http_service] - internal_port = 3000 - force_https = true - auto_stop_machines = false - auto_start_machines = true - min_machines_running = 1 - processes = ['app'] - -[[vm]] - memory = '1gb' - cpu_kind = 'shared' - cpus = 1 diff --git a/src/commands/admin_check_birthdays.ts b/src/commands/admin_check_birthdays.ts deleted file mode 100644 index c2d4347..0000000 --- a/src/commands/admin_check_birthdays.ts +++ /dev/null @@ -1,86 +0,0 @@ -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; -import { - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; - -import { getBirthdaysToday } from "./birthday"; -import { ADMINS } from "../utils/consts"; - -dayjs.extend(utc); - -export const data = new SlashCommandBuilder() - .setName("admin_check_birthdays") - .setDescription("ADMIN") - .addStringOption((option) => - option - .setName("month") - .setDescription("Month to test") - .addChoices( - { name: "January", value: "0" }, - { name: "February", value: "1" }, - { name: "March", value: "2" }, - { name: "April", value: "3" }, - { name: "May", value: "4" }, - { name: "June", value: "5" }, - { name: "July", value: "6" }, - { name: "August", value: "7" }, - { name: "September", value: "8" }, - { name: "October", value: "9" }, - { name: "November", value: "10" }, - { name: "December", value: "11" }, - ) - .setRequired(false), - ) - .addIntegerOption((option) => - option - .setName("day") - .setDescription("Day to test") - .setMinValue(1) - .setMaxValue(31) - .setRequired(false), - ); - -export async function command(interaction: ChatInputCommandInteraction) { - const { options } = interaction; - - const month = options.getString("month"); - const day = options.getInteger("day"); - - if ((month && !day) || (!month && day)) { - await interaction.reply({ - content: "Please provide both a month and day", - ephemeral: true, - }); - return; - } - - if (!ADMINS.includes(interaction.user.id)) { - await interaction.reply({ - content: "You do not have permission to run this command", - ephemeral: true, - }); - return; - } - - const date = - month && day - ? dayjs.utc(0).month(Number(month)).date(Number(day)) - : dayjs.utc(); - - const birthdays = await getBirthdaysToday(date); - - if (birthdays.length === 0) { - await interaction.reply({ - content: "No birthdays", - ephemeral: true, - }); - return; - } - - await interaction.reply({ - content: `Birthdays: ${birthdays.map((b) => `<@${b.userId}>`).join(", ")}`, - ephemeral: true, - }); -} diff --git a/src/commands/admin_get_current_time.ts b/src/commands/admin_get_current_time.ts deleted file mode 100644 index 37058f9..0000000 --- a/src/commands/admin_get_current_time.ts +++ /dev/null @@ -1,31 +0,0 @@ -import dayjs from "dayjs"; -import advancedFormat from "dayjs/plugin/advancedFormat"; -import { - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; - -import { ADMINS } from "../utils/consts"; - -dayjs.extend(advancedFormat); - -export const data = new SlashCommandBuilder() - .setName("admin_get_current_time") - .setDescription("ADMIN"); - -export async function command(interaction: ChatInputCommandInteraction) { - if (!ADMINS.includes(interaction.user.id)) { - await interaction.reply({ - content: "You do not have permission to run this command", - ephemeral: true, - }); - return; - } - - const now = dayjs(); - - await interaction.reply({ - content: `Container time: ${now.format("YYYY-MM-DD HH:mm:ss")}. Timezone: ${Bun.env.TZ}`, - ephemeral: true, - }); -} diff --git a/src/commands/birthday.ts b/src/commands/birthday.ts deleted file mode 100644 index 3a9979a..0000000 --- a/src/commands/birthday.ts +++ /dev/null @@ -1,148 +0,0 @@ -import dayjs, { type Dayjs } from "dayjs"; -import advancedFormat from "dayjs/plugin/advancedFormat"; -import utc from "dayjs/plugin/utc"; -import { - MessageFlags, - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; - -import { getState, setState } from "../utils/state"; - -dayjs.extend(advancedFormat); -dayjs.extend(utc); - -export const data = new SlashCommandBuilder() - .setName("birthday") - .setDescription("Let me remember your birthday!") - .addStringOption((option) => - option - .setName("month") - .setDescription("Month of your birthday") - .addChoices( - { name: "January", value: "0" }, - { name: "February", value: "1" }, - { name: "March", value: "2" }, - { name: "April", value: "3" }, - { name: "May", value: "4" }, - { name: "June", value: "5" }, - { name: "July", value: "6" }, - { name: "August", value: "7" }, - { name: "September", value: "8" }, - { name: "October", value: "9" }, - { name: "November", value: "10" }, - { name: "December", value: "11" }, - ) - .setRequired(true), - ) - .addIntegerOption((option) => - option - .setName("day") - .setDescription("Day of your birthday") - .setMinValue(1) - .setMaxValue(31) - .setRequired(true), - ); - -export async function command(interaction: ChatInputCommandInteraction) { - const { options } = interaction; - - const month = options.getString("month", true); - const day = options.getInteger("day", true); - - if (!month) { - await interaction.reply({ - content: "Please provide a month", - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (!day) { - await interaction.reply({ - content: "Please provide a day", - flags: MessageFlags.Ephemeral, - }); - return; - } - - const monthsWith31Days = ["0", "2", "4", "6", "7", "9", "11"]; - - if (!monthsWith31Days.includes(month) && day === 31) { - await interaction.reply({ - content: "Invalid day for this month", - flags: MessageFlags.Ephemeral, - }); - return; - } - - const date = dayjs.utc(0).set("month", Number(month)).set("date", day); - - try { - await setBirthday(interaction.user.id, date.toDate()); - } catch (error) { - console.error(error); - await interaction.reply({ - content: "failed to set birthday :( please let ray know", - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (date.isSame(dayjs.utc(), "day")) { - await interaction.reply({ - content: "happy birthday!!! :D added your birthday!", - flags: MessageFlags.Ephemeral, - }); - return; - } - - await interaction.reply({ - content: `added your birthday as ${date.format("MMMM Do").toLowerCase()}! :)`, - flags: MessageFlags.Ephemeral, - }); -} - -async function setBirthday(userId: string, date: Date) { - const state = await getState(); - const birthdays = state.birthdays ?? []; - - const userBirthday = birthdays.find((b) => b.userId === userId); - - if (userBirthday) { - userBirthday.date = date.toISOString(); - } else { - birthdays.push({ - userId: userId, - date: date.toISOString(), - }); - } - - await setState({ ...state, birthdays }); -} - -export async function getBirthdaysToday(date?: Dayjs) { - const state = await getState(); - const birthdays = state.birthdays ?? []; - - return birthdays.filter((b) => { - const birthday = dayjs.utc(b.date); - const day = date ? date.date() : dayjs.utc().date(); - const month = date ? date.month() : dayjs.utc().month(); - - return birthday.date() === day && birthday.month() === month; - }); -} - -export function generateBirthdayMessage(userId: string) { - const rats = [ - // "636701123620634653", // @rayhanadev - "1323107429164122225", // @theshadoweevee - ]; - - if (rats.includes(userId)) { - return `Rats, rats, we are the rats, celebrating yet another birthday bash. <@${userId}>, it's your birthday today. Cake and ice-cream is on its way, and ${userId}'s been such a good boy this year! Open up your gifts while we all cheer!`; - } - - return `<@${userId}> happy birthday!!! :D`; -} diff --git a/src/commands/index.ts b/src/commands/index.ts index 67aa7d1..13f75fd 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -3,10 +3,6 @@ import type { SlashCommandOptionsOnlyBuilder, } from "discord.js"; -import * as adminCheckBirthdays from "./admin_check_birthdays"; -import * as adminGetCurrentTime from "./admin_get_current_time"; -import * as birthday from "./birthday"; -import * as removeBirthday from "./remove_birthday"; import * as summarize from "./summarize"; type Command = { @@ -14,10 +10,4 @@ type Command = { command: (interaction: ChatInputCommandInteraction) => Promise; }; -export const commands: Command[] = [ - adminCheckBirthdays, - adminGetCurrentTime, - birthday, - removeBirthday, - summarize, -]; +export const commands: Command[] = [summarize]; diff --git a/src/commands/remove_birthday.ts b/src/commands/remove_birthday.ts deleted file mode 100644 index e4b923a..0000000 --- a/src/commands/remove_birthday.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - MessageFlags, - SlashCommandBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; - -import { getState, setState } from "../utils/state"; - -export const data = new SlashCommandBuilder() - .setName("rm_birthday") - .setDescription("Make me forget your birthday!"); - -export async function command(interaction: ChatInputCommandInteraction) { - const { options } = interaction; - - const state = await getState(); - - if (!state.birthdays) { - await interaction.reply({ - content: "i don't have any birthdays?? oopsie ask ray for help", - flags: MessageFlags.Ephemeral, - }); - return; - } - - const user = state.birthdays.find((b) => b.userId === interaction.user.id); - - if (!user) { - await interaction.reply({ - content: "i already forgot your birthday! you goober!", - flags: MessageFlags.Ephemeral, - }); - return; - } - - const newBirthdays = state.birthdays.filter( - (b) => b.userId !== interaction.user.id, - ); - - await setState({ ...state, birthdays: newBirthdays }); - - await interaction.reply({ - content: "i forgot your birthday! you're freeeeee!", - flags: MessageFlags.Ephemeral, - }); -} diff --git a/src/utils/hack-night.ts b/src/crons/hack-night-photo-cleanup.ts similarity index 66% rename from src/utils/hack-night.ts rename to src/crons/hack-night-photo-cleanup.ts index 9dbd1af..c6ad178 100644 --- a/src/utils/hack-night.ts +++ b/src/crons/hack-night-photo-cleanup.ts @@ -1,83 +1,19 @@ -import { - ChannelType, - ThreadAutoArchiveDuration, - type Client, -} from "discord.js"; +import { schedule } from "node-cron"; +import { type Client, ChannelType } from "discord.js"; import { HACK_NIGHT_CHANNEL_ID, HACK_NIGHT_PHOTOGRAPHY_AWARD_ROLE_ID, -} from "./consts"; - -// TODO(@rayhanadev): add more fun messages -const HACK_NIGHT_MESSAGES = [ - "Happy Hack Night! :D", - "Welcome to Hack Night! :D", - "Hack Night is here! :D", - "It's Hack Night! :D", - "Hack Night is starting! :D", - "Let's get hacking! :D", - "Time to hack! :D", - "Hack Night is live! :D", - "Hack Night is a go! :D", -]; -const SEND_LEADERBOARD_MESSAGE = true; - -export function createHackNightImagesThread(client: Client) { - return async function () { - const channel = client.channels.cache.get(HACK_NIGHT_CHANNEL_ID); - - if (!channel) { - console.error("Could not find channel: #hack-night"); - return; - } - - if (!channel.isSendable()) { - console.error("Cannot send messages to #hack-night"); - return; - } - - const message = await channel.send({ - content: `${HACK_NIGHT_MESSAGES[Math.floor(Math.random() * HACK_NIGHT_MESSAGES.length)]} 🎉` - +`\n\n<@&1348025087894355979>: Share your pictures from the day in this thread!`, - }); +} from "../utils/consts"; - if (!message) { - console.error("Could not create Hack Night images thread"); - return; - } - - await message.pin(); - - const dateObj = new Date(); - const date = `${(1 + dateObj.getMonth() + "").padStart(2, "0")}/${(dateObj.getDate() + "").padStart(2, "0")}`; - - await message.startThread({ - name: `Hack Night Images - ${date}`, - autoArchiveDuration: ThreadAutoArchiveDuration.OneDay, - }); - - const pinnedMessage = await channel.messages.fetch({ limit: 1 }); - - if (!pinnedMessage) { - console.error("Could not fetch last message"); - return; - } - - const systemMessage = pinnedMessage.first(); - - if (!systemMessage) { - console.error("Could not find last message"); - return; - } - - await systemMessage.delete(); +const SEND_LEADERBOARD_MESSAGE = true; - console.log("Created Hack Night images thread"); - }; +export default async function startTask(client: Client) { + const task = schedule("0 18 * * 7", handler(client)); + return task.start(); } -export function cleanupHackNightImagesThread(client: Client) { +function handler(client: Client) { return async function () { const channel = client.channels.cache.get(HACK_NIGHT_CHANNEL_ID); @@ -173,7 +109,7 @@ export function cleanupHackNightImagesThread(client: Client) { await channel.send({ content: `Our top contributors this week are:\n${topContributors .map(([id, count], index) => `\n#${index + 1}: <@${id}> - ${count}`) - .join("")}` + .join("")}`, }); } diff --git a/src/crons/hack-night-photos.ts b/src/crons/hack-night-photos.ts new file mode 100644 index 0000000..7e25dd6 --- /dev/null +++ b/src/crons/hack-night-photos.ts @@ -0,0 +1,77 @@ +import { schedule } from "node-cron"; +import { ThreadAutoArchiveDuration, type Client } from "discord.js"; + +import { HACK_NIGHT_CHANNEL_ID } from "../utils/consts"; + +// TODO(@rayhanadev): add more fun messages +const HACK_NIGHT_MESSAGES = [ + "Happy Hack Night! :D", + "Welcome to Hack Night! :D", + "Hack Night is here! :D", + "It's Hack Night! :D", + "Hack Night is starting! :D", + "Let's get hacking! :D", + "Time to hack! :D", + "Hack Night is live! :D", + "Hack Night is a go! :D", +]; + +export default async function startTask(client: Client) { + const task = schedule("0 20 * * 5", handler(client)); + return task.start(); +} + +function handler(client: Client) { + return async function () { + const channel = client.channels.cache.get(HACK_NIGHT_CHANNEL_ID); + + if (!channel) { + console.error("Could not find channel: #hack-night"); + return; + } + + if (!channel.isSendable()) { + console.error("Cannot send messages to #hack-night"); + return; + } + + const message = await channel.send({ + content: + `${HACK_NIGHT_MESSAGES[Math.floor(Math.random() * HACK_NIGHT_MESSAGES.length)]} 🎉` + + `\n\n<@&1348025087894355979>: Share your pictures from the day in this thread!`, + }); + + if (!message) { + console.error("Could not create Hack Night images thread"); + return; + } + + await message.pin(); + + const dateObj = new Date(); + const date = `${(1 + dateObj.getMonth() + "").padStart(2, "0")}/${(dateObj.getDate() + "").padStart(2, "0")}`; + + await message.startThread({ + name: `Hack Night Images - ${date}`, + autoArchiveDuration: ThreadAutoArchiveDuration.OneDay, + }); + + const pinnedMessage = await channel.messages.fetch({ limit: 1 }); + + if (!pinnedMessage) { + console.error("Could not fetch last message"); + return; + } + + const systemMessage = pinnedMessage.first(); + + if (!systemMessage) { + console.error("Could not find last message"); + return; + } + + await systemMessage.delete(); + + console.log("Created Hack Night images thread"); + }; +} diff --git a/src/crons/index.ts b/src/crons/index.ts new file mode 100644 index 0000000..56905bf --- /dev/null +++ b/src/crons/index.ts @@ -0,0 +1,4 @@ +import hackNightPhotos from "./hack-night-photos"; +import hackNightPhotosCleanup from "./hack-night-photo-cleanup"; + +export const tasks = [hackNightPhotos, hackNightPhotosCleanup]; diff --git a/src/env.ts b/src/env.ts index c8095f4..bf21ee6 100644 --- a/src/env.ts +++ b/src/env.ts @@ -3,15 +3,11 @@ import { z } from "zod"; export const env = createEnv({ server: { - AWS_ACCESS_KEY_ID: z.string(), - AWS_ENDPOINT_URL_S3: z.string(), - AWS_REGION: z.string(), - AWS_SECRET_ACCESS_KEY: z.string(), - BUCKET_NAME: z.string(), DISCORD_CLIENT_ID: z.string(), DISCORD_BOT_TOKEN: z.string(), GROQ_API_KEY: z.string(), GITHUB_TOKEN: z.string(), + PHACK_API_TOKEN: z.string(), TZ: z.string().default("America/Indiana/Indianapolis"), }, runtimeEnv: Bun.env, diff --git a/src/events/index.ts b/src/events/index.ts index e01388b..3b7ed9f 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -1,3 +1,3 @@ import * as messageCreate from "./message_create"; -export { messageCreate }; +export const events = [messageCreate]; diff --git a/src/events/message_create/dashboard.ts b/src/events/message_create/dashboard.ts new file mode 100644 index 0000000..e9b5f31 --- /dev/null +++ b/src/events/message_create/dashboard.ts @@ -0,0 +1,21 @@ +import { type Message } from "discord.js"; +import { connectToApi, sendDashboardMessage } from "../../utils/phack"; +import { env } from "../../env"; + +const client = await connectToApi(); + +export default async function handler(message: Message) { + if (message.author.bot) return; + if (message.channel.isDMBased()) return; + + await sendDashboardMessage(client, { + image: message.author.avatarURL(), + timestamp: message.createdAt.toISOString(), + username: message.author.username, + content: message.content, + attachments: + message.attachments.size > 0 + ? [...message.attachments.entries().map(([, { url }]) => url)] + : undefined, + }); +} diff --git a/src/events/message_create.ts b/src/events/message_create/evergreen-it.ts similarity index 55% rename from src/events/message_create.ts rename to src/events/message_create/evergreen-it.ts index 2c7b801..6f1c396 100644 --- a/src/events/message_create.ts +++ b/src/events/message_create/evergreen-it.ts @@ -1,19 +1,13 @@ -import { Events, MessageFlags, type Message } from "discord.js"; -import Groq from "groq-sdk"; +import { type Message } from "discord.js"; -import { env } from "../env"; import { - ORGANIZER_ROLE_ID, BISHOP_ROLE_ID, + ORGANIZER_ROLE_ID, EVERGREEN_CREATE_ISSUE_STRING, -} from "../utils/consts"; -import { createGithubIssue, getAssociationsFile } from "../utils/github"; - -const groq = new Groq({ apiKey: env.GROQ_API_KEY }); +} from "../../utils/consts"; +import { createGithubIssue, getAssociationsFile } from "../../utils/github"; -export const eventType = Events.MessageCreate; - -export async function evergreenIssueWorkflow(message: Message) { +export default async function handler(message: Message) { if (message.author.bot) return; if (message.channel.isDMBased()) return; @@ -28,8 +22,6 @@ export async function evergreenIssueWorkflow(message: Message) { if (!message.content.toLowerCase().startsWith(EVERGREEN_CREATE_ISSUE_STRING)) return; - - let original: Message; if (!message.reference || !message.reference.messageId) { @@ -54,7 +46,10 @@ export async function evergreenIssueWorkflow(message: Message) { let title = `Evergreen request from @${people[message.author.id] ?? message.author.tag} in #${message.channel.name}`; if (message.content.match(/^evergreen it\s?/i)) { - title = (message.content.replace(/^evergreen it\s?/i, "") + ` - @${people[message.author.id] ?? message.author.tag} in #${message.channel.name}`).substring(0, 255) // Limit 0-255 to accomadate Github's 256 Issue Title Length Limit + title = ( + message.content.replace(/^evergreen it\s?/i, "") + + ` - @${people[message.author.id] ?? message.author.tag} in #${message.channel.name}` + ).substring(0, 255); // Limit 0-255 to accomadate Github's 256 Issue Title Length Limit } const body = `**@${people[original.author.id] ?? original.author.tag}**[^1] said in **[#${message.channel.name}](<${message.url}>)**: @@ -71,35 +66,3 @@ ${original.content await message.reply(`Created issue: ${html_url}`); } - -export async function voiceMessageTranscription(message: Message) { - if (message.author.bot) return; - if (message.channel.isDMBased()) return; - if (!message.flags.has(MessageFlags.IsVoiceMessage)) return; - - await message.react("🎙️"); - - const audioFile = message.attachments.find( - (m) => m.name === "voice-message.ogg", - ); - if (!audioFile) return; - - const file = await fetch(audioFile.url); - - const response = await groq.audio.transcriptions.create({ - file, - model: "whisper-large-v3", - language: "en", - }); - - if (!response.text) { - await message.reply({ - content: "Sorry, I couldn't transcribe that audio message.", - }); - return; - } - - await message.reply({ - content: response.text.trim(), - }); -} diff --git a/src/events/message_create/index.ts b/src/events/message_create/index.ts new file mode 100644 index 0000000..7c63a15 --- /dev/null +++ b/src/events/message_create/index.ts @@ -0,0 +1,8 @@ +import { Events } from "discord.js"; + +import dashboard from "./dashboard"; +import evergreenIt from "./evergreen-it"; +import voiceMessageTranscription from "./voice-transcription"; + +export const eventType = Events.MessageCreate; +export { dashboard, evergreenIt, voiceMessageTranscription }; diff --git a/src/events/message_create/voice-transcription.ts b/src/events/message_create/voice-transcription.ts new file mode 100644 index 0000000..40c6dc8 --- /dev/null +++ b/src/events/message_create/voice-transcription.ts @@ -0,0 +1,38 @@ +import { MessageFlags, type Message } from "discord.js"; +import Groq from "groq-sdk"; + +import { env } from "../../env"; + +const groq = new Groq({ apiKey: env.GROQ_API_KEY }); + +export default async function handler(message: Message) { + if (message.author.bot) return; + if (message.channel.isDMBased()) return; + if (!message.flags.has(MessageFlags.IsVoiceMessage)) return; + + await message.react("🎙️"); + + const audioFile = message.attachments.find( + (m) => m.name === "voice-message.ogg", + ); + if (!audioFile) return; + + const file = await fetch(audioFile.url); + + const response = await groq.audio.transcriptions.create({ + file, + model: "whisper-large-v3", + language: "en", + }); + + if (!response.text) { + await message.reply({ + content: "Sorry, I couldn't transcribe that audio message.", + }); + return; + } + + await message.reply({ + content: response.text.trim(), + }); +} diff --git a/src/index.ts b/src/index.ts index bfdfbc1..9a86a37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,16 +7,10 @@ import { REST, Routes, } from "discord.js"; -import cron from "node-cron"; +import { tasks } from "./crons"; +import { events } from "./events"; import { commands } from "./commands"; -import { messageCreate } from "./events"; - -import { checkBirthdays } from "./utils/birthdays"; -import { - createHackNightImagesThread, - cleanupHackNightImagesThread, -} from "./utils/hack-night"; import { env } from "./env"; @@ -26,7 +20,6 @@ try { console.log("Started refreshing application (/) commands."); const body = commands.map(({ data }) => data.toJSON()); - await rest.put(Routes.applicationCommands(env.DISCORD_CLIENT_ID), { body }); console.log("Successfully reloaded application (/) commands."); @@ -43,6 +36,19 @@ const client = new Client({ ], }); +client.on(Events.ClientReady, (event) => { + console.log(`Logged in as ${event.user.tag}!`); + + client.user?.setActivity({ + name: "eggz", + type: ActivityType.Watching, + }); + + for (const startTask of tasks) { + startTask(event); + } +}); + client.on(Events.InteractionCreate, async (interaction) => { if (!interaction.isChatInputCommand()) return; @@ -76,35 +82,12 @@ client.on(Events.InteractionCreate, async (interaction) => { } }); -const checkBirthdaysTask = cron.schedule("1 0 * * *", checkBirthdays(client)); -const createHackNightImagesThreadTask = cron.schedule( - "0 20 * * 5", - createHackNightImagesThread(client), -); -const cleanupHackNightImagesThreadTask = cron.schedule( - "0 18 * * 7", - cleanupHackNightImagesThread(client), -); - -client.on(Events.ClientReady, (event) => { - console.log(`Logged in as ${event.user.tag}!`); - - client.user?.setActivity({ - name: "eggz", - type: ActivityType.Watching, +for (const event of events) { + const { eventType, ...handlers } = event; + client.on(eventType, async (e) => { + await Promise.allSettled(Object.values(handlers).map((func) => func(e))); }); - - checkBirthdaysTask.start(); -# createHackNightImagesThreadTask.start(); -# cleanupHackNightImagesThreadTask.start(); -}); - -client.on(messageCreate.eventType, async (message) => { - await Promise.allSettled([ - messageCreate.evergreenIssueWorkflow(message), - messageCreate.voiceMessageTranscription(message), - ]); -}); +} client.login(env.DISCORD_BOT_TOKEN); diff --git a/src/utils/birthdays.ts b/src/utils/birthdays.ts deleted file mode 100644 index 8da4121..0000000 --- a/src/utils/birthdays.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Client } from "discord.js"; - -import { - getBirthdaysToday, - generateBirthdayMessage, -} from "../commands/birthday"; -import { LOUNGE_CHANNEL_ID } from "./consts"; - -export function checkBirthdays(client: Client) { - return async function () { - const birthdays = await getBirthdaysToday(); - - if (birthdays.length === 0) return; - - const channel = client.channels.cache.get(LOUNGE_CHANNEL_ID); - - if (!channel) { - console.error("Could not find channel: #lounge"); - return; - } - - if (!channel.isSendable()) { - console.error("Cannot send messages to #lounge"); - return; - } - - for (const birthday of birthdays) { - channel.send({ - content: generateBirthdayMessage(birthday.userId), - }); - } - }; -} diff --git a/src/utils/phack.ts b/src/utils/phack.ts new file mode 100644 index 0000000..7ebc2b3 --- /dev/null +++ b/src/utils/phack.ts @@ -0,0 +1,66 @@ +import { env } from "../env"; + +const ENDPOINT = "https://api.purduehackers.com/discord/bot"; + +export async function connectToApi(timeout = 3000) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(ENDPOINT); + + const fail = (reason: string) => { + cleanup(); + if (ws.readyState === WebSocket.OPEN) ws.close(); + reject(new Error(reason)); + }; + + const cleanup = () => { + clearTimeout(killTimer); + ws.onmessage = null; + ws.onclose = null; + ws.onerror = null; + }; + + const killTimer = setTimeout( + () => fail("Auth timeout – no reply from server"), + timeout, + ); + + ws.onerror = () => fail("WebSocket error during handshake"); + ws.onclose = () => fail("Socket closed before auth completed"); + + ws.onopen = () => { + ws.send(JSON.stringify({ token: env.PHACK_API_TOKEN })); + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data as string); + if (msg?.auth === "complete") { + cleanup(); + resolve(ws); + } else if (msg?.auth === "rejected") { + fail("Auth rejected by server"); + } + } catch { + /* ignore non-JSON frames until auth finishes */ + } + }; + }; + }); +} + +export interface DiscordMessage { + image: string | null; + timestamp: string; + username: string; + content: string; + attachments?: string[]; +} + +export async function sendDashboardMessage( + client: WebSocket, + message: DiscordMessage, +) { + if (client.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket is not open"); + } + + client.send(JSON.stringify(message)); +} diff --git a/src/utils/state.ts b/src/utils/state.ts deleted file mode 100644 index 65b8a6e..0000000 --- a/src/utils/state.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Readable } from "node:stream"; - -import { - S3Client, - PutObjectCommand, - GetObjectCommand, -} from "@aws-sdk/client-s3"; - -import { env } from "../env"; - -const s3 = new S3Client({ - region: env.AWS_REGION, - credentials: { - accessKeyId: env.AWS_ACCESS_KEY_ID, - secretAccessKey: env.AWS_SECRET_ACCESS_KEY, - }, -}); - -type Birthday = { - userId: string; - date: string; -}; - -type State = { - birthdays?: Birthday[]; -}; - -export async function getState(): Promise { - const { Body } = await s3.send( - new GetObjectCommand({ - Bucket: env.BUCKET_NAME, - Key: "state.json", - }), - ); - - if (!Body || !(Body instanceof Readable)) { - throw new Error("Invalid body"); - } - - const body = await streamToString(Body); - - return JSON.parse(body); -} - -export async function setState(state: State): Promise { - await s3.send( - new PutObjectCommand({ - Bucket: env.BUCKET_NAME, - Key: "state.json", - Body: JSON.stringify(state), - ContentType: "application/json", - }), - ); -} - -const streamToString = (stream: Readable): Promise => - new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - - stream.on("data", (chunk) => chunks.push(chunk)); - stream.on("error", reject); - stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - });