diff --git a/ CONTRIBUTING.md b/ CONTRIBUTING.md index b4ee4d9..e09ac55 100644 --- a/ CONTRIBUTING.md +++ b/ CONTRIBUTING.md @@ -4,7 +4,7 @@ Contributing to Quest Bot requires basic knowledge of Typescript, Discord.JS and ## AI Rules -Please refrain from using AI to create new features. Using AI to help you understand something is fine but you can't have it vibecode or make features for you. This is especially important for HackClub events. +Please refrain from using AI to create new features. Using AI to help you *understand* how to make something is fine but you can't have it vibecode or make features for you. This is especially important for HackClub events. ## Pull requests @@ -18,6 +18,6 @@ Please write in the commit message what you have changed. ## Team -You will be invited to the Quest team if you have contributed a lot towards on or more Quest projects. +You will be invited to the Quest team if you have contributed a lot towards one or more Quest projects. ## Thanks! diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..e465a47 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] ..." +labels: bug +assignees: '' +type: Bug + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +--- + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..655cb54 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[Feature] Add..." +labels: enhancement +assignees: '' +type: Feature + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 1263ef9..f37111e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -9,6 +9,7 @@ on: permissions: contents: read + packages: write jobs: deploy: @@ -18,28 +19,41 @@ jobs: (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') steps: - - uses: actions/checkout@v6 + - name: Check out repository + uses: actions/checkout@v6 with: ref: ${{ github.event.workflow_run.head_sha || github.sha }} - - uses: actions/setup-node@v6 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Preset Image Name + run: | + SHA="${{ github.event.workflow_run.head_sha || github.sha }}" + IMAGE=$(echo "ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:$(echo ${SHA} | cut -c1-7)" | tr '[:upper:]' '[:lower:]') + echo "IMAGE_URL=${IMAGE}" >> $GITHUB_ENV + + - name: Build and push Docker Image + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ env.IMAGE_URL }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Deploy Image to CapRover + uses: caprover/deploy-from-github@v1.1.2 with: - node-version: '24' - - - name: Install CapRover CLI - run: npm install -g caprover - - - name: Create deploy tarball - run: tar -cvf deploy.tar --exclude='./.git' --exclude='./node_modules' --exclude='./dist' --exclude='./deploy.tar' . - - - name: Deploy - env: - CAPROVER_URL: ${{ secrets.CAPROVER_URL }} - CAPROVER_APP_TOKEN: ${{ secrets.CAPROVER_APP_TOKEN }} - CAPROVER_APP: ${{ secrets.CAPROVER_APP }} - run: > - caprover deploy - --caproverUrl "$CAPROVER_URL" - --appToken "$CAPROVER_APP_TOKEN" - --caproverApp "$CAPROVER_APP" - --tarFile ./deploy.tar + server: "${{ secrets.CAPROVER_URL }}" + app: "${{ secrets.CAPROVER_APP }}" + token: "${{ secrets.CAPROVER_APP_TOKEN }}" + image: ${{ env.IMAGE_URL }} diff --git a/Dockerfile b/Dockerfile index da308e1..f74b144 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,9 +16,10 @@ WORKDIR /app RUN corepack enable COPY package.json pnpm-lock.yaml ./ +COPY prisma.config.ts ./ COPY prisma ./prisma/ RUN pnpm install --prod --frozen-lockfile COPY --from=builder /app/dist ./dist -CMD ["sh", "-c", "pnpx prisma db push && pnpm run deploy && pnpm start"] +CMD ["sh", "-c", "pnpm prisma db push && pnpm run deploy && pnpm start"] diff --git a/package.json b/package.json index b91b630..32a5162 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@types/ms": "^2.1.0", "discord.js": "^14.24.0", "dotenv": "^17.4.2", + "prisma": "^7.7.0", "ms": "^2.1.3", "zod": "^4.3.6" }, @@ -30,7 +31,6 @@ "eslint-formatter-compact": "^8.40.0", "eslint-formatter-pretty": "^7.0.0", "prettier": "^3.6.2", - "prisma": "^7.7.0", "rimraf": "^6.1.3", "typescript": "~5.9.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 584067d..b89327c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: ms: specifier: ^2.1.3 version: 2.1.3 + prisma: + specifier: ^7.7.0 + version: 7.7.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) zod: specifier: ^4.3.6 version: 4.3.6 @@ -54,9 +57,6 @@ importers: prettier: specifier: ^3.6.2 version: 3.8.3 - prisma: - specifier: ^7.7.0 - version: 7.7.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) rimraf: specifier: ^6.1.3 version: 6.1.3 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9ac5068..9bd7698 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,6 +16,8 @@ model Server { updatedAt DateTime @updatedAt warns Warn[] + mutes Mute[] + reminders Reminder[] @@map("server") } @@ -42,6 +44,8 @@ model Mute { reason String? expiresAt DateTime? createdAt DateTime @default(now()) + + server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) @@unique([guildId, userId]) @@index([expiresAt]) @@ -49,12 +53,14 @@ model Mute { model Reminder { id String @id @default(cuid()) - userId String guildId String? + userId String channelId String? message String remindAt DateTime createdAt DateTime @default(now()) + + server Server? @relation(fields: [guildId], references: [id], onDelete: Cascade) @@index([remindAt]) @@index([userId]) diff --git a/src/commands/moderation/ban.ts b/src/commands/moderation/ban.ts index 630bfa7..7daee70 100644 --- a/src/commands/moderation/ban.ts +++ b/src/commands/moderation/ban.ts @@ -68,7 +68,7 @@ export default { const row = new ActionRowBuilder().addComponents(cancel, confirm); const response = await interaction.reply({ - content: `${emojis.rightArrow1} Are you sure you want to ban ${targetMember.user.username} for reason: ${reason}?`, + content: `${emojis.rightArrow1} Are you sure you want to ban <@${targetMember.user.username}> for reason: ${reason}?`, components: [row], withResponse: true, }); @@ -79,11 +79,20 @@ export default { if (confirmation.customId === 'confirm') { await interaction.guild!.members.ban(targetMember); - await confirmation.update({ content: `${emojis.rightArrow2} ${targetMember.user.username} has been banned with reason: ${reason}`, components: [] }); + await confirmation.update({ content: `${emojis.rightArrow2} <@${targetMember.user.username}> has been banned with reason: ${reason}`, components: [] }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); } else if (confirmation.customId === 'cancel') { await confirmation.update({ content: `${emojis.rightArrow2} Cancelled.`, components: [] }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); } - } catch { + } catch (err) { + console.error(err) await interaction.editReply({ content: `${emojis.rightArrow2} No response within a minute or errored.`, components: [] }); } }, diff --git a/src/commands/moderation/kick.ts b/src/commands/moderation/kick.ts index f20e572..49cdf62 100644 --- a/src/commands/moderation/kick.ts +++ b/src/commands/moderation/kick.ts @@ -69,7 +69,7 @@ export default { const row = new ActionRowBuilder().addComponents(cancel, confirm); const response = await interaction.reply({ - content: `${emojis.rightArrow1} Are you sure you want to kick ${targetMember.user.username} with reason: ${reason}?`, + content: `${emojis.rightArrow1} Are you sure you want to kick <@${targetMember.user.id}> with reason: ${reason}?`, components: [row], withResponse: true, }); @@ -83,11 +83,20 @@ export default { `You have been kicked from **${interaction.guild.name}**.\nReason: ${reason}` ).catch(() => {}); await interaction.guild!.members.kick(targetMember); - await confirmation.update({ content: `${emojis.rightArrow2} **${targetMember.user.username}** has been kicked for reason: ${reason}\nYou must have had a real ick towards that person.`, components: [] }); + await confirmation.update({ content: `${emojis.rightArrow2} <@${targetMember.user.id}> has been kicked for reason: ${reason}\nYou must have had a real ick towards that person.`, components: [] }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); } else if (confirmation.customId === 'cancel') { await confirmation.update({ content: `${emojis.rightArrow2} Cancelled.`, components: [] }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); } - } catch { + } catch (err) { + console.error(err) await interaction.editReply({ content: `${emojis.rightArrow2} No response within a minute or errored.`, components: [] }); } }, diff --git a/src/commands/moderation/mute.ts b/src/commands/moderation/mute.ts index d5fcc11..6d7be19 100644 --- a/src/commands/moderation/mute.ts +++ b/src/commands/moderation/mute.ts @@ -17,13 +17,13 @@ export default { }, { type: ApplicationCommandOptionType.String, - name: "reason", - description: "Provide a reason for their mute" + name: "duration", + description: "Specify a duration for the mute" }, { type: ApplicationCommandOptionType.String, - name: "duration", - description: "Specify a duration for the mute" + name: "reason", + description: "Provide a reason for their mute" } ] @@ -44,13 +44,18 @@ export default { const targetMember = interaction.options.getMember('target') as GuildMember const reason = interaction.options.getString('reason') ?? 'No reason provided'; const durationStr = interaction.options.getString('duration') as StringValue - const duration = ms(durationStr) ?? 'No duration provided' + const duration = durationStr ? ms(durationStr as StringValue) : null; const expiresAt = duration ? new Date(Date.now() + duration) : null; if (!targetMember) { await interaction.reply({ content: `${emojis.rightArrow2} That user is not in this server.`, flags: MessageFlags.Ephemeral }); return; } + + if (durationStr && (typeof duration !== 'number' || isNaN(duration))) { + await interaction.reply({ content: `${emojis.rightArrow2} Invalid duration format.`, flags: MessageFlags.Ephemeral }); + return; + } if (targetMember.id === interaction.user.id) { await interaction.reply({ content: `${emojis.rightArrow2} You cannot mute yourself.`, flags: MessageFlags.Ephemeral }); @@ -79,7 +84,7 @@ export default { const row = new ActionRowBuilder().addComponents(cancel, confirm); const response = await interaction.reply({ - content: `${emojis.rightArrow1} Are you sure you want to mute ${targetMember.user.username} for ${durationStr} with reason: ${reason}?`, + content: `${emojis.rightArrow1} Are you sure you want to mute <@${targetMember.user.id}> with reason: ${reason}?`, components: [row], withResponse: true, }); @@ -89,13 +94,23 @@ export default { const confirmation = await response.resource!.message!.awaitMessageComponent({ filter: collectorFilter, time: 60_000 }); if (confirmation.customId === 'confirm') { - await createMute(interaction.guild.id, targetMember.id, expiresAt, reason); + await createMute(interaction.guild.id, interaction.guild.name, targetMember.id, expiresAt, reason); await enforceMute(interaction.guild, targetMember.id); - await confirmation.update({ content: `${emojis.rightArrow2} ${targetMember.user.username} has been muted for ${durationStr} with reason: ${reason}`, components: [] }); + await confirmation.update({ content: `${emojis.rightArrow2} <@${targetMember.user.id}> has been muted with reason: ${reason}`, components: [] }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); + } else if (confirmation.customId === 'cancel') { await confirmation.update({ content: `${emojis.rightArrow2} Cancelled.`, components: [] }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); } - } catch { + } catch (err) { + console.error(err) await interaction.editReply({ content: `${emojis.rightArrow2} No response within a minute or errored.`, components: [] }); } }, diff --git a/src/commands/moderation/unmute.ts b/src/commands/moderation/unmute.ts index 0e96ea5..ef2cbfc 100644 --- a/src/commands/moderation/unmute.ts +++ b/src/commands/moderation/unmute.ts @@ -60,7 +60,7 @@ export default { const row = new ActionRowBuilder().addComponents(cancel, confirm); const response = await interaction.reply({ - content: `${emojis.rightArrow1} Are you sure you want to unmute ${targetMember.user.username} with reason: ${reason}?`, + content: `${emojis.rightArrow1} Are you sure you want to unmute <@${targetMember.user.id}> with reason: ${reason}?`, components: [row], withResponse: true, }); @@ -72,9 +72,17 @@ export default { if (confirmation.customId === 'confirm') { await removeMute(interaction.guild.id, targetMember.id); targetMember.timeout(null, reason) - await confirmation.update({ content: `${emojis.rightArrow2} ${targetMember.user.username} has been unmuted with reason: ${reason}`, components: [] }); + await confirmation.update({ content: `${emojis.rightArrow2} <@${targetMember.user.id}> has been unmuted with reason: ${reason}`, components: [] }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); } else if (confirmation.customId === 'cancel') { await confirmation.update({ content: `${emojis.rightArrow2} Cancelled.`, components: [] }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); } } catch { await interaction.editReply({ content: `${emojis.rightArrow2} No response within a minute or errored.`, components: [] }); diff --git a/src/commands/moderation/unwarn.ts b/src/commands/moderation/unwarn.ts index 8e90ce9..33b0524 100644 --- a/src/commands/moderation/unwarn.ts +++ b/src/commands/moderation/unwarn.ts @@ -57,7 +57,7 @@ export default { const row = new ActionRowBuilder().addComponents(cancel, confirm); const response = await interaction.reply({ - content: `${emojis.rightArrow1} Are you sure you want to unwarn ${warn.userId} with reason: ${reason}?\n${emojis.rightArrow2} They were warned for: ${warn.reason} `, + content: `${emojis.rightArrow1} Are you sure you want to unwarn <@${warn.userId}> with reason: ${reason}?\n${emojis.rightArrow2} They were warned for: ${warn.reason} `, components: [row], withResponse: true, }); @@ -69,8 +69,16 @@ export default { if (confirmation.customId === 'confirm') { await removeWarn(warn.id); await confirmation.update({ content: `${emojis.rightArrow2} <@${warn.userId}> has been unwarned with reason: ${reason}`, components: [] }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); } else if (confirmation.customId === 'cancel') { await confirmation.update({ content: `${emojis.rightArrow2} Cancelled.`, components: [] }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); } } catch { await interaction.editReply({ content: `${emojis.rightArrow2} No response within a minute or errored.`, components: [] }); diff --git a/src/commands/moderation/warn.ts b/src/commands/moderation/warn.ts index b3e4a4e..05e73f4 100644 --- a/src/commands/moderation/warn.ts +++ b/src/commands/moderation/warn.ts @@ -44,8 +44,13 @@ export default { const targetMember = interaction.options.getMember('target') as GuildMember const reason = interaction.options.getString('reason') ?? 'No reason provided'; const durationStr = interaction.options.getString('duration') as StringValue - const duration = durationStr ? ms(durationStr) : null + const duration = durationStr ? ms(durationStr as StringValue) : null; const expiresAt = duration ? new Date(Date.now() + duration) : null; + + if (durationStr && (typeof duration !== 'number' || isNaN(duration))) { + await interaction.reply({ content: `${emojis.rightArrow2} Invalid duration format.`, flags: MessageFlags.Ephemeral }); + return; + } if (!targetMember) { await interaction.reply({ content: `${emojis.rightArrow2} That user is not in this server.`, flags: MessageFlags.Ephemeral }); @@ -79,7 +84,7 @@ export default { const row = new ActionRowBuilder().addComponents(cancel, confirm); const response = await interaction.reply({ - content: `${emojis.rightArrow1} Are you sure you want to warn ${targetMember.user.username} for ${durationStr} with reason: ${reason}?`, + content: `${emojis.rightArrow1} Are you sure you want to warn <@${targetMember.user.id}> with reason: ${reason}?`, components: [row], withResponse: true, }); @@ -89,12 +94,21 @@ export default { const confirmation = await response.resource!.message!.awaitMessageComponent({ filter: collectorFilter, time: 60_000 }); if (confirmation.customId === 'confirm') { - await createWarn(interaction.guild.id, targetMember.id, interaction.user.id, reason, expiresAt); - await confirmation.update({ content: `${emojis.rightArrow2} ${targetMember.user.username} has been warned for ${durationStr} with reason: ${reason}`, components: [] }); + await createWarn(interaction.guild.id, interaction.guild.name, targetMember.id, interaction.user.id, reason, expiresAt); + await confirmation.update({ content: `${emojis.rightArrow2} <@${targetMember.user.id}> has been warned with reason: ${reason}`, components: [] }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); } else if (confirmation.customId === 'cancel') { await confirmation.update({ content: `${emojis.rightArrow2} Cancelled.`, components: [] }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); } - } catch { + } catch(err) { + console.error(err) await interaction.editReply({ content: `${emojis.rightArrow2} No response within a minute or errored.`, components: [] }); } }, diff --git a/src/commands/moderation/warns.ts b/src/commands/moderation/warns.ts index 9b52355..343b49c 100644 --- a/src/commands/moderation/warns.ts +++ b/src/commands/moderation/warns.ts @@ -36,7 +36,7 @@ export default { const active = await getActiveWarns(interaction.guild.id, targetMember.id); if (active.length === 0) { - await interaction.reply({ content: `${emojis.rightArrow2} ${targetMember.user.username} has no active warns.`, flags: MessageFlags.Ephemeral }); + await interaction.reply({ content: `${emojis.rightArrow2} <@${targetMember.user.id}> has no active warns.`, flags: MessageFlags.Ephemeral }); return; } @@ -47,11 +47,8 @@ export default { return `${emojis.rightArrow2} **${warn.reason ?? 'No reason provided'}** by <@${warn.moderatorId}>\n Expires: ${expires} \`${warn.id}\``; }); - let content = `${emojis.rightArrow1} ${active.length} active warn(s) for ${targetMember.user.username}:\n${lines.join('\n')}`; + let content = `${emojis.rightArrow1} ${active.length} active warn(s) for <@${targetMember.user.id}>:\n${lines.join('\n')}`; await interaction.reply({ content }); - - - }, } satisfies Command; diff --git a/src/commands/ping.ts b/src/commands/ping.ts index d39f75c..5678a5d 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -14,5 +14,9 @@ export default { const ping = reply.createdTimestamp - interaction.createdTimestamp; interaction.editReply(`${emojis.rightArrow1} Client: ${ping}ms\n${emojis.rightArrow1} Websocket: ${interaction.client.ws.ping}ms`) + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); }, } satisfies Command; diff --git a/src/commands/utility/reminder.ts b/src/commands/utility/reminder.ts new file mode 100644 index 0000000..cfa1c72 --- /dev/null +++ b/src/commands/utility/reminder.ts @@ -0,0 +1,127 @@ +import type { Command } from '../index.js'; +import { ApplicationCommandOptionType, MessageFlags } from 'discord.js'; +import { emojis } from '#utils/emoji.js'; +import ms, { type StringValue } from 'ms'; +import { createReminder, getReminder, removeReminder } from '#lib/reminders.js'; + +export default { + data: { + name: 'reminder', + description: 'Set reminders!', + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: 'add', + description: 'Set a new reminder.', + options: [ + { + type: ApplicationCommandOptionType.String, + name: 'duration', + description: 'When to remind you', + required: true, + }, + { + type: ApplicationCommandOptionType.String, + name: 'message', + description: 'What to remind you about', + required: true, + }, + ], + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: 'remove', + description: 'Cancel a reminder.', + options: [ + { + type: ApplicationCommandOptionType.String, + name: 'id', + description: 'The reminder ID', + required: true, + }, + ], + }, + ], + }, + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'add') { + + const durationStr = interaction.options.getString('duration', true); + const message = interaction.options.getString('message', true); + + const duration = ms(durationStr as StringValue); + if (typeof duration !== 'number' || isNaN(duration) || duration <= 0) { + await interaction.reply({ + content: `${emojis.rightArrow2} Invalid duration.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (duration > 31536000000 ) { + await interaction.reply({ + content: `${emojis.rightArrow2} Reminder cannot be longer than 1 year.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + const remindAt = new Date(Date.now() + duration); + + const reminder = interaction.inCachedGuild() + ? await createReminder( + interaction.user.id, + message, + remindAt, + interaction.guild.id, + interaction.guild.name, + interaction.channelId, + ) + : await createReminder(interaction.user.id, message, remindAt); + + const unix = Math.floor(remindAt.getTime() / 1000); + await interaction.reply({ + content: `${emojis.rightArrow2} Reminder set to go off in message: ${message}\nID: \`${reminder.id}\``, + }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 5000); + return; + } + + if (subcommand === 'remove') { + const id = interaction.options.getString('id', true); + const reminder = await getReminder(id); + + if (!reminder) { + await interaction.reply({ + content: `${emojis.rightArrow2} No reminder found.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (reminder.userId !== interaction.user.id) { + await interaction.reply({ + content: `${emojis.rightArrow2} You can't remove other's reminders.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + await removeReminder(id); + await interaction.reply({ + content: `${emojis.rightArrow2} Reminder removed.`, + flags: MessageFlags.Ephemeral, + }); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 5000); + return; + } + }, +} satisfies Command; diff --git a/src/commands/utility/reminders.ts b/src/commands/utility/reminders.ts new file mode 100644 index 0000000..86f69c0 --- /dev/null +++ b/src/commands/utility/reminders.ts @@ -0,0 +1,34 @@ +import type { Command } from '../index.js'; +import { MessageFlags } from 'discord.js'; +import { emojis } from '#utils/emoji.js'; +import { getReminders } from '#lib/reminders.js'; + +export default { + data: { + name: 'reminders', + description: 'List your current reminders.', + }, + async execute(interaction) { + const reminders = interaction.inCachedGuild() + ? await getReminders(interaction.user.id, interaction.guild.id) + : await getReminders(interaction.user.id); + + if (reminders.length === 0) { + await interaction.reply({ + content: `${emojis.rightArrow2} You have no active reminders${interaction.inCachedGuild() ? ' in this server' : ''}.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + const lines = reminders.map((r) => { + const unix = Math.floor(r.remindAt.getTime() / 1000); + return ` | ${r.message}\n \`${r.id}\``; + }); + + await interaction.reply({ + content: `${emojis.rightArrow2} Your reminders:\n${lines.join('\n')}`, + flags: MessageFlags.Ephemeral, + }); + }, +} satisfies Command; diff --git a/src/commands/utility/user.ts b/src/commands/utility/user.ts index c0a746b..8ef82c7 100644 --- a/src/commands/utility/user.ts +++ b/src/commands/utility/user.ts @@ -8,5 +8,9 @@ export default { }, async execute(interaction) { await interaction.reply(`${emojis.rightArrow2} This command was run by **${interaction.user.username}**.`); + + setTimeout(() => { + interaction.deleteReply().catch(() => {}); + }, 3000); }, } satisfies Command; diff --git a/src/events/ready.ts b/src/events/ready.ts index 495e44f..b0d0333 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,6 +1,9 @@ -import { Events } from 'discord.js'; +import { ActivityType, Events } from 'discord.js'; import type { Event } from './index.js'; import { enforceMute, getActiveMutes } from '#lib/mutes.js' +import { heartbeat } from '#utils/heartbeat.js'; +import { purgeExpiredWarns } from '#lib/warns.js'; +import { reminderScheduler } from '#lib/reminderEvent.js'; export default { name: Events.ClientReady, @@ -8,7 +11,28 @@ export default { async execute(client) { console.log(`Ready! Logged in as ${client.user.tag}`); + heartbeat(client); + + reminderScheduler(client); + purgeExpiredWarns().catch(err => console.error('[cleanup] purge failed:', err)); + + client.user.setPresence({ + activities: [{ + name: 'custom', + type: ActivityType.Custom, + state: 'Upd: /reminder!', + }], + status: 'online', + }); + + const mutes = await getActiveMutes(); + for (const mute of mutes) { + const guild = client.guilds.cache.get(mute.guildId); + if (guild) await enforceMute(guild, mute.userId); + } + setInterval(async () => { + await purgeExpiredWarns(); const mutes = await getActiveMutes(); for (const mute of mutes) { const guild = client.guilds.cache.get(mute.guildId); diff --git a/src/lib/mutes.ts b/src/lib/mutes.ts index c8f886a..f963fbe 100644 --- a/src/lib/mutes.ts +++ b/src/lib/mutes.ts @@ -1,11 +1,24 @@ import { prisma } from "./prisma.js"; import { Guild } from 'discord.js'; -export async function createMute(guildId: string, userId: string, expiresAt: Date | null, reason?: string) { - return prisma.mute.upsert({ - where: { guildId_userId: { guildId, userId } }, - create: { guildId, userId, expiresAt, reason }, - update: { expiresAt, reason }, +export async function createMute( + guildId: string, + guildName: string, + userId: string, + expiresAt: Date | null, + reason?: string, +) { + return prisma.$transaction(async (tx) => { + await tx.server.upsert({ + where: { id: guildId }, + create: { id: guildId, name: guildName }, + update: { name: guildName }, + }); + return tx.mute.upsert({ + where: { guildId_userId: { guildId, userId } }, + create: { guildId, userId, expiresAt, reason }, + update: { expiresAt, reason }, + }); }); } diff --git a/src/lib/reminderEvent.ts b/src/lib/reminderEvent.ts new file mode 100644 index 0000000..c6d0e59 --- /dev/null +++ b/src/lib/reminderEvent.ts @@ -0,0 +1,40 @@ +import type { Client } from 'discord.js'; +import { emojis } from '#utils/emoji.js'; +import { getDueReminders, removeReminder } from './reminders.js'; + +export function reminderScheduler(client: Client) { + const checkReminders = async () => { + try { + const due = await getDueReminders(); + + for (const reminder of due) { + if (reminder.channelId){ + const channel = await client.channels.fetch(reminder.channelId).catch(() => null); + + if (channel?.isSendable()) { + await channel.send({ + content: `${emojis.rightArrow2} <@${reminder.userId}> reminder: ${reminder.message}`,} + ); + } + } else { + await dmUser(client, reminder.userId, reminder.message); + } + + await removeReminder(reminder.id); + } + } catch (err) { + console.error(err) + } + } + checkReminders(); + setInterval(checkReminders, 30_000); +} + +async function dmUser(client: Client, userId: string, message: string ){ + const user = await client.users.fetch(userId).catch(() => null); + if (!user) return; + + await user.send({ + content: `${emojis.rightArrow2} <@${userId}> reminder: ${message}`, + }).catch(() => {}); +} diff --git a/src/lib/reminders.ts b/src/lib/reminders.ts new file mode 100644 index 0000000..6724b44 --- /dev/null +++ b/src/lib/reminders.ts @@ -0,0 +1,51 @@ +import { prisma } from "./prisma.js"; + +export async function createReminder( + userId: string, + message: string, + remindAt: Date, + guildId?: string, + guildName?: string, + channelId?: string, +) { + if (guildId && guildName) { + return prisma.$transaction(async (tx) => { + await tx.server.upsert({ + where: { id: guildId }, + create: { id: guildId, name: guildName }, + update: { name: guildName }, + }); + return tx.reminder.create({ + data: { guildId, userId, channelId, message, remindAt }, + }); + }); + } + return prisma.reminder.create({ + data: { userId, channelId, message, remindAt }, + }); +} + +export async function getReminders( userId: string, guildId?: string) { + return prisma.reminder.findMany({ + where: { userId, guildId }, + orderBy: { remindAt: 'asc' }, + }); +} + +export async function removeReminder(reminderId: string) { + return prisma.reminder.delete({ where: { id: reminderId } }); +} + +export async function clearReminders(guildId: string, userId: string) { + return prisma.reminder.deleteMany({ where: { guildId, userId } }); +} + +export async function getReminder(reminderId: string) { + return prisma.reminder.findUnique({ where: { id: reminderId } }); +} + +export async function getDueReminders() { + return prisma.reminder.findMany({ + where: { remindAt: { lte: new Date() } }, + }); +} diff --git a/src/lib/warns.ts b/src/lib/warns.ts index 8a20ed9..db42d6f 100644 --- a/src/lib/warns.ts +++ b/src/lib/warns.ts @@ -2,13 +2,21 @@ import { prisma } from "./prisma.js"; export async function createWarn( guildId: string, + guildName: string, userId: string, moderatorId: string, reason: string, expiresAt: Date | null = null, ) { - return prisma.warn.create({ - data: { guildId, userId, moderatorId, reason, expiresAt }, + return prisma.$transaction(async (tx) => { + await tx.server.upsert({ + where: { id: guildId }, + create: { id: guildId, name: guildName }, + update: { name: guildName }, + }); + return tx.warn.create({ + data: { guildId, userId, moderatorId, reason, expiresAt }, + }); }); } @@ -46,3 +54,12 @@ export async function getWarn(warnId: string, guildId: string) { if (!warn || warn.guildId !== guildId) return null; return warn; } + +export async function purgeExpiredWarns(gracePeriodDays = 90) { + const cutoff = new Date(Date.now() - gracePeriodDays * 24 * 60 * 60 * 1000); + return prisma.warn.deleteMany({ + where: { + expiresAt: { not: null, lte: cutoff }, + }, + }); +} diff --git a/src/util/heartbeat.ts b/src/util/heartbeat.ts new file mode 100644 index 0000000..7097763 --- /dev/null +++ b/src/util/heartbeat.ts @@ -0,0 +1,18 @@ +import type { Client } from 'discord.js'; + +export function heartbeat(client: Client) { + const pushURL = process.env['KUMA_PUSH_URL']; + if (!pushURL) return; + + const push = async () => { + if (!client.isReady()) return; + try { + await fetch(pushURL); + } catch (err) { + console.error("Push failed:", err); + } + }; + + push(); + setInterval(push, 60_000); +}