diff --git a/package.json b/package.json index 7c9713d..7de76b2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@nhcarrigan/prettier-config": "3.2.0", "@nhcarrigan/typescript-config": "3.0.0", "@types/express": "4.17.21", + "@types/node-schedule": "2.1.7", "eslint": "8.57.0", "knip": "5.15.0", "prettier": "3.2.5", @@ -42,6 +43,7 @@ "dotenv": "16.4.5", "express": "4.19.2", "node-html-to-image": "4.0.0", + "node-schedule": "2.1.1", "winston": "3.13.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99a6485..7614c81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: node-html-to-image: specifier: 4.0.0 version: 4.0.0 + node-schedule: + specifier: 2.1.1 + version: 2.1.1 winston: specifier: 3.13.0 version: 3.13.0 @@ -42,6 +45,9 @@ importers: '@types/express': specifier: 4.17.21 version: 4.17.21 + '@types/node-schedule': + specifier: 2.1.7 + version: 2.1.7 eslint: specifier: 8.57.0 version: 8.57.0 @@ -323,6 +329,9 @@ packages: '@types/mime@3.0.1': resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==} + '@types/node-schedule@2.1.7': + resolution: {integrity: sha512-G7Z3R9H7r3TowoH6D2pkzUHPhcJrDF4Jz1JOQ80AX0K2DWTHoN9VC94XzFAPNMdbW9TBzMZ3LjpFi7RYdbxtXA==} + '@types/node@20.3.1': resolution: {integrity: sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==} @@ -650,6 +659,10 @@ packages: resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==} engines: {node: '>=14'} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cross-fetch@4.0.0: resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} @@ -1439,6 +1452,9 @@ packages: logform@2.5.1: resolution: {integrity: sha512-9FyqAm9o9NKKfiAKfZoYo9bGXXuwMkxQiQttkT4YjjVtQVIQtK6LmVtlxmCaFswo6N4AfEkHqZTV0taDtPotNg==} + long-timeout@0.1.1: + resolution: {integrity: sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==} + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -1447,6 +1463,10 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + luxon@3.4.4: + resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} + engines: {node: '>=12'} + magic-bytes.js@1.10.0: resolution: {integrity: sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==} @@ -1539,6 +1559,10 @@ packages: node-html-to-image@4.0.0: resolution: {integrity: sha512-lB8fkRleAKG4afJ2Wr7qJzIA5+//ue9OEoz+BMxQsowriGKR8sf4j4lK/pIXKakYwf/3aZHoDUNgOXuJ4HOzYA==} + node-schedule@2.1.1: + resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==} + engines: {node: '>=6'} + object-inspect@1.12.3: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} @@ -1866,6 +1890,9 @@ packages: resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sorted-array-functions@1.3.0: + resolution: {integrity: sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -2338,14 +2365,14 @@ snapshots: '@nhcarrigan/eslint-config@3.2.0(eslint@8.57.0)(prettier@3.2.5)(typescript@5.4.5)': dependencies: - '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0)(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5) '@typescript-eslint/parser': 5.62.0(eslint@8.57.0)(typescript@5.4.5) eslint: 8.57.0 eslint-config-prettier: 9.0.0(eslint@8.57.0) - eslint-plugin-import: 2.28.1(@typescript-eslint/parser@5.62.0)(eslint@8.57.0) + eslint-plugin-import: 2.28.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0) eslint-plugin-jsdoc: 41.1.2(eslint@8.57.0) eslint-plugin-no-only-tests: 3.1.0 - eslint-plugin-prettier: 5.0.0(eslint-config-prettier@9.0.0)(eslint@8.57.0)(prettier@3.2.5) + eslint-plugin-prettier: 5.0.0(eslint-config-prettier@9.0.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.2.5) transitivePeerDependencies: - '@types/eslint' - eslint-import-resolver-typescript @@ -2452,7 +2479,7 @@ snapshots: '@pkgr/core@0.1.1': {} '@prisma/client@5.13.0(prisma@5.13.0)': - dependencies: + optionalDependencies: prisma: 5.13.0 '@prisma/debug@5.13.0': {} @@ -2536,6 +2563,10 @@ snapshots: '@types/mime@3.0.1': {} + '@types/node-schedule@2.1.7': + dependencies: + '@types/node': 20.3.1 + '@types/node@20.3.1': {} '@types/qs@6.9.7': {} @@ -2565,7 +2596,7 @@ snapshots: '@types/node': 20.3.1 optional: true - '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0)(eslint@8.57.0)(typescript@5.4.5)': + '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)': dependencies: '@eslint-community/regexpp': 4.5.1 '@typescript-eslint/parser': 5.62.0(eslint@8.57.0)(typescript@5.4.5) @@ -2579,6 +2610,7 @@ snapshots: natural-compare-lite: 1.4.0 semver: 7.5.2 tsutils: 3.21.0(typescript@5.4.5) + optionalDependencies: typescript: 5.4.5 transitivePeerDependencies: - supports-color @@ -2590,6 +2622,7 @@ snapshots: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.4.5) debug: 4.3.4 eslint: 8.57.0 + optionalDependencies: typescript: 5.4.5 transitivePeerDependencies: - supports-color @@ -2606,6 +2639,7 @@ snapshots: debug: 4.3.4 eslint: 8.57.0 tsutils: 3.21.0(typescript@5.4.5) + optionalDependencies: typescript: 5.4.5 transitivePeerDependencies: - supports-color @@ -2621,6 +2655,7 @@ snapshots: is-glob: 4.0.3 semver: 7.5.2 tsutils: 3.21.0(typescript@5.4.5) + optionalDependencies: typescript: 5.4.5 transitivePeerDependencies: - supports-color @@ -2912,6 +2947,10 @@ snapshots: parse-json: 5.2.0 path-type: 4.0.0 + cron-parser@4.9.0: + dependencies: + luxon: 3.4.4 + cross-fetch@4.0.0: dependencies: node-fetch: 2.7.0 @@ -3202,18 +3241,18 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.7)(eslint@8.57.0): + eslint-module-utils@2.8.0(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.7)(eslint@8.57.0): dependencies: - '@typescript-eslint/parser': 5.62.0(eslint@8.57.0)(typescript@5.4.5) debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 5.62.0(eslint@8.57.0)(typescript@5.4.5) eslint: 8.57.0 eslint-import-resolver-node: 0.3.7 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.28.1(@typescript-eslint/parser@5.62.0)(eslint@8.57.0): + eslint-plugin-import@2.28.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0): dependencies: - '@typescript-eslint/parser': 5.62.0(eslint@8.57.0)(typescript@5.4.5) array-includes: 3.1.6 array.prototype.findlastindex: 1.2.5 array.prototype.flat: 1.3.1 @@ -3222,7 +3261,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.7 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.7)(eslint@8.57.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.7)(eslint@8.57.0) has: 1.0.3 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -3232,6 +3271,8 @@ snapshots: object.values: 1.1.6 semver: 6.3.1 tsconfig-paths: 3.14.2 + optionalDependencies: + '@typescript-eslint/parser': 5.62.0(eslint@8.57.0)(typescript@5.4.5) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -3253,13 +3294,14 @@ snapshots: eslint-plugin-no-only-tests@3.1.0: {} - eslint-plugin-prettier@5.0.0(eslint-config-prettier@9.0.0)(eslint@8.57.0)(prettier@3.2.5): + eslint-plugin-prettier@5.0.0(eslint-config-prettier@9.0.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.2.5): dependencies: eslint: 8.57.0 - eslint-config-prettier: 9.0.0(eslint@8.57.0) prettier: 3.2.5 prettier-linter-helpers: 1.0.0 synckit: 0.8.8 + optionalDependencies: + eslint-config-prettier: 9.0.0(eslint@8.57.0) eslint-scope@5.1.1: dependencies: @@ -3891,12 +3933,16 @@ snapshots: safe-stable-stringify: 2.4.3 triple-beam: 1.3.0 + long-timeout@0.1.1: {} + lru-cache@6.0.0: dependencies: yallist: 4.0.0 lru-cache@7.18.3: {} + luxon@3.4.4: {} + magic-bytes.js@1.10.0: {} magic-string@0.16.0: @@ -3967,6 +4013,12 @@ snapshots: - supports-color - utf-8-validate + node-schedule@2.1.1: + dependencies: + cron-parser: 4.9.0 + long-timeout: 0.1.1 + sorted-array-functions: 1.3.0 + object-inspect@1.12.3: {} object-inspect@1.13.1: {} @@ -4346,6 +4398,8 @@ snapshots: ip-address: 9.0.5 smart-buffer: 4.2.0 + sorted-array-functions@1.3.0: {} + source-map@0.6.1: {} spdx-exceptions@2.3.0: {} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index be7c7c9..8275dce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,6 +60,7 @@ model configs { eventLogChannel String @default("") messageReportChannel String @default("") joinRole String @default("") + birthdayChannel String @default("") @@unique([serverId], map: "serverId") } @@ -80,3 +81,12 @@ model roles { @@unique([serverId, roleId], map: "serverId_roleId") } + +model birthdays { + id String @id @default(auto()) @map("_id") @db.ObjectId + serverId String + userId String + birthday DateTime + + @@unique([serverId, userId], map: "serverId_userId") +} diff --git a/src/commands/birthday.ts b/src/commands/birthday.ts new file mode 100644 index 0000000..654368d --- /dev/null +++ b/src/commands/birthday.ts @@ -0,0 +1,143 @@ +import { SlashCommandBuilder } from "discord.js"; + +import { Command } from "../interfaces/Command"; +import { errorHandler } from "../utils/errorHandler"; + +/** + * Validates that the day provided is a valid day of the month. + * + * @param {string} month The month to validate. + * @param {number} day The day to validate. + * @returns {boolean} True if the day is within the month's range. + */ +const validateDate = (month: string, day: number): boolean => { + switch (month) { + case "Jan": + case "Mar": + case "May": + case "Jul": + case "Aug": + case "Oct": + case "Dec": + return day >= 1 && day <= 31; + case "Feb": + return day >= 1 && day <= 29; + case "Apr": + case "Jun": + case "Sep": + case "Nov": + return day >= 1 && day <= 30; + default: + return false; + } +}; + +export const bbset: Command = { + data: new SlashCommandBuilder() + .setName("bbset") + .setDescription("Set your birthday!") + .addStringOption((option) => + option + .setName("month") + .setDescription("Your Birth Month") + .setRequired(true) + .setChoices( + { + name: "January", + value: "Jan" + }, + { + name: "February", + value: "Feb" + }, + { + name: "March", + value: "Mar" + }, + { + name: "April", + value: "Apr" + }, + { + name: "May", + value: "May" + }, + { + name: "June", + value: "Jun" + }, + { + name: "July", + value: "Jul" + }, + { + name: "August", + value: "Aug" + }, + { + name: "September", + value: "Sep" + }, + { + name: "October", + value: "Oct" + }, + { + name: "November", + value: "Nov" + }, + { + name: "December", + value: "Dec" + } + ) + ) + .addIntegerOption((option) => + option + .setName("day") + .setDescription("Your Birth Day (1-31)") + .setRequired(true) + .setMinValue(1) + .setMaxValue(31) + ), + run: async (bot, interaction) => { + try { + await interaction.deferReply(); + const month = interaction.options.getString("month", true); + const day = interaction.options.getInteger("day", true); + + if (!validateDate(month, day)) { + await interaction.editReply({ + content: `${month} ${day} is not a valid date!` + }); + return; + } + + await bot.db.birthdays.upsert({ + where: { + serverId_userId: { + serverId: interaction.guild.id, + userId: interaction.user.id + } + }, + update: { + birthday: new Date(`${month}-${day}-2000`) + }, + create: { + serverId: interaction.guild.id, + userId: interaction.user.id, + birthday: new Date(`${month}-${day}-2000`) + } + }); + + await interaction.editReply( + `Your birthday has been set to ${month}-${day}!` + ); + } catch (err) { + const id = await errorHandler(bot, "birthday command", err); + await interaction.editReply({ + content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\`` + }); + } + } +}; diff --git a/src/commands/config.ts b/src/commands/config.ts index fa1e9f5..8029485 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -11,6 +11,7 @@ import { Command } from "../interfaces/Command"; import { CommandHandler } from "../interfaces/CommandHandler"; import { getConfig } from "../modules/data/getConfig"; import { handleAppealLink } from "../modules/subcommands/config/handleAppealLink"; +import { handleBirthdayChannel } from "../modules/subcommands/config/handleBirthdayChannel"; import { handleInviteLink } from "../modules/subcommands/config/handleInviteLink"; import { handleJoinRole } from "../modules/subcommands/config/handleJoinRole"; import { handleList } from "../modules/subcommands/config/handleList"; @@ -24,7 +25,8 @@ const handlers: { [key: string]: CommandHandler } = { "appeal-link": handleAppealLink, logging: handleLogging, role: handleRole, - "join-role": handleJoinRole + "join-role": handleJoinRole, + "birthday-channel": handleBirthdayChannel }; export const config: Command = { @@ -101,6 +103,19 @@ export const config: Command = { .setDescription("The role to assign.") .setRequired(true) ) + ) + .addSubcommand( + new SlashCommandSubcommandBuilder() + .setName("birthday-channel") + .setDescription( + "Configure a channel where members can be wished a happy birthday." + ) + .addChannelOption((o) => + o + .setName("channel") + .setDescription("The channel to send birthday messages in.") + .setRequired(true) + ) ), run: async (bot, interaction) => { try { diff --git a/src/events/client/onReady.ts b/src/events/client/onReady.ts index a92e418..42dd95f 100644 --- a/src/events/client/onReady.ts +++ b/src/events/client/onReady.ts @@ -1,4 +1,7 @@ +import { scheduleJob } from "node-schedule"; + import { ExtendedClient } from "../../interfaces/ExtendedClient"; +import { postBirthdays } from "../../modules/postBirthdays"; import { registerCommands } from "../../utils/registerCommands"; import { sendDebugMessage } from "../../utils/sendDebugMessage"; @@ -10,4 +13,7 @@ import { sendDebugMessage } from "../../utils/sendDebugMessage"; export const onReady = async (bot: ExtendedClient) => { await sendDebugMessage(bot, `Logged in as ${bot.user?.tag}`); await registerCommands(bot); + + // Daily at 9am PST + scheduleJob("birthdays", "0 9 * * *", async () => await postBirthdays(bot)); }; diff --git a/src/events/member/onMemberRemove.ts b/src/events/member/onMemberRemove.ts index 5799536..3150e66 100644 --- a/src/events/member/onMemberRemove.ts +++ b/src/events/member/onMemberRemove.ts @@ -20,6 +20,12 @@ export const onMemberRemove = async ( const config = await getConfig(bot, guild.id); + await bot.db.birthdays.delete({ + where: { + serverId_userId: { serverId: guild.id, userId: user.id } + } + }); + if (!config.eventLogChannel) { return; } diff --git a/src/modules/postBirthdays.ts b/src/modules/postBirthdays.ts new file mode 100644 index 0000000..4895f83 --- /dev/null +++ b/src/modules/postBirthdays.ts @@ -0,0 +1,41 @@ +import { ExtendedClient } from "../interfaces/ExtendedClient"; +import { errorHandler } from "../utils/errorHandler"; + +/** + * Fetches the configs from the database, then for each config that + * has a birthday channel set, fetch birthdays. If any are from today, + * post! + * + * @param {ExtendedClient} bot The bot's Discord instance. + */ +export const postBirthdays = async (bot: ExtendedClient) => { + try { + const configs = await bot.db.configs.findMany(); + const withChannel = configs.filter((c) => c.birthdayChannel); + for (const record of withChannel) { + const guild = bot.guilds.cache.get(record.serverId); + if (!guild) { + continue; + } + const channel = guild.channels.cache.get(record.birthdayChannel); + if (!channel || !("send" in channel)) { + continue; + } + + const hasBirthdaySet = await bot.db.birthdays.findMany({ + where: { serverId: guild.id } + }); + const today = new Date(); + const todayIn2000 = new Date( + `2000-${today.getMonth() + 1}-${today.getDate()}` + ); + const isBirthdayToday = hasBirthdaySet.filter( + (r) => r.birthday === todayIn2000 + ); + const names = isBirthdayToday.map((r) => `<@${r.userId}>`).join(", "); + await channel.send(`Happy birthday to these lovely people~!\n${names}`); + } + } catch (err) { + await errorHandler(bot, "post birthdays", err); + } +}; diff --git a/src/modules/subcommands/config/handleBirthdayChannel.ts b/src/modules/subcommands/config/handleBirthdayChannel.ts new file mode 100644 index 0000000..6c54a28 --- /dev/null +++ b/src/modules/subcommands/config/handleBirthdayChannel.ts @@ -0,0 +1,52 @@ +import { PermissionFlagsBits } from "discord.js"; + +import { CommandHandler } from "../../../interfaces/CommandHandler"; +import { errorHandler } from "../../../utils/errorHandler"; +import { setConfig } from "../../data/setConfig"; + +/** + * Sets the birthday channel for the server. + */ +export const handleBirthdayChannel: CommandHandler = async ( + bot, + interaction +) => { + try { + const channel = interaction.options.getChannel("channel", true); + if (!("send" in channel)) { + await interaction.editReply({ + content: "You must specify a text channel!" + }); + return; + } + const me = await interaction.guild.members.fetchMe(); + if (!me.permissionsIn(channel).has(PermissionFlagsBits.SendMessages)) { + await interaction.editReply({ + content: "I can't send messages there. :c" + }); + return; + } + + const success = await setConfig( + bot, + interaction.guild.id, + "birthdayChannel", + channel.id + ); + + if (success) { + await interaction.editReply({ + content: `Birthdays will be posted in ${channel.toString()}. Members can set their birthdays with the \`/birthday\` command.` + }); + return; + } + await interaction.editReply({ + content: "Failed to set the settings." + }); + } catch (err) { + const id = await errorHandler(bot, "automod logging subcommand", err); + await interaction.editReply({ + content: `Something went wrong. Please [join our support server](https://chat.naomi.lgbt) and provide this ID: \`${id}\`` + }); + } +};