feat(bot): merge pull request #343 from ahmadk953/utils

chore(bot): finish base bot
This commit is contained in:
Ahmad 2025-04-17 01:31:29 -04:00 committed by GitHub
commit 15cb83be7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1276 additions and 172 deletions

View file

@ -11,7 +11,7 @@
> [!WARNING] > [!WARNING]
> Documentation is still under construction. Expect incomplete and undocumented features. > Documentation is still under construction. Expect incomplete and undocumented features.
All documentation and setup instructions can be found at [https://docs.poixpixel.ahmadk953.org/](https://docs.poixpixel.ahmadk953.org/) All documentation and setup instructions can be found at [https://docs.poixpixel.ahmadk953.org/](https://docs.poixpixel.ahmadk953.org/?utm_source=github&utm_medium=readme&utm_campaign=repository&utm_content=docs_link)
## Development Commands ## Development Commands

View file

@ -2,6 +2,7 @@
"token": "DISCORD_BOT_TOKEN", "token": "DISCORD_BOT_TOKEN",
"clientId": "DISCORD_BOT_ID", "clientId": "DISCORD_BOT_ID",
"guildId": "DISCORD_SERVER_ID", "guildId": "DISCORD_SERVER_ID",
"serverInvite": "DISCORD_SERVER_INVITE_LINK",
"database": { "database": {
"dbConnectionString": "POSTGRESQL_CONNECTION_STRING", "dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
"maxRetryAttempts": "MAX_RETRY_ATTEMPTS", "maxRetryAttempts": "MAX_RETRY_ATTEMPTS",

View file

@ -10,8 +10,10 @@
"compile": "npx tsc", "compile": "npx tsc",
"target": "node ./target/discord-bot.js", "target": "node ./target/discord-bot.js",
"start:dev": "yarn run compile && yarn run target", "start:dev": "yarn run compile && yarn run target",
"start:dev:no-deploy": "cross-env SKIP_COMMAND_DEPLOY=true yarn run start:dev",
"start:prod": "yarn compile && pm2 start ./target/discord-bot.js --name poixpixel-discord-bot", "start:prod": "yarn compile && pm2 start ./target/discord-bot.js --name poixpixel-discord-bot",
"restart": "pm2 restart poixpixel-discord-bot", "restart": "pm2 restart poixpixel-discord-bot",
"undeploy-commands": "yarn compile && node --experimental-specifier-resolution=node ./target/util/undeployCommands.js",
"lint": "npx eslint ./src && npx tsc --noEmit", "lint": "npx eslint ./src && npx tsc --noEmit",
"format": "prettier --check --ignore-path .prettierignore .", "format": "prettier --check --ignore-path .prettierignore .",
"format:fix": "prettier --write --ignore-path .prettierignore .", "format:fix": "prettier --write --ignore-path .prettierignore .",
@ -34,6 +36,7 @@
"@types/pg": "^8.11.13", "@types/pg": "^8.11.13",
"@typescript-eslint/eslint-plugin": "^8.30.1", "@typescript-eslint/eslint-plugin": "^8.30.1",
"@typescript-eslint/parser": "^8.30.1", "@typescript-eslint/parser": "^8.30.1",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.0", "drizzle-kit": "^0.31.0",
"eslint": "^9.24.0", "eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2", "eslint-config-prettier": "^10.1.2",

View file

@ -148,8 +148,9 @@ const command = {
), ),
async execute(interaction: ChatInputCommandInteraction) { async execute(interaction: ChatInputCommandInteraction) {
await interaction.deferReply(); if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply();
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
switch (subcommand) { switch (subcommand) {

View file

@ -33,8 +33,9 @@ const command: SubcommandCommand = {
), ),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply();
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
if (subcommand === 'status') { if (subcommand === 'status') {
@ -82,33 +83,40 @@ const command: SubcommandCommand = {
}); });
} }
await interaction.reply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
} else if (subcommand === 'setcount') { } else if (subcommand === 'setcount') {
if ( if (
!interaction.memberPermissions?.has( !interaction.memberPermissions?.has(
PermissionsBitField.Flags.Administrator, PermissionsBitField.Flags.Administrator,
) )
) { ) {
await interaction.reply({ await interaction.editReply({
content: 'You need administrator permissions to use this command.', content: 'You need administrator permissions to use this command.',
flags: ['Ephemeral'],
}); });
return; return;
} }
const count = interaction.options.getInteger('count'); const count = interaction.options.getInteger('count');
if (count === null) { if (count === null) {
await interaction.reply({ await interaction.editReply({
content: 'Invalid count specified.', content: 'Invalid count specified.',
flags: ['Ephemeral'],
}); });
return; return;
} }
try {
await setCount(count); await setCount(count);
await interaction.reply({ await interaction.editReply({
content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`,
});
} catch (error) {
await interaction.editReply({
content: `Failed to set the count: ${error}`,
});
}
await interaction.editReply({
content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`, content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`,
flags: ['Ephemeral'],
}); });
} }
}, },

View file

@ -74,12 +74,11 @@ const command: SubcommandCommand = {
), ),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply({ await interaction.deferReply({
flags: ['Ephemeral'], flags: ['Ephemeral'],
}); });
await interaction.editReply('Processing...');
const config = loadConfig(); const config = loadConfig();
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
@ -100,7 +99,7 @@ const command: SubcommandCommand = {
}); });
if (!isAdmin) { if (!isAdmin) {
const approvalChannel = interaction.guild?.channels.cache.get( const approvalChannel = interaction.guild.channels.cache.get(
config.channels.factApproval, config.channels.factApproval,
); );

View file

@ -18,6 +18,7 @@ import {
builder, builder,
} from '@/util/giveaways/giveawayManager.js'; } from '@/util/giveaways/giveawayManager.js';
import { createPaginationButtons } from '@/util/helpers.js'; import { createPaginationButtons } from '@/util/helpers.js';
import { loadConfig } from '@/util/configLoader';
const command: SubcommandCommand = { const command: SubcommandCommand = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@ -53,16 +54,30 @@ const command: SubcommandCommand = {
), ),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand() || !interaction.guild) return;
const config = loadConfig();
const communityManagerRoleId = config.roles.staffRoles.find(
(role) => role.name === 'Community Manager',
)?.roleId;
if (!communityManagerRoleId) {
await interaction.reply({
content:
'Community Manager role not found in the configuration. Please contact a server admin.',
flags: ['Ephemeral'],
});
return;
}
if ( if (
!interaction.memberPermissions?.has( !interaction.guild.members.cache
PermissionsBitField.Flags.ModerateMembers, .find((member) => member.id === interaction.user.id)
) ?.roles.cache.has(communityManagerRoleId)
) { ) {
await interaction.reply({ await interaction.reply({
content: 'You do not have permission to manage giveaways.', content: 'You do not have permission to manage giveaways.',
ephemeral: true, flags: ['Ephemeral'],
}); });
return; return;
} }
@ -152,7 +167,7 @@ async function handleListGiveaways(interaction: ChatInputCommandInteraction) {
if (i.user.id !== interaction.user.id) { if (i.user.id !== interaction.user.id) {
await i.reply({ await i.reply({
content: 'You cannot use these buttons.', content: 'You cannot use these buttons.',
ephemeral: true, flags: ['Ephemeral'],
}); });
return; return;
} }

View file

@ -22,13 +22,12 @@ const command: OptionsCommand = {
.setRequired(false), .setRequired(false),
), ),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.guild) return; if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply(); await interaction.deferReply();
try { try {
const usersPerPage = const usersPerPage = interaction.options.getInteger('limit') || 10;
(interaction.options.get('limit')?.value as number) || 10;
const allUsers = await getLevelLeaderboard(100); const allUsers = await getLevelLeaderboard(100);

View file

@ -15,18 +15,16 @@ const command: OptionsCommand = {
.setRequired(false), .setRequired(false),
), ),
execute: async (interaction) => { execute: async (interaction) => {
const member = await interaction.guild?.members.fetch( if (!interaction.isChatInputCommand() || !interaction.guild) return;
(interaction.options.get('user')?.value as string) || interaction.user.id,
);
if (!member) {
await interaction.reply('User not found in this server.');
return;
}
await interaction.deferReply(); await interaction.deferReply();
try { try {
const member = await interaction.guild.members.fetch(
(interaction.options.get('user')?.value as string) ||
interaction.user.id,
);
const userData = await getUserLevel(member.id); const userData = await getUserLevel(member.id);
const rankCard = await generateRankCard(member, userData); const rankCard = await generateRankCard(member, userData);

View file

@ -3,6 +3,7 @@ import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
import { updateMember, updateMemberModerationHistory } from '@/db/db.js'; import { updateMember, updateMemberModerationHistory } from '@/db/db.js';
import { parseDuration, scheduleUnban } from '@/util/helpers.js'; import { parseDuration, scheduleUnban } from '@/util/helpers.js';
import { OptionsCommand } from '@/types/CommandTypes.js'; import { OptionsCommand } from '@/types/CommandTypes.js';
import { loadConfig } from '@/util/configLoader.js';
import logAction from '@/util/logging/logAction.js'; import logAction from '@/util/logging/logAction.js';
const command: OptionsCommand = { const command: OptionsCommand = {
@ -30,10 +31,15 @@ const command: OptionsCommand = {
.setRequired(false), .setRequired(false),
), ),
execute: async (interaction) => { execute: async (interaction) => {
const moderator = await interaction.guild?.members.fetch( if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply({ flags: ['Ephemeral'] });
try {
const moderator = await interaction.guild.members.fetch(
interaction.user.id, interaction.user.id,
); );
const member = await interaction.guild?.members.fetch( const member = await interaction.guild.members.fetch(
interaction.options.get('member')!.value as string, interaction.options.get('member')!.value as string,
); );
const reason = interaction.options.get('reason')?.value as string; const reason = interaction.options.get('reason')?.value as string;
@ -44,26 +50,44 @@ const command: OptionsCommand = {
if ( if (
!interaction.memberPermissions?.has( !interaction.memberPermissions?.has(
PermissionsBitField.Flags.BanMembers, PermissionsBitField.Flags.BanMembers,
) || )
moderator!.roles.highest.position <= member!.roles.highest.position ||
!member?.bannable
) { ) {
await interaction.reply({ await interaction.editReply({
content: content: 'You do not have permission to ban members.',
'You do not have permission to ban members or this member cannot be banned.',
flags: ['Ephemeral'],
}); });
return; return;
} }
if (moderator.roles.highest.position <= member.roles.highest.position) {
await interaction.editReply({
content:
'You cannot ban a member with equal or higher role than yours.',
});
return;
}
if (!member.bannable) {
await interaction.editReply({
content: 'I do not have permission to ban this member.',
});
return;
}
const config = loadConfig();
const invite = interaction.guild.vanityURLCode ?? config.serverInvite;
const until = banDuration
? new Date(Date.now() + parseDuration(banDuration)).toUTCString()
: 'indefinitely';
try { try {
await member.user.send( await member.user.send(
banDuration banDuration
? `You have been banned from ${interaction.guild!.name} for ${banDuration}. Reason: ${reason}. You can join back at ${new Date( ? `You have been banned from ${interaction.guild.name} for ${banDuration}. Reason: ${reason}. You can join back at ${until} using the link below:\n${invite}`
Date.now() + parseDuration(banDuration), : `You been indefinitely banned from ${interaction.guild.name}. Reason: ${reason}.`,
).toUTCString()} using the link below:\nhttps://discord.gg/KRTGjxx7gY`
: `You been indefinitely banned from ${interaction.guild!.name}. Reason: ${reason}.`,
); );
} catch (error) {
console.error('Failed to send DM:', error);
}
await member.ban({ reason }); await member.ban({ reason });
if (banDuration) { if (banDuration) {
@ -72,7 +96,7 @@ const command: OptionsCommand = {
await scheduleUnban( await scheduleUnban(
interaction.client, interaction.client,
interaction.guild!.id, interaction.guild.id,
member.id, member.id,
expiresAt, expiresAt,
); );
@ -94,23 +118,22 @@ const command: OptionsCommand = {
}); });
await logAction({ await logAction({
guild: interaction.guild!, guild: interaction.guild,
action: 'ban', action: 'ban',
target: member, target: member,
moderator: moderator!, moderator,
reason, reason,
}); });
await interaction.reply({ await interaction.editReply({
content: banDuration content: banDuration
? `<@${member.id}> has been banned for ${banDuration}. Reason: ${reason}` ? `<@${member.id}> has been banned for ${banDuration}. Reason: ${reason}`
: `<@${member.id}> has been indefinitely banned. Reason: ${reason}`, : `<@${member.id}> has been indefinitely banned. Reason: ${reason}`,
}); });
} catch (error) { } catch (error) {
console.error('Ban command error:', error); console.error('Ban command error:', error);
await interaction.reply({ await interaction.editReply({
content: 'Unable to ban member.', content: 'Unable to ban member.',
flags: ['Ephemeral'],
}); });
} }
}, },

View file

@ -0,0 +1,103 @@
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
import { updateMemberModerationHistory } from '@/db/db.js';
import { OptionsCommand } from '@/types/CommandTypes.js';
import { loadConfig } from '@/util/configLoader.js';
import logAction from '@/util/logging/logAction.js';
const command: OptionsCommand = {
data: new SlashCommandBuilder()
.setName('kick')
.setDescription('Kick a member from the server')
.addUserOption((option) =>
option
.setName('member')
.setDescription('The member to kick')
.setRequired(true),
)
.addStringOption((option) =>
option
.setName('reason')
.setDescription('The reason for the kick')
.setRequired(true),
),
execute: async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply({ flags: ['Ephemeral'] });
try {
const moderator = await interaction.guild.members.fetch(
interaction.user.id,
);
const member = await interaction.guild.members.fetch(
interaction.options.get('member')!.value as string,
);
const reason = interaction.options.get('reason')?.value as string;
if (
!interaction.memberPermissions?.has(
PermissionsBitField.Flags.KickMembers,
)
) {
await interaction.editReply({
content: 'You do not have permission to kick members.',
});
return;
}
if (moderator!.roles.highest.position <= member.roles.highest.position) {
await interaction.editReply({
content:
'You cannot kick a member with equal or higher role than yours.',
});
return;
}
if (!member.kickable) {
await interaction.editReply({
content: 'I do not have permission to kick this member.',
});
return;
}
try {
await member.user.send(
`You have been kicked from ${interaction.guild!.name}. Reason: ${reason}. You can join back at: \n${interaction.guild.vanityURLCode ?? loadConfig().serverInvite}`,
);
} catch (error) {
console.error('Failed to send DM to kicked user:', error);
}
await member.kick(reason);
await updateMemberModerationHistory({
discordId: member.id,
moderatorDiscordId: interaction.user.id,
action: 'kick',
reason,
duration: '',
createdAt: new Date(),
});
await logAction({
guild: interaction.guild!,
action: 'kick',
target: member,
moderator,
reason,
});
await interaction.editReply({
content: `<@${member.id}> has been kicked. Reason: ${reason}`,
});
} catch (error) {
console.error('Kick command error:', error);
await interaction.editReply({
content: 'Unable to kick member.',
});
}
},
};
export default command;

View file

@ -0,0 +1,128 @@
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
import { updateMember, updateMemberModerationHistory } from '@/db/db.js';
import { parseDuration } from '@/util/helpers.js';
import { OptionsCommand } from '@/types/CommandTypes.js';
import logAction from '@/util/logging/logAction.js';
const command: OptionsCommand = {
data: new SlashCommandBuilder()
.setName('mute')
.setDescription('Timeout a member in the server')
.addUserOption((option) =>
option
.setName('member')
.setDescription('The member to timeout')
.setRequired(true),
)
.addStringOption((option) =>
option
.setName('reason')
.setDescription('The reason for the timeout')
.setRequired(true),
)
.addStringOption((option) =>
option
.setName('duration')
.setDescription(
'The duration of the timeout (ex. 5m, 1h, 1d, 1w). Max 28 days.',
)
.setRequired(true),
),
execute: async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply({ flags: ['Ephemeral'] });
try {
const moderator = await interaction.guild.members.fetch(
interaction.user.id,
);
const member = await interaction.guild.members.fetch(
interaction.options.get('member')!.value as string,
);
const reason = interaction.options.get('reason')?.value as string;
const muteDuration = interaction.options.get('duration')?.value as string;
if (
!interaction.memberPermissions?.has(
PermissionsBitField.Flags.KickMembers,
)
) {
await interaction.editReply({
content: 'You do not have permission to mute members.',
});
return;
}
if (moderator.roles.highest.position <= member.roles.highest.position) {
await interaction.editReply({
content:
'You cannot mute a member with equal or higher role than yours.',
});
return;
}
if (!member.moderatable) {
await interaction.editReply({
content: 'I do not have permission to mute this member.',
});
return;
}
const durationMs = parseDuration(muteDuration);
const maxTimeout = 28 * 24 * 60 * 60 * 1000;
if (durationMs > maxTimeout) {
await interaction.editReply({
content: 'Timeout duration cannot exceed 28 days.',
});
return;
}
await member.user.send(
`You have been timed out in ${interaction.guild!.name} for ${muteDuration}. Reason: ${reason}.`,
);
await member.timeout(durationMs, reason);
const expiresAt = new Date(Date.now() + durationMs);
await updateMemberModerationHistory({
discordId: member.id,
moderatorDiscordId: interaction.user.id,
action: 'mute',
reason,
duration: muteDuration,
createdAt: new Date(),
expiresAt,
active: true,
});
await updateMember({
discordId: member.id,
currentlyMuted: true,
});
await logAction({
guild: interaction.guild!,
action: 'mute',
target: member,
moderator,
reason,
duration: muteDuration,
});
await interaction.editReply({
content: `<@${member.id}> has been muted for ${muteDuration}. Reason: ${reason}`,
});
} catch (error) {
console.error('Mute command error:', error);
await interaction.editReply({
content: 'Unable to timeout member.',
});
}
},
};
export default command;

View file

@ -20,52 +20,54 @@ const command: OptionsCommand = {
.setRequired(true), .setRequired(true),
), ),
execute: async (interaction) => { execute: async (interaction) => {
const userId = interaction.options.get('userid')!.value as string; if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply({ flags: ['Ephemeral'] });
try {
const userId = interaction.options.get('userid')?.value as string;
const reason = interaction.options.get('reason')?.value as string; const reason = interaction.options.get('reason')?.value as string;
if ( if (
!interaction.memberPermissions?.has(PermissionsBitField.Flags.BanMembers) !interaction.memberPermissions?.has(
PermissionsBitField.Flags.BanMembers,
)
) { ) {
await interaction.reply({ await interaction.editReply({
content: 'You do not have permission to unban users.', content: 'You do not have permission to unban users.',
flags: ['Ephemeral'],
}); });
return; return;
} }
try { try {
try { const ban = await interaction.guild.bans.fetch(userId);
const ban = await interaction.guild?.bans.fetch(userId);
if (!ban) { if (!ban) {
await interaction.reply({ await interaction.editReply({
content: 'This user is not banned.', content: 'This user is not banned.',
flags: ['Ephemeral'],
}); });
return; return;
} }
} catch { } catch {
await interaction.reply({ await interaction.editReply({
content: 'Error getting ban. Is this user banned?', content: 'Error getting ban. Is this user banned?',
flags: ['Ephemeral'],
}); });
return; return;
} }
await executeUnban( await executeUnban(
interaction.client, interaction.client,
interaction.guildId!, interaction.guild.id,
userId, userId,
reason, reason,
); );
await interaction.reply({ await interaction.editReply({
content: `<@${userId}> has been unbanned. Reason: ${reason}`, content: `<@${userId}> has been unbanned. Reason: ${reason}`,
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(`Unable to unban user: ${error}`);
await interaction.reply({ await interaction.editReply({
content: 'Unable to unban user.', content: 'Unable to unban user.',
flags: ['Ephemeral'],
}); });
} }
}, },

View file

@ -0,0 +1,66 @@
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
import { executeUnmute } from '@/util/helpers.js';
import { OptionsCommand } from '@/types/CommandTypes.js';
const command: OptionsCommand = {
data: new SlashCommandBuilder()
.setName('unmute')
.setDescription('Remove a timeout from a member')
.addUserOption((option) =>
option
.setName('member')
.setDescription('The member to unmute')
.setRequired(true),
)
.addStringOption((option) =>
option
.setName('reason')
.setDescription('The reason for removing the timeout')
.setRequired(true),
),
execute: async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply({ flags: ['Ephemeral'] });
try {
const moderator = await interaction.guild.members.fetch(
interaction.user.id,
);
const member = await interaction.guild.members.fetch(
interaction.options.get('member')!.value as string,
);
const reason = interaction.options.get('reason')?.value as string;
if (
!interaction.memberPermissions?.has(
PermissionsBitField.Flags.ModerateMembers,
)
) {
await interaction.editReply({
content: 'You do not have permission to unmute members.',
});
return;
}
await executeUnmute(
interaction.client,
interaction.guild.id,
member.id,
reason,
moderator,
);
await interaction.editReply({
content: `<@${member.id}>'s timeout has been removed. Reason: ${reason}`,
});
} catch (error) {
console.error('Unmute command error:', error);
await interaction.editReply({
content: 'Unable to unmute member.',
});
}
},
};
export default command;

View file

@ -21,29 +21,38 @@ const command: OptionsCommand = {
.setRequired(true), .setRequired(true),
), ),
execute: async (interaction) => { execute: async (interaction) => {
const moderator = await interaction.guild?.members.fetch( if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply({ flags: ['Ephemeral'] });
try {
const moderator = await interaction.guild.members.fetch(
interaction.user.id, interaction.user.id,
); );
const member = await interaction.guild?.members.fetch( const member = await interaction.guild.members.fetch(
interaction.options.get('member')!.value as unknown as string, interaction.options.get('member')!.value as unknown as string,
); );
const reason = interaction.options.get('reason') const reason = interaction.options.getString('reason')!;
?.value as unknown as string;
if ( if (
!interaction.memberPermissions?.has( !interaction.memberPermissions?.has(
PermissionsBitField.Flags.ModerateMembers, PermissionsBitField.Flags.ModerateMembers,
) || )
moderator!.roles.highest.position <= member!.roles.highest.position
) { ) {
await interaction.reply({ await interaction.editReply({
content: 'You do not have permission to warn this member.', content: 'You do not have permission to warn members.',
flags: ['Ephemeral'], });
return;
}
if (moderator.roles.highest.position <= member.roles.highest.position) {
await interaction.editReply({
content:
'You cannot warn a member with equal or higher role than yours.',
}); });
return; return;
} }
try {
await updateMemberModerationHistory({ await updateMemberModerationHistory({
discordId: member!.user.id, discordId: member!.user.id,
moderatorDiscordId: interaction.user.id, moderatorDiscordId: interaction.user.id,
@ -54,9 +63,6 @@ const command: OptionsCommand = {
await member!.user.send( await member!.user.send(
`You have been warned in **${interaction?.guild?.name}**. Reason: **${reason}**.`, `You have been warned in **${interaction?.guild?.name}**. Reason: **${reason}**.`,
); );
await interaction.reply(
`<@${member!.user.id}> has been warned. Reason: ${reason}`,
);
await logAction({ await logAction({
guild: interaction.guild!, guild: interaction.guild!,
action: 'warn', action: 'warn',
@ -64,11 +70,13 @@ const command: OptionsCommand = {
moderator: moderator!, moderator: moderator!,
reason: reason, reason: reason,
}); });
await interaction.editReply(
`<@${member!.user.id}> has been warned. Reason: ${reason}`,
);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await interaction.reply({ await interaction.editReply({
content: 'There was an error trying to warn the member.', content: 'There was an error trying to warn the member.',
flags: ['Ephemeral'],
}); });
} }
}, },

View file

@ -8,25 +8,27 @@ const command: Command = {
.setDescription('Simulates a new member joining'), .setDescription('Simulates a new member joining'),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
const guild = interaction.guild; const guild = interaction.guild;
await interaction.deferReply({ flags: ['Ephemeral'] });
if ( if (
!interaction.memberPermissions!.has( !interaction.memberPermissions!.has(
PermissionsBitField.Flags.Administrator, PermissionsBitField.Flags.Administrator,
) )
) { ) {
await interaction.reply({ await interaction.editReply({
content: 'You do not have permission to use this command.', content: 'You do not have permission to use this command.',
flags: ['Ephemeral'],
}); });
return;
} }
const fakeMember = await guild!.members.fetch(interaction.user.id); const fakeMember = await guild.members.fetch(interaction.user.id);
guild!.client.emit('guildMemberAdd', fakeMember); guild.client.emit('guildMemberAdd', fakeMember);
await interaction.reply({ await interaction.editReply({
content: 'Triggered the join event!', content: 'Triggered the join event!',
flags: ['Ephemeral'],
}); });
}, },
}; };

View file

@ -9,25 +9,26 @@ const command: Command = {
.setDescription('Simulates a member leaving'), .setDescription('Simulates a member leaving'),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
const guild = interaction.guild; const guild = interaction.guild;
await interaction.deferReply({ flags: ['Ephemeral'] });
if ( if (
!interaction.memberPermissions!.has( !interaction.memberPermissions!.has(
PermissionsBitField.Flags.Administrator, PermissionsBitField.Flags.Administrator,
) )
) { ) {
await interaction.reply({ await interaction.editReply({
content: 'You do not have permission to use this command.', content: 'You do not have permission to use this command.',
flags: ['Ephemeral'],
}); });
} }
const fakeMember = await guild!.members.fetch(interaction.user.id); const fakeMember = await guild.members.fetch(interaction.user.id);
guild!.client.emit('guildMemberRemove', fakeMember); guild.client.emit('guildMemberRemove', fakeMember);
await interaction.reply({ await interaction.editReply({
content: 'Triggered the leave event!', content: 'Triggered the leave event!',
flags: ['Ephemeral'],
}); });
await updateMember({ await updateMember({

237
src/commands/util/config.ts Normal file
View file

@ -0,0 +1,237 @@
import {
SlashCommandBuilder,
EmbedBuilder,
PermissionFlagsBits,
} from 'discord.js';
import { Command } from '@/types/CommandTypes.js';
import { loadConfig } from '@/util/configLoader.js';
import { createPaginationButtons } from '@/util/helpers.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('config')
.setDescription('(Admin Only) Display the current configuration')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply({ flags: ['Ephemeral'] });
if (
!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)
) {
await interaction.editReply({
content: 'You do not have permission to use this command.',
});
return;
}
const config = loadConfig();
const displayConfig = JSON.parse(JSON.stringify(config));
if (displayConfig.token) displayConfig.token = '••••••••••••••••••••••••••';
if (displayConfig.database?.dbConnectionString) {
displayConfig.database.dbConnectionString = '••••••••••••••••••••••••••';
}
if (displayConfig.redis?.redisConnectionString) {
displayConfig.redis.redisConnectionString = '••••••••••••••••••••••••••';
}
const pages: EmbedBuilder[] = [];
const basicConfigEmbed = new EmbedBuilder()
.setColor(0x0099ff)
.setTitle('Bot Configuration')
.setDescription(
'Current configuration settings (sensitive data redacted)',
)
.addFields(
{
name: 'Client ID',
value: displayConfig.clientId || 'Not set',
inline: true,
},
{
name: 'Guild ID',
value: displayConfig.guildId || 'Not set',
inline: true,
},
{
name: 'Token',
value: displayConfig.token || 'Not set',
inline: true,
},
);
pages.push(basicConfigEmbed);
if (displayConfig.database || displayConfig.redis) {
const dbRedisEmbed = new EmbedBuilder()
.setColor(0x0099ff)
.setTitle('Database and Redis Configuration')
.setDescription('Database and cache settings');
if (displayConfig.database) {
dbRedisEmbed.addFields({
name: 'Database',
value: `Connection: ${displayConfig.database.dbConnectionString}\nMax Retry: ${displayConfig.database.maxRetryAttempts}\nRetry Delay: ${displayConfig.database.retryDelay}ms`,
});
}
if (displayConfig.redis) {
dbRedisEmbed.addFields({
name: 'Redis',
value: `Connection: ${displayConfig.redis.redisConnectionString}\nRetry Attempts: ${displayConfig.redis.retryAttempts}\nInitial Retry Delay: ${displayConfig.redis.initialRetryDelay}ms`,
});
}
pages.push(dbRedisEmbed);
}
if (displayConfig.channels || displayConfig.roles) {
const channelsRolesEmbed = new EmbedBuilder()
.setColor(0x0099ff)
.setTitle('Channels and Roles Configuration')
.setDescription('Server channel and role settings');
if (displayConfig.channels) {
const channelsText = Object.entries(displayConfig.channels)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
channelsRolesEmbed.addFields({
name: 'Channels',
value: channelsText || 'None configured',
});
}
if (displayConfig.roles) {
let rolesText = '';
if (displayConfig.roles.joinRoles?.length) {
rolesText += `Join Roles: ${displayConfig.roles.joinRoles.join(', ')}\n`;
}
if (displayConfig.roles.levelRoles?.length) {
rolesText += `Level Roles: ${displayConfig.roles.levelRoles.length} configured\n`;
}
if (displayConfig.roles.staffRoles?.length) {
rolesText += `Staff Roles: ${displayConfig.roles.staffRoles.length} configured\n`;
}
if (displayConfig.roles.factPingRole) {
rolesText += `Fact Ping Role: ${displayConfig.roles.factPingRole}`;
}
channelsRolesEmbed.addFields({
name: 'Roles',
value: rolesText || 'None configured',
});
}
pages.push(channelsRolesEmbed);
}
if (
displayConfig.leveling ||
displayConfig.counting ||
displayConfig.giveaways
) {
const featuresEmbed = new EmbedBuilder()
.setColor(0x0099ff)
.setTitle('Feature Configurations')
.setDescription('Settings for bot features');
if (displayConfig.leveling) {
featuresEmbed.addFields({
name: 'Leveling',
value: `XP Cooldown: ${displayConfig.leveling.xpCooldown}s\nMin XP: ${displayConfig.leveling.minXpAwarded}\nMax XP: ${displayConfig.leveling.maxXpAwarded}`,
});
}
if (displayConfig.counting) {
const countingText = Object.entries(displayConfig.counting)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
featuresEmbed.addFields({
name: 'Counting',
value: countingText || 'Default settings',
});
}
if (displayConfig.giveaways) {
const giveawaysText = Object.entries(displayConfig.giveaways)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
featuresEmbed.addFields({
name: 'Giveaways',
value: giveawaysText || 'Default settings',
});
}
pages.push(featuresEmbed);
}
let currentPage = 0;
const components =
pages.length > 1
? [createPaginationButtons(pages.length, currentPage)]
: [];
const reply = await interaction.editReply({
embeds: [pages[currentPage]],
components,
});
if (pages.length <= 1) return;
const collector = reply.createMessageComponentCollector({
time: 300000,
});
collector.on('collect', async (i) => {
if (i.user.id !== interaction.user.id) {
await i.reply({
content: 'You cannot use these buttons.',
flags: ['Ephemeral'],
});
return;
}
switch (i.customId) {
case 'first':
currentPage = 0;
break;
case 'prev':
if (currentPage > 0) currentPage--;
break;
case 'next':
if (currentPage < pages.length - 1) currentPage++;
break;
case 'last':
currentPage = pages.length - 1;
break;
}
await i.update({
embeds: [pages[currentPage]],
components: [createPaginationButtons(pages.length, currentPage)],
});
});
collector.on('end', async () => {
try {
await interaction.editReply({ components: [] });
} catch (error) {
console.error('Failed to remove pagination buttons:', error);
}
});
},
};
export default command;

273
src/commands/util/help.ts Normal file
View file

@ -0,0 +1,273 @@
import {
SlashCommandBuilder,
EmbedBuilder,
ActionRowBuilder,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
ComponentType,
} from 'discord.js';
import { OptionsCommand } from '@/types/CommandTypes.js';
import { ExtendedClient } from '@/structures/ExtendedClient.js';
const DOC_BASE_URL = 'https://docs.poixpixel.ahmadk953.org/';
const getDocUrl = (location: string) =>
`${DOC_BASE_URL}?utm_source=discord&utm_medium=bot&utm_campaign=help_command&utm_content=${location}`;
const command: OptionsCommand = {
data: new SlashCommandBuilder()
.setName('help')
.setDescription('Shows a list of all available commands')
.addStringOption((option) =>
option
.setName('command')
.setDescription('Get detailed help for a specific command')
.setRequired(false),
),
execute: async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply();
try {
const client = interaction.client as ExtendedClient;
const commandName = interaction.options.getString('command');
if (commandName) {
return handleSpecificCommand(interaction, client, commandName);
}
const categories = new Map();
for (const [name, cmd] of client.commands) {
const category = getCategoryFromCommand(name);
if (!categories.has(category)) {
categories.set(category, []);
}
categories.get(category).push({
name,
description: cmd.data.toJSON().description,
});
}
const embed = new EmbedBuilder()
.setColor('#0099ff')
.setTitle('Poixpixel Bot Commands')
.setDescription(
'**Welcome to Poixpixel Discord Bot!**\n\n' +
'Select a category from the dropdown menu below to see available commands.\n\n' +
`📚 **Documentation:** [Visit Our Documentation](${getDocUrl('main_description')})`,
)
.setThumbnail(client.user!.displayAvatarURL())
.setFooter({
text: 'Use /help [command] for detailed info about a command',
});
const categoryEmojis: Record<string, string> = {
fun: '🎮',
moderation: '🛡️',
util: '🔧',
testing: '🧪',
};
Array.from(categories.keys()).forEach((category) => {
const emoji = categoryEmojis[category] || '📁';
embed.addFields({
name: `${emoji} ${category.charAt(0).toUpperCase() + category.slice(1)}`,
value: `Use the dropdown to see ${category} commands`,
inline: true,
});
});
embed.addFields({
name: '📚 Documentation',
value: `[Click here to access our full documentation](${getDocUrl('main_footer_field')})`,
inline: false,
});
const selectMenu =
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder()
.setCustomId('help_category_select')
.setPlaceholder('Select a command category')
.addOptions(
Array.from(categories.keys()).map((category) => {
const emoji = categoryEmojis[category] || '📁';
return new StringSelectMenuOptionBuilder()
.setLabel(
category.charAt(0).toUpperCase() + category.slice(1),
)
.setDescription(`View ${category} commands`)
.setValue(category)
.setEmoji(emoji);
}),
),
);
const message = await interaction.editReply({
embeds: [embed],
components: [selectMenu],
});
const collector = message.createMessageComponentCollector({
componentType: ComponentType.StringSelect,
time: 60000,
});
collector.on('collect', async (i) => {
if (i.user.id !== interaction.user.id) {
await i.reply({
content: 'You cannot use this menu.',
ephemeral: true,
});
return;
}
const selectedCategory = i.values[0];
const commands = categories.get(selectedCategory);
const emoji = categoryEmojis[selectedCategory] || '📁';
const categoryEmbed = new EmbedBuilder()
.setColor('#0099ff')
.setTitle(
`${emoji} ${selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1)} Commands`,
)
.setDescription('Here are all the commands in this category:')
.setFooter({
text: 'Use /help [command] for detailed info about a command',
});
commands.forEach((cmd: any) => {
categoryEmbed.addFields({
name: `/${cmd.name}`,
value: cmd.description || 'No description available',
inline: false,
});
});
categoryEmbed.addFields({
name: '📚 Documentation',
value: `[Click here to access our full documentation](${getDocUrl(`category_${selectedCategory}`)})`,
inline: false,
});
await i.update({ embeds: [categoryEmbed], components: [selectMenu] });
});
collector.on('end', () => {
interaction.editReply({ components: [] }).catch(console.error);
});
} catch (error) {
console.error('Error in help command:', error);
await interaction.editReply({
content: 'An error occurred while processing your request.',
});
}
},
};
/**
* Handle showing help for a specific command
*/
async function handleSpecificCommand(
interaction: any,
client: ExtendedClient,
commandName: string,
) {
const cmd = client.commands.get(commandName);
if (!cmd) {
return interaction.editReply({
content: `Command \`${commandName}\` not found.`,
ephemeral: true,
});
}
const embed = new EmbedBuilder()
.setColor('#0099ff')
.setTitle(`Help: /${commandName}`)
.setDescription(cmd.data.toJSON().description || 'No description available')
.addFields({
name: 'Category',
value: getCategoryFromCommand(commandName),
inline: true,
})
.setFooter({
text: `Poixpixel Discord Bot • Documentation: ${getDocUrl(`cmd_footer_${commandName}`)}`,
});
const options = cmd.data.toJSON().options;
if (options && options.length > 0) {
if (options[0].type === 1) {
embed.addFields({
name: 'Subcommands',
value: options
.map((opt: any) => `\`${opt.name}\`: ${opt.description}`)
.join('\n'),
inline: false,
});
} else {
embed.addFields({
name: 'Options',
value: options
.map(
(opt: any) =>
`\`${opt.name}\`: ${opt.description} ${opt.required ? '(Required)' : '(Optional)'}`,
)
.join('\n'),
inline: false,
});
}
}
embed.addFields({
name: '📚 Documentation',
value: `[Click here to access our full documentation](${getDocUrl(`cmd_field_${commandName}`)})`,
inline: false,
});
return interaction.editReply({ embeds: [embed] });
}
/**
* Get the category of a command based on its name
*/
function getCategoryFromCommand(commandName: string): string {
const commandCategories: Record<string, string> = {
achievement: 'fun',
fact: 'fun',
rank: 'fun',
counting: 'fun',
giveaway: 'fun',
leaderboard: 'fun',
ban: 'moderation',
kick: 'moderation',
mute: 'moderation',
unmute: 'moderation',
warn: 'moderation',
unban: 'moderation',
ping: 'util',
server: 'util',
userinfo: 'util',
members: 'util',
rules: 'util',
restart: 'util',
reconnect: 'util',
xp: 'util',
recalculatelevels: 'util',
help: 'util',
config: 'util',
testjoin: 'testing',
testleave: 'testing',
};
return commandCategories[commandName.toLowerCase()] || 'other';
}
export default command;

View file

@ -16,6 +16,10 @@ const command: Command = {
.setName('members') .setName('members')
.setDescription('Lists all non-bot members of the server'), .setDescription('Lists all non-bot members of the server'),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply();
let members = await getAllMembers(); let members = await getAllMembers();
members = members.sort((a, b) => members = members.sort((a, b) =>
(a.discordUsername ?? '').localeCompare(b.discordUsername ?? ''), (a.discordUsername ?? '').localeCompare(b.discordUsername ?? ''),
@ -63,7 +67,7 @@ const command: Command = {
const components = const components =
pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : []; pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : [];
await interaction.reply({ await interaction.editReply({
embeds: [pages[currentPage]], embeds: [pages[currentPage]],
components, components,
}); });

View file

@ -8,7 +8,7 @@ const command: Command = {
.setDescription('Check the latency from you to the bot'), .setDescription('Check the latency from you to the bot'),
execute: async (interaction) => { execute: async (interaction) => {
await interaction.reply( await interaction.reply(
`Pong! Latency: ${Date.now() - interaction.createdTimestamp}ms`, `🏓 Pong! Latency: ${Date.now() - interaction.createdTimestamp}ms`,
); );
}, },
}; };

View file

@ -8,21 +8,22 @@ const command: Command = {
.setName('recalculatelevels') .setName('recalculatelevels')
.setDescription('(Admin Only) Recalculate all user levels'), .setDescription('(Admin Only) Recalculate all user levels'),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply({ flags: ['Ephemeral'] });
await interaction.editReply('Recalculating levels...');
if ( if (
!interaction.memberPermissions?.has( !interaction.memberPermissions?.has(
PermissionsBitField.Flags.Administrator, PermissionsBitField.Flags.Administrator,
) )
) { ) {
await interaction.reply({ await interaction.editReply({
content: 'You do not have permission to use this command.', content: 'You do not have permission to use this command.',
flags: ['Ephemeral'],
}); });
return; return;
} }
await interaction.deferReply();
await interaction.editReply('Recalculating levels...');
try { try {
await recalculateUserLevels(); await recalculateUserLevels();
await interaction.editReply('Levels recalculated successfully!'); await interaction.editReply('Levels recalculated successfully!');

View file

@ -36,7 +36,9 @@ const command: SubcommandCommand = {
), ),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply({ flags: ['Ephemeral'] });
const config = loadConfig(); const config = loadConfig();
const managerRoleId = config.roles.staffRoles.find( const managerRoleId = config.roles.staffRoles.find(
@ -52,18 +54,15 @@ const command: SubcommandCommand = {
PermissionsBitField.Flags.Administrator, PermissionsBitField.Flags.Administrator,
) )
) { ) {
await interaction.reply({ await interaction.editReply({
content: content:
'You do not have permission to use this command. This command is restricted to users with the Manager role.', 'You do not have permission to use this command. This command is restricted to users with the Manager role.',
flags: ['Ephemeral'],
}); });
return; return;
} }
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
await interaction.deferReply({ flags: ['Ephemeral'] });
try { try {
if (subcommand === 'database') { if (subcommand === 'database') {
await handleDatabaseReconnect(interaction); await handleDatabaseReconnect(interaction);

View file

@ -18,12 +18,16 @@ const command: Command = {
.setName('restart') .setName('restart')
.setDescription('(Manager Only) Restart the bot'), .setDescription('(Manager Only) Restart the bot'),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply({ flags: ['Ephemeral'] });
const config = loadConfig(); const config = loadConfig();
const managerRoleId = config.roles.staffRoles.find( const managerRoleId = config.roles.staffRoles.find(
(role) => role.name === 'Manager', (role) => role.name === 'Manager',
)?.roleId; )?.roleId;
const member = await interaction.guild?.members.fetch(interaction.user.id); const member = await interaction.guild.members.fetch(interaction.user.id);
const hasManagerRole = member?.roles.cache.has(managerRoleId || ''); const hasManagerRole = member?.roles.cache.has(managerRoleId || '');
if ( if (
@ -32,17 +36,15 @@ const command: Command = {
PermissionsBitField.Flags.Administrator, PermissionsBitField.Flags.Administrator,
) )
) { ) {
await interaction.reply({ await interaction.editReply({
content: content:
'You do not have permission to restart the bot. This command is restricted to users with the Manager role.', 'You do not have permission to restart the bot. This command is restricted to users with the Manager role.',
flags: ['Ephemeral'],
}); });
return; return;
} }
await interaction.reply({ await interaction.editReply({
content: 'Restarting the bot... This may take a few moments.', content: 'Restarting the bot... This may take a few moments.',
flags: ['Ephemeral'],
}); });
const dbConnected = await ensureDatabaseConnection(); const dbConnected = await ensureDatabaseConnection();

View file

@ -7,8 +7,10 @@ const command: Command = {
.setName('server') .setName('server')
.setDescription('Provides information about the server.'), .setDescription('Provides information about the server.'),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.reply( await interaction.reply(
`The server **${interaction!.guild!.name}** has **${interaction!.guild!.memberCount}** members and was created on **${interaction!.guild!.createdAt}**. It is **${new Date().getFullYear() - interaction!.guild!.createdAt.getFullYear()!}** years old.`, `The server **${interaction.guild.name}** has **${interaction.guild.memberCount}** members and was created on **${interaction.guild.createdAt}**. It is **${new Date().getFullYear() - interaction.guild.createdAt.getFullYear()}** years old.`,
); );
}, },
}; };

View file

@ -19,21 +19,25 @@ const command: OptionsCommand = {
.setRequired(true), .setRequired(true),
), ),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
await interaction.deferReply();
const userOption = interaction.options.get( const userOption = interaction.options.get(
'user', 'user',
) as unknown as GuildMember; ) as unknown as GuildMember;
const user = userOption.user; const user = userOption.user;
if (!userOption || !user) { if (!userOption || !user) {
await interaction.reply('User not found'); await interaction.editReply('User not found');
return; return;
} }
if ( if (
!interaction.memberPermissions!.has( !interaction.memberPermissions?.has(
PermissionsBitField.Flags.ModerateMembers, PermissionsBitField.Flags.ModerateMembers,
) )
) { ) {
await interaction.reply( await interaction.editReply(
'You do not have permission to view member information.', 'You do not have permission to view member information.',
); );
return; return;
@ -140,7 +144,7 @@ const command: OptionsCommand = {
iconURL: interaction.user.displayAvatarURL(), iconURL: interaction.user.displayAvatarURL(),
}); });
await interaction.reply({ embeds: [embed] }); await interaction.editReply({ embeds: [embed] });
}, },
}; };

View file

@ -71,12 +71,16 @@ const command: SubcommandCommand = {
), ),
), ),
execute: async (interaction) => { execute: async (interaction) => {
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand() || !interaction.guild) return;
const commandUser = interaction.guild?.members.cache.get( const commandUser = interaction.guild.members.cache.get(
interaction.user.id, interaction.user.id,
); );
await interaction.deferReply({
flags: ['Ephemeral'],
});
const config = loadConfig(); const config = loadConfig();
const managerRoleId = config.roles.staffRoles.find( const managerRoleId = config.roles.staffRoles.find(
(role) => role.name === 'Manager', (role) => role.name === 'Manager',
@ -87,18 +91,12 @@ const command: SubcommandCommand = {
!managerRoleId || !managerRoleId ||
commandUser.roles.highest.comparePositionTo(managerRoleId) < 0 commandUser.roles.highest.comparePositionTo(managerRoleId) < 0
) { ) {
await interaction.reply({ await interaction.editReply({
content: 'You do not have permission to use this command', content: 'You do not have permission to use this command',
flags: ['Ephemeral'],
}); });
return; return;
} }
await interaction.deferReply({
flags: ['Ephemeral'],
});
await interaction.editReply('Processing...');
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const amount = interaction.options.getInteger('amount', false); const amount = interaction.options.getInteger('amount', false);

View file

@ -133,6 +133,7 @@ export async function updateMember({
discordUsername, discordUsername,
currentlyInServer, currentlyInServer,
currentlyBanned, currentlyBanned,
currentlyMuted,
}: schema.memberTableTypes): Promise<void> { }: schema.memberTableTypes): Promise<void> {
try { try {
await ensureDbInitialized(); await ensureDbInitialized();
@ -147,6 +148,7 @@ export async function updateMember({
discordUsername, discordUsername,
currentlyInServer, currentlyInServer,
currentlyBanned, currentlyBanned,
currentlyMuted,
}) })
.where(eq(schema.memberTable.discordId, discordId)); .where(eq(schema.memberTable.discordId, discordId));

View file

@ -12,9 +12,10 @@ async function startBot() {
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildModeration,
GatewayIntentBits.GuildInvites,
GatewayIntentBits.MessageContent, GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildModeration,
], ],
}, },
config, config,

View file

@ -6,7 +6,7 @@ import {
} from 'discord.js'; } from 'discord.js';
import { updateMember, setMembers } from '@/db/db.js'; import { updateMember, setMembers } from '@/db/db.js';
import { generateMemberBanner } from '@/util/helpers.js'; import { executeUnmute, generateMemberBanner } from '@/util/helpers.js';
import { loadConfig } from '@/util/configLoader.js'; import { loadConfig } from '@/util/configLoader.js';
import { Event } from '@/types/EventTypes.js'; import { Event } from '@/types/EventTypes.js';
import logAction from '@/util/logging/logAction.js'; import logAction from '@/util/logging/logAction.js';
@ -144,6 +144,21 @@ export const memberUpdate: Event<typeof Events.GuildMemberUpdate> = {
}); });
} }
} }
if (
oldMember.communicationDisabledUntil !==
newMember.communicationDisabledUntil &&
newMember.communicationDisabledUntil === null
) {
await executeUnmute(
newMember.client,
guild.id,
newMember.user.id,
undefined,
guild.members.me!,
true,
);
}
} catch (error) { } catch (error) {
console.error('Error handling member update:', error); console.error('Error handling member update:', error);
} }

View file

@ -11,6 +11,7 @@ import {
setDiscordClient as setRedisDiscordClient, setDiscordClient as setRedisDiscordClient,
} from '@/db/redis.js'; } from '@/db/redis.js';
import { setDiscordClient as setDbDiscordClient } from '@/db/db.js'; import { setDiscordClient as setDbDiscordClient } from '@/db/db.js';
import { loadActiveBans, loadActiveMutes } from '@/util/helpers.js';
export default { export default {
name: Events.ClientReady, name: Events.ClientReady,
@ -36,6 +37,9 @@ export default {
const nonBotMembers = members.filter((m) => !m.user.bot); const nonBotMembers = members.filter((m) => !m.user.bot);
await setMembers(nonBotMembers); await setMembers(nonBotMembers);
await loadActiveBans(client, guild);
await loadActiveMutes(client, guild);
await scheduleFactOfTheDay(client); await scheduleFactOfTheDay(client);
await scheduleGiveaways(client); await scheduleGiveaways(client);

View file

@ -1,7 +1,7 @@
import { Client, ClientOptions, Collection } from 'discord.js'; import { Client, ClientOptions, Collection } from 'discord.js';
import { Command } from '@/types/CommandTypes.js'; import { Command } from '@/types/CommandTypes.js';
import { Config } from '@/types/ConfigTypes.js'; import { Config } from '@/types/ConfigTypes.js';
import { deployCommands } from '@/util/deployCommand.js'; import { deployCommands, getFilesRecursively } from '@/util/deployCommand.js';
import { registerEvents } from '@/util/eventLoader.js'; import { registerEvents } from '@/util/eventLoader.js';
/** /**
@ -29,6 +29,19 @@ export class ExtendedClient extends Client {
private async loadModules() { private async loadModules() {
try { try {
if (process.env.SKIP_COMMAND_DEPLOY === 'true') {
console.log('Skipping command deployment (SKIP_COMMAND_DEPLOY=true)');
const commandFiles = await this.loadCommandsWithoutDeploying();
if (!commandFiles?.length) {
throw new Error('No commands found');
}
await registerEvents(this);
console.log(
`Loaded ${commandFiles.length} commands and registered events (without deployment)`,
);
} else {
const commands = await deployCommands(); const commands = await deployCommands();
if (!commands?.length) { if (!commands?.length) {
throw new Error('No commands found'); throw new Error('No commands found');
@ -40,9 +53,49 @@ export class ExtendedClient extends Client {
await registerEvents(this); await registerEvents(this);
console.log(`Loaded ${commands.length} commands and registered events`); console.log(`Loaded ${commands.length} commands and registered events`);
}
} catch (error) { } catch (error) {
console.error('Error loading modules:', error); console.error('Error loading modules:', error);
process.exit(1); process.exit(1);
} }
} }
/**
* Loads commands without deploying them to Discord
* @returns Array of command objects
*/
private async loadCommandsWithoutDeploying(): Promise<Command[]> {
try {
const path = await import('path');
const __dirname = path.resolve();
const commandsPath = path.join(__dirname, 'target', 'commands');
const commandFiles = getFilesRecursively(commandsPath);
const commands: Command[] = [];
for (const file of commandFiles) {
const commandModule = await import(`file://${file}`);
const command = commandModule.default;
if (
command instanceof Object &&
'data' in command &&
'execute' in command
) {
commands.push(command);
this.commands.set(command.data.name, command);
} else {
console.warn(
`[WARNING] The command at ${file} is missing a required "data" or "execute" property.`,
);
}
}
return commands;
} catch (error) {
console.error('Error loading commands:', error);
throw error;
}
}
} }

View file

@ -5,6 +5,7 @@ export interface Config {
token: string; token: string;
clientId: string; clientId: string;
guildId: string; guildId: string;
serverInvite: string;
database: { database: {
dbConnectionString: string; dbConnectionString: string;
maxRetryAttempts: number; maxRetryAttempts: number;

View file

@ -17,7 +17,7 @@ const rest = new REST({ version: '10' }).setToken(token);
* @param directory - The directory to get files from * @param directory - The directory to get files from
* @returns - An array of file paths * @returns - An array of file paths
*/ */
const getFilesRecursively = (directory: string): string[] => { export const getFilesRecursively = (directory: string): string[] => {
const files: string[] = []; const files: string[] = [];
const filesInDirectory = fs.readdirSync(directory); const filesInDirectory = fs.readdirSync(directory);

View file

@ -10,11 +10,12 @@ import {
ButtonStyle, ButtonStyle,
ButtonBuilder, ButtonBuilder,
ActionRowBuilder, ActionRowBuilder,
DiscordAPIError,
} from 'discord.js'; } from 'discord.js';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { moderationTable } from '@/db/schema.js'; import { moderationTable } from '@/db/schema.js';
import { db, handleDbError, updateMember } from '@/db/db.js'; import { db, getMember, handleDbError, updateMember } from '@/db/db.js';
import logAction from './logging/logAction.js'; import logAction from './logging/logAction.js';
const __dirname = path.resolve(); const __dirname = path.resolve();
@ -116,6 +117,107 @@ export async function generateMemberBanner({
return attachment; return attachment;
} }
/**
* Executes an unmute for a user
* @param client - The client to use
* @param guildId - The guild ID to unmute the user in
* @param userId - The user ID to unmute
* @param reason - The reason for the unmute
* @param moderator - The moderator who is unmuting the user
* @param alreadyUnmuted - Whether the user is already unmuted
*/
export async function executeUnmute(
client: Client,
guildId: string,
userId: string,
reason?: string,
moderator?: GuildMember,
alreadyUnmuted: boolean = false,
): Promise<void> {
try {
const guild = await client.guilds.fetch(guildId);
let member;
try {
member = await guild.members.fetch(userId);
if (!alreadyUnmuted) {
await member.timeout(null, reason ?? 'Temporary mute expired');
}
} catch (error) {
console.log(
`Member ${userId} not found in server, just updating database`,
);
}
if (!(await getMember(userId))?.currentlyMuted) return;
await db
.update(moderationTable)
.set({ active: false })
.where(
and(
eq(moderationTable.discordId, userId),
eq(moderationTable.action, 'mute'),
eq(moderationTable.active, true),
),
);
await updateMember({
discordId: userId,
currentlyMuted: false,
});
if (member) {
await logAction({
guild,
action: 'unmute',
target: member,
reason: reason ?? 'Temporary mute expired',
moderator: moderator ? moderator : guild.members.me!,
});
}
} catch (error) {
console.error('Error executing unmute:', error);
if (!(error instanceof DiscordAPIError && error.code === 10007)) {
handleDbError('Failed to execute unmute', error as Error);
}
}
}
/**
* Loads all active mutes and schedules unmute events
* @param client - The client to use
* @param guild - The guild to load mutes for
*/
export async function loadActiveMutes(
client: Client,
guild: Guild,
): Promise<void> {
try {
const activeMutes = await db
.select()
.from(moderationTable)
.where(
and(
eq(moderationTable.action, 'mute'),
eq(moderationTable.active, true),
),
);
for (const mute of activeMutes) {
if (!mute.expiresAt) continue;
const timeUntilUnmute = mute.expiresAt.getTime() - Date.now();
if (timeUntilUnmute <= 0) {
await executeUnmute(client, guild.id, mute.discordId);
}
}
} catch (error) {
handleDbError('Failed to load active mutes', error as Error);
}
}
/** /**
* Schedules an unban for a user * Schedules an unban for a user
* @param client - The client to use * @param client - The client to use
@ -174,7 +276,7 @@ export async function executeUnban(
guild, guild,
action: 'unban', action: 'unban',
target: guild.members.cache.get(userId)!, target: guild.members.cache.get(userId)!,
moderator: guild.members.cache.get(client.user!.id)!, moderator: guild.members.me!,
reason: reason ?? 'Temporary ban expired', reason: reason ?? 'Temporary ban expired',
}); });
} catch (error) { } catch (error) {

View file

@ -0,0 +1,36 @@
import { REST, Routes } from 'discord.js';
import { loadConfig } from './configLoader.js';
const config = loadConfig();
const { token, clientId, guildId } = config;
const rest = new REST({ version: '10' }).setToken(token);
/**
* Undeploys all commands from the Discord API
*/
export const undeployCommands = async () => {
try {
console.log('Undeploying all commands from the Discord API...');
await rest.put(Routes.applicationGuildCommands(clientId, guildId), {
body: [],
});
console.log('Successfully undeployed all commands');
} catch (error) {
console.error('Error undeploying commands:', error);
throw error;
}
};
if (import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'))) {
undeployCommands()
.then(() => {
console.log('Undeploy process completed successfully');
})
.catch((err) => {
console.error('Undeploy process failed:', err);
process.exitCode = 1;
});
}

View file

@ -1882,7 +1882,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": "cross-env@npm:^7.0.3":
version: 7.0.3
resolution: "cross-env@npm:7.0.3"
dependencies:
cross-spawn: "npm:^7.0.1"
bin:
cross-env: src/bin/cross-env.js
cross-env-shell: src/bin/cross-env-shell.js
checksum: 10c0/f3765c25746c69fcca369655c442c6c886e54ccf3ab8c16847d5ad0e91e2f337d36eedc6599c1227904bf2a228d721e690324446876115bc8e7b32a866735ecf
languageName: node
linkType: hard
"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6":
version: 7.0.6 version: 7.0.6
resolution: "cross-spawn@npm:7.0.6" resolution: "cross-spawn@npm:7.0.6"
dependencies: dependencies:
@ -4201,6 +4213,7 @@ __metadata:
"@types/pg": "npm:^8.11.13" "@types/pg": "npm:^8.11.13"
"@typescript-eslint/eslint-plugin": "npm:^8.30.1" "@typescript-eslint/eslint-plugin": "npm:^8.30.1"
"@typescript-eslint/parser": "npm:^8.30.1" "@typescript-eslint/parser": "npm:^8.30.1"
cross-env: "npm:^7.0.3"
discord.js: "npm:^14.18.0" discord.js: "npm:^14.18.0"
drizzle-kit: "npm:^0.31.0" drizzle-kit: "npm:^0.31.0"
drizzle-orm: "npm:^0.42.0" drizzle-orm: "npm:^0.42.0"