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 @@
@@ -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")
+}