diff --git a/.gitignore b/.gitignore index 062cec5..a103daa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ main.db main.db-shm main.db-wal migrations + +.idea \ No newline at end of file diff --git a/package.json b/package.json index 8066142..5532f25 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "module": "src/index.ts", "type": "module", "devDependencies": { - "better-sqlite3": "^8.5.1", - "bun-types": "^0.7.3", + "better-sqlite3": "^8.5.2", + "bun-types": "^0.8.0", "drizzle-kit": "^0.19.13", "rome": "^12.1.3" }, @@ -15,7 +15,7 @@ "bufferutil": "^4.0.7", "discord.js": "^14.13.0", "djs-fsrouter": "^0.0.4", - "drizzle-orm": "^0.28.3", + "drizzle-orm": "^0.28.5", "erlpack": "^0.1.4", "utf-8-validate": "^6.0.3", "zlib-sync": "^0.1.8" diff --git a/src/Exports.ts b/src/Exports.ts new file mode 100644 index 0000000..0127752 --- /dev/null +++ b/src/Exports.ts @@ -0,0 +1,10 @@ +import {Client, GatewayIntentBits} from "discord.js"; + +export const client: Client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildMembers, + ], +}); \ No newline at end of file diff --git a/src/commands/info.ts b/src/commands/info.ts index 2a8b65a..61da665 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -1,18 +1,17 @@ +// noinspection JSUnusedGlobalSymbols + import { - type ChatInputCommandInteraction, type APIEmbed, - ApplicationCommandType, channelMention, } from "discord.js"; import type { Command } from "djs-fsrouter"; -export const type = ApplicationCommandType.ChatInput; const Info: Command = { description: "Get info about the bot and server", defaultMemberPermissions: "0", async run(interaction) { if (!interaction.guild) { - interaction.reply({ + await interaction.reply({ content: "Run this command in a server to get server info", ephemeral: true, }); diff --git a/src/commands/qotw-date.ts b/src/commands/qotw-date.ts new file mode 100644 index 0000000..00f978a --- /dev/null +++ b/src/commands/qotw-date.ts @@ -0,0 +1,49 @@ +// noinspection JSUnusedGlobalSymbols + +import type {Command} from "djs-fsrouter"; +import type { + ChatInputCommandInteraction, + ApplicationCommandOptionData, + InteractionReplyOptions +} from "discord.js"; +import {EmbedBuilder, GuildMemberRoleManager, Role, SlashCommandStringOption,} from "discord.js"; +import {type QotwDate, QotwReminderManager} from "../types/qotw.js"; +import type {IntRange} from "../types/IntRange.js"; + +export default { + dmPermission: false, + description: "lets you change the next QOTW reminder date.", + options: [new SlashCommandStringOption().setName("time").setDescription("the time formatted as dd:hh:mm").setRequired(true)] as ApplicationCommandOptionData[], + + async run(interaction: ChatInputCommandInteraction): Promise { + function errMsg(title: string, desc: string): InteractionReplyOptions { + return {embeds: [new EmbedBuilder().setTitle(title).setColor(0xFF0000).setDescription(desc)], ephemeral: true}; + } + + if (!(interaction.member!.roles).cache.find(r => r.id === "1140826891599757382" || r.id === "722933309633462274")) { + await interaction.reply(errMsg("Insufficient permissions", "You require the following roles: `Owner` | `QOTW Manger`")); + return; + } + + const timeRegex: RegExpExecArray | null = /^(\d+):(\d+):(\d+)$/gm.exec(interaction.options.getString("time")!); + + if (!timeRegex || timeRegex.length !== 4) { + await interaction.reply(errMsg("Invalid time", "The time format should follow the `1-7:1-24:0-59` format.")); + return; + } + + const newDate: QotwDate = { + weekDay: parseInt(timeRegex[1]) as IntRange<1, 7>, + hour: parseInt(timeRegex[2]) as IntRange<1, 24>, + minute: parseInt(timeRegex[3]) as IntRange<0, 59> + } + + QotwReminderManager.uploadDate(newDate); + + await interaction.reply({embeds: [new EmbedBuilder() + .setTitle("Success") + .setColor(0x00FF00) + .setDescription(`Set the new date for reminder to:\`\`\`\nday of the week: ${newDate.weekDay}\nhour of the day: ${newDate.hour}:${newDate.minute}\`\`\``) + ]}); + } +} as Command; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 584010c..e06d826 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,5 @@ import loadCommands from "djs-fsrouter"; import { - Client, - GatewayIntentBits, Events, type ClientEvents, type Awaitable, @@ -10,15 +8,7 @@ import { join } from "path"; import { readdir } from "fs/promises"; import { castArray } from "./utils.ts"; import type { Listener } from "./types/listener.ts"; - -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, - GatewayIntentBits.GuildMembers, - ], -}); +import { client } from "./Exports.js"; client.once(Events.ClientReady, async (bot) => { try { @@ -57,4 +47,4 @@ client.once(Events.ClientReady, async (bot) => { console.log(`Bot ${bot.user.username} ready!`); }); -client.login(process.env.TOKEN); +await client.login(process.env.TOKEN); diff --git a/src/listeners/qotwReminder.ts b/src/listeners/qotwReminder.ts new file mode 100644 index 0000000..730c9f0 --- /dev/null +++ b/src/listeners/qotwReminder.ts @@ -0,0 +1,14 @@ +// noinspection JSUnusedGlobalSymbols + +import type { Listener } from "../types/listener.ts"; +import {QotwReminderManager} from "../types/qotw.js"; + +export default [ + { + event: "ready", + + handler(): void { + QotwReminderManager.reloadInterval(); + } + } +] as Listener[] \ No newline at end of file diff --git a/src/schemas/config.ts b/src/schemas/config.ts index 5a2d4ed..95f0d1f 100644 --- a/src/schemas/config.ts +++ b/src/schemas/config.ts @@ -11,4 +11,4 @@ export const Config = sqliteTable("guildConfig", { gatewayLeaveContent: text("gatewayLeaveContent"), }); export type ConfigSelect = InferSelectModel; -export type ConfigInsert = InferInsertModel; +export type ConfigInsert = InferInsertModel; \ No newline at end of file diff --git a/src/schemas/keyvaluepair.ts b/src/schemas/keyvaluepair.ts new file mode 100644 index 0000000..6b37637 --- /dev/null +++ b/src/schemas/keyvaluepair.ts @@ -0,0 +1,11 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const Keyvaluepair = sqliteTable("keyvaluepair", { + key: text("key").notNull().primaryKey(), + value: text("value") +}); + +export interface KeyValuePairDisposition { + key: string; + value: string | null; +} \ No newline at end of file diff --git a/src/types/IntRange.ts b/src/types/IntRange.ts new file mode 100644 index 0000000..9257a74 --- /dev/null +++ b/src/types/IntRange.ts @@ -0,0 +1,6 @@ + +type Enumerate = Acc['length'] extends N + ? Acc[number] + : Enumerate; + +export type IntRange = Exclude, Enumerate> | F | T; diff --git a/src/types/qotw.ts b/src/types/qotw.ts new file mode 100644 index 0000000..5ea449b --- /dev/null +++ b/src/types/qotw.ts @@ -0,0 +1,81 @@ +import type {IntRange} from "./IntRange.js"; +import {client} from "../Exports.js"; +import {EmbedBuilder} from "discord.js"; +import db from "../db.js"; +import {Keyvaluepair, type KeyValuePairDisposition} from "../schemas/keyvaluepair.js"; +import {eq} from "drizzle-orm"; + +export interface QotwDate { + weekDay: IntRange<1, 7>; + hour: IntRange<1, 24>; + minute: IntRange<0, 59>; +} + +export class QotwReminderManager { + private static currentInterval: NodeJS.Timeout | undefined; + private static currentTimeout: NodeJS.Timeout | undefined; + + public static uploadDate(date: QotwDate) { + (!QotwReminderManager.getDate() + ? db.insert(Keyvaluepair).values({key: "qotwdate", value: JSON.stringify(date)}) + : db.update(Keyvaluepair).set({value: JSON.stringify(date)}).where(eq(Keyvaluepair.key, "qotwdate"))) + .execute().then() + + QotwReminderManager.reloadInterval(); + } + + public static getDate(): QotwDate | undefined { + const value: KeyValuePairDisposition[] = db.select().from(Keyvaluepair).where(eq(Keyvaluepair.key, "qotwdate")).all() + return value.length === 0 ? undefined : JSON.parse(value[0].value!); + } + + public static reloadInterval(): void { + if (QotwReminderManager.currentInterval) { + clearInterval(QotwReminderManager.currentInterval); + QotwReminderManager.currentInterval = undefined; + } + + if (QotwReminderManager.currentTimeout) { + clearInterval(QotwReminderManager.currentTimeout); + QotwReminderManager.currentTimeout = undefined; + } + + const qotwRemindDate: QotwDate | undefined = QotwReminderManager.getDate(); + + if (!qotwRemindDate) { + QotwReminderManager.currentInterval = undefined; + QotwReminderManager.currentTimeout = undefined; + return; + } + + async function searchAndSendReminder() { + for (const [_, member] of (await (await client.guilds.fetch("779474636780863488")).roles.fetch("1140826891599757382"))!.members) + await member.send({embeds: [new EmbedBuilder() + .setColor(0x9b59b6) + .setTitle("QOTW reminder") + .setDescription("The time for a new QOTW is here, go to <#779826597729271828> and create a new question thread.")]}); + } + + QotwReminderManager.currentTimeout = setTimeout(async () => { + await searchAndSendReminder(); + QotwReminderManager.currentInterval = setInterval(async () => { + await searchAndSendReminder(); + }, 604800000); + QotwReminderManager.currentTimeout = undefined; + }, QotwReminderManager.getRemainingMilliseconds(qotwRemindDate as QotwDate)) + } + + private static getRemainingMilliseconds(qotw: QotwDate): number { + const now: Date = new Date(), + currentDay: number = now.getDay() || 7, + targetDay: number = qotw.weekDay === 7 ? 0 : qotw.weekDay, + cd: boolean = currentDay < targetDay || (currentDay === targetDay && now.getHours() < qotw.hour) || (currentDay === targetDay && now.getHours() === qotw.hour && now.getMinutes() < qotw.minute), + targetDate: Date = cd + ? new Date(now.getFullYear(), now.getMonth(), now.getDate() + (targetDay - currentDay)) + : new Date(now.getFullYear(), now.getMonth(), now.getDate() + (7 - currentDay + (targetDay === 0 ? 7 : targetDay))); + + cd ? targetDate.setHours(qotw.hour, qotw.minute, 0, 0) : targetDate.setHours(qotw.hour, qotw.minute, 0, 0); + + return targetDate.getTime() - now.getTime(); + } +} diff --git a/src/utils.ts b/src/utils.ts index 303ebba..6d7b927 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import db from "./db.ts"; -import { Config } from "./schemas/config"; +import { Config } from "./schemas/config.ts"; import { sql, eq } from "drizzle-orm"; /** * Casts a value into an array. @@ -19,3 +19,7 @@ export const getConfig = db .from(Config) .where(eq(Config.id, sql.placeholder("guildId"))) .prepare(); + +export function isEmptyObject(obj: T): boolean { + return typeof obj === "object" && Object.keys(obj).length === 0; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 9481ce7..1b4a064 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "allowImportingTsExtensions": true, "moduleDetection": "force", // drizzle orm errors with nodenext - "moduleResolution": "Bundler", + "moduleResolution": "NodeNext", "esModuleInterop": true, "strict": true,