diff --git a/.dockerignore b/.dockerignore index 5f67f3c..f6af850 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,7 @@ node_modules dist README.md +.git +.turbo +.env +.env.* diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index b1a62b4..1c02b65 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,6 @@ # These are supported funding model platforms -github: duck-organization +github: vantern-org patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username diff --git a/.github/assets/LargeDuckBanner.png b/.github/assets/LargeDuckBanner.png deleted file mode 100644 index 120cd5a..0000000 Binary files a/.github/assets/LargeDuckBanner.png and /dev/null differ diff --git a/.github/assets/LargeVanternBanner.png b/.github/assets/LargeVanternBanner.png new file mode 100644 index 0000000..ced5375 Binary files /dev/null and b/.github/assets/LargeVanternBanner.png differ diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3fc0734..c9d0efa 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -29,10 +29,10 @@ jobs: - name: Build run: pnpm turbo build - name: Build production image - run: docker build -t quest-bot:test . + run: docker build -t questbot:test . - name: Test production image run: > - docker run --rm --entrypoint sh quest-bot:test -c + docker run --rm --entrypoint sh questbot:test -c "set -e; test -f /app/apps/bot/dist/index.js; node --check /app/apps/bot/dist/index.js" diff --git a/Dockerfile b/Dockerfile index 2b188f2..3c7a823 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,10 @@ COPY apps/bot/package.json ./apps/bot/package.json COPY packages/database/package.json ./packages/database/package.json COPY packages/database/prisma.config.ts ./packages/database/prisma.config.ts COPY packages/database/prisma ./packages/database/prisma/ -RUN pnpm install --frozen-lockfile --filter @duckorganization/questbot --filter @questbot/database +RUN pnpm install --frozen-lockfile --filter @questbot/bot --filter @questbot/database COPY . . -RUN pnpm turbo run build --filter=@duckorganization/questbot... +RUN pnpm turbo run build --filter=@questbot/bot... FROM node:24-alpine WORKDIR /app @@ -25,10 +25,10 @@ COPY apps/bot/package.json ./apps/bot/package.json COPY packages/database/package.json ./packages/database/package.json COPY packages/database/prisma.config.ts ./packages/database/prisma.config.ts COPY packages/database/prisma ./packages/database/prisma/ -RUN pnpm install --prod --frozen-lockfile --filter @duckorganization/questbot --filter @questbot/database +RUN pnpm install --prod --frozen-lockfile --filter @questbot/bot --filter @questbot/database COPY --from=builder /app/apps/bot/dist ./apps/bot/dist COPY --from=builder /app/packages/database/dist ./packages/database/dist COPY --from=builder /app/packages/database/src/generated ./packages/database/src/generated -CMD ["sh", "-c", "pnpm --filter @questbot/database db:push && pnpm --filter @duckorganization/questbot start"] +CMD ["sh", "-c", "pnpm --filter @questbot/database db:push && pnpm --filter @questbot/bot start"] diff --git a/README.md b/README.md index bfb3a81..ce920c8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@
- Duck Organization Banner + Vantern Banner --- - Created By Badge + Created By Badge Created Using Badge
@@ -24,10 +24,10 @@ Quest Bot is capable of: # Contributing -Quest Bot is worked on by Duck Organization but we are opensource! +Quest Bot is worked on by Vantern but we are opensource! Anyone can contribute! -Feel free to open a pull request! Just make sure to follow the guidelines at [CONTRIBUTING.md](https://github.com/duck-organization/quest-bot/blob/main/CONTRIBUTING.md). +Feel free to open a pull request! Just make sure to follow the guidelines at [CONTRIBUTING.md](https://github.com/vantern-org/questbot/blob/main/CONTRIBUTING.md). ## Running locally @@ -50,8 +50,8 @@ This project is licensed under the Affero GNU General Public License v3.0 (AGPL- ## Links -[Status Page](https://status.duckorg.com), [Bot Documentation](https://duckorg.com/bot/docs) & [Official Discord](https://discord.gg/ksuqZ77R88) +[Status Page](https://status.vantern.org), [Bot Documentation](https://vantern.org/bot/docs) & [Official Discord](https://discord.gg/ksuqZ77R88) ## Contact -If you have any questions or suggestions, feel free to reach out to us on our [Discord server](https://discord.gg/ksuqZ77R88) or send us an email at contact@duckorg.com. \ No newline at end of file +If you have any questions or suggestions, feel free to reach out to us on our [Discord server](https://discord.gg/ksuqZ77R88) or send us an email at contact@vantern.org. \ No newline at end of file diff --git a/apps/bot/package.json b/apps/bot/package.json index 6a381a1..cded73d 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -1,15 +1,15 @@ { "$schema": "https://json.schemastore.org/package.json", - "name": "@duckorganization/questbot", + "name": "@questbot/bot", "version": "0.1.5", "private": true, "type": "module", "description": "A Discord bot built with Sapphire framework", "license": "AGPL-3.0-or-later", - "author": "Duck Organization", + "author": "Vantern", "repository": { "type": "git", - "url": "git+https://github.com/duck-organization/quest-bot.git" + "url": "git+https://github.com/vantern-org/questbot.git" }, "main": "./dist/index.js", "bin": { diff --git a/apps/bot/src/commands/moderation/automod.ts b/apps/bot/src/commands/moderation/automod.ts index 1fc478d..c854987 100644 --- a/apps/bot/src/commands/moderation/automod.ts +++ b/apps/bot/src/commands/moderation/automod.ts @@ -23,7 +23,7 @@ export class AutoModCommand extends Command { registry.registerChatInputCommand((builder) => builder .setName('automod') - .setDescription('Block words from being said!') + .setDescription('Keep your server clean!') .setDefaultMemberPermissions(0) .addSubcommand((subcommand: SlashCommandSubcommandBuilder) => subcommand diff --git a/apps/bot/src/commands/moderation/nick.ts b/apps/bot/src/commands/moderation/nick.ts index 0d67ec8..3abfa84 100644 --- a/apps/bot/src/commands/moderation/nick.ts +++ b/apps/bot/src/commands/moderation/nick.ts @@ -7,6 +7,7 @@ import { type SlashCommandStringOption, type SlashCommandUserOption, } from 'discord.js'; +import { containsBlockedWord } from '#lib/automod.js'; import { errorEmbed, successEmbed } from '#utils/embeds.js'; import { emojis } from '#utils/emoji.js'; @@ -84,6 +85,14 @@ export class NickCommand extends Command { return; } + if (nickname && (await containsBlockedWord(interaction.guild.id, nickname))) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} That nickname contains a word blocked by this server.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + try { await targetMember.setNickname(nickname); await interaction.reply({ diff --git a/apps/bot/src/commands/moderation/sticky.ts b/apps/bot/src/commands/moderation/sticky.ts new file mode 100644 index 0000000..3259085 --- /dev/null +++ b/apps/bot/src/commands/moderation/sticky.ts @@ -0,0 +1,154 @@ +import { Command } from '@sapphire/framework'; +import { + type GuildTextBasedChannel, + MessageFlags, + PermissionFlagsBits, + PermissionsBitField, + type SlashCommandStringOption, + type SlashCommandSubcommandBuilder, +} from 'discord.js'; +import { containsBlockedWord } from '#lib/automod.js'; +import { getSticky, removeSticky, repostSticky, setSticky } from '#lib/sticky.js'; +import { errorEmbed, infoEmbed, successEmbed } from '#utils/embeds.js'; +import { emojis } from '#utils/emoji.js'; + +export class StickyCommand extends Command { + public constructor(context: Command.LoaderContext, options: Command.Options) { + super(context, { ...options, preconditions: ['devMode'] }); + } + + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand((builder) => + builder + .setName('sticky') + .setDescription('Pin a message to the bottom of this channel.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) + .addSubcommand((subcommand: SlashCommandSubcommandBuilder) => + subcommand + .setName('set') + .setDescription('Set the sticky message for this channel') + .addStringOption((option: SlashCommandStringOption) => + option + .setName('message') + .setDescription('The message to pin at the bottom of this channel') + .setRequired(true) + .setMaxLength(300), + ), + ) + .addSubcommand((subcommand: SlashCommandSubcommandBuilder) => + subcommand.setName('remove').setDescription('Remove the sticky message from this channel'), + ), + ); + } + + public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { + if (!interaction.inCachedGuild()) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} This command can only be used in a server.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const channel = interaction.channel; + + if (!channel?.isTextBased()) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} This channel cannot have a sticky message.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (!channel.permissionsFor(interaction.member)?.has(PermissionsBitField.Flags.ManageChannels)) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} You do not have permission to manage this channel.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (interaction.options.getSubcommand() === 'set') { + await this.set(interaction, channel); + return; + } + + await this.remove(interaction, channel); + } + + private async set(interaction: Command.ChatInputCommandInteraction<'cached'>, channel: GuildTextBasedChannel) { + const me = interaction.guild.members.me; + + if (!me || !channel.permissionsFor(me).has(PermissionsBitField.Flags.SendMessages)) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} I cannot send messages here.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const content = interaction.options.getString('message', true).trim(); + + if (!content) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} The sticky message cannot be empty.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (await containsBlockedWord(interaction.guildId, content)) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} That message contains a word blocked by this server.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + try { + const sticky = await setSticky(interaction.guildId, interaction.guild.name, channel.id, content); + await repostSticky(channel, sticky, true); + + await interaction.reply({ + embeds: [successEmbed(`${emojis.rightArrow1} Sticky message set for this channel.`)], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + console.error(error); + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} Failed to set the sticky message for this channel.`)], + flags: MessageFlags.Ephemeral, + }); + } + } + + private async remove(interaction: Command.ChatInputCommandInteraction<'cached'>, channel: GuildTextBasedChannel) { + try { + const sticky = await getSticky(interaction.guildId, channel.id); + const removed = await removeSticky(interaction.guildId, channel.id); + + if (!removed) { + await interaction.reply({ + embeds: [infoEmbed(`${emojis.rightArrow2} There is no sticky message in this channel.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (sticky?.stickyMessageId) { + await channel.messages.delete(sticky.stickyMessageId).catch(() => {}); + } + + await interaction.reply({ + embeds: [successEmbed(`${emojis.rightArrow1} Sticky message removed from this channel.`)], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + console.error(error); + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} Failed to remove the sticky message from this channel.`)], + flags: MessageFlags.Ephemeral, + }); + } + } +} diff --git a/apps/bot/src/commands/promotion/bot.ts b/apps/bot/src/commands/promotion/bot.ts index 57b85c9..bfa5b9d 100644 --- a/apps/bot/src/commands/promotion/bot.ts +++ b/apps/bot/src/commands/promotion/bot.ts @@ -14,6 +14,6 @@ export class BotCommand extends Command { } public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { - await interaction.reply({ embeds: [infoEmbed(`${emojis.rightArrow1} https://duckorg.com/bot/invite`)] }); + await interaction.reply({ embeds: [infoEmbed(`${emojis.rightArrow1} https://vantern.org/bot/invite`)] }); } } diff --git a/apps/bot/src/commands/promotion/discord.ts b/apps/bot/src/commands/promotion/discord.ts index 8c0b035..62db355 100644 --- a/apps/bot/src/commands/promotion/discord.ts +++ b/apps/bot/src/commands/promotion/discord.ts @@ -14,6 +14,6 @@ export class DiscordCommand extends Command { } public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { - await interaction.reply({ embeds: [infoEmbed(`${emojis.rightArrow1} https://duckorg.com/discord`)] }); + await interaction.reply({ embeds: [infoEmbed(`${emojis.rightArrow1} https://vantern.org/discord`)] }); } } diff --git a/apps/bot/src/commands/promotion/invite.ts b/apps/bot/src/commands/promotion/invite.ts index 38cc841..57eaaf6 100644 --- a/apps/bot/src/commands/promotion/invite.ts +++ b/apps/bot/src/commands/promotion/invite.ts @@ -14,6 +14,6 @@ export class InviteCommand extends Command { } public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { - await interaction.reply({ embeds: [infoEmbed(`${emojis.rightArrow1} https://duckorg.com/bot/invite`)] }); + await interaction.reply({ embeds: [infoEmbed(`${emojis.rightArrow1} https://vantern.org/bot/invite`)] }); } } diff --git a/apps/bot/src/commands/utility/fun/confess.ts b/apps/bot/src/commands/utility/fun/confess.ts index 54b97a6..26ff67f 100644 --- a/apps/bot/src/commands/utility/fun/confess.ts +++ b/apps/bot/src/commands/utility/fun/confess.ts @@ -13,6 +13,7 @@ import { TextInputBuilder, TextInputStyle, } from 'discord.js'; +import { containsBlockedWord } from '#lib/automod.js'; import { isConfessionBlacklisted, storeConfessionContext } from '#lib/confessions.js'; import { getSettings } from '#lib/settings.js'; import { errorEmbed, successEmbed } from '#utils/embeds.js'; @@ -38,7 +39,7 @@ export class ConfessCommand extends Command { return; } - const settings = await getSettings(interaction.guild.id, interaction.guild.name); + const settings = await getSettings(interaction.guild.id); if (settings.confessionEnabled === false) { await interaction.reply({ @@ -99,6 +100,13 @@ export class ConfessCommand extends Command { const confession = modalSubmit.fields.getTextInputValue('confession-text'); + if (await containsBlockedWord(interaction.guild.id, confession)) { + await modalSubmit.editReply({ + embeds: [errorEmbed(`${emojis.rightArrow2} Your confession contains a word blocked by this server.`)], + }); + return; + } + const confessionChannel = await interaction.guild.channels.fetch(settings.confessionChannelId).catch(() => null); if (!(confessionChannel instanceof TextChannel)) { diff --git a/apps/bot/src/commands/utility/giveaway.ts b/apps/bot/src/commands/utility/giveaway.ts new file mode 100644 index 0000000..d7f34b0 --- /dev/null +++ b/apps/bot/src/commands/utility/giveaway.ts @@ -0,0 +1,272 @@ +import { Command } from '@sapphire/framework'; +import { + type GuildMember, + type Message, + MessageFlags, + PermissionFlagsBits, + PermissionsBitField, + type SlashCommandBuilder, + type SlashCommandIntegerOption, + type SlashCommandStringOption, + type SlashCommandSubcommandBuilder, +} from 'discord.js'; +import ms, { type StringValue } from 'ms'; +import { containsBlockedWord } from '#lib/automod.js'; +import { endGiveaway } from '#lib/giveawayEvent.js'; +import { + buildGiveawayComponents, + buildGiveawayEmbed, + createGiveaway, + deleteGiveaway, + formatWinnersLine, + getGiveawayByMessageId, + setGiveawayMessageId, +} from '#lib/giveaways.js'; +import { errorEmbed, infoEmbed, successEmbed } from '#utils/embeds.js'; +import { emojis } from '#utils/emoji.js'; +import { getChannel } from '#utils/getChannel.js'; + +const MAX_DURATION = ms('30d'); + +export class GiveawayCommand extends Command { + public constructor(context: Command.LoaderContext, options: Command.Options) { + super(context, { ...options, preconditions: ['devMode'] }); + } + + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand((builder: SlashCommandBuilder) => + builder + .setName('giveaway') + .setDescription('Start, end or delete a giveaway.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((subcommand: SlashCommandSubcommandBuilder) => + subcommand + .setName('start') + .setDescription('Start a new giveaway.') + .addStringOption((option: SlashCommandStringOption) => + option.setName('prize').setDescription('The prize being given away').setRequired(true).setMaxLength(100), + ) + .addStringOption((option: SlashCommandStringOption) => + option.setName('duration').setDescription('Duration of the giveaway').setRequired(true).setMaxLength(20), + ) + .addIntegerOption((option: SlashCommandIntegerOption) => + option + .setName('max_entries') + .setDescription('Maximum number of entries allowed') + .setMinValue(1) + .setMaxValue(10000), + ) + .addIntegerOption((option: SlashCommandIntegerOption) => + option.setName('winners').setDescription('Amount of winners to pick').setMinValue(1).setMaxValue(100), + ), + ) + .addSubcommand((subcommand: SlashCommandSubcommandBuilder) => + subcommand + .setName('end') + .setDescription('End a giveaway early.') + .addStringOption((option: SlashCommandStringOption) => + option.setName('id').setDescription('The message ID of the giveaway').setRequired(true).setMaxLength(32), + ), + ) + .addSubcommand((subcommand: SlashCommandSubcommandBuilder) => + subcommand + .setName('delete') + .setDescription('Delete a giveaway.') + .addStringOption((option: SlashCommandStringOption) => + option.setName('id').setDescription('The message ID of the giveaway').setRequired(true).setMaxLength(32), + ), + ), + ); + } + + public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { + if (!interaction.inCachedGuild()) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} This command can only be used in a server.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const member = interaction.member as GuildMember; + + if (!member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} You do not have permission to manage giveaways.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'start') { + await this.handleStart(interaction); + return; + } + + if (subcommand === 'end') { + await this.handleEnd(interaction); + return; + } + + if (subcommand === 'delete') { + await this.handleDelete(interaction); + return; + } + } + + private async handleStart(interaction: Command.ChatInputCommandInteraction<'cached'>) { + const prize = interaction.options.getString('prize', true); + const durationStr = interaction.options.getString('duration', true) as StringValue; + const maxEntries = interaction.options.getInteger('max_entries') ?? 10000; + const winners = interaction.options.getInteger('winners') ?? undefined; + + const duration = ms(durationStr); + if (typeof duration !== 'number' || Number.isNaN(duration) || duration <= 0) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} Invalid duration format.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (duration > MAX_DURATION) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} Giveaway duration cannot exceed 30 days.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (!interaction.channel?.isSendable()) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} I can't post a giveaway in this channel.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (await containsBlockedWord(interaction.guildId, prize)) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} That prize contains a word blocked by this server.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const endsAt = new Date(Date.now() + duration); + + const giveaway = await createGiveaway( + interaction.guild.id, + interaction.guild.name, + interaction.channel.id, + interaction.user.id, + prize, + endsAt, + maxEntries, + winners, + ); + + let message: Message; + try { + message = await interaction.channel.send({ + embeds: [buildGiveawayEmbed(giveaway)], + components: buildGiveawayComponents(giveaway), + }); + } catch (err) { + console.error(err); + await deleteGiveaway(giveaway.id).catch((cleanupErr) => console.error(cleanupErr)); + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} Failed to post the giveaway. Please try again.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + try { + await setGiveawayMessageId(giveaway.id, message.id); + } catch (err) { + console.error(err); + await deleteGiveaway(giveaway.id).catch((cleanupErr) => console.error(cleanupErr)); + await message.delete().catch((cleanupErr) => console.error(cleanupErr)); + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} Failed to post the giveaway. Please try again.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.reply({ + embeds: [successEmbed(`${emojis.rightArrow2} Giveaway started for **${prize}**!\nMessage ID: \`${message.id}\``)], + flags: MessageFlags.Ephemeral, + }); + } + + private async handleEnd(interaction: Command.ChatInputCommandInteraction<'cached'>) { + const messageId = interaction.options.getString('id', true); + const giveaway = await getGiveawayByMessageId(interaction.guild.id, messageId); + + if (!giveaway) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} No giveaway found for that message.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const result = await endGiveaway(interaction.client, giveaway.id); + + if (result.status === 'not-found') { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} No giveaway found for that message.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (result.status === 'already-ended') { + await interaction.reply({ + embeds: [infoEmbed(`${emojis.rightArrow2} That giveaway has already ended.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const winnersLine = formatWinnersLine(result.giveaway.winnerIds); + + await interaction.reply({ + embeds: [ + successEmbed(`${emojis.rightArrow2} Giveaway for **${giveaway.prize}** ended.\nWinner(s): ${winnersLine}`), // todo: technically could make it swap between "winner" and "winners" based on the number of winners, but i'm too lazy to do that right now + ], + allowedMentions: { parse: [] }, + flags: MessageFlags.Ephemeral, + }); + } + + private async handleDelete(interaction: Command.ChatInputCommandInteraction<'cached'>) { + const messageId = interaction.options.getString('id', true); + const giveaway = await getGiveawayByMessageId(interaction.guild.id, messageId); + + if (!giveaway) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} No giveaway found for that message.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + await deleteGiveaway(giveaway.id); + + const channel = await getChannel(interaction.guild.channels, giveaway.channelId); + + if (channel?.isTextBased()) { + await channel.messages.delete(messageId).catch(() => {}); + } + + await interaction.reply({ + embeds: [successEmbed(`${emojis.rightArrow2} Giveaway for **${giveaway.prize}** has been deleted.`)], + flags: MessageFlags.Ephemeral, + }); + } +} diff --git a/apps/bot/src/commands/utility/help.ts b/apps/bot/src/commands/utility/help.ts index 46a12da..b7dcc45 100644 --- a/apps/bot/src/commands/utility/help.ts +++ b/apps/bot/src/commands/utility/help.ts @@ -31,7 +31,7 @@ export class HelpCommand extends Command { const embed = new EmbedBuilder().setTitle('Commands').setDescription(commandList).addFields({ name: 'Links', value: - '**Status:** https://status.duckorg.com/\n**Official Discord Server:** https://discord.gg/F4HYE8frK2\n**Documentation:** https://docs.duckorg.com/', + '**Status:** https://status.vantern.org/\n**Official Discord Server:** https://discord.gg/F4HYE8frK2\n**Documentation:** https://docs.vantern.org/', }); await interaction.reply({ diff --git a/apps/bot/src/commands/ping.ts b/apps/bot/src/commands/utility/ping.ts similarity index 100% rename from apps/bot/src/commands/ping.ts rename to apps/bot/src/commands/utility/ping.ts diff --git a/apps/bot/src/commands/utility/profile/pfp.ts b/apps/bot/src/commands/utility/profile/pfp.ts new file mode 100644 index 0000000..d273975 --- /dev/null +++ b/apps/bot/src/commands/utility/profile/pfp.ts @@ -0,0 +1,30 @@ +import { Command } from '@sapphire/framework'; +import type { SlashCommandUserOption } from 'discord.js'; +import { ASSET_SIZE, assetMessage } from '#utils/profile.js'; + +export class PfpCommand extends Command { + public constructor(context: Command.LoaderContext, options: Command.Options) { + super(context, { ...options, preconditions: ['devMode'] }); + } + + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand((builder) => + builder + .setName('pfp') + .setDescription("Easily download or view your own or someone else's pfp.") + .addUserOption((option: SlashCommandUserOption) => + option.setName('user').setDescription('The user whose profile picture you want to view').setRequired(false), + ), + ); + } + + public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { + await interaction.deferReply(); + + const user = interaction.options.getUser('user') ?? interaction.user; + // todo: in the future size could become an option rather than 4096 hard coded + const avatarUrl = user.displayAvatarURL({ size: ASSET_SIZE }); + + await interaction.editReply(assetMessage(user, avatarUrl)); + } +} diff --git a/apps/bot/src/commands/utility/profile/user.ts b/apps/bot/src/commands/utility/profile/user.ts new file mode 100644 index 0000000..6255389 --- /dev/null +++ b/apps/bot/src/commands/utility/profile/user.ts @@ -0,0 +1,151 @@ +import { Command } from '@sapphire/framework'; +import { + EmbedBuilder, + type GuildMember, + type SlashCommandBuilder, + type SlashCommandSubcommandBuilder, + type SlashCommandUserOption, + type User, +} from 'discord.js'; +import { Colors, errorEmbed } from '#utils/embeds.js'; +import { emojis } from '#utils/emoji.js'; +import { ASSET_SIZE, assetMessage } from '#utils/profile.js'; + +function addUserOption(subcommand: SlashCommandSubcommandBuilder, description: string): SlashCommandSubcommandBuilder { + return subcommand.addUserOption((option: SlashCommandUserOption) => + option.setName('user').setDescription(description).setRequired(false), + ); +} + +function toUnix(timestamp: number): number { + return Math.floor(timestamp / 1000); +} + +function formatRoles(member: GuildMember): string { + const roles = member.roles.cache + .filter((role) => role.id !== member.guild.id) + .sorted((a, b) => b.position - a.position); + + if (roles.size === 0) return 'None'; + + const shown = [...roles.values()].slice(0, 15).map((role) => role.toString()); + const remaining = roles.size - shown.length; + + return remaining > 0 ? `${shown.join(' ')} +${remaining} more` : shown.join(' '); +} + +export class UserCommand extends Command { + public constructor(context: Command.LoaderContext, options: Command.Options) { + super(context, { ...options, preconditions: ['devMode'] }); + } + + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand((builder: SlashCommandBuilder) => + builder + .setName('user') + .setDescription("View a user's profile picture, banner, or info.") + .addSubcommand((subcommand: SlashCommandSubcommandBuilder) => + addUserOption( + subcommand.setName('pfp').setDescription("Easily download or view your own or someone else's pfp."), + 'The user whose profile picture you want to view', + ), + ) + .addSubcommand((subcommand: SlashCommandSubcommandBuilder) => + addUserOption( + subcommand.setName('banner').setDescription("Easily download or view your own or someone else's banner."), + 'The user whose banner you want to view', + ), + ) + .addSubcommand((subcommand: SlashCommandSubcommandBuilder) => + addUserOption( + subcommand.setName('info').setDescription('View information about yourself or someone else.'), + 'The user whose info you want to view', + ), + ), + ); + } + + public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { + await interaction.deferReply(); + + const subcommand = interaction.options.getSubcommand(); + const user = interaction.options.getUser('user') ?? interaction.user; + + if (subcommand === 'pfp') { + await this.sendPfp(interaction, user); + return; + } + + if (subcommand === 'banner') { + await this.sendBanner(interaction, user); + return; + } + + if (subcommand === 'info') { + await this.sendInfo(interaction, user); + return; + } + } + + private async sendPfp(interaction: Command.ChatInputCommandInteraction, user: User) { + const avatarUrl = user.displayAvatarURL({ size: ASSET_SIZE }); + + await interaction.editReply(assetMessage(user, avatarUrl)); + } + + private async sendBanner(interaction: Command.ChatInputCommandInteraction, user: User) { + const fetched = await user.fetch(true).catch(() => null); + + if (!fetched) { + await interaction.editReply({ + embeds: [errorEmbed(`${emojis.rightArrow2} Couldn't fetch that user, please try again.`)], + }); + return; + } + + const bannerUrl = fetched.bannerURL({ size: ASSET_SIZE }); + + if (!bannerUrl) { + await interaction.editReply({ + embeds: [errorEmbed(`${emojis.rightArrow2} **${fetched.displayName}** doesn't have a banner!`)], + }); + return; + } + + await interaction.editReply(assetMessage(fetched, bannerUrl)); + } + + private async sendInfo(interaction: Command.ChatInputCommandInteraction, user: User) { + const member = interaction.inCachedGuild() + ? await interaction.guild.members.fetch(user.id).catch(() => null) + : null; + + const created = toUnix(user.createdTimestamp); + const lines = [ + `${emojis.rightArrow1} **Username:** ${user.username}${user.bot ? ' (bot)' : ''}`, + `${emojis.rightArrow1} **Created:** ()`, + ]; + + if (member?.nickname) { + lines.push(`${emojis.rightArrow1} **Nickname:** ${member.nickname}`); + } + + if (member?.joinedTimestamp) { + const joined = toUnix(member.joinedTimestamp); + lines.push(`${emojis.rightArrow1} **Joined:** ()`); + } + + if (member) { + lines.push(`${emojis.rightArrow1} **Roles:**\n${formatRoles(member)}`); + } + + const embed = new EmbedBuilder() + .setColor(Colors.info) + .setTitle(`${user.displayName}`) + .setThumbnail(user.displayAvatarURL({ size: ASSET_SIZE })) + .setDescription(lines.join('\n')) + .setFooter({ text: `ID: ${user.id}` }); + + await interaction.editReply({ embeds: [embed] }); + } +} diff --git a/apps/bot/src/commands/utility/reminder/reminder.ts b/apps/bot/src/commands/utility/reminder/reminder.ts index 3ae0e54..22e8bba 100644 --- a/apps/bot/src/commands/utility/reminder/reminder.ts +++ b/apps/bot/src/commands/utility/reminder/reminder.ts @@ -6,6 +6,7 @@ import { type SlashCommandSubcommandBuilder, } from 'discord.js'; import ms, { type StringValue } from 'ms'; +import { containsBlockedWord } from '#lib/automod.js'; import { LimitError } from '#lib/limits.js'; import { createReminder, getReminder, removeReminder } from '#lib/reminders.js'; import { errorEmbed, infoEmbed, successEmbed } from '#utils/embeds.js'; @@ -67,6 +68,14 @@ export class ReminderCommand extends Command { return; } + if (interaction.inCachedGuild() && (await containsBlockedWord(interaction.guild.id, message))) { + await interaction.reply({ + embeds: [errorEmbed(`${emojis.rightArrow2} That reminder contains a word blocked by this server.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + const remindAt = new Date(Date.now() + duration); try { diff --git a/apps/bot/src/commands/utility/settings.ts b/apps/bot/src/commands/utility/settings.ts index f0fa6f9..8270333 100644 --- a/apps/bot/src/commands/utility/settings.ts +++ b/apps/bot/src/commands/utility/settings.ts @@ -240,6 +240,33 @@ function buildHaikuPanel(settings: ServerSettings, status?: string) { }; } +function buildAutoPublisherPanel(settings: ServerSettings, status?: string) { + const toggleMenu = new StringSelectMenuBuilder() + .setCustomId('autoPublisherToggle') + .setPlaceholder(`${settings.autoPublisher ? 'Enabled' : 'Disabled'}`) + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Enable') + .setDescription('Automatically publish messages posted in announcement channels.') + .setValue('enable'), + new StringSelectMenuOptionBuilder() + .setLabel('Disable') + .setDescription("Don't publish announcements automatically.") + .setValue('disable'), + ); + + return { + embeds: [ + infoEmbed( + status + ? `${emojis.rightArrow1} **Auto Publisher** module:\n${emojis.rightArrow2} ${status}` + : `${emojis.rightArrow1} **Auto Publisher** module:\n${emojis.rightArrow2} I need **Manage Messages** (in your announcement channels) to publish other people's messages.`, + ), + ], + components: [new ActionRowBuilder().addComponents(toggleMenu)], + }; +} + async function normalizeTicketSettings(guildId: string, guild: Guild, settings: ServerSettings) { if (!settings.ticketCategoryId) return settings; @@ -301,6 +328,10 @@ export class SettingsCommand extends Command { .setLabel('Haiku') .setDescription('Reply when a message forms a haiku.') .setValue('haiku'), + new StringSelectMenuOptionBuilder() + .setLabel('Auto Publisher') + .setDescription('Automatically publish messages posted in announcement channels.') + .setValue('autoPublisher'), ); const response = await interaction.reply({ @@ -338,7 +369,7 @@ export class SettingsCommand extends Command { return; } - const settings = await normalizeTicketSettings(guildId, guild, await getSettings(guildId, guild.name)); + const settings = await normalizeTicketSettings(guildId, guild, await getSettings(guildId)); if (settingChoice.values[0] === 'welcome') { await settingChoice.update(buildWelcomePanel(settings, guild)); @@ -350,6 +381,8 @@ export class SettingsCommand extends Command { await settingChoice.update(buildConfessionPanel(settings, guild)); } else if (settingChoice.values[0] === 'haiku') { await settingChoice.update(buildHaikuPanel(settings)); + } else if (settingChoice.values[0] === 'autoPublisher') { + await settingChoice.update(buildAutoPublisherPanel(settings)); } else { return; } @@ -422,6 +455,11 @@ export class SettingsCommand extends Command { const next = await updateSettings(guildId, guild.name, { haikuEnabled: enable }); await i.update(buildHaikuPanel(next, `Haiku **${enable ? 'enabled' : 'disabled'}**.`)); + } else if (i.customId === 'autoPublisherToggle' && i.isStringSelectMenu()) { + const enable = i.values[0] === 'enable'; + const next = await updateSettings(guildId, guild.name, { autoPublisher: enable }); + + await i.update(buildAutoPublisherPanel(next, `Auto Publisher **${enable ? 'enabled' : 'disabled'}**.`)); } }); diff --git a/apps/bot/src/commands/utility/setup.ts b/apps/bot/src/commands/utility/setup.ts index 3ddd118..f1b61dd 100644 --- a/apps/bot/src/commands/utility/setup.ts +++ b/apps/bot/src/commands/utility/setup.ts @@ -141,7 +141,7 @@ export class SetupCommand extends Command { return; } - let settings = await getSettings(guildId, guild.name); + let settings = await getSettings(guildId); const summary: string[] = []; // 1. welcome messages diff --git a/apps/bot/src/commands/utility/user.ts b/apps/bot/src/commands/utility/user.ts deleted file mode 100644 index 7aada0b..0000000 --- a/apps/bot/src/commands/utility/user.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Command } from '@sapphire/framework'; -import { infoEmbed } from '#utils/embeds.js'; -import { emojis } from '#utils/emoji.js'; - -export class UserCommand extends Command { - public constructor(context: Command.LoaderContext, options: Command.Options) { - super(context, { ...options, preconditions: ['devMode'] }); - } - - public override registerApplicationCommands(registry: Command.Registry) { - registry.registerChatInputCommand((builder) => - builder.setName('user').setDescription('Provides information about the user.'), - ); - } - - public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { - await interaction.reply({ - embeds: [infoEmbed(`${emojis.rightArrow2} This command was run by **${interaction.user.username}**.`)], - }); - } -} diff --git a/apps/bot/src/interaction-handlers/giveaway/giveawayHandler.ts b/apps/bot/src/interaction-handlers/giveaway/giveawayHandler.ts new file mode 100644 index 0000000..0e53921 --- /dev/null +++ b/apps/bot/src/interaction-handlers/giveaway/giveawayHandler.ts @@ -0,0 +1,130 @@ +import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework'; +import { ActionRowBuilder, ButtonBuilder, type ButtonInteraction, ButtonStyle, MessageFlags } from 'discord.js'; +import { + AlreadyEnteredError, + buildGiveawayComponents, + buildGiveawayEmbed, + enterGiveaway, + GiveawayFullError, + leaveGiveaway, + NotEnteredError, + refreshGiveawayMessage, +} from '#lib/giveaways.js'; +import { emojis } from '#utils/emoji.js'; + +const ENTER_PREFIX = 'giveaway-enter-'; +const LEAVE_PREFIX = 'giveaway-leave-'; + +interface GiveawayAction { + action: 'enter' | 'leave'; + giveawayId: string; +} + +export class GiveawayEnterHandler extends InteractionHandler { + public constructor(ctx: InteractionHandler.LoaderContext, options: InteractionHandler.Options) { + super(ctx, { + ...options, + interactionHandlerType: InteractionHandlerTypes.Button, + }); + } + + public override parse(interaction: ButtonInteraction) { + if (interaction.customId.startsWith(ENTER_PREFIX)) { + return this.some({ action: 'enter', giveawayId: interaction.customId.slice(ENTER_PREFIX.length) }); + } + + if (interaction.customId.startsWith(LEAVE_PREFIX)) { + return this.some({ action: 'leave', giveawayId: interaction.customId.slice(LEAVE_PREFIX.length) }); + } + + return this.none(); + } + + public async run(interaction: ButtonInteraction, payload: GiveawayAction) { + if (payload.action === 'enter') { + await this.handleEnter(interaction, payload.giveawayId); + return; + } + + await this.handleLeave(interaction, payload.giveawayId); + } + + private async handleEnter(interaction: ButtonInteraction, giveawayId: string) { + try { + const giveaway = await enterGiveaway(giveawayId, interaction.user.id); + + if (!giveaway) { + await interaction.reply({ + content: `${emojis.rightArrow2} This giveaway has ended.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.update({ + embeds: [buildGiveawayEmbed(giveaway)], + components: buildGiveawayComponents(giveaway), + }); + + await interaction.followUp({ + content: `${emojis.rightArrow2} You've entered the giveaway for **${giveaway.prize}**!`, + flags: MessageFlags.Ephemeral, + }); + } catch (err) { + if (err instanceof AlreadyEnteredError) { + const leaveButton = new ButtonBuilder() + .setCustomId(`${LEAVE_PREFIX}${giveawayId}`) + .setLabel('Leave') + .setStyle(ButtonStyle.Danger); + + await interaction.reply({ + content: `${emojis.rightArrow2} ${err.message}`, + components: [new ActionRowBuilder().addComponents(leaveButton)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (err instanceof GiveawayFullError) { + await interaction.reply({ + content: `${emojis.rightArrow2} ${err.message}`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + throw err; + } + } + + private async handleLeave(interaction: ButtonInteraction, giveawayId: string) { + try { + const giveaway = await leaveGiveaway(giveawayId, interaction.user.id); + + if (!giveaway) { + await interaction.update({ + content: `${emojis.rightArrow2} This giveaway has ended.`, + components: [], + }); + return; + } + + await interaction.update({ + content: `${emojis.rightArrow2} You have left the giveaway for **${giveaway.prize}**.`, + components: [], + }); + + await refreshGiveawayMessage(interaction.client, giveaway); + } catch (err) { + if (err instanceof NotEnteredError) { + await interaction.update({ + content: `${emojis.rightArrow2} ${err.message}`, + components: [], + }); + return; + } + + throw err; + } + } +} diff --git a/apps/bot/src/interaction-handlers/ticket/createTicketHandler.ts b/apps/bot/src/interaction-handlers/ticket/createTicketHandler.ts index 6f5d926..bf603c3 100644 --- a/apps/bot/src/interaction-handlers/ticket/createTicketHandler.ts +++ b/apps/bot/src/interaction-handlers/ticket/createTicketHandler.ts @@ -70,7 +70,7 @@ export class ButtonHandler extends InteractionHandler { }); const reason = modalSubmit.fields.getTextInputValue('ticket-reason'); - const settings = await getSettings(interaction.guild.id, interaction.guild.name); + const settings = await getSettings(interaction.guild.id); let parent: string | undefined; if (settings.ticketCategoryId) { diff --git a/apps/bot/src/interaction-handlers/ticket/removeTicketHandler.ts b/apps/bot/src/interaction-handlers/ticket/removeTicketHandler.ts index 6a448e0..6c794e4 100644 --- a/apps/bot/src/interaction-handlers/ticket/removeTicketHandler.ts +++ b/apps/bot/src/interaction-handlers/ticket/removeTicketHandler.ts @@ -185,7 +185,7 @@ export class ButtonHandler extends InteractionHandler { try { if (ticket && channel.isTextBased()) { - const settings = await getSettings(interaction.guild.id, interaction.guild.name); + const settings = await getSettings(interaction.guild.id); // Send transcript if configured if (settings.ticketTranscriptChannelId) { diff --git a/apps/bot/src/lib/autoPublisher.ts b/apps/bot/src/lib/autoPublisher.ts new file mode 100644 index 0000000..19884ec --- /dev/null +++ b/apps/bot/src/lib/autoPublisher.ts @@ -0,0 +1,23 @@ +import { type Message, MessageFlags } from 'discord.js'; + +const crosspostWindows = new Map(); + +export async function autoPublish(message: Message): Promise { + if (!message.crosspostable) return; + + if (message.flags.has(MessageFlags.IsCrosspost)) return; + + const now = Date.now(); + const existing = crosspostWindows.get(message.channel.id); + const window = existing && existing.resetAt > now ? existing : { count: 0, resetAt: now + 60 * 60 * 1000 }; // 1h + + if (window.count >= 10) return; + + crosspostWindows.set(message.channel.id, { ...window, count: window.count + 1 }); + + try { + await message.crosspost(); + } catch (err) { + console.error(err); + } +} diff --git a/apps/bot/src/lib/automod.ts b/apps/bot/src/lib/automod.ts index c2361b2..a0a4da5 100644 --- a/apps/bot/src/lib/automod.ts +++ b/apps/bot/src/lib/automod.ts @@ -8,6 +8,24 @@ export class DuplicateAutoModError extends Error { } } +const blockedWordsCache = new Map(); + +async function getBlockedWords(guildId: string): Promise { + const cached = blockedWordsCache.get(guildId); + if (cached && cached.expiresAt > Date.now()) return cached.words; + + const rows = await prisma.autoMod.findMany({ where: { guildId }, select: { word: true } }); + const words = rows.map((row) => row.word.trim().toLowerCase()).filter(Boolean); + + blockedWordsCache.set(guildId, { words, expiresAt: Date.now() + 5 * 60 * 1000 }); // 5 min ttl + + return words; +} + +export function forgetBlockedWords(guildId: string): void { + blockedWordsCache.delete(guildId); +} + export async function createAutoMod(guildId: string, guildName: string, word: string) { if (!word?.trim()) { throw new Error('Automod word cannot be empty.'); @@ -27,24 +45,15 @@ export async function createAutoMod(guildId: string, guildName: string, word: st create: { id: guildId, name: guildName }, update: { name: guildName }, }); - - try { - return await prisma.autoMod.create({ - data: { guildId, word }, - }); - } catch (error) { - if ((error as { code?: string }).code === 'P2002') { - throw new DuplicateAutoModError(); - } - - throw error; - } } try { - return await prisma.autoMod.create({ + const created = await prisma.autoMod.create({ data: { guildId, word }, }); + blockedWordsCache.delete(guildId); + + return created; } catch (error) { if ((error as { code?: string }).code === 'P2002') { throw new DuplicateAutoModError(); @@ -62,17 +71,25 @@ export async function getAutoMods(guildId: string) { } export async function containsBlockedWord(guildId: string, text: string): Promise { - const autoMods = await getAutoMods(guildId); + const words = await getBlockedWords(guildId); + if (words.length === 0) return false; + const lowerText = text.toLowerCase(); - return autoMods.some((autoMod) => autoMod.word.trim() && lowerText.includes(autoMod.word.toLowerCase())); + return words.some((word) => lowerText.includes(word)); } export async function removeAutoMod(autoModId: string) { - return prisma.autoMod.delete({ where: { id: autoModId } }); + const removed = await prisma.autoMod.delete({ where: { id: autoModId } }); + blockedWordsCache.delete(removed.guildId); + + return removed; } export async function clearAutoMods(guildId: string) { - return prisma.autoMod.deleteMany({ where: { guildId } }); + const removed = await prisma.autoMod.deleteMany({ where: { guildId } }); + blockedWordsCache.delete(guildId); + + return removed; } export async function getAutoMod(autoModId: string) { diff --git a/apps/bot/src/lib/bans.ts b/apps/bot/src/lib/bans.ts index 200f421..82f42af 100644 --- a/apps/bot/src/lib/bans.ts +++ b/apps/bot/src/lib/bans.ts @@ -1,5 +1,6 @@ -import { prisma } from '@questbot/database'; +import { Prisma, prisma } from '@questbot/database'; import { type Client, type Guild, PermissionFlagsBits } from 'discord.js'; +import { getShardInfo, shardOwns } from '#utils/sharding.js'; export async function createBan( guildId: string, @@ -65,11 +66,12 @@ export async function removeBan(guild: Guild, userId: string): Promise } export async function purgeExpiredBans(client: Client) { - const expired = await prisma.ban.findMany({ - where: { - expiresAt: { not: null, lte: new Date() }, - }, - }); + const expired = await prisma.$queryRaw` + SELECT * FROM "Ban" + WHERE "expiresAt" IS NOT NULL AND "expiresAt" <= ${new Date()} + AND ${shardOwns(Prisma.sql`"guildId"::bigint`, getShardInfo(client))} + LIMIT 100 + `; for (const ban of expired) { const guild = client.guilds.cache.get(ban.guildId); diff --git a/apps/bot/src/lib/giveawayEvent.ts b/apps/bot/src/lib/giveawayEvent.ts new file mode 100644 index 0000000..1ce127e --- /dev/null +++ b/apps/bot/src/lib/giveawayEvent.ts @@ -0,0 +1,48 @@ +import type { Client } from 'discord.js'; +import { getChannel } from '#utils/getChannel.js'; +import { startShardedPoller } from '#utils/sharding.js'; +import { buildGiveawayEmbed, type FinishGiveawayResult, finishGiveaway, getDueGiveaways } from './giveaways.js'; + +export function giveawayScheduler(client: Client) { + startShardedPoller({ + client, + getDue: getDueGiveaways, + handle: (giveaway) => endGiveaway(client, giveaway.id).then(() => undefined), + }); +} + +export async function endGiveaway(client: Client, giveawayId: string): Promise { + const result = await finishGiveaway(giveawayId); + if (result.status !== 'ended') return result; + + const ended = result.giveaway; + const channel = await getChannel(client.channels, ended.channelId); + + if (channel?.isSendable()) { + if (ended.messageId) { + const message = await channel.messages.fetch(ended.messageId).catch(() => null); + if (message) { + await message.edit({ embeds: [buildGiveawayEmbed(ended)], components: [] }).catch(() => {}); + } + } + + if (ended.winnerIds.length) { + await channel + .send({ + content: `Congratulations ${ended.winnerIds.map((id) => `<@${id}>`).join(', ')}! You've won **${ended.prize}**!`, + allowedMentions: { users: ended.winnerIds }, + ...(ended.messageId ? { reply: { messageReference: ended.messageId } } : {}), + }) + .catch((err) => console.error(err)); + } else { + await channel + .send({ + content: `The giveaway for **${ended.prize}** ended with no entries.`, + allowedMentions: { parse: [] }, + }) + .catch((err) => console.error(err)); + } + } + + return result; +} diff --git a/apps/bot/src/lib/giveaways.ts b/apps/bot/src/lib/giveaways.ts new file mode 100644 index 0000000..12c2eeb --- /dev/null +++ b/apps/bot/src/lib/giveaways.ts @@ -0,0 +1,239 @@ +import { randomInt } from 'node:crypto'; +import { Prisma, prisma } from '@questbot/database'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type Client, EmbedBuilder } from 'discord.js'; +import { Colors } from '#utils/embeds.js'; +import { getChannel } from '#utils/getChannel.js'; +import { type ShardInfo, shardOwns } from '#utils/sharding.js'; + +type GiveawayView = Prisma.GiveawayModel; + +export function formatWinnersLine(winnerIds: string[]): string { + return winnerIds.length ? winnerIds.map((id) => `<@${id}>`).join(', ') : 'No valid entries.'; +} + +export function buildGiveawayEmbed(giveaway: GiveawayView): EmbedBuilder { + const unix = Math.floor(giveaway.endsAt.getTime() / 1000); + const entriesLine = + giveaway.maxEntries !== null ? `${giveaway.entries.length}/${giveaway.maxEntries}` : `${giveaway.entries.length}`; + + if (giveaway.ended) { + const winnersLine = formatWinnersLine(giveaway.winnerIds); + + return new EmbedBuilder() + .setColor(Colors.success) + .setTitle('Giveaway Ended') + .setDescription( + [ + `**Prize:** ${giveaway.prize}`, + `**Winner(s):** ${winnersLine}`, + `**Hosted by:** <@${giveaway.hostId}>`, + `**Entries:** ${entriesLine}`, + ].join('\n'), + ); + } + + return new EmbedBuilder() + .setColor(Colors.info) + .setTitle('Giveaway') + .setDescription( + [ + `**Prize:** ${giveaway.prize}`, + `**Winners:** ${giveaway.winnerCount}`, + `**Ends:** `, + `**Hosted by:** <@${giveaway.hostId}>`, + `**Entries:** ${entriesLine}`, + `\nClick the button below to enter!`, + ].join('\n'), + ); +} + +export function buildGiveawayComponents(giveaway: GiveawayView) { + const isFull = giveaway.maxEntries !== null && giveaway.entries.length >= giveaway.maxEntries; + + const button = new ButtonBuilder() + .setCustomId(`giveaway-enter-${giveaway.id}`) + .setLabel('Enter') + .setEmoji('🎉') + .setStyle(ButtonStyle.Primary) + .setDisabled(giveaway.ended || isFull); + + return [new ActionRowBuilder().addComponents(button)]; +} + +export async function refreshGiveawayMessage(client: Client, giveaway: GiveawayView) { + if (!giveaway.messageId) return; + + const channel = await getChannel(client.channels, giveaway.channelId); + if (!channel?.isTextBased()) return; + + const message = await channel.messages.fetch(giveaway.messageId).catch(() => null); + if (!message) return; + + await message + .edit({ embeds: [buildGiveawayEmbed(giveaway)], components: buildGiveawayComponents(giveaway) }) + .catch(() => {}); +} + +export class AlreadyEnteredError extends Error { + public constructor() { + super('You have already entered this giveaway.'); + this.name = 'AlreadyEnteredError'; + } +} + +// this happens when a user attempts to leave a giveaway they haven't entered with an old (ephemeral) button +export class NotEnteredError extends Error { + public constructor() { + super("You haven't entered this giveaway."); + this.name = 'NotEnteredError'; + } +} + +export class GiveawayFullError extends Error { + public constructor() { + super("This giveaway is full, it has reached it's maximum number of entries."); + this.name = 'GiveawayFullError'; + } +} + +export async function createGiveaway( + guildId: string, + guildName: string, + channelId: string, + hostId: string, + prize: string, + endsAt: Date, + maxEntries?: number, + winnerCount?: number, +) { + await prisma.server.upsert({ + where: { id: guildId }, + create: { id: guildId, name: guildName }, + update: { name: guildName }, + }); + + return prisma.giveaway.create({ + data: { guildId, channelId, hostId, prize, endsAt, maxEntries, winnerCount }, + }); +} + +export async function setGiveawayMessageId(giveawayId: string, messageId: string) { + return prisma.giveaway.update({ where: { id: giveawayId }, data: { messageId } }); +} + +export async function getGiveaway(giveawayId: string) { + return prisma.giveaway.findUnique({ where: { id: giveawayId } }); +} + +export async function getGiveawayByMessageId(guildId: string, messageId: string) { + const giveaway = await prisma.giveaway.findUnique({ where: { messageId } }); + if (!giveaway || giveaway.guildId !== guildId) return null; + return giveaway; +} + +export async function deleteGiveawayByMessageId(messageId: string) { + await prisma.giveaway.deleteMany({ where: { messageId } }); +} + +export async function deleteGiveawaysByMessageIds(messageIds: string[]) { + if (messageIds.length === 0) return; + + await prisma.giveaway.deleteMany({ where: { messageId: { in: messageIds } } }); +} + +type TransactionClient = Parameters[0]>[0]; + +async function lockGiveawayRow(tx: TransactionClient, giveawayId: string): Promise { + await tx.$executeRaw`SELECT id FROM "giveaways" WHERE id = ${giveawayId} FOR UPDATE`; +} + +export async function enterGiveaway(giveawayId: string, userId: string) { + return prisma.$transaction(async (tx) => { + await lockGiveawayRow(tx, giveawayId); + + const giveaway = await tx.giveaway.findUnique({ where: { id: giveawayId } }); + if (!giveaway || giveaway.ended) return null; + + if (giveaway.entries.includes(userId)) { + throw new AlreadyEnteredError(); + } + + if (giveaway.maxEntries !== null && giveaway.entries.length >= giveaway.maxEntries) { + throw new GiveawayFullError(); + } + + return tx.giveaway.update({ + where: { id: giveawayId }, + data: { entries: { push: userId } }, + }); + }); +} + +export async function leaveGiveaway(giveawayId: string, userId: string) { + return prisma.$transaction(async (tx) => { + await lockGiveawayRow(tx, giveawayId); + + const giveaway = await tx.giveaway.findUnique({ where: { id: giveawayId } }); + if (!giveaway || giveaway.ended) return null; + + if (!giveaway.entries.includes(userId)) { + throw new NotEnteredError(); + } + + return tx.giveaway.update({ + where: { id: giveawayId }, + data: { entries: giveaway.entries.filter((id) => id !== userId) }, + }); + }); +} + +export async function getDueGiveaways(shard: ShardInfo) { + return prisma.$queryRaw` + SELECT * FROM "giveaways" + WHERE "ended" = false + AND "endsAt" <= ${new Date()} + AND ${shardOwns(Prisma.sql`"guildId"::bigint`, shard)} + ORDER BY "endsAt" ASC + LIMIT 100 + `; +} + +export async function deleteGiveaway(giveawayId: string) { + return prisma.giveaway.delete({ where: { id: giveawayId } }); +} + +export type FinishGiveawayResult = + | { status: 'ended'; giveaway: GiveawayView } + | { status: 'already-ended' } + | { status: 'not-found' }; + +export async function finishGiveaway(giveawayId: string): Promise { + return prisma.$transaction(async (tx) => { + await lockGiveawayRow(tx, giveawayId); + + const giveaway = await tx.giveaway.findUnique({ where: { id: giveawayId } }); + if (!giveaway) return { status: 'not-found' }; + if (giveaway.ended) return { status: 'already-ended' }; + + const winnerIds = pickWinners(giveaway.entries, giveaway.winnerCount); + const updated = await tx.giveaway.update({ + where: { id: giveawayId }, + data: { ended: true, winnerIds }, + }); + + return { status: 'ended', giveaway: updated }; + }); +} + +export function pickWinners(entries: string[], count: number): string[] { + const pool = [...entries]; + const winners: string[] = []; + + while (winners.length < count && pool.length > 0) { + const index = randomInt(pool.length); + winners.push(pool[index]!); + pool.splice(index, 1); + } + + return winners; +} diff --git a/apps/bot/src/lib/logging.ts b/apps/bot/src/lib/logging.ts index b898ea5..beb3f28 100644 --- a/apps/bot/src/lib/logging.ts +++ b/apps/bot/src/lib/logging.ts @@ -2,7 +2,7 @@ import type { AuditLogEvent, EmbedBuilder, Guild } from 'discord.js'; import { getSettings } from '#lib/settings.js'; export async function logEmbed(guild: Guild, embed: EmbedBuilder) { - const settings = await getSettings(guild.id, guild.name).catch((err) => { + const settings = await getSettings(guild.id).catch((err) => { console.error(err); return null; }); @@ -18,7 +18,7 @@ export async function logEmbed(guild: Guild, embed: EmbedBuilder) { export async function isLoggingChannel(guild: Guild, channelId: string | null | undefined) { if (!channelId) return false; - const settings = await getSettings(guild.id, guild.name).catch((err) => { + const settings = await getSettings(guild.id).catch((err) => { console.error(err); return null; }); @@ -40,7 +40,7 @@ export async function getRecentAuditLogEntry(guild: Guild, type: AuditLogEvent, ); } -export function truncate(text: string | null | undefined, length = 1900) { +export function truncate(text: string | null | undefined, length = 1024): string { if (!text) return ''; - return text.length > length ? `${text.slice(0, length)}…` : text; + return text.length > length ? `${text.slice(0, length - 1)}…` : text; } diff --git a/apps/bot/src/lib/mutes.ts b/apps/bot/src/lib/mutes.ts index 69f354c..3210d37 100644 --- a/apps/bot/src/lib/mutes.ts +++ b/apps/bot/src/lib/mutes.ts @@ -1,5 +1,6 @@ -import { prisma } from '@questbot/database'; +import { Prisma, prisma } from '@questbot/database'; import type { Guild } from 'discord.js'; +import { type ShardInfo, shardOwns } from '#utils/sharding.js'; export async function createMute( guildId: string, @@ -32,12 +33,12 @@ export async function getMute(guildId: string, userId: string) { }); } -export async function getActiveMutes() { - return prisma.mute.findMany({ - where: { - OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], - }, - }); +export async function getActiveMutes(shard: ShardInfo) { + return prisma.$queryRaw` + SELECT * FROM "Mute" + WHERE ("expiresAt" IS NULL OR "expiresAt" > ${new Date()}) + AND ${shardOwns(Prisma.sql`"guildId"::bigint`, shard)} + `; } export async function enforceMute(guild: Guild, userId: string): Promise { diff --git a/apps/bot/src/lib/reminderEvent.ts b/apps/bot/src/lib/reminderEvent.ts index d07a6cf..ee6e495 100644 --- a/apps/bot/src/lib/reminderEvent.ts +++ b/apps/bot/src/lib/reminderEvent.ts @@ -1,50 +1,34 @@ +import type { Prisma } from '@questbot/database'; import type { Client } from 'discord.js'; import { emojis } from '#utils/emoji.js'; +import { startShardedPoller } from '#utils/sharding.js'; import { getDueReminders, removeReminder } from './reminders.js'; -function ownerShardId(snowflake: string, totalShards: number) { - return Number((BigInt(snowflake) >> 22n) % BigInt(totalShards)); -} - export function reminderScheduler(client: Client) { - const checkReminders = async () => { - try { - const due = await getDueReminders(); - const totalShards = client.shard?.count ?? 1; - const shardId = client.shard?.ids[0] ?? 0; - - for (const reminder of due) { - // each shard handles its reminders for its own guild - const targetShard = ownerShardId(reminder.guildId ?? reminder.userId, totalShards); - if (targetShard !== shardId) continue; - - try { - let sent = false; - if (reminder.channelId) { - const channel = client.channels.cache.get(reminder.channelId); - if (channel?.isSendable()) { - await channel.send({ - content: `${emojis.rightArrow2} <@${reminder.userId}> reminder: ${reminder.message ?? 'No message provided'}`, - allowedMentions: { users: [reminder.userId] }, - }); - sent = true; - } - } - if (!sent) { - await dmUser(client, reminder.userId, reminder.message); - } + startShardedPoller({ + client, + getDue: getDueReminders, + handle: (reminder) => handleReminder(client, reminder), + }); +} - await removeReminder(reminder.id); - } catch (err) { - console.error(err); - } - } - } catch (err) { - console.error(err); +async function handleReminder(client: Client, reminder: Prisma.ReminderModel) { + let sent = false; + if (reminder.channelId) { + const channel = client.channels.cache.get(reminder.channelId); + if (channel?.isSendable()) { + await channel.send({ + content: `${emojis.rightArrow2} <@${reminder.userId}> reminder: ${reminder.message ?? 'No message provided'}`, + allowedMentions: { users: [reminder.userId] }, + }); + sent = true; } - }; - checkReminders(); - setInterval(checkReminders, 30_000); + } + if (!sent) { + await dmUser(client, reminder.userId, reminder.message); + } + + await removeReminder(reminder.id); } async function dmUser(client: Client, userId: string, message: string) { diff --git a/apps/bot/src/lib/reminders.ts b/apps/bot/src/lib/reminders.ts index 4943f6f..64bd3ae 100644 --- a/apps/bot/src/lib/reminders.ts +++ b/apps/bot/src/lib/reminders.ts @@ -1,4 +1,5 @@ -import { prisma } from '@questbot/database'; +import { Prisma, prisma } from '@questbot/database'; +import { type ShardInfo, shardOwns } from '#utils/sharding.js'; import { LIMITS_ENABLED, LimitError } from './limits.js'; export async function createReminder( @@ -51,8 +52,12 @@ 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() } }, - }); +export async function getDueReminders(shard: ShardInfo) { + return prisma.$queryRaw` + SELECT * FROM "Reminder" + WHERE "remindAt" <= ${new Date()} + AND ${shardOwns(Prisma.sql`COALESCE("guildId", "userId")::bigint`, shard)} + ORDER BY "remindAt" ASC + LIMIT 100 + `; } diff --git a/apps/bot/src/lib/settings.ts b/apps/bot/src/lib/settings.ts index 056f2bb..93d6481 100644 --- a/apps/bot/src/lib/settings.ts +++ b/apps/bot/src/lib/settings.ts @@ -11,6 +11,7 @@ export type ServerSettings = { loggingEnabled?: boolean; loggingChannelId?: string | null; haikuEnabled?: boolean; + autoPublisher?: boolean; }; export const DefaultSettings: ServerSettings = { @@ -24,15 +25,29 @@ export const DefaultSettings: ServerSettings = { loggingEnabled: false, loggingChannelId: null, haikuEnabled: false, + autoPublisher: false, }; -export async function getSettings(guildId: string, guildName: string): Promise { - const row = await prisma.server.upsert({ +// caching rather than ending up fetching the settings basically each message +const settingsCache = new Map(); + +async function readSettings(guildId: string): Promise { + const row = await prisma.server.findUnique({ where: { id: guildId }, - create: { id: guildId, name: guildName, settings: DefaultSettings as Prisma.InputJsonValue }, - update: { name: guildName }, + select: { settings: true }, }); - return { ...DefaultSettings, ...(row.settings as Partial) }; + + return { ...DefaultSettings, ...((row?.settings ?? {}) as Partial) }; +} + +export async function getSettings(guildId: string): Promise { + const cached = settingsCache.get(guildId); + if (cached && cached.expiresAt > Date.now()) return cached.settings; + + const settings = await readSettings(guildId); + settingsCache.set(guildId, { settings, expiresAt: Date.now() + 5 * 60 * 1000 }); // 5 min ttl + + return settings; } export async function updateSettings( @@ -40,19 +55,20 @@ export async function updateSettings( guildName: string, patch: Partial, ): Promise { - const row = await prisma.server.upsert({ - where: { id: guildId }, - create: { id: guildId, name: guildName, settings: DefaultSettings as Prisma.InputJsonValue }, - update: { name: guildName }, - }); - - const current = { ...DefaultSettings, ...(row.settings as Partial) }; + const current = await readSettings(guildId); const next = { ...current, ...patch }; - await prisma.server.update({ + await prisma.server.upsert({ where: { id: guildId }, - data: { name: guildName, settings: next as Prisma.InputJsonValue }, + create: { id: guildId, name: guildName, settings: next as Prisma.InputJsonValue }, + update: { name: guildName, settings: next as Prisma.InputJsonValue }, }); + settingsCache.set(guildId, { settings: next, expiresAt: Date.now() + 5 * 60 * 1000 }); // 5 min ttl + return next; } + +export function forgetSettings(guildId: string): void { + settingsCache.delete(guildId); +} diff --git a/apps/bot/src/lib/sticky.ts b/apps/bot/src/lib/sticky.ts new file mode 100644 index 0000000..1b3bbc5 --- /dev/null +++ b/apps/bot/src/lib/sticky.ts @@ -0,0 +1,119 @@ +import { type Prisma, prisma } from '@questbot/database'; +import type { GuildTextBasedChannel } from 'discord.js'; + +export type Sticky = Prisma.ChannelModel; + +//* checked on every message so we cache it per guild (same as automod) +const stickyCache = new Map(); + +const lastRepostAt = new Map(); +const pendingRepost = new Set(); + +async function getGuildStickies(guildId: string): Promise { + const cached = stickyCache.get(guildId); + if (cached && cached.expiresAt > Date.now()) return cached.stickies; + + const stickies = await prisma.channel.findMany({ + where: { guildId, stickyContent: { not: null } }, + }); + + stickyCache.set(guildId, { stickies, expiresAt: Date.now() + 5 * 60 * 1000 }); // 5 min ttl + + return stickies; +} + +export async function getSticky(guildId: string, channelId: string): Promise { + const stickies = await getGuildStickies(guildId); + + return stickies.find((sticky) => sticky.id === channelId) ?? null; +} + +export function forgetStickies(guildId: string): void { + const cached = stickyCache.get(guildId); + if (cached) { + for (const sticky of cached.stickies) { + lastRepostAt.delete(sticky.id); + pendingRepost.delete(sticky.id); + } + } + stickyCache.delete(guildId); +} + +export async function setSticky(guildId: string, guildName: string, channelId: string, content: string) { + await prisma.server.upsert({ + where: { id: guildId }, + create: { id: guildId, name: guildName }, + update: { name: guildName }, + }); + + const sticky = await prisma.channel.upsert({ + where: { id: channelId }, + create: { id: channelId, guildId, stickyContent: content }, + update: { stickyContent: content }, + }); + stickyCache.delete(guildId); + + return sticky; +} + +export async function removeSticky(guildId: string, channelId: string): Promise { + const { count } = await prisma.channel.updateMany({ + where: { id: channelId, stickyContent: { not: null } }, + data: { stickyContent: null, stickyMessageId: null }, + }); + lastRepostAt.delete(channelId); + pendingRepost.delete(channelId); + stickyCache.delete(guildId); + + return count > 0; +} + +export async function repostSticky(channel: GuildTextBasedChannel, sticky: Sticky, force = false): Promise { + if (!sticky.stickyContent || !channel.isSendable()) return; + + const since = Date.now() - (lastRepostAt.get(channel.id) ?? 0); + + // when a new message is sent while on cooldown, we queue it and send it after the cooldown (5s) + if (!force && since < 5_000) { + if (pendingRepost.has(channel.id)) return; + pendingRepost.add(channel.id); + + setTimeout(() => { + pendingRepost.delete(channel.id); + + void getSticky(sticky.guildId, channel.id) + .then((fresh) => (fresh ? repostSticky(channel, fresh) : undefined)) + .catch(() => console.error(`Failed to repost sticky in ${sticky.guildId}#${channel.id}`)); + }, 5_000 - since); + + return; + } + + lastRepostAt.set(channel.id, Date.now()); + + try { + if (sticky.stickyMessageId) { + await channel.messages.delete(sticky.stickyMessageId).catch(() => {}); + } + + const posted = await channel.send({ + content: `${sticky.stickyContent}\n-# This is a sticky message set by the moderators of this server.`, + allowedMentions: { parse: [] }, + }); + await prisma.channel.update({ where: { id: channel.id }, data: { stickyMessageId: posted.id } }); + + const cached = stickyCache.get(sticky.guildId); + if (cached) { + stickyCache.set(sticky.guildId, { + ...cached, + stickies: cached.stickies.map((entry) => + entry.id === channel.id ? { ...entry, stickyMessageId: posted.id } : entry, + ), + }); + } + } catch { + console.error(`Failed to send sticky in ${sticky.guildId}#${channel.id}`); + } finally { + lastRepostAt.set(channel.id, Date.now()); + } +} diff --git a/apps/bot/src/lib/welcomeModule.ts b/apps/bot/src/lib/welcomeModule.ts index 4ea22f9..5b6a348 100644 --- a/apps/bot/src/lib/welcomeModule.ts +++ b/apps/bot/src/lib/welcomeModule.ts @@ -6,7 +6,7 @@ const ownerDmCooldown = new Map(); const DM_COOLDOWN = 24 * 60 * 60 * 1000; // 24h export async function sendWelcome(member: GuildMember): Promise { - const settings = await getSettings(member.guild.id, member.guild.name); + const settings = await getSettings(member.guild.id); if (!settings.welcomePeople || !settings.welcomeChannelId) return; const channel = await member.guild.channels.fetch(settings.welcomeChannelId).catch(() => null); diff --git a/apps/bot/src/listeners/guildDelete.ts b/apps/bot/src/listeners/guildDelete.ts index 2aac745..587ee50 100644 --- a/apps/bot/src/listeners/guildDelete.ts +++ b/apps/bot/src/listeners/guildDelete.ts @@ -1,6 +1,9 @@ import { prisma } from '@questbot/database'; import { Listener } from '@sapphire/framework'; import { EmbedBuilder, Events, type Guild } from 'discord.js'; +import { forgetBlockedWords } from '#lib/automod.js'; +import { forgetSettings } from '#lib/settings.js'; +import { forgetStickies } from '#lib/sticky.js'; export class GuildDeleteListener extends Listener { public constructor(context: Listener.LoaderContext, options: Listener.Options) { @@ -9,6 +12,9 @@ export class GuildDeleteListener extends Listener { public async run(guild: Guild) { await prisma.server.delete({ where: { id: guild.id } }).catch(() => null); + forgetSettings(guild.id); + forgetBlockedWords(guild.id); + forgetStickies(guild.id); const owner = await guild.client.users.fetch(guild.ownerId).catch(() => null); if (!owner) return; @@ -17,7 +23,7 @@ export class GuildDeleteListener extends Listener { .setColor(0xffffff) .setTitle('Sorry to see you go!') .setDescription( - `If you had any issues or feedback, feel free to join the support server by using the \`/discord\` command.\n\nWe'd also appreciate it if you could fill out our feedback form at https://duckorg.com/feedback/questbot.`, + `If you had any issues or have feedback, feel free to join the support server by using the \`/discord\` command.\n\nWe'd also appreciate it if you could fill out our feedback form at https://vantern.org/feedback/deletion. Thank you in advance!`, ); await owner.send({ embeds: [embed] }).catch(() => null); diff --git a/apps/bot/src/listeners/messageCreate.ts b/apps/bot/src/listeners/messageCreate.ts index 382ea34..86c612f 100644 --- a/apps/bot/src/listeners/messageCreate.ts +++ b/apps/bot/src/listeners/messageCreate.ts @@ -1,6 +1,7 @@ import { Listener } from '@sapphire/framework'; import { Events, type Message } from 'discord.js'; import { containsBlockedWord } from '#lib/automod.js'; +import { autoPublish } from '#lib/autoPublisher.js'; import { isHaiku } from '#lib/haiku.js'; import { getSettings } from '#lib/settings.js'; @@ -17,14 +18,14 @@ export class MessageCreateListener extends Listener // by doing this you ALLOW it to reply to bots haiku's such as ai bots writing one :D const content = message.content.toLowerCase(); - const settings = await getSettings(message.guild.id, message.guild.name); + const settings = await getSettings(message.guild.id); if (settings.haikuEnabled && isHaiku(message.content)) { await message.reply("That's a haiku!").catch((err) => console.error(err)); } - if (message.author.bot) return; + const isBlocked = await containsBlockedWord(message.guild.id, message.content); - if (await containsBlockedWord(message.guild.id, message.content)) { + if (isBlocked && !message.author.bot) { await message.delete().catch((err) => console.error(err)); const channel = message.channel; @@ -33,8 +34,15 @@ export class MessageCreateListener extends Listener await channel.send(`<@${message.author.id}>, that word is not allowed here!`).catch((err) => { console.error(err); }); + return; + } + + if (settings.autoPublisher && !isBlocked) { + await autoPublish(message); } + if (message.author.bot) return; + const moderatorIds = [ ...new Set( (process.env.MODERATORS ?? '') diff --git a/apps/bot/src/listeners/messageDelete.ts b/apps/bot/src/listeners/messageDelete.ts index 2967d41..604f06b 100644 --- a/apps/bot/src/listeners/messageDelete.ts +++ b/apps/bot/src/listeners/messageDelete.ts @@ -1,6 +1,7 @@ import { Listener } from '@sapphire/framework'; import { EmbedBuilder, Events, type Message, type PartialMessage } from 'discord.js'; import { removeConfessionContext } from '#lib/confessions.js'; +import { deleteGiveawayByMessageId } from '#lib/giveaways.js'; import { isLoggingChannel, logEmbed, truncate } from '#lib/logging.js'; export class MessageDeleteListener extends Listener { @@ -32,6 +33,7 @@ export class MessageDeleteListener extends Listener .setTimestamp(); await removeConfessionContext(message.id).catch(() => null); + await deleteGiveawayByMessageId(message.id).catch(() => null); await logEmbed(guild, embed); } } diff --git a/apps/bot/src/listeners/messageDeleteBulk.ts b/apps/bot/src/listeners/messageDeleteBulk.ts index 4c16ea9..20f3351 100644 --- a/apps/bot/src/listeners/messageDeleteBulk.ts +++ b/apps/bot/src/listeners/messageDeleteBulk.ts @@ -1,6 +1,7 @@ import { Listener } from '@sapphire/framework'; import { type Collection, EmbedBuilder, Events, type Message, type PartialMessage, type Snowflake } from 'discord.js'; import { removeConfessionContexts } from '#lib/confessions.js'; +import { deleteGiveawaysByMessageIds } from '#lib/giveaways.js'; import { isLoggingChannel, logEmbed } from '#lib/logging.js'; export class MessageDeleteBulkListener extends Listener { @@ -26,6 +27,7 @@ export class MessageDeleteBulkListener extends Listener { .setTimestamp(); await removeConfessionContexts([...messages.keys()]).catch(() => null); + await deleteGiveawaysByMessageIds([...messages.keys()]).catch(() => null); await logEmbed(guild, embed); } } diff --git a/apps/bot/src/listeners/ready.ts b/apps/bot/src/listeners/ready.ts index cbb45bb..55f5afe 100644 --- a/apps/bot/src/listeners/ready.ts +++ b/apps/bot/src/listeners/ready.ts @@ -1,10 +1,12 @@ import { Listener } from '@sapphire/framework'; import { ActivityType, type Client, Events } from 'discord.js'; import { purgeExpiredBans } from '#lib/bans.js'; +import { giveawayScheduler } from '#lib/giveawayEvent.js'; import { enforceMute, getActiveMutes } from '#lib/mutes.js'; import { reminderScheduler } from '#lib/reminderEvent.js'; import { purgeExpiredWarns } from '#lib/warns.js'; import { heartbeat } from '#utils/heartbeat.js'; +import { getShardInfo, isPrimaryShard } from '#utils/sharding.js'; export class ReadyListener extends Listener { public constructor(context: Listener.LoaderContext, options: Listener.Options) { @@ -26,24 +28,27 @@ export class ReadyListener extends Listener { }); heartbeat(client); + //* all schedulers currently run on a 30s interval reminderScheduler(client); + giveawayScheduler(client); const enforceMutes = async () => { - const mutes = await getActiveMutes(); + const mutes = await getActiveMutes(getShardInfo(client)); for (const mute of mutes) { const guild = client.guilds.cache.get(mute.guildId); if (guild) await enforceMute(guild, mute.userId).catch((err) => console.error(err)); } }; + const purge = () => { + if (isPrimaryShard(client)) purgeExpiredWarns().catch((err) => console.error(err)); + purgeExpiredBans(client).catch((err) => console.error(err)); + }; + await enforceMutes().catch((err) => console.error(err)); - purgeExpiredWarns().catch((err) => console.error(err)); - purgeExpiredBans(client).catch((err) => console.error(err)); + purge(); - setInterval(() => { - purgeExpiredWarns().catch((err) => console.error(err)); - purgeExpiredBans(client).catch((err) => console.error(err)); - }, 60 * 1000); // 1 min + setInterval(purge, 60 * 1000); // 1 min setInterval( () => { diff --git a/apps/bot/src/listeners/sticky.ts b/apps/bot/src/listeners/sticky.ts new file mode 100644 index 0000000..fc7be23 --- /dev/null +++ b/apps/bot/src/listeners/sticky.ts @@ -0,0 +1,23 @@ +import { Listener } from '@sapphire/framework'; +import { Events, type Message } from 'discord.js'; +import { getSticky, repostSticky } from '#lib/sticky.js'; + +export class StickyListener extends Listener { + public constructor(context: Listener.LoaderContext, options: Listener.Options) { + super(context, { + ...options, + event: Events.MessageCreate, + }); + } + + public async run(message: Message) { + if (!message.inGuild()) return; + + if (message.author.id === message.client.user.id) return; // don't repost on our own message + + const sticky = await getSticky(message.guild.id, message.channel.id); + if (!sticky) return; + + await repostSticky(message.channel, sticky); + } +} diff --git a/apps/bot/src/util/getChannel.ts b/apps/bot/src/util/getChannel.ts new file mode 100644 index 0000000..c00e080 --- /dev/null +++ b/apps/bot/src/util/getChannel.ts @@ -0,0 +1,10 @@ +import type { Collection } from 'discord.js'; + +interface ChannelManagerLike { + cache: Collection; + fetch(id: string): Promise; +} + +export async function getChannel(manager: ChannelManagerLike, channelId: string): Promise { + return manager.cache.get(channelId) ?? (await manager.fetch(channelId).catch(() => null)); +} diff --git a/apps/bot/src/util/profile.ts b/apps/bot/src/util/profile.ts new file mode 100644 index 0000000..f0d8e94 --- /dev/null +++ b/apps/bot/src/util/profile.ts @@ -0,0 +1,23 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, type User } from 'discord.js'; +import { Colors } from '#utils/embeds.js'; + +export const ASSET_SIZE = 4096; //* global size for fetching an asset from a user + +export interface AssetMessage { + embeds: EmbedBuilder[]; + components: ActionRowBuilder[]; +} + +export function assetMessage(user: User, url: string): AssetMessage { + const embed = new EmbedBuilder() + .setColor(Colors.info) + .setTitle(`${user.displayName}`) + .setImage(url) + .setFooter({ text: `ID: ${user.id}` }); + + const downloadRow = new ActionRowBuilder().addComponents( + new ButtonBuilder().setLabel('Download').setStyle(ButtonStyle.Link).setURL(url), + ); + + return { embeds: [embed], components: [downloadRow] }; +} diff --git a/apps/bot/src/util/sharding.ts b/apps/bot/src/util/sharding.ts new file mode 100644 index 0000000..3ba82d8 --- /dev/null +++ b/apps/bot/src/util/sharding.ts @@ -0,0 +1,62 @@ +import { Prisma } from '@questbot/database'; +import type { Client } from 'discord.js'; + +const INTERVAL = 30_000; // this can be overridden by passing intervalMs into the function + +export interface ShardInfo { + shardId: number; + totalShards: number; +} + +export function getShardInfo(client: Client): ShardInfo { + return { + shardId: client.shard?.ids[0] ?? 0, + totalShards: client.shard?.count ?? 1, + }; +} + +// for the bot wide cleanup that only needs to happen once +export function isPrimaryShard(client: Client): boolean { + return getShardInfo(client).shardId === 0; +} + +export function shardOwns(snowflake: Prisma.Sql, { shardId, totalShards }: ShardInfo): Prisma.Sql { + return Prisma.sql`(${snowflake} >> 22) % ${totalShards}::bigint = ${shardId}::bigint`; +} + +interface ShardedPollerOptions { + client: Client; + getDue: (shard: ShardInfo) => Promise; + handle: (item: T) => Promise; + intervalMs?: number; +} + +// used across multiple schedulers to handle sharded polling +export function startShardedPoller({ + client, + getDue, + handle, + intervalMs = INTERVAL, +}: ShardedPollerOptions): void { + let isTickRunning = false; + + const tick = async () => { + if (isTickRunning) { + return; + } + + isTickRunning = true; + try { + const due = await getDue(getShardInfo(client)); + + await Promise.allSettled(due.map((item) => handle(item).catch((err) => console.error(err)))); + } catch (err) { + console.error(err); + } finally { + isTickRunning = false; + } + }; + + tick(); + setInterval(tick, intervalMs); +} diff --git a/apps/dash/app/routes/auth/auth.tsx b/apps/dash/app/routes/auth/auth.tsx index a16da68..446d675 100644 --- a/apps/dash/app/routes/auth/auth.tsx +++ b/apps/dash/app/routes/auth/auth.tsx @@ -23,8 +23,8 @@ export default function Auth() {

Powered by{' '} - - Duck Organization + + Vantern

diff --git a/biome.json b/biome.json index 71fca4b..e8e83d5 100644 --- a/biome.json +++ b/biome.json @@ -28,7 +28,7 @@ "conventions": [ { "selector": { "kind": "const", "scope": "global" }, - "match": "(client|manager|shardFile|shardCountEnv|emojis|ownerDmCooldown|prisma|globalForPrisma|auth|authClient|links|[A-Z][a-zA-Z0-9]*(?:_[A-Z0-9]+)*)" + "match": "(client|manager|shardFile|shardCountEnv|emojis|ownerDmCooldown|settingsCache|blockedWordsCache|stickyCache|lastRepostAt|pendingRepost|crosspostWindows|prisma|globalForPrisma|auth|authClient|links|[A-Z][a-zA-Z0-9]*(?:_[A-Z0-9]+)*)" }, { "selector": { "kind": "any" }, diff --git a/package.json b/package.json index 904ad7c..d57359a 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,10 @@ "dev": "dotenv -e .env -- turbo run dev", "lint": "biome check .", "format": "biome check --write .", - "start": "pnpm --filter @duckorganization/bot start", + "start": "pnpm --filter @questbot/bot start", "db:push": "dotenv -e .env -- turbo run db:push", "db:generate": "dotenv -e .env -- turbo run db:generate", - "prisma": "pnpm --filter @duckorganization/bot prisma", + "prisma": "pnpm --filter @questbot/bot prisma", "clean": "dotenv -e .env -- turbo run build --force && rm -rf node_modules apps/*/node_modules dist .turbo" }, "dependencies": { diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 3a78d06..75e753e 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -22,35 +22,35 @@ model User { } model Session { - id String @id - expiresAt DateTime - token String @unique - createdAt DateTime - updatedAt DateTime - ipAddress String? - userAgent String? - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id + expiresAt DateTime + token String @unique + createdAt DateTime + updatedAt DateTime + ipAddress String? + userAgent String? + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("session") } model Account { - id String @id - accountId String - providerId String - userId String - accessToken String? - refreshToken String? - idToken String? - accessTokenExpiresAt DateTime? + id String @id + accountId String + providerId String + userId String + accessToken String? + refreshToken String? + idToken String? + accessTokenExpiresAt DateTime? refreshTokenExpiresAt DateTime? - scope String? - password String? - createdAt DateTime - updatedAt DateTime + scope String? + password String? + createdAt DateTime + updatedAt DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("account") } @@ -66,25 +66,26 @@ model Verification { @@map("verification") } - model Server { - id String @id - name String - blacklisted Boolean @default(false) - nextTicketNumber Int @default(1) - settings Json @default("{}") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - warns Warn[] - mutes Mute[] - bans Ban[] - reminders Reminder[] - tickets Ticket[] - autoroles AutoRole[] - automods AutoMod[] - confessions Confession[] - afks Afk[] + id String @id + name String + blacklisted Boolean @default(false) + nextTicketNumber Int @default(1) + settings Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + warns Warn[] + mutes Mute[] + bans Ban[] + reminders Reminder[] + tickets Ticket[] + autoroles AutoRole[] + automods AutoMod[] + confessions Confession[] + afks Afk[] + giveaways Giveaway[] + channels Channel[] @@map("server") } @@ -98,35 +99,35 @@ model Warn { expiresAt DateTime? createdAt DateTime @default(now()) - server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) + server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) @@index([guildId, userId]) @@map("warns") } model Mute { - id String @id @default(cuid()) + id String @id @default(cuid()) guildId String userId String reason String? expiresAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) - server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) + server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) @@unique([guildId, userId]) @@index([expiresAt]) } model Ban { - id String @id @default(cuid()) + id String @id @default(cuid()) guildId String userId String reason String? expiresAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) - server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) + server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) @@unique([guildId, userId]) @@index([expiresAt]) @@ -141,22 +142,22 @@ model Reminder { remindAt DateTime createdAt DateTime @default(now()) - server Server? @relation(fields: [guildId], references: [id], onDelete: Cascade) + server Server? @relation(fields: [guildId], references: [id], onDelete: Cascade) @@index([remindAt]) @@index([userId]) } model Ticket { - id String @id @default(cuid()) + id String @id @default(cuid()) guildId String ticketNumber Int channelId String? @unique userId String reason String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) - server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) + server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) @@unique([guildId, ticketNumber]) @@index([guildId, userId]) @@ -164,13 +165,13 @@ model Ticket { } model AutoRole { - id String @id @default(cuid()) - guildId String - roleId String - botRole Boolean? - createdAt DateTime @default(now()) + id String @id @default(cuid()) + guildId String + roleId String + botRole Boolean? + createdAt DateTime @default(now()) - server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) + server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) @@unique([guildId, roleId]) @@index([guildId]) @@ -178,12 +179,12 @@ model AutoRole { } model AutoMod { - id String @id @default(cuid()) - guildId String - word String - createdAt DateTime @default(now()) + id String @id @default(cuid()) + guildId String + word String + createdAt DateTime @default(now()) - server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) + server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) @@unique([guildId, word]) @@index([guildId]) @@ -198,7 +199,7 @@ model Confession { creatorId String? createdAt DateTime @default(now()) - server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) + server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) @@index([guildId]) @@map("confessions") @@ -218,9 +219,45 @@ model Afk { message String? createdAt DateTime @default(now()) - server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) + server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) @@unique([guildId, userId]) @@index([guildId]) @@map("afks") } + +model Giveaway { + id String @id @default(cuid()) + guildId String + channelId String + messageId String? @unique + hostId String + prize String + maxEntries Int? + winnerCount Int @default(1) + entries String[] @default([]) + winnerIds String[] @default([]) + ended Boolean @default(false) + endsAt DateTime + createdAt DateTime @default(now()) + + server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@index([guildId]) + @@index([ended, endsAt]) + @@map("giveaways") +} + +model Channel { + id String @id + guildId String + stickyContent String? + stickyMessageId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + server Server @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@index([guildId]) + @@map("channels") +}