(Feat): Added a minimal pikanetwork client

This commit is contained in:
2025-12-01 13:08:01 +00:00
commit 101d093965
68 changed files with 18007 additions and 0 deletions

View File

@@ -0,0 +1,276 @@
/**
* Application Admin Handlers (Export/Purge)
*/
import {
EmbedBuilder,
AttachmentBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { EllyClient } from '../../../client/EllyClient.ts';
import { ApplicationRepository, type Application } from '../../../database/repositories/ApplicationRepository.ts';
import { PermissionLevel } from '../../../types/index.ts';
/**
* Handle exporting applications to CSV
*/
export async function handleExport(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Admin)) {
await interaction.reply({
content: '❌ You need Admin permission to export applications.',
ephemeral: true,
});
return;
}
const statusFilter = interaction.options.getString('status') ?? 'all';
await interaction.deferReply({ ephemeral: true });
// Get applications
let applications: Application[];
if (statusFilter === 'all') {
applications = await repo.getAll();
} else {
applications = await repo.getByStatus(statusFilter as Application['status']);
}
if (applications.length === 0) {
await interaction.editReply({
content: '📭 No applications to export.',
});
return;
}
// Create CSV content
const headers = [
'ID',
'User ID',
'MC Username',
'Status',
'Discord Age',
'Timezone',
'Activity',
'Why Join',
'Experience',
'Reviewed By',
'Created At',
'Reviewed At',
];
const rows = applications.map((app) => [
app.id,
app.userId,
app.minecraftUsername,
app.status,
app.discordAge ?? '',
app.timezone ?? '',
app.activity ?? '',
`"${(app.whyJoin ?? '').replace(/"/g, '""')}"`,
`"${(app.experience ?? '').replace(/"/g, '""')}"`,
app.reviewedBy ?? '',
new Date(app.createdAt).toISOString(),
app.reviewedAt ? new Date(app.reviewedAt).toISOString() : '',
]);
const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
// Create attachment
const buffer = new TextEncoder().encode(csv);
const attachment = new AttachmentBuilder(buffer, {
name: `applications_${statusFilter}_${Date.now()}.csv`,
});
const embed = new EmbedBuilder()
.setColor(0x57f287)
.setTitle('📤 Applications Exported')
.addFields(
{ name: 'Filter', value: statusFilter === 'all' ? 'All' : statusFilter, inline: true },
{ name: 'Count', value: String(applications.length), inline: true },
{ name: 'Format', value: 'CSV', inline: true }
)
.setFooter({ text: `Exported by ${interaction.user.tag}` })
.setTimestamp();
await interaction.editReply({
embeds: [embed],
files: [attachment],
});
// Log export
const logChannel = client.channels_cache.applicationLogs;
if (logChannel) {
await logChannel.send({
embeds: [
new EmbedBuilder()
.setColor(0x3498db)
.setTitle('📤 Applications Exported')
.addFields(
{ name: 'Exported By', value: interaction.user.tag, inline: true },
{ name: 'Filter', value: statusFilter, inline: true },
{ name: 'Count', value: String(applications.length), inline: true }
)
.setTimestamp(),
],
});
}
}
/**
* Handle purging old applications
*/
export async function handlePurge(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Admin)) {
await interaction.reply({
content: '❌ You need Admin permission to purge applications.',
ephemeral: true,
});
return;
}
const days = interaction.options.getInteger('days', true);
const statusFilter = interaction.options.getString('status');
await interaction.deferReply({ ephemeral: true });
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
// Get applications to purge
const allApplications = await repo.getAll();
let toPurge = allApplications.filter((a) => a.createdAt < cutoff);
// Apply status filter
if (statusFilter === 'denied') {
toPurge = toPurge.filter((a) => a.status === 'denied');
} else if (statusFilter === 'reviewed') {
toPurge = toPurge.filter((a) => a.status !== 'pending');
}
if (toPurge.length === 0) {
await interaction.editReply({
content: '📭 No applications match the purge criteria.',
});
return;
}
// Confirm purge
const { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } = await import('discord.js');
const confirmEmbed = new EmbedBuilder()
.setColor(0xed4245)
.setTitle('⚠️ Confirm Purge')
.setDescription(
`You are about to permanently delete **${toPurge.length}** applications.\n\n` +
`**Criteria:**\n` +
`• Older than: ${days} days\n` +
`• Status filter: ${statusFilter ?? 'All reviewed'}\n\n` +
`This action cannot be undone!`
)
.setTimestamp();
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('purge:confirm')
.setLabel(`Delete ${toPurge.length} Applications`)
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId('purge:cancel')
.setLabel('Cancel')
.setStyle(ButtonStyle.Secondary)
);
const response = await interaction.editReply({
embeds: [confirmEmbed],
components: [row],
});
try {
const buttonInteraction = await response.awaitMessageComponent({
componentType: ComponentType.Button,
filter: (i) => i.user.id === interaction.user.id,
time: 30000,
});
if (buttonInteraction.customId === 'purge:cancel') {
await buttonInteraction.update({
embeds: [
new EmbedBuilder()
.setColor(0x3498db)
.setTitle('❌ Purge Cancelled')
.setDescription('No applications were deleted.')
.setTimestamp(),
],
components: [],
});
return;
}
// Perform purge
let deleted = 0;
for (const app of toPurge) {
const success = await repo.delete(app.id);
if (success) deleted++;
}
// Also delete associated notes
const notes = client.database.get<Array<{ appId: string }>>('application_notes') ?? [];
const purgedIds = new Set(toPurge.map((a) => a.id));
const remainingNotes = notes.filter((n) => !purgedIds.has(n.appId));
client.database.set('application_notes', remainingNotes);
const resultEmbed = new EmbedBuilder()
.setColor(0x57f287)
.setTitle('🗑️ Purge Complete')
.addFields(
{ name: 'Deleted', value: String(deleted), inline: true },
{ name: 'Notes Removed', value: String(notes.length - remainingNotes.length), inline: true },
{ name: 'Criteria', value: `Older than ${days} days`, inline: true }
)
.setFooter({ text: `Purged by ${interaction.user.tag}` })
.setTimestamp();
await buttonInteraction.update({
embeds: [resultEmbed],
components: [],
});
// Log purge
const logChannel = client.channels_cache.applicationLogs;
if (logChannel) {
await logChannel.send({
embeds: [
new EmbedBuilder()
.setColor(0xed4245)
.setTitle('🗑️ Applications Purged')
.addFields(
{ name: 'Purged By', value: interaction.user.tag, inline: true },
{ name: 'Count', value: String(deleted), inline: true },
{ name: 'Criteria', value: `Older than ${days} days, ${statusFilter ?? 'all reviewed'}`, inline: true }
)
.setTimestamp(),
],
});
}
} catch {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(0xed4245)
.setTitle('⏰ Timed Out')
.setDescription('Purge cancelled due to timeout.')
.setTimestamp(),
],
components: [],
});
}
}

View File

@@ -0,0 +1,708 @@
/**
* Application Submission Handler
*/
import {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
ChannelType,
type ChatInputCommandInteraction,
type ModalSubmitInteraction,
ComponentType,
type ButtonInteraction,
} from 'discord.js';
import type { EllyClient } from '../../../client/EllyClient.ts';
import { ApplicationRepository, type Application } from '../../../database/repositories/ApplicationRepository.ts';
import { PermissionLevel } from '../../../types/index.ts';
/**
* Handle application submission
*/
export async function handleApply(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
// Check if user is blacklisted
const blacklists = client.database.get<Array<{ userId: string; type: string }>>('blacklists') ?? [];
const isBlacklisted = blacklists.some(
(b) => b.userId === interaction.user.id && (b.type === 'applications' || b.type === 'bot')
);
if (isBlacklisted) {
await interaction.reply({
content: '❌ You are blacklisted from submitting applications.',
ephemeral: true,
});
return;
}
// Check if user already has a pending application
const existing = await repo.hasPendingApplication(interaction.user.id);
if (existing) {
await interaction.reply({
content: '❌ You already have a pending application. Please wait for it to be reviewed.',
ephemeral: true,
});
return;
}
// Check cooldown (can't apply again within 7 days of denial)
const recentDenied = await repo.getRecentDenied(interaction.user.id, 7 * 24 * 60 * 60 * 1000);
if (recentDenied) {
const waitUntil = recentDenied.createdAt + 7 * 24 * 60 * 60 * 1000;
await interaction.reply({
content: `❌ You were recently denied. You can apply again <t:${Math.floor(waitUntil / 1000)}:R>.`,
ephemeral: true,
});
return;
}
// Show application modal
const modal = new ModalBuilder()
.setCustomId('application:submit')
.setTitle('Guild Application')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('minecraft_username')
.setLabel('Minecraft Username')
.setPlaceholder('Your in-game name (case-sensitive)')
.setStyle(TextInputStyle.Short)
.setMaxLength(16)
.setRequired(true)
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('timezone')
.setLabel('Timezone & Availability')
.setPlaceholder('e.g., EST, usually online 6-10 PM')
.setStyle(TextInputStyle.Short)
.setMaxLength(50)
.setRequired(true)
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('activity')
.setLabel('Weekly Activity')
.setPlaceholder('How many hours per week can you play?')
.setStyle(TextInputStyle.Short)
.setMaxLength(100)
.setRequired(true)
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('why_join')
.setLabel('Why do you want to join our guild?')
.setPlaceholder('Tell us about yourself and why you want to join')
.setStyle(TextInputStyle.Paragraph)
.setMinLength(50)
.setMaxLength(1000)
.setRequired(true)
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('experience')
.setLabel('Gaming Experience')
.setPlaceholder('Your experience with Minecraft, PvP, guilds, etc.')
.setStyle(TextInputStyle.Paragraph)
.setMaxLength(500)
.setRequired(true)
)
);
await interaction.showModal(modal);
// Wait for modal submission
try {
const modalInteraction = await interaction.awaitModalSubmit({
time: 600000, // 10 minutes
filter: (i) => i.customId === 'application:submit' && i.user.id === interaction.user.id,
});
await processApplicationSubmit(modalInteraction, client, repo);
} catch {
// Modal timed out - no action needed
}
}
/**
* Process application modal submission
*/
async function processApplicationSubmit(
interaction: ModalSubmitInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
await interaction.deferReply({ ephemeral: true });
const minecraftUsername = interaction.fields.getTextInputValue('minecraft_username');
const timezone = interaction.fields.getTextInputValue('timezone');
const activity = interaction.fields.getTextInputValue('activity');
const whyJoin = interaction.fields.getTextInputValue('why_join');
const experience = interaction.fields.getTextInputValue('experience');
// Calculate Discord account age
const accountAge = Date.now() - interaction.user.createdTimestamp;
const days = Math.floor(accountAge / (1000 * 60 * 60 * 24));
const discordAge = `${days} days`;
// Fetch PikaNetwork stats if available
let pikaStats = null;
try {
const profile = await client.pikaAPI.getProfile(minecraftUsername);
if (profile) {
pikaStats = {
exists: true,
lastSeen: profile.lastSeen,
};
}
} catch {
// API error - continue without stats
}
// Create application
const application = await repo.create({
userId: interaction.user.id,
messageId: '',
channelId: '',
minecraftUsername,
discordAge,
timezone,
activity,
whyJoin,
experience,
extra: pikaStats ? JSON.stringify(pikaStats) : undefined,
});
// Find applications channel
const channel = client.channels_cache.applications;
if (!channel) {
await interaction.editReply({
content: '❌ Applications channel not found. Please contact an administrator.',
});
return;
}
// Create application embed
const embed = createApplicationEmbed(application, interaction.user, client, pikaStats);
// Create action buttons
const row1 = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`app:accept:${application.id}`)
.setLabel('Accept')
.setStyle(ButtonStyle.Success)
.setEmoji('✅'),
new ButtonBuilder()
.setCustomId(`app:deny:${application.id}`)
.setLabel('Deny')
.setStyle(ButtonStyle.Danger)
.setEmoji('❌'),
new ButtonBuilder()
.setCustomId(`app:interview:${application.id}`)
.setLabel('Request Interview')
.setStyle(ButtonStyle.Primary)
.setEmoji('🎤'),
new ButtonBuilder()
.setCustomId(`app:note:${application.id}`)
.setLabel('Add Note')
.setStyle(ButtonStyle.Secondary)
.setEmoji('📝')
);
const row2 = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`app:stats:${minecraftUsername}`)
.setLabel('View Stats')
.setStyle(ButtonStyle.Secondary)
.setEmoji('📊'),
new ButtonBuilder()
.setCustomId(`app:history:${interaction.user.id}`)
.setLabel('User History')
.setStyle(ButtonStyle.Secondary)
.setEmoji('📜')
);
// Post to applications channel
const message = await channel.send({
content: `<@&${client.roles.officer?.id ?? ''}>`,
embeds: [embed],
components: [row1, row2],
});
// Update application with message ID
await repo.updateMessageId(application.id, message.id, channel.id);
await interaction.editReply({
content: `✅ Your application has been submitted!\n\n**Application ID:** \`${application.id}\`\n**Status:** ⏳ Pending Review\n\nYou will be notified via DM when your application is reviewed.`,
});
// Set up button collector
setupApplicationCollector(message, application.id, client, repo);
// Log to application logs channel
const logChannel = client.channels_cache.applicationLogs;
if (logChannel) {
await logChannel.send({
embeds: [
new EmbedBuilder()
.setColor(0x3498db)
.setTitle('📥 New Application Received')
.addFields(
{ name: 'Applicant', value: `${interaction.user.tag} (<@${interaction.user.id}>)`, inline: true },
{ name: 'MC Username', value: minecraftUsername, inline: true },
{ name: 'Application ID', value: `\`${application.id}\``, inline: true }
)
.setTimestamp(),
],
});
}
}
/**
* Create application embed
*/
function createApplicationEmbed(
application: Application,
user: import('discord.js').User,
client: EllyClient,
pikaStats: { exists: boolean; lastSeen?: string } | null
): EmbedBuilder {
const embed = new EmbedBuilder()
.setColor(0xfee75c)
.setTitle('📝 New Guild Application')
.setThumbnail(user.displayAvatarURL({ size: 256 }))
.addFields(
{ name: '👤 Applicant', value: `${user.tag}\n<@${user.id}>`, inline: true },
{ name: '🎮 MC Username', value: `\`${application.minecraftUsername}\``, inline: true },
{ name: '🆔 Application ID', value: `\`${application.id}\``, inline: true },
{ name: '📅 Discord Age', value: application.discordAge, inline: true },
{ name: '🌍 Timezone', value: application.timezone, inline: true },
{ name: '⏰ Activity', value: application.activity, inline: true },
{ name: '❓ Why Join', value: application.whyJoin.substring(0, 1024) },
{ name: '🎯 Experience', value: application.experience.substring(0, 1024) }
)
.setFooter({ text: `Submitted at` })
.setTimestamp(application.createdAt);
// Add PikaNetwork verification
if (pikaStats) {
embed.addFields({
name: '🔍 PikaNetwork',
value: pikaStats.exists ? '✅ Account Found' : '❌ Account Not Found',
inline: true,
});
}
return embed;
}
/**
* Set up collector for application buttons
*/
function setupApplicationCollector(
message: import('discord.js').Message,
applicationId: string,
client: EllyClient,
repo: ApplicationRepository
): void {
const collector = message.createMessageComponentCollector({
componentType: ComponentType.Button,
time: 14 * 24 * 60 * 60 * 1000, // 14 days
});
collector.on('collect', async (i: ButtonInteraction) => {
const member = i.guild?.members.cache.get(i.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await i.reply({
content: '❌ You need Officer permission to review applications.',
ephemeral: true,
});
return;
}
const [, action, id] = i.customId.split(':');
switch (action) {
case 'accept':
await handleButtonAccept(i, applicationId, client, repo, message);
break;
case 'deny':
await handleButtonDeny(i, applicationId, client, repo, message);
break;
case 'interview':
await handleButtonInterview(i, applicationId, client, repo);
break;
case 'note':
await handleButtonNote(i, applicationId, client, repo);
break;
case 'stats':
await handleButtonStats(i, id, client);
break;
case 'history':
await handleButtonHistory(i, id, client, repo);
break;
}
});
collector.on('end', async () => {
// Disable buttons after collector ends
try {
const row1 = ActionRowBuilder.from(message.components[0]).setComponents(
message.components[0].components.map((c) =>
ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true)
)
);
const row2 = ActionRowBuilder.from(message.components[1]).setComponents(
message.components[1].components.map((c) =>
ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true)
)
);
await message.edit({ components: [row1 as ActionRowBuilder<ButtonBuilder>, row2 as ActionRowBuilder<ButtonBuilder>] });
} catch {
// Message might be deleted
}
});
}
async function handleButtonAccept(
i: ButtonInteraction,
applicationId: string,
client: EllyClient,
repo: ApplicationRepository,
message: import('discord.js').Message
): Promise<void> {
const app = await repo.updateStatus(applicationId, 'accepted', i.user.id);
if (!app) {
await i.reply({ content: '❌ Application not found.', ephemeral: true });
return;
}
// Update message
const embed = EmbedBuilder.from(message.embeds[0])
.setColor(0x57f287)
.setTitle('✅ Application Accepted')
.addFields({ name: '👤 Reviewed By', value: i.user.tag, inline: true });
// Disable buttons
const row1 = new ActionRowBuilder<ButtonBuilder>().addComponents(
...message.components[0].components.map((c) =>
ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true)
)
);
const row2 = new ActionRowBuilder<ButtonBuilder>().addComponents(
...message.components[1].components.map((c) =>
ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true)
)
);
await message.edit({ embeds: [embed], components: [row1, row2] });
// Notify applicant
await notifyApplicant(client, app.userId, 'accepted');
// Add guild member role
try {
const member = await i.guild?.members.fetch(app.userId);
if (member && client.roles.guildMember) {
await member.roles.add(client.roles.guildMember);
}
} catch {
// Member might have left
}
await i.reply({ content: '✅ Application accepted! User has been notified.', ephemeral: true });
// Log
const logChannel = client.channels_cache.applicationLogs;
if (logChannel) {
await logChannel.send({
embeds: [
new EmbedBuilder()
.setColor(0x57f287)
.setTitle('✅ Application Accepted')
.addFields(
{ name: 'Applicant', value: `<@${app.userId}>`, inline: true },
{ name: 'MC Username', value: app.minecraftUsername, inline: true },
{ name: 'Reviewed By', value: i.user.tag, inline: true }
)
.setTimestamp(),
],
});
}
}
async function handleButtonDeny(
i: ButtonInteraction,
applicationId: string,
client: EllyClient,
repo: ApplicationRepository,
message: import('discord.js').Message
): Promise<void> {
// Show denial reason modal
const modal = new ModalBuilder()
.setCustomId(`app:deny_modal:${applicationId}`)
.setTitle('Deny Application')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('reason')
.setLabel('Reason for denial')
.setPlaceholder('This will be sent to the applicant')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
)
);
await i.showModal(modal);
try {
const modalInteraction = await i.awaitModalSubmit({
time: 300000,
filter: (mi) => mi.customId === `app:deny_modal:${applicationId}`,
});
const reason = modalInteraction.fields.getTextInputValue('reason');
const app = await repo.updateStatus(applicationId, 'denied', i.user.id);
if (!app) {
await modalInteraction.reply({ content: '❌ Application not found.', ephemeral: true });
return;
}
// Update message
const embed = EmbedBuilder.from(message.embeds[0])
.setColor(0xed4245)
.setTitle('❌ Application Denied')
.addFields(
{ name: '👤 Reviewed By', value: i.user.tag, inline: true },
{ name: '📝 Reason', value: reason }
);
const row1 = new ActionRowBuilder<ButtonBuilder>().addComponents(
...message.components[0].components.map((c) =>
ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true)
)
);
const row2 = new ActionRowBuilder<ButtonBuilder>().addComponents(
...message.components[1].components.map((c) =>
ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true)
)
);
await message.edit({ embeds: [embed], components: [row1, row2] });
// Notify applicant
await notifyApplicant(client, app.userId, 'denied', reason);
await modalInteraction.reply({ content: '❌ Application denied. User has been notified.', ephemeral: true });
// Log
const logChannel = client.channels_cache.applicationLogs;
if (logChannel) {
await logChannel.send({
embeds: [
new EmbedBuilder()
.setColor(0xed4245)
.setTitle('❌ Application Denied')
.addFields(
{ name: 'Applicant', value: `<@${app.userId}>`, inline: true },
{ name: 'Reviewed By', value: i.user.tag, inline: true },
{ name: 'Reason', value: reason }
)
.setTimestamp(),
],
});
}
} catch {
// Modal timed out
}
}
async function handleButtonInterview(
i: ButtonInteraction,
applicationId: string,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
const app = await repo.getById(applicationId);
if (!app) {
await i.reply({ content: '❌ Application not found.', ephemeral: true });
return;
}
// Notify applicant about interview request
try {
const user = await client.users.fetch(app.userId);
await user.send({
embeds: [
new EmbedBuilder()
.setColor(0x3498db)
.setTitle('🎤 Interview Requested')
.setDescription(
`A staff member has requested an interview regarding your guild application.\n\n` +
`Please contact <@${i.user.id}> to schedule your interview.`
)
.setTimestamp(),
],
});
await i.reply({
content: `✅ Interview request sent to <@${app.userId}>.`,
ephemeral: true,
});
} catch {
await i.reply({
content: '❌ Could not send interview request. User may have DMs disabled.',
ephemeral: true,
});
}
}
async function handleButtonNote(
i: ButtonInteraction,
applicationId: string,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
const modal = new ModalBuilder()
.setCustomId(`app:add_note:${applicationId}`)
.setTitle('Add Note')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('note')
.setLabel('Internal Note')
.setPlaceholder('This note is only visible to staff')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
)
);
await i.showModal(modal);
try {
const modalInteraction = await i.awaitModalSubmit({
time: 300000,
filter: (mi) => mi.customId === `app:add_note:${applicationId}`,
});
const note = modalInteraction.fields.getTextInputValue('note');
// Store note (would need to add notes array to application)
const notes = client.database.get<Array<{ appId: string; note: string; by: string; at: number }>>('application_notes') ?? [];
notes.push({
appId: applicationId,
note,
by: i.user.id,
at: Date.now(),
});
client.database.set('application_notes', notes);
await modalInteraction.reply({
content: '✅ Note added to application.',
ephemeral: true,
});
} catch {
// Modal timed out
}
}
async function handleButtonStats(
i: ButtonInteraction,
username: string,
client: EllyClient
): Promise<void> {
await i.deferReply({ ephemeral: true });
try {
const profile = await client.pikaAPI.getProfile(username);
if (!profile) {
await i.editReply({ content: `❌ Could not find PikaNetwork profile for \`${username}\`.` });
return;
}
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle(`📊 ${username}'s PikaNetwork Stats`)
.addFields(
{ name: 'Last Seen', value: profile.lastSeen ?? 'Unknown', inline: true },
{ name: 'Rank', value: profile.rank ?? 'None', inline: true }
)
.setTimestamp();
await i.editReply({ embeds: [embed] });
} catch {
await i.editReply({ content: '❌ Failed to fetch stats.' });
}
}
async function handleButtonHistory(
i: ButtonInteraction,
userId: string,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
const applications = await repo.getByUserId(userId);
if (applications.length === 0) {
await i.reply({
content: '📭 This user has no previous applications.',
ephemeral: true,
});
return;
}
const history = applications.slice(0, 5).map((app) => {
const status = app.status === 'accepted' ? '✅' : app.status === 'denied' ? '❌' : '⏳';
const date = new Date(app.createdAt).toLocaleDateString();
return `${status} \`${app.id}\` - ${date}`;
});
await i.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📜 Application History')
.setDescription(history.join('\n'))
.setFooter({ text: `Total: ${applications.length} applications` }),
],
ephemeral: true,
});
}
/**
* Notify applicant of decision
*/
async function notifyApplicant(
client: EllyClient,
userId: string,
status: 'accepted' | 'denied',
reason?: string
): Promise<void> {
try {
const user = await client.users.fetch(userId);
const embed = new EmbedBuilder()
.setColor(status === 'accepted' ? 0x57f287 : 0xed4245)
.setTitle(status === 'accepted' ? '🎉 Application Accepted!' : '❌ Application Denied')
.setDescription(
status === 'accepted'
? `Congratulations! Your guild application has been accepted!\n\nWelcome to **${client.config.guild.name}**! You now have access to guild channels and features.`
: `Unfortunately, your guild application has been denied.\n\n**Reason:** ${reason ?? 'No reason provided'}\n\nYou may reapply in 7 days.`
)
.setTimestamp();
await user.send({ embeds: [embed] });
} catch {
// User might have DMs disabled
}
}

View File

@@ -0,0 +1,299 @@
/**
* Application List/Search/History Handlers
*/
import {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type ChatInputCommandInteraction,
ComponentType,
} from 'discord.js';
import type { EllyClient } from '../../../client/EllyClient.ts';
import { ApplicationRepository, type Application } from '../../../database/repositories/ApplicationRepository.ts';
import { PermissionLevel } from '../../../types/index.ts';
const ITEMS_PER_PAGE = 10;
/**
* Handle listing applications with pagination
*/
export async function handleList(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
// Check permission for viewing all applications
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to list applications.',
ephemeral: true,
});
return;
}
const statusFilter = interaction.options.getString('status') ?? 'pending';
const sortOrder = interaction.options.getString('sort') ?? 'newest';
const page = interaction.options.getInteger('page') ?? 1;
await interaction.deferReply({ ephemeral: true });
// Get applications
let applications: Application[];
if (statusFilter === 'all') {
applications = await repo.getAll();
} else {
applications = await repo.getByStatus(statusFilter as Application['status']);
}
// Sort
applications.sort((a, b) => {
return sortOrder === 'newest'
? b.createdAt - a.createdAt
: a.createdAt - b.createdAt;
});
if (applications.length === 0) {
await interaction.editReply({
content: `📭 No ${statusFilter === 'all' ? '' : statusFilter} applications found.`,
});
return;
}
const totalPages = Math.ceil(applications.length / ITEMS_PER_PAGE);
const currentPage = Math.min(page, totalPages);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const pageApplications = applications.slice(startIndex, startIndex + ITEMS_PER_PAGE);
const embed = createListEmbed(pageApplications, statusFilter, currentPage, totalPages, applications.length);
// Create pagination buttons
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`applist:prev:${currentPage}:${statusFilter}:${sortOrder}`)
.setLabel('◀ Previous')
.setStyle(ButtonStyle.Secondary)
.setDisabled(currentPage <= 1),
new ButtonBuilder()
.setCustomId(`applist:page:${currentPage}`)
.setLabel(`Page ${currentPage}/${totalPages}`)
.setStyle(ButtonStyle.Primary)
.setDisabled(true),
new ButtonBuilder()
.setCustomId(`applist:next:${currentPage}:${statusFilter}:${sortOrder}`)
.setLabel('Next ▶')
.setStyle(ButtonStyle.Secondary)
.setDisabled(currentPage >= totalPages)
);
const response = await interaction.editReply({
embeds: [embed],
components: [row],
});
// Set up pagination collector
const collector = response.createMessageComponentCollector({
componentType: ComponentType.Button,
time: 300000, // 5 minutes
});
collector.on('collect', async (i) => {
if (i.user.id !== interaction.user.id) {
await i.reply({ content: '❌ This is not your menu.', ephemeral: true });
return;
}
const [, action, pageStr, status, sort] = i.customId.split(':');
let newPage = parseInt(pageStr);
if (action === 'prev') newPage--;
if (action === 'next') newPage++;
const newStartIndex = (newPage - 1) * ITEMS_PER_PAGE;
const newPageApplications = applications.slice(newStartIndex, newStartIndex + ITEMS_PER_PAGE);
const newEmbed = createListEmbed(newPageApplications, status, newPage, totalPages, applications.length);
const newRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`applist:prev:${newPage}:${status}:${sort}`)
.setLabel('◀ Previous')
.setStyle(ButtonStyle.Secondary)
.setDisabled(newPage <= 1),
new ButtonBuilder()
.setCustomId(`applist:page:${newPage}`)
.setLabel(`Page ${newPage}/${totalPages}`)
.setStyle(ButtonStyle.Primary)
.setDisabled(true),
new ButtonBuilder()
.setCustomId(`applist:next:${newPage}:${status}:${sort}`)
.setLabel('Next ▶')
.setStyle(ButtonStyle.Secondary)
.setDisabled(newPage >= totalPages)
);
await i.update({ embeds: [newEmbed], components: [newRow] });
});
collector.on('end', async () => {
try {
await interaction.editReply({ components: [] });
} catch {
// Message might be deleted
}
});
}
/**
* Create list embed
*/
function createListEmbed(
applications: Application[],
status: string,
page: number,
totalPages: number,
total: number
): EmbedBuilder {
const statusEmoji: Record<string, string> = {
pending: '⏳',
accepted: '✅',
denied: '❌',
all: '📋',
};
const statusColors: Record<string, number> = {
pending: 0xfee75c,
accepted: 0x57f287,
denied: 0xed4245,
all: 0x3498db,
};
const list = applications.map((app, i) => {
const num = (page - 1) * ITEMS_PER_PAGE + i + 1;
const emoji = statusEmoji[app.status];
const date = new Date(app.createdAt).toLocaleDateString();
return `**${num}.** ${emoji} \`${app.id}\` - **${app.minecraftUsername}** (${date})`;
});
return new EmbedBuilder()
.setColor(statusColors[status] ?? 0x3498db)
.setTitle(`${statusEmoji[status]} ${status === 'all' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)} Applications`)
.setDescription(list.join('\n') || 'No applications')
.setFooter({ text: `Page ${page}/${totalPages} • Total: ${total} applications` })
.setTimestamp();
}
/**
* Handle searching applications
*/
export async function handleSearch(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to search applications.',
ephemeral: true,
});
return;
}
const query = interaction.options.getString('query', true).toLowerCase();
await interaction.deferReply({ ephemeral: true });
const allApplications = await repo.getAll();
// Search by ID, username, or user ID
const results = allApplications.filter((app) =>
app.id.toLowerCase().includes(query) ||
app.minecraftUsername.toLowerCase().includes(query) ||
app.userId.includes(query)
);
if (results.length === 0) {
await interaction.editReply({
content: `🔍 No applications found matching \`${query}\`.`,
});
return;
}
const list = results.slice(0, 15).map((app, i) => {
const emoji = app.status === 'accepted' ? '✅' : app.status === 'denied' ? '❌' : '⏳';
const date = new Date(app.createdAt).toLocaleDateString();
return `**${i + 1}.** ${emoji} \`${app.id}\` - **${app.minecraftUsername}** (<@${app.userId}>) - ${date}`;
});
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle(`🔍 Search Results for "${query}"`)
.setDescription(list.join('\n'))
.setFooter({ text: `Found ${results.length} application(s)` })
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
}
/**
* Handle viewing user application history
*/
export async function handleHistory(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to view application history.',
ephemeral: true,
});
return;
}
const targetUser = interaction.options.getUser('user', true);
await interaction.deferReply({ ephemeral: true });
const applications = await repo.getByUserId(targetUser.id);
if (applications.length === 0) {
await interaction.editReply({
content: `📭 ${targetUser.tag} has no application history.`,
});
return;
}
// Calculate stats
const accepted = applications.filter((a) => a.status === 'accepted').length;
const denied = applications.filter((a) => a.status === 'denied').length;
const pending = applications.filter((a) => a.status === 'pending').length;
const history = applications.slice(0, 10).map((app, i) => {
const emoji = app.status === 'accepted' ? '✅' : app.status === 'denied' ? '❌' : '⏳';
const date = new Date(app.createdAt).toLocaleDateString();
const reviewer = app.reviewedBy ? ` by <@${app.reviewedBy}>` : '';
return `**${i + 1}.** ${emoji} \`${app.id}\` - ${app.minecraftUsername} (${date})${reviewer}`;
});
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle(`📜 Application History: ${targetUser.tag}`)
.setThumbnail(targetUser.displayAvatarURL())
.setDescription(history.join('\n'))
.addFields(
{ name: '✅ Accepted', value: String(accepted), inline: true },
{ name: '❌ Denied', value: String(denied), inline: true },
{ name: '⏳ Pending', value: String(pending), inline: true },
{ name: '📊 Total', value: String(applications.length), inline: true },
{ name: '📈 Accept Rate', value: applications.length > 0 ? `${Math.round((accepted / applications.length) * 100)}%` : 'N/A', inline: true }
)
.setFooter({ text: `Showing last ${Math.min(10, applications.length)} applications` })
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
}

View File

@@ -0,0 +1,278 @@
/**
* Application Review Handlers (Accept/Deny)
*/
import {
EmbedBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { EllyClient } from '../../../client/EllyClient.ts';
import { ApplicationRepository } from '../../../database/repositories/ApplicationRepository.ts';
import { PermissionLevel } from '../../../types/index.ts';
/**
* Handle accepting an application via command
*/
export async function handleAccept(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
// Check permission
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to accept applications.',
ephemeral: true,
});
return;
}
const id = interaction.options.getString('id', true);
const note = interaction.options.getString('note');
const application = await repo.getById(id);
if (!application) {
await interaction.reply({
content: '❌ Application not found.',
ephemeral: true,
});
return;
}
if (application.status !== 'pending') {
await interaction.reply({
content: `❌ This application has already been ${application.status}.`,
ephemeral: true,
});
return;
}
// Update status
const updated = await repo.updateStatus(id, 'accepted', interaction.user.id);
if (!updated) {
await interaction.reply({
content: '❌ Failed to update application.',
ephemeral: true,
});
return;
}
// Add note if provided
if (note) {
const notes = client.database.get<Array<{ appId: string; note: string; by: string; at: number }>>('application_notes') ?? [];
notes.push({
appId: id,
note,
by: interaction.user.id,
at: Date.now(),
});
client.database.set('application_notes', notes);
}
// Notify applicant
await notifyApplicant(client, application.userId, 'accepted');
// Add guild member role
try {
const applicantMember = await interaction.guild?.members.fetch(application.userId);
if (applicantMember && client.roles.guildMember) {
await applicantMember.roles.add(client.roles.guildMember);
}
} catch {
// Member might have left
}
// Update original message if exists
if (application.messageId && application.channelId) {
try {
const channel = await client.channels.fetch(application.channelId);
if (channel?.isTextBased()) {
const message = await channel.messages.fetch(application.messageId);
const embed = EmbedBuilder.from(message.embeds[0])
.setColor(0x57f287)
.setTitle('✅ Application Accepted')
.addFields({ name: '👤 Reviewed By', value: interaction.user.tag });
await message.edit({ embeds: [embed], components: [] });
}
} catch {
// Message might be deleted
}
}
const embed = new EmbedBuilder()
.setColor(0x57f287)
.setTitle('✅ Application Accepted')
.addFields(
{ name: 'Application ID', value: `\`${id}\``, inline: true },
{ name: 'Applicant', value: `<@${application.userId}>`, inline: true },
{ name: 'MC Username', value: application.minecraftUsername, inline: true }
)
.setFooter({ text: `Accepted by ${interaction.user.tag}` })
.setTimestamp();
if (note) {
embed.addFields({ name: 'Note', value: note });
}
await interaction.reply({ embeds: [embed] });
// Log
const logChannel = client.channels_cache.applicationLogs;
if (logChannel) {
await logChannel.send({
embeds: [
new EmbedBuilder()
.setColor(0x57f287)
.setTitle('✅ Application Accepted')
.addFields(
{ name: 'Applicant', value: `<@${application.userId}>`, inline: true },
{ name: 'MC Username', value: application.minecraftUsername, inline: true },
{ name: 'Reviewed By', value: interaction.user.tag, inline: true }
)
.setTimestamp(),
],
});
}
}
/**
* Handle denying an application via command
*/
export async function handleDeny(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
// Check permission
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to deny applications.',
ephemeral: true,
});
return;
}
const id = interaction.options.getString('id', true);
const reason = interaction.options.getString('reason', true);
const application = await repo.getById(id);
if (!application) {
await interaction.reply({
content: '❌ Application not found.',
ephemeral: true,
});
return;
}
if (application.status !== 'pending') {
await interaction.reply({
content: `❌ This application has already been ${application.status}.`,
ephemeral: true,
});
return;
}
// Update status
const updated = await repo.updateStatus(id, 'denied', interaction.user.id);
if (!updated) {
await interaction.reply({
content: '❌ Failed to update application.',
ephemeral: true,
});
return;
}
// Store denial reason
const notes = client.database.get<Array<{ appId: string; note: string; by: string; at: number }>>('application_notes') ?? [];
notes.push({
appId: id,
note: `[DENIAL REASON] ${reason}`,
by: interaction.user.id,
at: Date.now(),
});
client.database.set('application_notes', notes);
// Notify applicant
await notifyApplicant(client, application.userId, 'denied', reason);
// Update original message if exists
if (application.messageId && application.channelId) {
try {
const channel = await client.channels.fetch(application.channelId);
if (channel?.isTextBased()) {
const message = await channel.messages.fetch(application.messageId);
const embed = EmbedBuilder.from(message.embeds[0])
.setColor(0xed4245)
.setTitle('❌ Application Denied')
.addFields(
{ name: '👤 Reviewed By', value: interaction.user.tag },
{ name: '📝 Reason', value: reason }
);
await message.edit({ embeds: [embed], components: [] });
}
} catch {
// Message might be deleted
}
}
const embed = new EmbedBuilder()
.setColor(0xed4245)
.setTitle('❌ Application Denied')
.addFields(
{ name: 'Application ID', value: `\`${id}\``, inline: true },
{ name: 'Applicant', value: `<@${application.userId}>`, inline: true },
{ name: 'Reason', value: reason }
)
.setFooter({ text: `Denied by ${interaction.user.tag}` })
.setTimestamp();
await interaction.reply({ embeds: [embed] });
// Log
const logChannel = client.channels_cache.applicationLogs;
if (logChannel) {
await logChannel.send({
embeds: [
new EmbedBuilder()
.setColor(0xed4245)
.setTitle('❌ Application Denied')
.addFields(
{ name: 'Applicant', value: `<@${application.userId}>`, inline: true },
{ name: 'Reviewed By', value: interaction.user.tag, inline: true },
{ name: 'Reason', value: reason }
)
.setTimestamp(),
],
});
}
}
/**
* Notify applicant of decision
*/
async function notifyApplicant(
client: EllyClient,
userId: string,
status: 'accepted' | 'denied',
reason?: string
): Promise<void> {
try {
const user = await client.users.fetch(userId);
const embed = new EmbedBuilder()
.setColor(status === 'accepted' ? 0x57f287 : 0xed4245)
.setTitle(status === 'accepted' ? '🎉 Application Accepted!' : '❌ Application Denied')
.setDescription(
status === 'accepted'
? `Congratulations! Your guild application has been accepted!\n\nWelcome to **${client.config.guild.name}**!`
: `Unfortunately, your guild application has been denied.\n\n**Reason:** ${reason ?? 'No reason provided'}\n\nYou may reapply in 7 days.`
)
.setTimestamp();
await user.send({ embeds: [embed] });
} catch {
// User might have DMs disabled
}
}

View File

@@ -0,0 +1,89 @@
/**
* Application Settings Handlers (Placeholder for future features)
*/
import {
EmbedBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { EllyClient } from '../../../client/EllyClient.ts';
import { ApplicationRepository } from '../../../database/repositories/ApplicationRepository.ts';
import { PermissionLevel } from '../../../types/index.ts';
/**
* Handle application settings (placeholder)
*/
export async function handleSettings(
interaction: ChatInputCommandInteraction,
client: EllyClient,
_repo: ApplicationRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Admin)) {
await interaction.reply({
content: '❌ You need Admin permission to manage settings.',
ephemeral: true,
});
return;
}
// Get current settings
const settings = client.database.get<{
cooldownDays: number;
autoClose: boolean;
autoCloseHours: number;
requireVerification: boolean;
notifyOnSubmit: boolean;
}>('application_settings') ?? {
cooldownDays: 7,
autoClose: false,
autoCloseHours: 168,
requireVerification: false,
notifyOnSubmit: true,
};
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('⚙️ Application Settings')
.addFields(
{ name: '⏱️ Reapply Cooldown', value: `${settings.cooldownDays} days`, inline: true },
{ name: '🔒 Auto-Close', value: settings.autoClose ? `After ${settings.autoCloseHours}h` : 'Disabled', inline: true },
{ name: '✅ Require Verification', value: settings.requireVerification ? 'Yes' : 'No', inline: true },
{ name: '🔔 Notify on Submit', value: settings.notifyOnSubmit ? 'Yes' : 'No', inline: true }
)
.setFooter({ text: 'Use /applications settings set to modify' })
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
/**
* Handle application templates (placeholder)
*/
export async function handleTemplates(
interaction: ChatInputCommandInteraction,
client: EllyClient,
_repo: ApplicationRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Admin)) {
await interaction.reply({
content: '❌ You need Admin permission to manage templates.',
ephemeral: true,
});
return;
}
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📝 Application Templates')
.setDescription('Application templates allow you to customize the questions asked during the application process.')
.addFields(
{ name: 'Current Template', value: 'Default (5 questions)', inline: true },
{ name: 'Custom Templates', value: '0', inline: true }
)
.setFooter({ text: 'Template customization coming soon!' })
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}

View File

@@ -0,0 +1,219 @@
/**
* Application Statistics Handlers
*/
import {
EmbedBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { EllyClient } from '../../../client/EllyClient.ts';
import { ApplicationRepository, type Application } from '../../../database/repositories/ApplicationRepository.ts';
import { PermissionLevel } from '../../../types/index.ts';
/**
* Handle viewing detailed statistics
*/
export async function handleStats(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to view statistics.',
ephemeral: true,
});
return;
}
const period = interaction.options.getString('period') ?? 'all';
await interaction.deferReply({ ephemeral: true });
const allApplications = await repo.getAll();
// Filter by period
const now = Date.now();
const periodMs: Record<string, number> = {
today: 24 * 60 * 60 * 1000,
week: 7 * 24 * 60 * 60 * 1000,
month: 30 * 24 * 60 * 60 * 1000,
all: Infinity,
};
const cutoff = now - (periodMs[period] ?? Infinity);
const applications = allApplications.filter((a) => a.createdAt >= cutoff);
// Calculate stats
const total = applications.length;
const pending = applications.filter((a) => a.status === 'pending').length;
const accepted = applications.filter((a) => a.status === 'accepted').length;
const denied = applications.filter((a) => a.status === 'denied').length;
const reviewed = accepted + denied;
const acceptRate = reviewed > 0 ? Math.round((accepted / reviewed) * 100) : 0;
const denyRate = reviewed > 0 ? Math.round((denied / reviewed) * 100) : 0;
// Calculate average review time
const reviewedApps = applications.filter((a) => a.reviewedAt && a.status !== 'pending');
let avgReviewTime = 0;
if (reviewedApps.length > 0) {
const totalReviewTime = reviewedApps.reduce((sum, a) => sum + ((a.reviewedAt ?? 0) - a.createdAt), 0);
avgReviewTime = totalReviewTime / reviewedApps.length;
}
// Get top reviewers
const reviewerCounts: Record<string, number> = {};
for (const app of applications) {
if (app.reviewedBy) {
reviewerCounts[app.reviewedBy] = (reviewerCounts[app.reviewedBy] ?? 0) + 1;
}
}
const topReviewers = Object.entries(reviewerCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 3);
// Get applications by day (last 7 days)
const dailyStats: Record<string, number> = {};
const last7Days = applications.filter((a) => a.createdAt >= now - 7 * 24 * 60 * 60 * 1000);
for (const app of last7Days) {
const day = new Date(app.createdAt).toLocaleDateString('en-US', { weekday: 'short' });
dailyStats[day] = (dailyStats[day] ?? 0) + 1;
}
const periodLabel = {
today: 'Today',
week: 'This Week',
month: 'This Month',
all: 'All Time',
}[period] ?? 'All Time';
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle(`📊 Application Statistics - ${periodLabel}`)
.addFields(
{ name: '📥 Total Received', value: String(total), inline: true },
{ name: '⏳ Pending', value: String(pending), inline: true },
{ name: '✅ Accepted', value: String(accepted), inline: true },
{ name: '❌ Denied', value: String(denied), inline: true },
{ name: '📈 Accept Rate', value: `${acceptRate}%`, inline: true },
{ name: '📉 Deny Rate', value: `${denyRate}%`, inline: true },
{ name: '⏱️ Avg Review Time', value: formatDuration(avgReviewTime), inline: true },
{ name: '📋 Reviewed', value: String(reviewed), inline: true },
{ name: '📊 Pending Rate', value: total > 0 ? `${Math.round((pending / total) * 100)}%` : '0%', inline: true }
);
// Add top reviewers
if (topReviewers.length > 0) {
const reviewerList = topReviewers
.map(([id, count], i) => `${['🥇', '🥈', '🥉'][i]} <@${id}> - ${count} reviews`)
.join('\n');
embed.addFields({ name: '🏆 Top Reviewers', value: reviewerList });
}
// Add daily breakdown
if (Object.keys(dailyStats).length > 0) {
const dailyList = Object.entries(dailyStats)
.map(([day, count]) => `${day}: ${'█'.repeat(Math.min(count, 10))} ${count}`)
.join('\n');
embed.addFields({ name: '📅 Last 7 Days', value: '```\n' + dailyList + '\n```' });
}
embed.setTimestamp();
await interaction.editReply({ embeds: [embed] });
}
/**
* Handle viewing reviewer leaderboard
*/
export async function handleLeaderboard(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to view the leaderboard.',
ephemeral: true,
});
return;
}
await interaction.deferReply({ ephemeral: true });
const allApplications = await repo.getAll();
// Calculate reviewer stats
const reviewerStats: Record<string, { total: number; accepted: number; denied: number }> = {};
for (const app of allApplications) {
if (app.reviewedBy) {
if (!reviewerStats[app.reviewedBy]) {
reviewerStats[app.reviewedBy] = { total: 0, accepted: 0, denied: 0 };
}
reviewerStats[app.reviewedBy].total++;
if (app.status === 'accepted') reviewerStats[app.reviewedBy].accepted++;
if (app.status === 'denied') reviewerStats[app.reviewedBy].denied++;
}
}
const leaderboard = Object.entries(reviewerStats)
.sort((a, b) => b[1].total - a[1].total)
.slice(0, 10);
if (leaderboard.length === 0) {
await interaction.editReply({
content: '📭 No reviews have been made yet.',
});
return;
}
const medals = ['🥇', '🥈', '🥉'];
const list = leaderboard.map(([userId, stats], i) => {
const medal = medals[i] ?? `**${i + 1}.**`;
const acceptRate = stats.total > 0 ? Math.round((stats.accepted / stats.total) * 100) : 0;
return `${medal} <@${userId}> - **${stats.total}** reviews (✅ ${stats.accepted} | ❌ ${stats.denied} | ${acceptRate}% accept)`;
});
// Find user's rank
const userRank = leaderboard.findIndex(([id]) => id === interaction.user.id);
const userStats = reviewerStats[interaction.user.id];
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('🏆 Application Reviewer Leaderboard')
.setDescription(list.join('\n'))
.setTimestamp();
if (userStats) {
embed.setFooter({
text: `Your rank: #${userRank + 1} with ${userStats.total} reviews`,
});
}
await interaction.editReply({ embeds: [embed] });
}
/**
* Format duration in human readable format
*/
function formatDuration(ms: number): string {
if (ms === 0 || !isFinite(ms)) return 'N/A';
const hours = Math.floor(ms / (60 * 60 * 1000));
const minutes = Math.floor((ms % (60 * 60 * 1000)) / (60 * 1000));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}

View File

@@ -0,0 +1,159 @@
/**
* Application View Handler
*/
import {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { EllyClient } from '../../../client/EllyClient.ts';
import { ApplicationRepository, type Application } from '../../../database/repositories/ApplicationRepository.ts';
/**
* Handle viewing an application
*/
export async function handleView(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
const id = interaction.options.getString('id', true);
const application = await repo.getById(id);
if (!application) {
await interaction.reply({
content: '❌ Application not found.',
ephemeral: true,
});
return;
}
// Get applicant info
let applicant;
try {
applicant = await client.users.fetch(application.userId);
} catch {
applicant = null;
}
// Get notes
const notes = client.database.get<Array<{ appId: string; note: string; by: string; at: number }>>('application_notes') ?? [];
const appNotes = notes.filter((n) => n.appId === id);
const embed = createDetailedApplicationEmbed(application, applicant, client);
// Add notes if any
if (appNotes.length > 0) {
const notesList = appNotes.slice(-3).map((n) => {
const date = new Date(n.at).toLocaleDateString();
return `• <@${n.by}> (${date}): ${n.note.substring(0, 100)}`;
});
embed.addFields({
name: `📝 Staff Notes (${appNotes.length})`,
value: notesList.join('\n'),
});
}
// Create action buttons if pending
const components: ActionRowBuilder<ButtonBuilder>[] = [];
if (application.status === 'pending') {
components.push(
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`app:accept:${id}`)
.setLabel('Accept')
.setStyle(ButtonStyle.Success),
new ButtonBuilder()
.setCustomId(`app:deny:${id}`)
.setLabel('Deny')
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId(`app:interview:${id}`)
.setLabel('Request Interview')
.setStyle(ButtonStyle.Primary)
)
);
}
await interaction.reply({
embeds: [embed],
components,
ephemeral: true,
});
}
/**
* Create detailed application embed
*/
function createDetailedApplicationEmbed(
application: Application,
applicant: import('discord.js').User | null,
client: EllyClient
): EmbedBuilder {
const statusConfig = {
pending: { color: 0xfee75c, emoji: '⏳', label: 'Pending Review' },
accepted: { color: 0x57f287, emoji: '✅', label: 'Accepted' },
denied: { color: 0xed4245, emoji: '❌', label: 'Denied' },
};
const status = statusConfig[application.status];
const submittedAt = Math.floor(application.createdAt / 1000);
const reviewedAt = application.reviewedAt ? Math.floor(application.reviewedAt / 1000) : null;
const embed = new EmbedBuilder()
.setColor(status.color)
.setTitle(`${status.emoji} Application Details`)
.setDescription(`**Status:** ${status.label}`)
.addFields(
{ name: '🆔 Application ID', value: `\`${application.id}\``, inline: true },
{ name: '👤 Applicant', value: applicant ? `${applicant.tag}\n<@${application.userId}>` : `<@${application.userId}>`, inline: true },
{ name: '🎮 MC Username', value: `\`${application.minecraftUsername}\``, inline: true },
{ name: '📅 Discord Age', value: application.discordAge ?? 'Unknown', inline: true },
{ name: '🌍 Timezone', value: application.timezone ?? 'Unknown', inline: true },
{ name: '⏰ Activity', value: application.activity ?? 'Unknown', inline: true },
{ name: '❓ Why Join', value: application.whyJoin?.substring(0, 1024) ?? 'Not provided' },
{ name: '🎯 Experience', value: application.experience?.substring(0, 1024) ?? 'Not provided' },
{ name: '📆 Submitted', value: `<t:${submittedAt}:F> (<t:${submittedAt}:R>)`, inline: true }
);
if (applicant) {
embed.setThumbnail(applicant.displayAvatarURL({ size: 256 }));
}
if (application.reviewedBy) {
embed.addFields({
name: '👤 Reviewed By',
value: `<@${application.reviewedBy}>`,
inline: true,
});
}
if (reviewedAt) {
embed.addFields({
name: '📆 Reviewed At',
value: `<t:${reviewedAt}:F>`,
inline: true,
});
}
// Parse extra data if available
if (application.extra) {
try {
const extra = JSON.parse(application.extra);
if (extra.exists !== undefined) {
embed.addFields({
name: '🔍 PikaNetwork Verified',
value: extra.exists ? '✅ Yes' : '❌ No',
inline: true,
});
}
} catch {
// Invalid JSON
}
}
return embed;
}

View File

@@ -0,0 +1,271 @@
/**
* Applications Command Module
* Advanced guild application management system
*/
import {
SlashCommandBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { ApplicationRepository } from '../../database/repositories/ApplicationRepository.ts';
// Import handlers
import { handleApply } from './handlers/apply.ts';
import { handleView } from './handlers/view.ts';
import { handleAccept, handleDeny } from './handlers/review.ts';
import { handleList, handleSearch, handleHistory } from './handlers/list.ts';
import { handleStats, handleLeaderboard } from './handlers/stats.ts';
import { handleSettings, handleTemplates } from './handlers/settings.ts';
import { handleExport, handlePurge } from './handlers/admin.ts';
export const applicationsCommand: Command = {
data: new SlashCommandBuilder()
.setName('applications')
.setDescription('Advanced guild application management')
// User commands
.addSubcommand((sub) =>
sub
.setName('apply')
.setDescription('Apply to join the guild')
)
.addSubcommand((sub) =>
sub
.setName('status')
.setDescription('Check your application status')
)
// Staff commands
.addSubcommand((sub) =>
sub
.setName('view')
.setDescription('View an application in detail')
.addStringOption((opt) =>
opt.setName('id').setDescription('Application ID').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('accept')
.setDescription('Accept an application')
.addStringOption((opt) =>
opt.setName('id').setDescription('Application ID').setRequired(true)
)
.addStringOption((opt) =>
opt.setName('note').setDescription('Internal note').setRequired(false)
)
)
.addSubcommand((sub) =>
sub
.setName('deny')
.setDescription('Deny an application')
.addStringOption((opt) =>
opt.setName('id').setDescription('Application ID').setRequired(true)
)
.addStringOption((opt) =>
opt.setName('reason').setDescription('Reason for denial').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('list')
.setDescription('List applications with filters')
.addStringOption((opt) =>
opt
.setName('status')
.setDescription('Filter by status')
.addChoices(
{ name: 'All', value: 'all' },
{ name: 'Pending', value: 'pending' },
{ name: 'Accepted', value: 'accepted' },
{ name: 'Denied', value: 'denied' }
)
)
.addStringOption((opt) =>
opt
.setName('sort')
.setDescription('Sort order')
.addChoices(
{ name: 'Newest First', value: 'newest' },
{ name: 'Oldest First', value: 'oldest' }
)
)
.addIntegerOption((opt) =>
opt.setName('page').setDescription('Page number').setMinValue(1)
)
)
.addSubcommand((sub) =>
sub
.setName('search')
.setDescription('Search applications')
.addStringOption((opt) =>
opt.setName('query').setDescription('Search by username or ID').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('history')
.setDescription('View application history for a user')
.addUserOption((opt) =>
opt.setName('user').setDescription('User to check').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('stats')
.setDescription('View detailed application statistics')
.addStringOption((opt) =>
opt
.setName('period')
.setDescription('Time period')
.addChoices(
{ name: 'Today', value: 'today' },
{ name: 'This Week', value: 'week' },
{ name: 'This Month', value: 'month' },
{ name: 'All Time', value: 'all' }
)
)
)
.addSubcommand((sub) =>
sub
.setName('leaderboard')
.setDescription('View reviewer leaderboard')
)
// Admin commands
.addSubcommand((sub) =>
sub
.setName('export')
.setDescription('Export applications to CSV')
.addStringOption((opt) =>
opt
.setName('status')
.setDescription('Filter by status')
.addChoices(
{ name: 'All', value: 'all' },
{ name: 'Accepted', value: 'accepted' },
{ name: 'Denied', value: 'denied' }
)
)
)
.addSubcommand((sub) =>
sub
.setName('purge')
.setDescription('Purge old applications')
.addIntegerOption((opt) =>
opt
.setName('days')
.setDescription('Delete applications older than X days')
.setRequired(true)
.setMinValue(30)
)
.addStringOption((opt) =>
opt
.setName('status')
.setDescription('Only purge specific status')
.addChoices(
{ name: 'Denied Only', value: 'denied' },
{ name: 'All Reviewed', value: 'reviewed' }
)
)
),
permission: PermissionLevel.User,
cooldown: 5,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const subcommand = interaction.options.getSubcommand();
const repo = new ApplicationRepository(client.database);
switch (subcommand) {
case 'apply':
await handleApply(interaction, client, repo);
break;
case 'status':
await handleUserStatus(interaction, client, repo);
break;
case 'view':
await handleView(interaction, client, repo);
break;
case 'accept':
await handleAccept(interaction, client, repo);
break;
case 'deny':
await handleDeny(interaction, client, repo);
break;
case 'list':
await handleList(interaction, client, repo);
break;
case 'search':
await handleSearch(interaction, client, repo);
break;
case 'history':
await handleHistory(interaction, client, repo);
break;
case 'stats':
await handleStats(interaction, client, repo);
break;
case 'leaderboard':
await handleLeaderboard(interaction, client, repo);
break;
case 'export':
await handleExport(interaction, client, repo);
break;
case 'purge':
await handlePurge(interaction, client, repo);
break;
}
},
};
/**
* Handle user checking their own application status
*/
async function handleUserStatus(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ApplicationRepository
): Promise<void> {
const applications = await repo.getByUserId(interaction.user.id);
if (applications.length === 0) {
await interaction.reply({
content: '📭 You have no applications on record.',
ephemeral: true,
});
return;
}
const { EmbedBuilder } = await import('discord.js');
const latest = applications[0];
const statusEmoji = {
pending: '⏳',
accepted: '✅',
denied: '❌',
};
const embed = new EmbedBuilder()
.setColor(
latest.status === 'pending' ? 0xfee75c :
latest.status === 'accepted' ? 0x57f287 : 0xed4245
)
.setTitle('📋 Your Application Status')
.addFields(
{ name: 'Latest Application', value: `\`${latest.id}\``, inline: true },
{ name: 'Status', value: `${statusEmoji[latest.status]} ${latest.status.charAt(0).toUpperCase() + latest.status.slice(1)}`, inline: true },
{ name: 'Submitted', value: `<t:${Math.floor(latest.createdAt / 1000)}:R>`, inline: true },
{ name: 'Total Applications', value: String(applications.length), inline: true }
);
if (latest.reviewedBy) {
embed.addFields({
name: 'Reviewed By',
value: `<@${latest.reviewedBy}>`,
inline: true,
});
}
await interaction.reply({ embeds: [embed], ephemeral: true });
}

View File

@@ -0,0 +1,292 @@
/**
* Blacklist Command
* Manage user blacklists for various features
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
interface BlacklistEntry {
userId: string;
type: string;
reason?: string;
createdBy: string;
createdAt: number;
}
export const blacklistCommand: Command = {
data: new SlashCommandBuilder()
.setName('blacklist')
.setDescription('Manage user blacklists')
.addSubcommand((sub) =>
sub
.setName('add')
.setDescription('Add a user to a blacklist')
.addUserOption((opt) =>
opt.setName('user').setDescription('The user to blacklist').setRequired(true)
)
.addStringOption((opt) =>
opt
.setName('type')
.setDescription('The blacklist type')
.setRequired(true)
.addChoices(
{ name: 'Bot (all features)', value: 'bot' },
{ name: 'Applications', value: 'applications' },
{ name: 'Suggestions', value: 'suggestions' },
{ name: 'Commands', value: 'commands' }
)
)
.addStringOption((opt) =>
opt.setName('reason').setDescription('Reason for blacklisting').setRequired(false)
)
)
.addSubcommand((sub) =>
sub
.setName('remove')
.setDescription('Remove a user from a blacklist')
.addUserOption((opt) =>
opt.setName('user').setDescription('The user to unblacklist').setRequired(true)
)
.addStringOption((opt) =>
opt
.setName('type')
.setDescription('The blacklist type')
.setRequired(true)
.addChoices(
{ name: 'Bot (all features)', value: 'bot' },
{ name: 'Applications', value: 'applications' },
{ name: 'Suggestions', value: 'suggestions' },
{ name: 'Commands', value: 'commands' }
)
)
)
.addSubcommand((sub) =>
sub
.setName('check')
.setDescription('Check if a user is blacklisted')
.addUserOption((opt) =>
opt.setName('user').setDescription('The user to check').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('list')
.setDescription('List all blacklisted users')
.addStringOption((opt) =>
opt
.setName('type')
.setDescription('Filter by blacklist type')
.setRequired(false)
.addChoices(
{ name: 'Bot (all features)', value: 'bot' },
{ name: 'Applications', value: 'applications' },
{ name: 'Suggestions', value: 'suggestions' },
{ name: 'Commands', value: 'commands' }
)
)
),
permission: PermissionLevel.Admin,
cooldown: 3,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const subcommand = interaction.options.getSubcommand();
switch (subcommand) {
case 'add':
await handleAdd(interaction, client);
break;
case 'remove':
await handleRemove(interaction, client);
break;
case 'check':
await handleCheck(interaction, client);
break;
case 'list':
await handleList(interaction, client);
break;
}
},
};
async function handleAdd(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const user = interaction.options.getUser('user', true);
const type = interaction.options.getString('type', true);
const reason = interaction.options.getString('reason');
// Get existing blacklists
const blacklists = client.database.get<BlacklistEntry[]>('blacklists') ?? [];
// Check if already blacklisted
const existing = blacklists.find((b) => b.userId === user.id && b.type === type);
if (existing) {
await interaction.reply({
content: `${user.tag} is already blacklisted from \`${type}\`.`,
ephemeral: true,
});
return;
}
// Add to blacklist
blacklists.push({
userId: user.id,
type,
reason: reason ?? undefined,
createdBy: interaction.user.id,
createdAt: Date.now(),
});
client.database.set('blacklists', blacklists);
const embed = new EmbedBuilder()
.setColor(0xED4245)
.setTitle('🚫 User Blacklisted')
.addFields(
{ name: 'User', value: `${user.tag} (${user.id})`, inline: true },
{ name: 'Type', value: type, inline: true },
{ name: 'Reason', value: reason ?? 'No reason provided' }
)
.setFooter({ text: `Blacklisted by ${interaction.user.tag}` })
.setTimestamp();
await interaction.reply({ embeds: [embed] });
}
async function handleRemove(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const user = interaction.options.getUser('user', true);
const type = interaction.options.getString('type', true);
// Get existing blacklists
const blacklists = client.database.get<BlacklistEntry[]>('blacklists') ?? [];
// Find and remove
const index = blacklists.findIndex((b) => b.userId === user.id && b.type === type);
if (index === -1) {
await interaction.reply({
content: `${user.tag} is not blacklisted from \`${type}\`.`,
ephemeral: true,
});
return;
}
blacklists.splice(index, 1);
client.database.set('blacklists', blacklists);
const embed = new EmbedBuilder()
.setColor(0x57F287)
.setTitle('✅ User Unblacklisted')
.addFields(
{ name: 'User', value: `${user.tag} (${user.id})`, inline: true },
{ name: 'Type', value: type, inline: true }
)
.setFooter({ text: `Removed by ${interaction.user.tag}` })
.setTimestamp();
await interaction.reply({ embeds: [embed] });
}
async function handleCheck(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const user = interaction.options.getUser('user', true);
// Get existing blacklists
const blacklists = client.database.get<BlacklistEntry[]>('blacklists') ?? [];
// Find all blacklists for user
const userBlacklists = blacklists.filter((b) => b.userId === user.id);
if (userBlacklists.length === 0) {
const embed = new EmbedBuilder()
.setColor(0x57F287)
.setTitle('✅ User Not Blacklisted')
.setDescription(`${user.tag} is not on any blacklists.`)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
return;
}
const blacklistInfo = userBlacklists.map((b) => {
const date = new Date(b.createdAt).toLocaleDateString();
return `• **${b.type}** - ${b.reason ?? 'No reason'} (${date})`;
});
const embed = new EmbedBuilder()
.setColor(0xED4245)
.setTitle('🚫 User Blacklisted')
.setDescription(`${user.tag} is on the following blacklists:\n\n${blacklistInfo.join('\n')}`)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleList(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const typeFilter = interaction.options.getString('type');
// Get existing blacklists
let blacklists = client.database.get<BlacklistEntry[]>('blacklists') ?? [];
// Filter by type if specified
if (typeFilter) {
blacklists = blacklists.filter((b) => b.type === typeFilter);
}
if (blacklists.length === 0) {
await interaction.reply({
content: typeFilter
? `📭 No users blacklisted from \`${typeFilter}\`.`
: '📭 No users blacklisted.',
ephemeral: true,
});
return;
}
// Group by type
const grouped = new Map<string, BlacklistEntry[]>();
for (const entry of blacklists) {
const list = grouped.get(entry.type) ?? [];
list.push(entry);
grouped.set(entry.type, list);
}
const fields = [];
for (const [type, entries] of grouped) {
const userList = entries
.slice(0, 10)
.map((e) => `<@${e.userId}>`)
.join(', ');
const extra = entries.length > 10 ? ` (+${entries.length - 10} more)` : '';
fields.push({
name: `${type} (${entries.length})`,
value: userList + extra,
});
}
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('🚫 Blacklisted Users')
.addFields(fields)
.setFooter({ text: `Total: ${blacklists.length} entries` })
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}

View File

@@ -0,0 +1,315 @@
/**
* Database Command
* Manage and inspect the database
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
codeBlock,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
export const databaseCommand: Command = {
data: new SlashCommandBuilder()
.setName('database')
.setDescription('Database management commands')
.addSubcommand((sub) =>
sub
.setName('stats')
.setDescription('View database statistics')
)
.addSubcommand((sub) =>
sub
.setName('backup')
.setDescription('Create a database backup')
)
.addSubcommand((sub) =>
sub
.setName('query')
.setDescription('Execute a read-only SQL query')
.addStringOption((opt) =>
opt
.setName('sql')
.setDescription('The SQL query to execute')
.setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('tables')
.setDescription('List all database tables')
)
.addSubcommand((sub) =>
sub
.setName('vacuum')
.setDescription('Optimize the database')
),
permission: PermissionLevel.Developer,
cooldown: 5,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const subcommand = interaction.options.getSubcommand();
// Check if SQLite database is available
if (!client.dbManager) {
await interaction.reply({
content: '❌ SQLite database is not initialized. Using legacy JSON database.',
ephemeral: true,
});
return;
}
switch (subcommand) {
case 'stats':
await handleStats(interaction, client);
break;
case 'backup':
await handleBackup(interaction, client);
break;
case 'query':
await handleQuery(interaction, client);
break;
case 'tables':
await handleTables(interaction, client);
break;
case 'vacuum':
await handleVacuum(interaction, client);
break;
}
},
};
async function handleStats(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
await interaction.deferReply({ ephemeral: true });
try {
const stats = await client.dbManager!.getStats();
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📊 Database Statistics')
.addFields(
{ name: '📁 Path', value: `\`${stats.path}\``, inline: false },
{ name: '💾 Size', value: formatBytes(stats.size), inline: true },
{ name: '🔌 Connected', value: stats.connected ? '✅ Yes' : '❌ No', inline: true },
{ name: '📋 Tables', value: String(stats.tables.length), inline: true },
{ name: '📝 Table List', value: stats.tables.join(', ') || 'None' }
)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await interaction.editReply({
content: `❌ Failed to get database stats: ${error}`,
});
}
}
async function handleBackup(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
await interaction.deferReply({ ephemeral: true });
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `./data/backups/elly_${timestamp}.sqlite`;
// Ensure backup directory exists
await Deno.mkdir('./data/backups', { recursive: true });
await client.dbManager!.backup(backupPath);
const embed = new EmbedBuilder()
.setColor(0x57F287)
.setTitle('✅ Backup Created')
.addFields(
{ name: '📁 Backup Path', value: `\`${backupPath}\`` },
{ name: '⏰ Created At', value: new Date().toISOString() }
)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await interaction.editReply({
content: `❌ Failed to create backup: ${error}`,
});
}
}
async function handleQuery(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const sql = interaction.options.getString('sql', true);
// Security: Only allow SELECT queries
const normalizedSql = sql.trim().toLowerCase();
if (!normalizedSql.startsWith('select') && !normalizedSql.startsWith('pragma')) {
await interaction.reply({
content: '❌ Only SELECT and PRAGMA queries are allowed.',
ephemeral: true,
});
return;
}
// Block dangerous patterns
const dangerousPatterns = [
/drop\s+table/i,
/delete\s+from/i,
/truncate/i,
/insert\s+into/i,
/update\s+\w+\s+set/i,
/alter\s+table/i,
/create\s+table/i,
];
for (const pattern of dangerousPatterns) {
if (pattern.test(sql)) {
await interaction.reply({
content: '❌ Query contains forbidden patterns.',
ephemeral: true,
});
return;
}
}
await interaction.deferReply({ ephemeral: true });
try {
const startTime = performance.now();
const result = client.dbManager!.connection.query(sql);
const endTime = performance.now();
if (!result.success) {
await interaction.editReply({
content: `❌ Query failed: ${result.error?.message}`,
});
return;
}
const rows = result.data ?? [];
let output = JSON.stringify(rows, null, 2);
if (output.length > 1800) {
output = output.substring(0, 1800) + '\n... (truncated)';
}
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📊 Query Result')
.addFields(
{ name: '📝 Query', value: codeBlock('sql', sql.substring(0, 500)) },
{ name: '📤 Result', value: codeBlock('json', output) },
{ name: '📊 Rows', value: String(rows.length), inline: true },
{ name: '⏱️ Time', value: `${(endTime - startTime).toFixed(2)}ms`, inline: true }
)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await interaction.editReply({
content: `❌ Query error: ${error}`,
});
}
}
async function handleTables(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
await interaction.deferReply({ ephemeral: true });
try {
const result = client.dbManager!.connection.query<{ name: string; sql: string }>(
"SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
);
if (!result.success || !result.data) {
await interaction.editReply({
content: `❌ Failed to list tables: ${result.error?.message}`,
});
return;
}
const tables = result.data;
const tableInfo = tables.map((t) => {
// Get row count
const countResult = client.dbManager!.connection.queryOne<{ count: number }>(
`SELECT COUNT(*) as count FROM ${t.name}`
);
const count = countResult.success ? countResult.data?.count ?? 0 : 0;
return `**${t.name}** - ${count} rows`;
});
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📋 Database Tables')
.setDescription(tableInfo.join('\n') || 'No tables found')
.addFields({
name: 'Total Tables',
value: String(tables.length),
inline: true,
})
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await interaction.editReply({
content: `❌ Failed to list tables: ${error}`,
});
}
}
async function handleVacuum(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
await interaction.deferReply({ ephemeral: true });
try {
const sizeBefore = await client.dbManager!.connection.getSize();
await client.dbManager!.vacuum();
const sizeAfter = await client.dbManager!.connection.getSize();
const saved = sizeBefore - sizeAfter;
const embed = new EmbedBuilder()
.setColor(0x57F287)
.setTitle('✅ Database Optimized')
.addFields(
{ name: 'Size Before', value: formatBytes(sizeBefore), inline: true },
{ name: 'Size After', value: formatBytes(sizeAfter), inline: true },
{ name: 'Space Saved', value: formatBytes(saved), inline: true }
)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await interaction.editReply({
content: `❌ Failed to vacuum database: ${error}`,
});
}
}
function formatBytes(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0;
let size = bytes;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}

View File

@@ -0,0 +1,240 @@
/**
* Debug Command
* View debug information about the bot
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
version as djsVersion,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
export const debugCommand: Command = {
data: new SlashCommandBuilder()
.setName('debug')
.setDescription('View debug information')
.addSubcommand((sub) =>
sub
.setName('info')
.setDescription('View general debug information')
)
.addSubcommand((sub) =>
sub
.setName('cache')
.setDescription('View cache statistics')
)
.addSubcommand((sub) =>
sub
.setName('memory')
.setDescription('View memory usage')
)
.addSubcommand((sub) =>
sub
.setName('errors')
.setDescription('View recent errors')
)
.addSubcommand((sub) =>
sub
.setName('config')
.setDescription('View configuration (sanitized)')
),
permission: PermissionLevel.Developer,
cooldown: 5,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const subcommand = interaction.options.getSubcommand();
switch (subcommand) {
case 'info':
await handleInfo(interaction, client);
break;
case 'cache':
await handleCache(interaction, client);
break;
case 'memory':
await handleMemory(interaction, client);
break;
case 'errors':
await handleErrors(interaction, client);
break;
case 'config':
await handleConfig(interaction, client);
break;
}
},
};
async function handleInfo(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const uptime = formatUptime(client.uptime ?? 0);
const memoryUsage = Deno.memoryUsage();
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('🔧 Debug Information')
.addFields(
{ name: '🤖 Bot', value: client.user?.tag ?? 'Unknown', inline: true },
{ name: '🆔 Client ID', value: client.user?.id ?? 'Unknown', inline: true },
{ name: '⏰ Uptime', value: uptime, inline: true },
{ name: '🦕 Deno Version', value: Deno.version.deno, inline: true },
{ name: '📦 Discord.js', value: djsVersion, inline: true },
{ name: '🔧 TypeScript', value: Deno.version.typescript, inline: true },
{ name: '💾 Heap Used', value: formatBytes(memoryUsage.heapUsed), inline: true },
{ name: '💾 Heap Total', value: formatBytes(memoryUsage.heapTotal), inline: true },
{ name: '💾 RSS', value: formatBytes(memoryUsage.rss), inline: true },
{ name: '📊 Guilds', value: String(client.guilds.cache.size), inline: true },
{ name: '👥 Users', value: String(client.users.cache.size), inline: true },
{ name: '📝 Commands', value: String(client.commands.size), inline: true }
)
.setFooter({ text: `PID: ${Deno.pid}` })
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleCache(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📊 Cache Statistics')
.addFields(
{ name: '🏠 Guilds', value: String(client.guilds.cache.size), inline: true },
{ name: '📺 Channels', value: String(client.channels.cache.size), inline: true },
{ name: '👥 Users', value: String(client.users.cache.size), inline: true },
{ name: '😀 Emojis', value: String(client.emojis.cache.size), inline: true },
{ name: '🎭 Roles', value: String(client.mainGuild?.roles.cache.size ?? 0), inline: true },
{ name: '👤 Members', value: String(client.mainGuild?.members.cache.size ?? 0), inline: true },
{ name: '📝 Commands', value: String(client.commands.size), inline: true },
{ name: '⏱️ Cooldowns', value: String(client.cooldowns.size), inline: true }
)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleMemory(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const memory = Deno.memoryUsage();
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('💾 Memory Usage')
.addFields(
{ name: 'RSS (Resident Set Size)', value: formatBytes(memory.rss), inline: true },
{ name: 'Heap Total', value: formatBytes(memory.heapTotal), inline: true },
{ name: 'Heap Used', value: formatBytes(memory.heapUsed), inline: true },
{ name: 'External', value: formatBytes(memory.external), inline: true },
{
name: 'Heap Usage',
value: `${((memory.heapUsed / memory.heapTotal) * 100).toFixed(1)}%`,
inline: true
}
)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleErrors(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const stats = client.errorHandler.getStats();
const recentErrors = client.errorHandler.getRecentErrors(5);
let errorList = 'No recent errors';
if (recentErrors.length > 0) {
errorList = recentErrors
.map((e, i) => `**${i + 1}.** \`${e.code}\` - ${e.message.substring(0, 50)}...`)
.join('\n');
}
const errorsByCode = Object.entries(stats.errorsByCode)
.map(([code, count]) => `\`${code}\`: ${count}`)
.join(', ') || 'None';
const embed = new EmbedBuilder()
.setColor(stats.totalErrors > 0 ? 0xED4245 : 0x57F287)
.setTitle('❌ Error Statistics')
.addFields(
{ name: 'Total Errors', value: String(stats.totalErrors), inline: true },
{ name: 'Recent Errors', value: String(stats.recentErrors), inline: true },
{ name: 'Errors by Code', value: errorsByCode },
{ name: 'Last 5 Errors', value: errorList }
)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleConfig(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
// Sanitize config - remove sensitive data
const config = {
bot: {
name: client.config.bot.name,
prefix: client.config.bot.prefix,
status: client.config.bot.status,
activity_type: client.config.bot.activity_type,
},
guild: {
id: client.config.guild.id,
name: client.config.guild.name,
},
api: {
pika_cache_ttl: client.config.api.pika_cache_ttl,
pika_request_timeout: client.config.api.pika_request_timeout,
},
logging: client.config.logging,
};
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('⚙️ Configuration (Sanitized)')
.setDescription('```json\n' + JSON.stringify(config, null, 2).substring(0, 4000) + '\n```')
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
function formatUptime(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours % 24 > 0) parts.push(`${hours % 24}h`);
if (minutes % 60 > 0) parts.push(`${minutes % 60}m`);
if (seconds % 60 > 0) parts.push(`${seconds % 60}s`);
return parts.join(' ') || '0s';
}
function formatBytes(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0;
let size = bytes;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}

View File

@@ -0,0 +1,101 @@
/**
* Emit Command
* Emit Discord events for testing
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
export const emitCommand: Command = {
data: new SlashCommandBuilder()
.setName('emit')
.setDescription('Emit Discord events for testing')
.addStringOption((opt) =>
opt
.setName('event')
.setDescription('The event to emit')
.setRequired(true)
.addChoices(
{ name: 'guildMemberAdd', value: 'guildMemberAdd' },
{ name: 'guildMemberRemove', value: 'guildMemberRemove' },
{ name: 'ready', value: 'ready' }
)
)
.addUserOption((opt) =>
opt
.setName('target')
.setDescription('Target user for member events')
.setRequired(false)
),
permission: PermissionLevel.Developer,
cooldown: 10,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const event = interaction.options.getString('event', true);
const targetUser = interaction.options.getUser('target');
await interaction.deferReply({ ephemeral: true });
try {
switch (event) {
case 'guildMemberAdd': {
const member = targetUser
? await interaction.guild?.members.fetch(targetUser.id)
: interaction.member;
if (!member) {
await interaction.editReply('❌ Could not find member.');
return;
}
client.emit('guildMemberAdd', member);
break;
}
case 'guildMemberRemove': {
const member = targetUser
? await interaction.guild?.members.fetch(targetUser.id)
: interaction.member;
if (!member) {
await interaction.editReply('❌ Could not find member.');
return;
}
client.emit('guildMemberRemove', member);
break;
}
case 'ready': {
client.emit('ready', client);
break;
}
default:
await interaction.editReply(`❌ Unknown event: ${event}`);
return;
}
const embed = new EmbedBuilder()
.setColor(0x57F287)
.setTitle('✅ Event Emitted')
.addFields(
{ name: 'Event', value: `\`${event}\``, inline: true },
{ name: 'Target', value: targetUser?.tag ?? 'Self', inline: true }
)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await interaction.editReply(`❌ Failed to emit event: ${error}`);
}
},
};

View File

@@ -0,0 +1,134 @@
/**
* Eval Command
* Execute JavaScript code (Owner only)
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
codeBlock,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
// Tokens and sensitive patterns to filter
const SENSITIVE_PATTERNS = [
/token/gi,
/secret/gi,
/password/gi,
/api[_-]?key/gi,
/auth/gi,
/credential/gi,
];
export const evalCommand: Command = {
data: new SlashCommandBuilder()
.setName('eval')
.setDescription('Execute JavaScript code (Owner only)')
.addStringOption((opt) =>
opt
.setName('code')
.setDescription('The code to execute')
.setRequired(true)
)
.addBooleanOption((opt) =>
opt
.setName('silent')
.setDescription('Hide the output')
.setRequired(false)
)
.addBooleanOption((opt) =>
opt
.setName('async')
.setDescription('Wrap code in async function')
.setRequired(false)
),
permission: PermissionLevel.Owner,
cooldown: 0,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const code = interaction.options.getString('code', true);
const silent = interaction.options.getBoolean('silent') ?? false;
const isAsync = interaction.options.getBoolean('async') ?? false;
// Check for sensitive patterns in code
for (const pattern of SENSITIVE_PATTERNS) {
if (pattern.test(code)) {
await interaction.reply({
content: '❌ Code contains potentially sensitive patterns.',
ephemeral: true,
});
return;
}
}
await interaction.deferReply({ ephemeral: silent });
const startTime = performance.now();
let result: unknown;
let error: Error | null = null;
try {
// Create evaluation context
const context = {
client,
interaction,
guild: interaction.guild,
channel: interaction.channel,
user: interaction.user,
member: interaction.member,
};
// Execute code
const codeToRun = isAsync ? `(async () => { ${code} })()` : code;
// Using Function constructor for evaluation
const fn = new Function(...Object.keys(context), `return ${codeToRun}`);
result = await fn(...Object.values(context));
} catch (e) {
error = e instanceof Error ? e : new Error(String(e));
}
const endTime = performance.now();
const executionTime = (endTime - startTime).toFixed(2);
// Format result
let output: string;
if (error) {
output = `${error.name}: ${error.message}`;
} else {
output = typeof result === 'string' ? result : Deno.inspect(result, {
depth: 2,
colors: false,
});
}
// Truncate if too long
if (output.length > 1900) {
output = output.substring(0, 1900) + '\n... (truncated)';
}
// Filter sensitive data from output
for (const pattern of SENSITIVE_PATTERNS) {
output = output.replace(pattern, '[REDACTED]');
}
const embed = new EmbedBuilder()
.setColor(error ? 0xED4245 : 0x57F287)
.setTitle(error ? '❌ Evaluation Error' : '✅ Evaluation Result')
.addFields(
{ name: '📥 Input', value: codeBlock('js', code.substring(0, 1000)) },
{ name: '📤 Output', value: codeBlock('js', output) },
{ name: '⏱️ Execution Time', value: `${executionTime}ms`, inline: true },
{ name: '📊 Type', value: `\`${typeof result}\``, inline: true }
)
.setFooter({ text: `Executed by ${interaction.user.tag}` })
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
},
};

View File

@@ -0,0 +1,119 @@
/**
* Reload Command
* Reload bot commands (owner only)
*/
import {
SlashCommandBuilder,
EmbedBuilder,
REST,
Routes,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
export const reloadCommand: Command = {
data: new SlashCommandBuilder()
.setName('reload')
.setDescription('Reload bot commands')
.addStringOption((option) =>
option
.setName('scope')
.setDescription('Where to reload commands')
.setRequired(false)
.addChoices(
{ name: 'Guild', value: 'guild' },
{ name: 'Global', value: 'global' }
)
),
permission: PermissionLevel.Owner,
cooldown: 30,
ownerOnly: true,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const scope = interaction.options.getString('scope') ?? 'guild';
await interaction.deferReply({ ephemeral: true });
const token = Deno.env.get('DISCORD_TOKEN');
if (!token || !client.user) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Error')
.setDescription('Missing token or client user.'),
],
});
return;
}
const rest = new REST({ version: '10' }).setToken(token);
const commands = client.commands.map((cmd) => cmd.data.toJSON());
try {
if (scope === 'global') {
await rest.put(Routes.applicationCommands(client.user.id), {
body: commands,
});
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Commands Reloaded')
.setDescription(
`Successfully reloaded **${commands.length}** commands globally.\n\n` +
'**Note:** Global commands may take up to 1 hour to update.'
),
],
});
} else {
if (!client.config.guild.id) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Error')
.setDescription('No guild ID configured.'),
],
});
return;
}
await rest.put(
Routes.applicationGuildCommands(client.user.id, client.config.guild.id),
{ body: commands }
);
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Commands Reloaded')
.setDescription(
`Successfully reloaded **${commands.length}** commands to guild.`
),
],
});
}
client.logger.info(`Commands reloaded (${scope}) by ${interaction.user.tag}`);
} catch (error) {
client.logger.error('Failed to reload commands', error);
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Error')
.setDescription('Failed to reload commands. Check the logs for details.'),
],
});
}
},
};

View File

@@ -0,0 +1,167 @@
/**
* Shell Command
* Execute shell commands (Owner only, with restrictions)
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
codeBlock,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
// Allowed commands whitelist
const ALLOWED_COMMANDS = [
'ls',
'pwd',
'whoami',
'date',
'uptime',
'df',
'free',
'cat',
'head',
'tail',
'wc',
'echo',
'deno',
];
// Blocked patterns
const BLOCKED_PATTERNS = [
/rm\s/i,
/sudo/i,
/chmod/i,
/chown/i,
/mv\s/i,
/cp\s/i,
/wget/i,
/curl/i,
/apt/i,
/yum/i,
/dnf/i,
/pacman/i,
/pip/i,
/npm\s+install/i,
/yarn\s+add/i,
/>\s*\//, // Redirect to root
/\|\s*sh/i,
/\|\s*bash/i,
/eval/i,
/exec/i,
/\$\(/, // Command substitution
/`/, // Backtick substitution
];
export const shellCommand: Command = {
data: new SlashCommandBuilder()
.setName('shell')
.setDescription('Execute shell commands (Owner only)')
.addStringOption((opt) =>
opt
.setName('command')
.setDescription('The command to execute')
.setRequired(true)
)
.addIntegerOption((opt) =>
opt
.setName('timeout')
.setDescription('Timeout in seconds (default: 10)')
.setRequired(false)
.setMinValue(1)
.setMaxValue(30)
),
permission: PermissionLevel.Owner,
cooldown: 5,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const command = interaction.options.getString('command', true);
const timeout = interaction.options.getInteger('timeout') ?? 10;
// Extract base command
const baseCommand = command.split(/\s+/)[0].toLowerCase();
// Check if command is allowed
if (!ALLOWED_COMMANDS.includes(baseCommand)) {
await interaction.reply({
content: `❌ Command \`${baseCommand}\` is not in the whitelist.\n**Allowed:** ${ALLOWED_COMMANDS.join(', ')}`,
ephemeral: true,
});
return;
}
// Check for blocked patterns
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(command)) {
await interaction.reply({
content: '❌ Command contains blocked patterns.',
ephemeral: true,
});
return;
}
}
await interaction.deferReply({ ephemeral: true });
const startTime = performance.now();
try {
// Create command with timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout * 1000);
const process = new Deno.Command('sh', {
args: ['-c', command],
stdout: 'piped',
stderr: 'piped',
signal: controller.signal,
});
const { code, stdout, stderr } = await process.output();
clearTimeout(timeoutId);
const endTime = performance.now();
const executionTime = (endTime - startTime).toFixed(2);
const stdoutText = new TextDecoder().decode(stdout);
const stderrText = new TextDecoder().decode(stderr);
let output = stdoutText || stderrText || '(no output)';
if (output.length > 1800) {
output = output.substring(0, 1800) + '\n... (truncated)';
}
const embed = new EmbedBuilder()
.setColor(code === 0 ? 0x57F287 : 0xED4245)
.setTitle(code === 0 ? '✅ Command Executed' : '❌ Command Failed')
.addFields(
{ name: '📥 Command', value: codeBlock('bash', command.substring(0, 500)) },
{ name: '📤 Output', value: codeBlock(output) },
{ name: '🔢 Exit Code', value: String(code), inline: true },
{ name: '⏱️ Time', value: `${executionTime}ms`, inline: true }
)
.setFooter({ text: `Executed by ${interaction.user.tag}` })
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const embed = new EmbedBuilder()
.setColor(0xED4245)
.setTitle('❌ Execution Error')
.addFields(
{ name: '📥 Command', value: codeBlock('bash', command.substring(0, 500)) },
{ name: '❌ Error', value: codeBlock(errorMessage.substring(0, 1000)) }
)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
}
},
};

View File

@@ -0,0 +1,174 @@
/**
* Sync Command
* Sync slash commands to Discord
*/
import {
SlashCommandBuilder,
EmbedBuilder,
REST,
Routes,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
export const syncCommand: Command = {
data: new SlashCommandBuilder()
.setName('sync')
.setDescription('Sync slash commands to Discord')
.addStringOption((option) =>
option
.setName('action')
.setDescription('Sync action to perform')
.setRequired(true)
.addChoices(
{ name: 'Sync to Guild', value: 'guild' },
{ name: 'Sync Globally', value: 'global' },
{ name: 'Clear Guild Commands', value: 'clear_guild' },
{ name: 'Clear Global Commands', value: 'clear_global' }
)
),
permission: PermissionLevel.Admin,
cooldown: 60,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const action = interaction.options.getString('action', true);
await interaction.deferReply({ ephemeral: true });
const token = Deno.env.get('DISCORD_TOKEN');
if (!token || !client.user) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Error')
.setDescription('Missing token or client user.'),
],
});
return;
}
const rest = new REST({ version: '10' }).setToken(token);
const commands = client.commands.map((cmd) => cmd.data.toJSON());
try {
switch (action) {
case 'guild': {
if (!client.config.guild.id) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Error')
.setDescription('No guild ID configured.'),
],
});
return;
}
await rest.put(
Routes.applicationGuildCommands(client.user.id, client.config.guild.id),
{ body: commands }
);
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Commands Synced')
.setDescription(
`Successfully synced **${commands.length}** commands to guild.`
),
],
});
break;
}
case 'global': {
await rest.put(Routes.applicationCommands(client.user.id), {
body: commands,
});
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Commands Synced')
.setDescription(
`Successfully synced **${commands.length}** commands globally.\n\n` +
'**Note:** Global commands may take up to 1 hour to update.'
),
],
});
break;
}
case 'clear_guild': {
if (!client.config.guild.id) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Error')
.setDescription('No guild ID configured.'),
],
});
return;
}
await rest.put(
Routes.applicationGuildCommands(client.user.id, client.config.guild.id),
{ body: [] }
);
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Commands Cleared')
.setDescription('Successfully cleared all guild commands.'),
],
});
break;
}
case 'clear_global': {
await rest.put(Routes.applicationCommands(client.user.id), {
body: [],
});
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Commands Cleared')
.setDescription(
'Successfully cleared all global commands.\n\n' +
'**Note:** Changes may take up to 1 hour to propagate.'
),
],
});
break;
}
}
client.logger.info(`Commands ${action} by ${interaction.user.tag}`);
} catch (error) {
client.logger.error(`Failed to ${action} commands`, error);
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Error')
.setDescription(`Failed to ${action} commands. Check the logs for details.`),
],
});
}
},
};

View File

@@ -0,0 +1,192 @@
/**
* Adopt Command
* Adopt another user as your child in the family system
*/
import {
SlashCommandBuilder,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type ChatInputCommandInteraction,
ComponentType,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { FamilyRepository } from '../../database/index.ts';
export const adoptCommand: Command = {
data: new SlashCommandBuilder()
.setName('adopt')
.setDescription('Adopt another user as your child')
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user you want to adopt')
.setRequired(true)
),
permission: PermissionLevel.User,
cooldown: 30,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const targetUser = interaction.options.getUser('user', true);
const repo = new FamilyRepository(client.database);
// Validation checks
if (targetUser.id === interaction.user.id) {
await interaction.reply({
content: "❌ You can't adopt yourself!",
ephemeral: true,
});
return;
}
if (targetUser.bot) {
await interaction.reply({
content: "❌ You can't adopt a bot!",
ephemeral: true,
});
return;
}
// Check if target already has a parent
const targetFamily = await repo.getFamily(targetUser.id);
if (targetFamily?.parentId) {
const parent = await client.users.fetch(targetFamily.parentId).catch(() => null);
await interaction.reply({
content: `${targetUser.tag} already has a parent (${parent?.tag ?? 'Unknown'}).`,
ephemeral: true,
});
return;
}
// Check if user is trying to adopt their parent
const userFamily = await repo.getFamily(interaction.user.id);
if (userFamily?.parentId === targetUser.id) {
await interaction.reply({
content: "❌ You can't adopt your own parent!",
ephemeral: true,
});
return;
}
// Check if user is trying to adopt their spouse
if (userFamily?.partnerId === targetUser.id) {
await interaction.reply({
content: "❌ You can't adopt your spouse!",
ephemeral: true,
});
return;
}
// Check if target is user's child already
if (userFamily?.children?.includes(targetUser.id)) {
await interaction.reply({
content: `${targetUser.tag} is already your child!`,
ephemeral: true,
});
return;
}
// Check max children limit (optional, set to 5)
const maxChildren = 5;
if (userFamily?.children && userFamily.children.length >= maxChildren) {
await interaction.reply({
content: `❌ You can't have more than ${maxChildren} children!`,
ephemeral: true,
});
return;
}
// Create adoption request
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('adopt:accept')
.setLabel('Accept')
.setStyle(ButtonStyle.Success)
.setEmoji('✅'),
new ButtonBuilder()
.setCustomId('adopt:decline')
.setLabel('Decline')
.setStyle(ButtonStyle.Danger)
.setEmoji('❌')
);
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('👨‍👧 Adoption Request')
.setDescription(
`${interaction.user} wants to adopt ${targetUser}!\n\n` +
`${targetUser}, do you accept?`
)
.setThumbnail(interaction.user.displayAvatarURL())
.setFooter({ text: 'This request expires in 60 seconds' })
.setTimestamp();
const response = await interaction.reply({
content: `${targetUser}`,
embeds: [embed],
components: [row],
fetchReply: true,
});
// Wait for response
try {
const buttonInteraction = await response.awaitMessageComponent({
componentType: ComponentType.Button,
filter: (i) => i.user.id === targetUser.id,
time: 60000,
});
if (buttonInteraction.customId === 'adopt:accept') {
// Process adoption
await repo.setParent(targetUser.id, interaction.user.id);
const successEmbed = new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('👨‍👧 Adoption Complete!')
.setDescription(
`🎉 Congratulations! ${interaction.user} has adopted ${targetUser}!\n\n` +
`Welcome to the family!`
)
.setThumbnail(targetUser.displayAvatarURL())
.setTimestamp();
await buttonInteraction.update({
content: null,
embeds: [successEmbed],
components: [],
});
} else {
const declineEmbed = new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('👨‍👧 Adoption Declined')
.setDescription(`${targetUser} declined the adoption request.`)
.setTimestamp();
await buttonInteraction.update({
content: null,
embeds: [declineEmbed],
components: [],
});
}
} catch {
// Timeout
const timeoutEmbed = new EmbedBuilder()
.setColor(client.config.colors.warning)
.setTitle('👨‍👧 Adoption Request Expired')
.setDescription('The adoption request was not answered in time.')
.setTimestamp();
await interaction.editReply({
content: null,
embeds: [timeoutEmbed],
components: [],
});
}
},
};

View File

@@ -0,0 +1,125 @@
/**
* Divorce Command
* End your marriage
*/
import {
SlashCommandBuilder,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ComponentType,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { FamilyRepository } from '../../database/repositories/FamilyRepository.ts';
export const divorceCommand: Command = {
data: new SlashCommandBuilder()
.setName('divorce')
.setDescription('End your marriage'),
permission: PermissionLevel.User,
cooldown: 30,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const familyRepo = new FamilyRepository(client.database);
// Check if user is married
const partnerId = familyRepo.getPartner(interaction.user.id);
if (!partnerId) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Not Married')
.setDescription("You're not married to anyone!"),
],
ephemeral: true,
});
return;
}
// Confirmation buttons
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('divorce:confirm')
.setLabel('Yes, divorce')
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId('divorce:cancel')
.setLabel('No, stay married')
.setStyle(ButtonStyle.Secondary)
);
const confirmEmbed = new EmbedBuilder()
.setColor(client.config.colors.warning)
.setTitle('💔 Divorce Confirmation')
.setDescription(
`Are you sure you want to divorce <@${partnerId}>?\n\n` +
`This action cannot be undone.`
)
.setFooter({ text: 'This confirmation expires in 30 seconds' })
.setTimestamp();
const response = await interaction.reply({
embeds: [confirmEmbed],
components: [row],
fetchReply: true,
});
try {
const buttonInteraction = await response.awaitMessageComponent({
componentType: ComponentType.Button,
filter: (i) => i.user.id === interaction.user.id,
time: 30000,
});
if (buttonInteraction.customId === 'divorce:confirm') {
// Perform the divorce
familyRepo.removePartner(interaction.user.id);
const successEmbed = new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('💔 Divorced')
.setDescription(
`${interaction.user} and <@${partnerId}> are no longer married.\n\n` +
`Sometimes things just don't work out... 😢`
)
.setTimestamp();
await buttonInteraction.update({
embeds: [successEmbed],
components: [],
});
} else {
const cancelEmbed = new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('💕 Marriage Saved')
.setDescription(`You decided to stay married to <@${partnerId}>. Love wins! 💖`)
.setTimestamp();
await buttonInteraction.update({
embeds: [cancelEmbed],
components: [],
});
}
} catch {
// Timeout
const timeoutEmbed = new EmbedBuilder()
.setColor(client.config.colors.info)
.setTitle('⏰ Confirmation Expired')
.setDescription('The divorce confirmation has expired. Your marriage remains intact.')
.setTimestamp();
await interaction.editReply({
embeds: [timeoutEmbed],
components: [],
});
}
},
};

View File

@@ -0,0 +1,196 @@
/**
* Marry Command
* Propose marriage to another user
*/
import {
SlashCommandBuilder,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ComponentType,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { FamilyRepository } from '../../database/repositories/FamilyRepository.ts';
export const marryCommand: Command = {
data: new SlashCommandBuilder()
.setName('marry')
.setDescription('Propose marriage to another user')
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user you want to marry')
.setRequired(true)
),
permission: PermissionLevel.User,
cooldown: 30,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const familyRepo = new FamilyRepository(client.database);
const targetUser = interaction.options.getUser('user', true);
// Validation checks
if (targetUser.id === interaction.user.id) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Invalid Target')
.setDescription("You can't marry yourself!"),
],
ephemeral: true,
});
return;
}
if (targetUser.bot) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Invalid Target')
.setDescription("You can't marry a bot!"),
],
ephemeral: true,
});
return;
}
// Check if proposer is already married
if (familyRepo.hasPartner(interaction.user.id)) {
const partnerId = familyRepo.getPartner(interaction.user.id);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Already Married')
.setDescription(`You're already married to <@${partnerId}>! Divorce first if you want to marry someone else.`),
],
ephemeral: true,
});
return;
}
// Check if target is already married
if (familyRepo.hasPartner(targetUser.id)) {
const partnerId = familyRepo.getPartner(targetUser.id);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Already Married')
.setDescription(`${targetUser} is already married to <@${partnerId}>!`),
],
ephemeral: true,
});
return;
}
// Check if they are parent/child
if (familyRepo.isChildOf(interaction.user.id, targetUser.id) ||
familyRepo.isChildOf(targetUser.id, interaction.user.id)) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Invalid Relationship')
.setDescription("You can't marry your parent or child!"),
],
ephemeral: true,
});
return;
}
// Create proposal buttons
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('marry:accept')
.setLabel('Accept 💍')
.setStyle(ButtonStyle.Success),
new ButtonBuilder()
.setCustomId('marry:deny')
.setLabel('Decline 💔')
.setStyle(ButtonStyle.Danger)
);
const proposalEmbed = new EmbedBuilder()
.setColor(0xff69b4) // Pink
.setTitle('💍 Marriage Proposal')
.setDescription(
`${interaction.user} has proposed to ${targetUser}!\n\n` +
`${targetUser}, do you accept this proposal?`
)
.setThumbnail(interaction.user.displayAvatarURL())
.setFooter({ text: 'This proposal expires in 60 seconds' })
.setTimestamp();
const response = await interaction.reply({
content: `${targetUser}`,
embeds: [proposalEmbed],
components: [row],
fetchReply: true,
});
// Wait for response
try {
const buttonInteraction = await response.awaitMessageComponent({
componentType: ComponentType.Button,
filter: (i) => i.user.id === targetUser.id,
time: 60000,
});
if (buttonInteraction.customId === 'marry:accept') {
// Perform the marriage
familyRepo.setPartner(interaction.user.id, targetUser.id);
const successEmbed = new EmbedBuilder()
.setColor(0x00ff00)
.setTitle('💒 Married!')
.setDescription(
`🎉 Congratulations! ${interaction.user} and ${targetUser} are now married! 🎉\n\n` +
`May your love last forever! 💕`
)
.setImage('https://media.giphy.com/media/l0MYt5jPR6QX5pnqM/giphy.gif')
.setTimestamp();
await buttonInteraction.update({
content: null,
embeds: [successEmbed],
components: [],
});
} else {
const rejectEmbed = new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('💔 Proposal Declined')
.setDescription(`${targetUser} has declined the proposal from ${interaction.user}.`)
.setTimestamp();
await buttonInteraction.update({
content: null,
embeds: [rejectEmbed],
components: [],
});
}
} catch {
// Timeout
const timeoutEmbed = new EmbedBuilder()
.setColor(client.config.colors.warning)
.setTitle('⏰ Proposal Expired')
.setDescription(`${targetUser} didn't respond in time. The proposal has expired.`)
.setTimestamp();
await interaction.editReply({
content: null,
embeds: [timeoutEmbed],
components: [],
});
}
},
};

View File

@@ -0,0 +1,114 @@
/**
* Relationship Command
* View family relationships
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { FamilyRepository } from '../../database/repositories/FamilyRepository.ts';
export const relationshipCommand: Command = {
data: new SlashCommandBuilder()
.setName('relationship')
.setDescription('View family relationships')
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user to view relationships for (defaults to yourself)')
.setRequired(false)
),
permission: PermissionLevel.User,
cooldown: 5,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const familyRepo = new FamilyRepository(client.database);
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const family = familyRepo.getOrCreate(targetUser.id);
// Format partner
const partnerDisplay = family.partnerId
? `<@${family.partnerId}> 💍`
: '*Nobody*';
// Format parent
const parentDisplay = family.parentId
? `<@${family.parentId}>`
: '*Nobody*';
// Format children
let childrenDisplay = '*None*';
if (family.children.length > 0) {
childrenDisplay = family.children
.map((id) => `<@${id}>`)
.join('\n');
}
// Build embed
const embed = new EmbedBuilder()
.setColor(0x1ab968) // Green
.setTitle(`👨‍👩‍👧‍👦 ${targetUser.displayName}'s Family`)
.setThumbnail(targetUser.displayAvatarURL())
.addFields(
{
name: '💑 Partner',
value: partnerDisplay,
inline: false,
},
{
name: '👪 Parent',
value: parentDisplay,
inline: false,
},
{
name: `👶 Children (${family.children.length})`,
value: childrenDisplay,
inline: false,
}
)
.setFooter({
text: `Requested by ${interaction.user.tag}`,
iconURL: interaction.user.displayAvatarURL(),
})
.setTimestamp();
// Add family tree visualization if they have relationships
if (family.partnerId || family.parentId || family.children.length > 0) {
let treeVisualization = '';
if (family.parentId) {
treeVisualization += `👤 Parent\n │\n`;
}
treeVisualization += `👤 ${targetUser.displayName}`;
if (family.partnerId) {
treeVisualization += ` ── 💍 ── 👤 Partner`;
}
if (family.children.length > 0) {
treeVisualization += `\n │`;
for (let i = 0; i < family.children.length; i++) {
const isLast = i === family.children.length - 1;
treeVisualization += `\n ${isLast ? '└' : '├'}── 👶 Child ${i + 1}`;
}
}
embed.addFields({
name: '🌳 Family Tree',
value: `\`\`\`\n${treeVisualization}\n\`\`\``,
inline: false,
});
}
await interaction.reply({ embeds: [embed] });
},
};

View File

@@ -0,0 +1,360 @@
/**
* Filter Command
* Manage channel message filters
*/
import {
SlashCommandBuilder,
EmbedBuilder,
ChannelType,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { FilterRepository, type FilterType } from '../../database/repositories/FilterRepository.ts';
export const filterCommand: Command = {
data: new SlashCommandBuilder()
.setName('filter')
.setDescription('Manage channel message filters')
.addSubcommand((sub) =>
sub
.setName('add')
.setDescription('Add a filter to a channel')
.addChannelOption((opt) =>
opt
.setName('channel')
.setDescription('Channel to filter')
.addChannelTypes(ChannelType.GuildText)
.setRequired(true)
)
.addStringOption((opt) =>
opt
.setName('type')
.setDescription('Type of filter')
.setRequired(true)
.addChoices(
{ name: 'Links', value: 'links' },
{ name: 'Images', value: 'images' },
{ name: 'Attachments', value: 'attachments' },
{ name: 'Discord Invites', value: 'invites' },
{ name: 'Custom Pattern', value: 'custom' }
)
)
.addStringOption((opt) =>
opt
.setName('pattern')
.setDescription('Regex pattern (for custom type)')
.setRequired(false)
)
)
.addSubcommand((sub) =>
sub
.setName('remove')
.setDescription('Remove a filter')
.addStringOption((opt) =>
opt.setName('id').setDescription('Filter ID').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('list')
.setDescription('List filters')
.addChannelOption((opt) =>
opt
.setName('channel')
.setDescription('Channel to list filters for (optional)')
.addChannelTypes(ChannelType.GuildText)
.setRequired(false)
)
)
.addSubcommand((sub) =>
sub
.setName('toggle')
.setDescription('Enable/disable a filter')
.addStringOption((opt) =>
opt.setName('id').setDescription('Filter ID').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('bypass')
.setDescription('Add a role that bypasses a filter')
.addStringOption((opt) =>
opt.setName('id').setDescription('Filter ID').setRequired(true)
)
.addRoleOption((opt) =>
opt.setName('role').setDescription('Role to add').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('unbypass')
.setDescription('Remove a bypass role from a filter')
.addStringOption((opt) =>
opt.setName('id').setDescription('Filter ID').setRequired(true)
)
.addRoleOption((opt) =>
opt.setName('role').setDescription('Role to remove').setRequired(true)
)
),
permission: PermissionLevel.Admin,
cooldown: 3,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const subcommand = interaction.options.getSubcommand();
const repo = new FilterRepository(client.database);
switch (subcommand) {
case 'add':
await handleAdd(interaction, client, repo);
break;
case 'remove':
await handleRemove(interaction, client, repo);
break;
case 'list':
await handleList(interaction, client, repo);
break;
case 'toggle':
await handleToggle(interaction, client, repo);
break;
case 'bypass':
await handleBypass(interaction, client, repo);
break;
case 'unbypass':
await handleUnbypass(interaction, client, repo);
break;
}
},
};
/**
* Handle adding a filter
*/
async function handleAdd(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: FilterRepository
): Promise<void> {
const channel = interaction.options.getChannel('channel', true);
const filterType = interaction.options.getString('type', true) as FilterType;
const pattern = interaction.options.getString('pattern');
// Validate custom pattern
if (filterType === 'custom') {
if (!pattern) {
await interaction.reply({
content: '❌ Custom filter type requires a regex pattern.',
ephemeral: true,
});
return;
}
// Test if pattern is valid regex
try {
new RegExp(pattern);
} catch {
await interaction.reply({
content: '❌ Invalid regex pattern.',
ephemeral: true,
});
return;
}
}
const filter = await repo.createFilter({
channelId: channel.id,
filterType,
pattern: pattern ?? undefined,
allowedRoles: [],
isEnabled: true,
createdBy: interaction.user.id,
});
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Filter Created')
.addFields(
{ name: 'Channel', value: `<#${channel.id}>`, inline: true },
{ name: 'Type', value: filterType, inline: true },
{ name: 'ID', value: `\`${filter.id}\``, inline: true }
)
.setFooter({ text: `Created by ${interaction.user.tag}` })
.setTimestamp(),
],
});
}
/**
* Handle removing a filter
*/
async function handleRemove(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: FilterRepository
): Promise<void> {
const id = interaction.options.getString('id', true);
const deleted = await repo.deleteFilter(id);
if (!deleted) {
await interaction.reply({
content: '❌ Filter not found.',
ephemeral: true,
});
return;
}
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Filter Removed')
.setDescription(`Filter \`${id}\` has been deleted.`)
.setTimestamp(),
],
});
}
/**
* Handle listing filters
*/
async function handleList(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: FilterRepository
): Promise<void> {
const channel = interaction.options.getChannel('channel');
let filters;
if (channel) {
filters = await repo.getChannelFilters(channel.id);
} else {
filters = await repo.getAllEnabled();
}
if (filters.length === 0) {
await interaction.reply({
content: `📭 No filters found${channel ? ` for ${channel}` : ''}.`,
ephemeral: true,
});
return;
}
const list = filters.map((f) => {
const status = f.isEnabled ? '🟢' : '🔴';
const bypasses = f.allowedRoles.length > 0
? ` (${f.allowedRoles.length} bypass roles)`
: '';
return `${status} \`${f.id}\` - <#${f.channelId}> - **${f.filterType}**${bypasses}`;
}).join('\n');
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle(`🛡️ ${channel ? `Filters for #${channel.name}` : 'All Filters'}`)
.setDescription(list)
.setFooter({ text: `${filters.length} filter(s)` })
.setTimestamp(),
],
});
}
/**
* Handle toggling a filter
*/
async function handleToggle(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: FilterRepository
): Promise<void> {
const id = interaction.options.getString('id', true);
const filter = await repo.toggleFilter(id);
if (!filter) {
await interaction.reply({
content: '❌ Filter not found.',
ephemeral: true,
});
return;
}
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(filter.isEnabled ? client.config.colors.success : client.config.colors.warning)
.setTitle(`${filter.isEnabled ? '🟢' : '🔴'} Filter ${filter.isEnabled ? 'Enabled' : 'Disabled'}`)
.setDescription(`Filter \`${id}\` has been ${filter.isEnabled ? 'enabled' : 'disabled'}.`)
.setTimestamp(),
],
});
}
/**
* Handle adding a bypass role
*/
async function handleBypass(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: FilterRepository
): Promise<void> {
const id = interaction.options.getString('id', true);
const role = interaction.options.getRole('role', true);
const filter = await repo.addAllowedRole(id, role.id);
if (!filter) {
await interaction.reply({
content: '❌ Filter not found.',
ephemeral: true,
});
return;
}
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Bypass Role Added')
.setDescription(`${role} can now bypass filter \`${id}\`.`)
.setTimestamp(),
],
});
}
/**
* Handle removing a bypass role
*/
async function handleUnbypass(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: FilterRepository
): Promise<void> {
const id = interaction.options.getString('id', true);
const role = interaction.options.getRole('role', true);
const filter = await repo.removeAllowedRole(id, role.id);
if (!filter) {
await interaction.reply({
content: '❌ Filter not found.',
ephemeral: true,
});
return;
}
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Bypass Role Removed')
.setDescription(`${role} can no longer bypass filter \`${id}\`.`)
.setTimestamp(),
],
});
}

View File

@@ -0,0 +1,180 @@
/**
* Purge Command
* Delete multiple messages at once
*/
import {
SlashCommandBuilder,
EmbedBuilder,
PermissionFlagsBits,
type ChatInputCommandInteraction,
type TextChannel,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
export const purgeCommand: Command = {
data: new SlashCommandBuilder()
.setName('purge')
.setDescription('Delete multiple messages at once')
.addIntegerOption((option) =>
option
.setName('amount')
.setDescription('Number of messages to delete (1-100)')
.setRequired(true)
.setMinValue(1)
.setMaxValue(100)
)
.addUserOption((option) =>
option
.setName('user')
.setDescription('Only delete messages from this user')
.setRequired(false)
)
.addStringOption((option) =>
option
.setName('contains')
.setDescription('Only delete messages containing this text')
.setRequired(false)
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
permission: PermissionLevel.Officer,
cooldown: 5,
guildOnly: true,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
if (!interaction.guild || !interaction.channel) {
await interaction.reply({
content: 'This command can only be used in a server.',
ephemeral: true,
});
return;
}
const channel = interaction.channel as TextChannel;
const amount = interaction.options.getInteger('amount', true);
const targetUser = interaction.options.getUser('user');
const containsText = interaction.options.getString('contains');
// Check bot permissions
const botMember = interaction.guild.members.me;
if (!botMember?.permissions.has(PermissionFlagsBits.ManageMessages)) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Missing Permissions')
.setDescription('I need the **Manage Messages** permission to delete messages.'),
],
ephemeral: true,
});
return;
}
// Defer reply since this might take a moment
await interaction.deferReply({ ephemeral: true });
try {
// Fetch messages
const messages = await channel.messages.fetch({ limit: 100 });
// Filter messages
let filtered = messages.filter((msg) => {
// Can't delete messages older than 14 days
const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000;
if (msg.createdTimestamp < twoWeeksAgo) return false;
// Filter by user if specified
if (targetUser && msg.author.id !== targetUser.id) return false;
// Filter by content if specified
if (containsText && !msg.content.toLowerCase().includes(containsText.toLowerCase())) {
return false;
}
return true;
});
// Limit to requested amount
filtered = filtered.first(amount);
if (filtered.length === 0) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.warning)
.setTitle('⚠️ No Messages Found')
.setDescription(
'No messages matching your criteria were found.\n\n' +
'**Note:** Messages older than 14 days cannot be bulk deleted.'
),
],
});
return;
}
// Delete messages
const deleted = await channel.bulkDelete(filtered, true);
// Build result embed
const embed = new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('🗑️ Messages Purged')
.setDescription(`Successfully deleted **${deleted.size}** message${deleted.size !== 1 ? 's' : ''}.`)
.addFields(
{ name: 'Channel', value: `${channel}`, inline: true },
{ name: 'Requested', value: String(amount), inline: true },
{ name: 'Deleted', value: String(deleted.size), inline: true }
)
.setFooter({
text: `Purged by ${interaction.user.tag}`,
iconURL: interaction.user.displayAvatarURL(),
})
.setTimestamp();
if (targetUser) {
embed.addFields({ name: 'User Filter', value: `${targetUser}`, inline: true });
}
if (containsText) {
embed.addFields({ name: 'Content Filter', value: `"${containsText}"`, inline: true });
}
await interaction.editReply({ embeds: [embed] });
// Log to development channel if configured
if (client.channels_cache.developmentLogs) {
const logEmbed = new EmbedBuilder()
.setColor(client.config.colors.warning)
.setTitle('📋 Purge Log')
.addFields(
{ name: 'Moderator', value: `${interaction.user} (${interaction.user.id})`, inline: true },
{ name: 'Channel', value: `${channel} (${channel.id})`, inline: true },
{ name: 'Messages Deleted', value: String(deleted.size), inline: true }
)
.setTimestamp();
if (targetUser) {
logEmbed.addFields({ name: 'Target User', value: `${targetUser} (${targetUser.id})`, inline: true });
}
await client.channels_cache.developmentLogs.send({ embeds: [logEmbed] });
}
} catch (error) {
client.logger.error('Error purging messages', error);
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Error')
.setDescription('An error occurred while trying to delete messages.'),
],
});
}
},
};

611
src/commands/qotd/index.ts Normal file
View File

@@ -0,0 +1,611 @@
/**
* QOTD Command Module
* Advanced Question of the Day management system
*/
import {
SlashCommandBuilder,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
type ChatInputCommandInteraction,
ComponentType,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { QOTDRepository } from '../../database/repositories/QOTDRepository.ts';
export const qotdCommand: Command = {
data: new SlashCommandBuilder()
.setName('qotd')
.setDescription('Question of the Day management')
// User commands
.addSubcommand((sub) =>
sub
.setName('answer')
.setDescription('Answer the current question of the day')
)
.addSubcommand((sub) =>
sub
.setName('suggest')
.setDescription('Suggest a question for QOTD')
.addStringOption((opt) =>
opt.setName('question').setDescription('Your question suggestion').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('streak')
.setDescription('View your QOTD answer streak')
.addUserOption((opt) =>
opt.setName('user').setDescription('User to check').setRequired(false)
)
)
// Staff commands
.addSubcommand((sub) =>
sub
.setName('add')
.setDescription('Add a question to the queue')
.addStringOption((opt) =>
opt.setName('question').setDescription('The question to add').setRequired(true)
)
.addStringOption((opt) =>
opt
.setName('category')
.setDescription('Question category')
.addChoices(
{ name: 'General', value: 'general' },
{ name: 'Gaming', value: 'gaming' },
{ name: 'Fun', value: 'fun' },
{ name: 'Deep', value: 'deep' },
{ name: 'Would You Rather', value: 'wyr' }
)
)
)
.addSubcommand((sub) =>
sub
.setName('queue')
.setDescription('View the question queue')
.addIntegerOption((opt) =>
opt.setName('page').setDescription('Page number').setMinValue(1)
)
)
.addSubcommand((sub) =>
sub
.setName('remove')
.setDescription('Remove a question from the queue')
.addStringOption((opt) =>
opt.setName('id').setDescription('Question ID').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('send')
.setDescription('Send the QOTD now')
.addStringOption((opt) =>
opt.setName('id').setDescription('Specific question ID (optional)')
)
)
.addSubcommand((sub) =>
sub
.setName('stats')
.setDescription('View QOTD statistics')
)
.addSubcommand((sub) =>
sub
.setName('leaderboard')
.setDescription('View QOTD participation leaderboard')
)
// Admin commands
.addSubcommand((sub) =>
sub
.setName('config')
.setDescription('Configure QOTD settings')
.addChannelOption((opt) =>
opt.setName('channel').setDescription('QOTD channel')
)
.addRoleOption((opt) =>
opt.setName('role').setDescription('Role to ping')
)
.addStringOption((opt) =>
opt.setName('time').setDescription('Time to send (HH:MM format)')
)
.addBooleanOption((opt) =>
opt.setName('enabled').setDescription('Enable/disable QOTD')
)
),
permission: PermissionLevel.User,
cooldown: 5,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const subcommand = interaction.options.getSubcommand();
const repo = new QOTDRepository(client.database);
switch (subcommand) {
case 'answer':
await handleAnswer(interaction, client, repo);
break;
case 'suggest':
await handleSuggest(interaction, client, repo);
break;
case 'streak':
await handleStreak(interaction, client, repo);
break;
case 'add':
await handleAdd(interaction, client, repo);
break;
case 'queue':
await handleQueue(interaction, client, repo);
break;
case 'remove':
await handleRemove(interaction, client, repo);
break;
case 'send':
await handleSend(interaction, client, repo);
break;
case 'stats':
await handleStats(interaction, client, repo);
break;
case 'leaderboard':
await handleLeaderboard(interaction, client, repo);
break;
case 'config':
await handleConfig(interaction, client, repo);
break;
}
},
};
async function handleAnswer(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: QOTDRepository
): Promise<void> {
const currentQuestion = await repo.getCurrentQuestion();
if (!currentQuestion) {
await interaction.reply({
content: '❌ There is no active question of the day right now.',
ephemeral: true,
});
return;
}
// Show answer modal
const modal = new ModalBuilder()
.setCustomId('qotd:answer')
.setTitle('Answer QOTD')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('answer')
.setLabel(currentQuestion.question.substring(0, 45))
.setPlaceholder('Type your answer here...')
.setStyle(TextInputStyle.Paragraph)
.setMaxLength(1000)
.setRequired(true)
)
);
await interaction.showModal(modal);
try {
const modalInteraction = await interaction.awaitModalSubmit({
time: 300000,
filter: (i) => i.customId === 'qotd:answer' && i.user.id === interaction.user.id,
});
const answer = modalInteraction.fields.getTextInputValue('answer');
// Record answer
await repo.recordAnswer(interaction.user.id, currentQuestion.id, answer);
// Update streak
await updateStreak(interaction.user.id, client);
await modalInteraction.reply({
content: '✅ Your answer has been recorded! Keep up your streak by answering daily.',
ephemeral: true,
});
} catch {
// Modal timed out
}
}
async function handleSuggest(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: QOTDRepository
): Promise<void> {
const question = interaction.options.getString('question', true);
// Store suggestion
const suggestions = client.database.get<Array<{
id: string;
question: string;
userId: string;
createdAt: number;
status: 'pending' | 'approved' | 'rejected';
}>>('qotd_suggestions') ?? [];
const suggestion = {
id: `qs_${Date.now()}`,
question,
userId: interaction.user.id,
createdAt: Date.now(),
status: 'pending' as const,
};
suggestions.push(suggestion);
client.database.set('qotd_suggestions', suggestions);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(0x57f287)
.setTitle('✅ Question Suggested')
.setDescription(`Your question has been submitted for review!\n\n**Question:** ${question}`)
.setFooter({ text: `Suggestion ID: ${suggestion.id}` })
.setTimestamp(),
],
ephemeral: true,
});
}
async function handleStreak(
interaction: ChatInputCommandInteraction,
client: EllyClient,
_repo: QOTDRepository
): Promise<void> {
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const streaks = client.database.get<Record<string, { current: number; best: number; lastAnswer: number }>>('qotd_streaks') ?? {};
const userStreak = streaks[targetUser.id] ?? { current: 0, best: 0, lastAnswer: 0 };
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle(`🔥 ${targetUser.username}'s QOTD Streak`)
.setThumbnail(targetUser.displayAvatarURL())
.addFields(
{ name: '🔥 Current Streak', value: `${userStreak.current} days`, inline: true },
{ name: '🏆 Best Streak', value: `${userStreak.best} days`, inline: true },
{ name: '📅 Last Answer', value: userStreak.lastAnswer ? `<t:${Math.floor(userStreak.lastAnswer / 1000)}:R>` : 'Never', inline: true }
)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleAdd(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: QOTDRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to add questions.',
ephemeral: true,
});
return;
}
const question = interaction.options.getString('question', true);
const category = interaction.options.getString('category') ?? 'general';
const added = await repo.addQuestion(question, interaction.user.id, category);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(0x57f287)
.setTitle('✅ Question Added')
.addFields(
{ name: 'Question', value: question },
{ name: 'Category', value: category, inline: true },
{ name: 'ID', value: `\`${added.id}\``, inline: true }
)
.setTimestamp(),
],
ephemeral: true,
});
}
async function handleQueue(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: QOTDRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to view the queue.',
ephemeral: true,
});
return;
}
const page = interaction.options.getInteger('page') ?? 1;
const questions = await repo.getUnusedQuestions();
if (questions.length === 0) {
await interaction.reply({
content: '📭 The question queue is empty.',
ephemeral: true,
});
return;
}
const perPage = 10;
const totalPages = Math.ceil(questions.length / perPage);
const currentPage = Math.min(page, totalPages);
const startIndex = (currentPage - 1) * perPage;
const pageQuestions = questions.slice(startIndex, startIndex + perPage);
const list = pageQuestions.map((q, i) => {
const num = startIndex + i + 1;
return `**${num}.** \`${q.id}\` - ${q.question.substring(0, 50)}...`;
});
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📋 QOTD Queue')
.setDescription(list.join('\n'))
.setFooter({ text: `Page ${currentPage}/${totalPages}${questions.length} questions` })
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleRemove(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: QOTDRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to remove questions.',
ephemeral: true,
});
return;
}
const id = interaction.options.getString('id', true);
const success = await repo.deleteQuestion(id);
if (!success) {
await interaction.reply({
content: '❌ Question not found.',
ephemeral: true,
});
return;
}
await interaction.reply({
content: `✅ Question \`${id}\` has been removed.`,
ephemeral: true,
});
}
async function handleSend(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: QOTDRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to send QOTD.',
ephemeral: true,
});
return;
}
await interaction.deferReply({ ephemeral: true });
const specificId = interaction.options.getString('id');
let question;
if (specificId) {
question = await repo.getQuestionById(specificId);
} else {
question = await repo.getRandomUnusedQuestion();
}
if (!question) {
await interaction.editReply({
content: '❌ No questions available to send.',
});
return;
}
// Get QOTD config
const config = await repo.getConfig();
const channel = config?.channelId ? await client.channels.fetch(config.channelId) : null;
if (!channel?.isTextBased()) {
await interaction.editReply({
content: '❌ QOTD channel not configured. Use `/qotd config` to set it up.',
});
return;
}
// Mark as used
await repo.markAsUsed(question.id);
// Send QOTD
const embed = new EmbedBuilder()
.setColor(0xf1c40f)
.setTitle('❓ Question of the Day')
.setDescription(question.question)
.addFields(
{ name: 'Category', value: question.category ?? 'General', inline: true },
{ name: 'Added By', value: `<@${question.addedBy}>`, inline: true }
)
.setFooter({ text: 'Use /qotd answer to submit your answer!' })
.setTimestamp();
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('qotd:quick_answer')
.setLabel('Answer')
.setStyle(ButtonStyle.Primary)
.setEmoji('💬')
);
const rolePing = config?.roleId ? `<@&${config.roleId}>` : '';
await channel.send({
content: rolePing,
embeds: [embed],
components: [row],
});
await interaction.editReply({
content: '✅ QOTD has been sent!',
});
}
async function handleStats(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: QOTDRepository
): Promise<void> {
const questions = await repo.getUnusedQuestions();
const usedQuestions = await repo.getUsedQuestions();
const config = await repo.getConfig();
const answers = client.database.get<Array<{ oderId: string }>>('qotd_answers') ?? [];
const uniqueParticipants = new Set(answers.map((a) => a.oderId)).size;
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📊 QOTD Statistics')
.addFields(
{ name: '📋 Questions in Queue', value: String(questions.length), inline: true },
{ name: '✅ Questions Used', value: String(usedQuestions.length), inline: true },
{ name: '👥 Unique Participants', value: String(uniqueParticipants), inline: true },
{ name: '💬 Total Answers', value: String(answers.length), inline: true },
{ name: '🔔 Status', value: config?.isEnabled ? '✅ Enabled' : '❌ Disabled', inline: true },
{ name: '⏰ Scheduled Time', value: config?.scheduledTime ?? 'Not set', inline: true }
)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleLeaderboard(
interaction: ChatInputCommandInteraction,
client: EllyClient,
_repo: QOTDRepository
): Promise<void> {
const streaks = client.database.get<Record<string, { current: number; best: number }>>('qotd_streaks') ?? {};
const leaderboard = Object.entries(streaks)
.sort((a, b) => b[1].current - a[1].current)
.slice(0, 10);
if (leaderboard.length === 0) {
await interaction.reply({
content: '📭 No one has answered any questions yet!',
ephemeral: true,
});
return;
}
const medals = ['🥇', '🥈', '🥉'];
const list = leaderboard.map(([oderId, data], i) => {
const medal = medals[i] ?? `**${i + 1}.**`;
return `${medal} <@${oderId}> - 🔥 ${data.current} day streak (Best: ${data.best})`;
});
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('🏆 QOTD Leaderboard')
.setDescription(list.join('\n'))
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleConfig(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: QOTDRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Admin)) {
await interaction.reply({
content: '❌ You need Admin permission to configure QOTD.',
ephemeral: true,
});
return;
}
const channel = interaction.options.getChannel('channel');
const role = interaction.options.getRole('role');
const time = interaction.options.getString('time');
const enabled = interaction.options.getBoolean('enabled');
const config = await repo.getConfig() ?? {
channelId: null,
roleId: null,
scheduledTime: '12:00',
isEnabled: false,
};
if (channel) config.channelId = channel.id;
if (role) config.roleId = role.id;
if (time) config.scheduledTime = time;
if (enabled !== null) config.isEnabled = enabled;
await repo.updateConfig(config);
const embed = new EmbedBuilder()
.setColor(0x57f287)
.setTitle('⚙️ QOTD Configuration Updated')
.addFields(
{ name: 'Channel', value: config.channelId ? `<#${config.channelId}>` : 'Not set', inline: true },
{ name: 'Ping Role', value: config.roleId ? `<@&${config.roleId}>` : 'None', inline: true },
{ name: 'Scheduled Time', value: config.scheduledTime ?? '12:00', inline: true },
{ name: 'Status', value: config.isEnabled ? '✅ Enabled' : '❌ Disabled', inline: true }
)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function updateStreak(userId: string, client: EllyClient): Promise<void> {
const streaks = client.database.get<Record<string, { current: number; best: number; lastAnswer: number }>>('qotd_streaks') ?? {};
const now = Date.now();
const oneDayMs = 24 * 60 * 60 * 1000;
const userStreak = streaks[userId] ?? { current: 0, best: 0, lastAnswer: 0 };
// Check if answered within last 48 hours (to maintain streak)
if (now - userStreak.lastAnswer < 2 * oneDayMs) {
// Check if it's a new day
const lastDate = new Date(userStreak.lastAnswer).toDateString();
const todayDate = new Date(now).toDateString();
if (lastDate !== todayDate) {
userStreak.current++;
}
} else {
// Streak broken, start new
userStreak.current = 1;
}
userStreak.lastAnswer = now;
userStreak.best = Math.max(userStreak.best, userStreak.current);
streaks[userId] = userStreak;
client.database.set('qotd_streaks', streaks);
}

View File

@@ -0,0 +1,173 @@
/**
* BedWars Statistics Command
* Displays BedWars stats for a PikaNetwork player
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import type { Interval } from '../../api/pika/types.ts';
export const bedwarsCommand: Command = {
data: new SlashCommandBuilder()
.setName('bedwars')
.setDescription('Get BedWars statistics for a PikaNetwork player')
.addStringOption((option) =>
option
.setName('username')
.setDescription('Minecraft username')
.setRequired(true)
.setMinLength(3)
.setMaxLength(16)
)
.addStringOption((option) =>
option
.setName('mode')
.setDescription('Game mode')
.setRequired(false)
.addChoices(
{ name: 'All Modes', value: 'all_modes' },
{ name: 'Solo', value: 'solo' },
{ name: 'Doubles', value: 'doubles' },
{ name: 'Triples', value: 'triples' },
{ name: 'Quads', value: 'quad' }
)
)
.addStringOption((option) =>
option
.setName('interval')
.setDescription('Time interval')
.setRequired(false)
.addChoices(
{ name: 'Weekly', value: 'weekly' },
{ name: 'Monthly', value: 'monthly' },
{ name: 'Yearly', value: 'yearly' },
{ name: 'Lifetime', value: 'lifetime' }
)
),
permission: PermissionLevel.User,
cooldown: 5,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
await interaction.deferReply();
const client = interaction.client as EllyClient;
const username = interaction.options.getString('username', true);
const mode = interaction.options.getString('mode') ?? 'all_modes';
const interval = (interaction.options.getString('interval') ?? 'lifetime') as Interval;
// Fetch profile and stats
const [profile, stats] = await Promise.all([
client.pikaAPI.getProfile(username),
client.pikaAPI.getBedWarsStats(username, interval, mode),
]);
if (!profile) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Player Not Found')
.setDescription(`Could not find player **${username}** on PikaNetwork.`)
.setFooter({ text: 'Make sure the username is correct.' }),
],
});
return;
}
if (!stats) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Stats Not Found')
.setDescription(`Could not fetch BedWars statistics for **${profile.username}**.`)
.setFooter({ text: 'The player may not have played BedWars.' }),
],
});
return;
}
// Format mode name
const modeNames: Record<string, string> = {
all_modes: 'All Modes',
solo: 'Solo',
doubles: 'Doubles',
triples: 'Triples',
quad: 'Quads',
};
// Get rank color
const rankColors: Record<string, number> = {
owner: 0xaa0000,
manager: 0xaa0000,
developer: 0xff5555,
admin: 0xff5555,
srmod: 0x00aaaa,
moderator: 0x00aa00,
helper: 0x5555ff,
trial: 0x55ffff,
champion: 0xaa0000,
titan: 0xffaa00,
elite: 0x55ffff,
vip: 0x55ff55,
};
let embedColor = client.config.colors.primary;
let rankDisplay = 'Unranked';
for (const rank of profile.ranks) {
const rankName = rank.displayName.toLowerCase();
if (rankColors[rankName]) {
embedColor = rankColors[rankName];
rankDisplay = rank.displayName;
break;
}
}
// Build embed
const embed = new EmbedBuilder()
.setColor(embedColor)
.setTitle(`🛏️ ${profile.username}'s BedWars Stats`)
.setThumbnail(`https://mc-heads.net/head/${profile.username}/right`)
.setDescription(
`**Rank:** ${rankDisplay}\n` +
`**Level:** ${profile.rank.level}\n` +
`**Clan:** ${profile.clan?.name ?? 'None'}`
)
.addFields(
// Combat stats
{ name: '⚔️ Kills', value: stats.kills.toLocaleString(), inline: true },
{ name: '💀 Deaths', value: stats.deaths.toLocaleString(), inline: true },
{ name: '📊 K/D', value: stats.kdr.toString(), inline: true },
// Final stats
{ name: '🗡️ Final Kills', value: stats.finalKills.toLocaleString(), inline: true },
{ name: '☠️ Final Deaths', value: stats.finalDeaths.toLocaleString(), inline: true },
{ name: '📈 FKDR', value: stats.fkdr.toString(), inline: true },
// Game stats
{ name: '🏆 Wins', value: stats.wins.toLocaleString(), inline: true },
{ name: '❌ Losses', value: stats.losses.toLocaleString(), inline: true },
{ name: '📉 W/L', value: stats.wlr.toString(), inline: true },
// Other stats
{ name: '🛏️ Beds Destroyed', value: stats.bedsDestroyed.toLocaleString(), inline: true },
{ name: '🎮 Games Played', value: stats.gamesPlayed.toLocaleString(), inline: true },
{ name: '🔥 Best Winstreak', value: stats.highestWinstreak.toLocaleString(), inline: true }
)
.setFooter({
text: `Mode: ${modeNames[mode]} | Interval: ${interval.charAt(0).toUpperCase() + interval.slice(1)} | Requested by ${interaction.user.tag}`,
iconURL: interaction.user.displayAvatarURL(),
})
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
},
};

View File

@@ -0,0 +1,128 @@
/**
* Guild Statistics Command
* View PikaNetwork guild information and member stats
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { createPaginator, createPaginatedEmbeds } from '../../utils/pagination.ts';
export const guildCommand: Command = {
data: new SlashCommandBuilder()
.setName('guild')
.setDescription('View PikaNetwork guild information')
.addStringOption((option) =>
option
.setName('name')
.setDescription('Guild name (defaults to configured guild)')
.setRequired(false)
),
permission: PermissionLevel.User,
cooldown: 10,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const guildName = interaction.options.getString('name') ?? client.config.guild.name;
await interaction.deferReply();
// Fetch guild data
const clan = await client.pikaAPI.getClan(guildName);
if (!clan) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Guild Not Found')
.setDescription(`Could not find guild **${guildName}** on PikaNetwork.`)
.setFooter({ text: 'Make sure the guild name is correct.' }),
],
});
return;
}
// Build main info embed
const mainEmbed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle(`📜 ${clan.name} | Guild Information`)
.setThumbnail(`https://mc-heads.net/head/${clan.owner.username}/right`)
.addFields(
{ name: '👑 Owner', value: clan.owner.username, inline: true },
{ name: '👥 Members', value: String(clan.members.length), inline: true },
{ name: '🏆 Trophies', value: clan.currentTrophies.toLocaleString(), inline: true },
{ name: '📊 Level', value: String(clan.leveling.level), inline: true },
{ name: '✨ Experience', value: clan.leveling.exp.toLocaleString(), inline: true },
{ name: '📅 Created', value: formatDate(clan.creationTime), inline: true }
)
.setFooter({
text: `Requested by ${interaction.user.tag}`,
iconURL: interaction.user.displayAvatarURL(),
})
.setTimestamp();
// Filter out members with missing user data
const validMembers = clan.members.filter((m) => m?.user?.username);
// If few members, show them in the main embed
if (validMembers.length <= 10) {
const memberList = validMembers
.map((m) => `${m.user.username}`)
.join('\n');
mainEmbed.addFields({
name: '📋 Members',
value: memberList || 'No members',
inline: false,
});
await interaction.editReply({ embeds: [mainEmbed] });
return;
}
// For larger guilds, create paginated member list
const memberEmbeds = createPaginatedEmbeds(
validMembers,
15,
(member, index) => `**${index + 1}.** ${member.user.username}`,
{
title: `📋 ${clan.name} Members`,
color: client.config.colors.primary,
description: `Total members: ${validMembers.length}`,
}
);
// Add main embed as first page
const allEmbeds = [mainEmbed, ...memberEmbeds];
const paginator = createPaginator(allEmbeds, {
authorId: interaction.user.id,
timeout: 120000,
});
await paginator.start(interaction);
},
};
/**
* Format a date string
*/
function formatDate(dateStr: string): string {
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return 'Unknown';
}
}

View File

@@ -0,0 +1,96 @@
/**
* Server Status Command
* View PikaNetwork server status
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
export const serverCommand: Command = {
data: new SlashCommandBuilder()
.setName('server')
.setDescription('View PikaNetwork server status')
.addStringOption((option) =>
option
.setName('ip')
.setDescription('Server IP (defaults to play.pika-network.net)')
.setRequired(false)
),
permission: PermissionLevel.User,
cooldown: 15,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const serverIP = interaction.options.getString('ip') ?? 'play.pika-network.net';
await interaction.deferReply();
const status = await client.pikaAPI.getServerStatus(serverIP);
if (!status) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Server Offline or Unreachable')
.setDescription(`Could not fetch status for **${serverIP}**.`)
.setFooter({ text: 'The server may be offline or the IP is incorrect.' }),
],
});
return;
}
const statusEmoji = status.online ? '🟢' : '🔴';
const statusText = status.online ? 'Online' : 'Offline';
const embed = new EmbedBuilder()
.setColor(status.online ? 0x00ff00 : 0xff0000)
.setTitle(`${statusEmoji} ${status.host}`)
.setThumbnail(status.icon)
.addFields(
{ name: '📊 Status', value: statusText, inline: true },
{ name: '👥 Players', value: `${status.playersOnline.toLocaleString()} / ${status.playersMax.toLocaleString()}`, inline: true },
{ name: '🔧 Version', value: status.software, inline: true },
{ name: '🌐 IP', value: `\`${status.host}\``, inline: true },
{ name: '🔌 Port', value: String(status.port), inline: true },
{ name: '📡 Protocol', value: String(status.protocol), inline: true }
)
.setImage(status.banner)
.setFooter({
text: `Requested by ${interaction.user.tag}`,
iconURL: interaction.user.displayAvatarURL(),
})
.setTimestamp();
// Add MOTD if available
if (status.motd.length > 0) {
embed.addFields({
name: '📝 MOTD',
value: status.motd.join('\n') || 'No MOTD',
inline: false,
});
}
// Add website and discord if available
if (status.website || status.discord) {
const links: string[] = [];
if (status.website) links.push(`[Website](${status.website})`);
if (status.discord) links.push(`[Discord](${status.discord})`);
embed.addFields({
name: '🔗 Links',
value: links.join(' • '),
inline: false,
});
}
await interaction.editReply({ embeds: [embed] });
},
};

View File

@@ -0,0 +1,176 @@
/**
* SkyWars Statistics Command
* Displays SkyWars stats for a PikaNetwork player
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import type { Interval } from '../../api/pika/types.ts';
export const skywarsCommand: Command = {
data: new SlashCommandBuilder()
.setName('skywars')
.setDescription('Get SkyWars statistics for a PikaNetwork player')
.addStringOption((option) =>
option
.setName('username')
.setDescription('Minecraft username')
.setRequired(true)
.setMinLength(3)
.setMaxLength(16)
)
.addStringOption((option) =>
option
.setName('mode')
.setDescription('Game mode')
.setRequired(false)
.addChoices(
{ name: 'All Modes', value: 'all_modes' },
{ name: 'Solo', value: 'solo' },
{ name: 'Doubles', value: 'doubles' }
)
)
.addStringOption((option) =>
option
.setName('interval')
.setDescription('Time interval')
.setRequired(false)
.addChoices(
{ name: 'Weekly', value: 'weekly' },
{ name: 'Monthly', value: 'monthly' },
{ name: 'Yearly', value: 'yearly' },
{ name: 'Lifetime', value: 'lifetime' }
)
),
permission: PermissionLevel.User,
cooldown: 5,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
await interaction.deferReply();
const client = interaction.client as EllyClient;
const username = interaction.options.getString('username', true);
const mode = interaction.options.getString('mode') ?? 'all_modes';
const interval = (interaction.options.getString('interval') ?? 'lifetime') as Interval;
// Fetch profile and stats
const [profile, stats] = await Promise.all([
client.pikaAPI.getProfile(username),
client.pikaAPI.getSkyWarsStats(username, interval, mode),
]);
if (!profile) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Player Not Found')
.setDescription(`Could not find player **${username}** on PikaNetwork.`)
.setFooter({ text: 'Make sure the username is correct.' }),
],
});
return;
}
if (!stats) {
await interaction.editReply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Stats Not Found')
.setDescription(`Could not fetch SkyWars statistics for **${profile.username}**.`)
.setFooter({ text: 'The player may not have played SkyWars.' }),
],
});
return;
}
// Format mode name
const modeNames: Record<string, string> = {
all_modes: 'All Modes',
solo: 'Solo',
doubles: 'Doubles',
};
// Get rank color
const rankColors: Record<string, number> = {
owner: 0xaa0000,
manager: 0xaa0000,
developer: 0xff5555,
admin: 0xff5555,
srmod: 0x00aaaa,
moderator: 0x00aa00,
helper: 0x5555ff,
trial: 0x55ffff,
champion: 0xaa0000,
titan: 0xffaa00,
elite: 0x55ffff,
vip: 0x55ff55,
};
let embedColor = client.config.colors.primary;
let rankDisplay = 'Unranked';
for (const rank of profile.ranks) {
const rankName = rank.displayName.toLowerCase();
if (rankColors[rankName]) {
embedColor = rankColors[rankName];
rankDisplay = rank.displayName;
break;
}
}
// Build embed
const embed = new EmbedBuilder()
.setColor(embedColor)
.setTitle(`⚔️ ${profile.username}'s SkyWars Stats`)
.setThumbnail(`https://mc-heads.net/head/${profile.username}/right`)
.setDescription(
`**Rank:** ${rankDisplay}\n` +
`**Level:** ${profile.rank.level}\n` +
`**Clan:** ${profile.clan?.name ?? 'None'}`
)
.addFields(
// Combat stats
{ name: '⚔️ Kills', value: stats.kills.toLocaleString(), inline: true },
{ name: '💀 Deaths', value: stats.deaths.toLocaleString(), inline: true },
{ name: '📊 K/D', value: stats.kdr.toString(), inline: true },
// Game stats
{ name: '🏆 Wins', value: stats.wins.toLocaleString(), inline: true },
{ name: '❌ Losses', value: stats.losses.toLocaleString(), inline: true },
{ name: '📉 W/L', value: stats.wlr.toString(), inline: true },
// Other stats
{ name: '🎮 Games Played', value: stats.gamesPlayed.toLocaleString(), inline: true },
{ name: '🔥 Best Winstreak', value: stats.highestWinstreak.toLocaleString(), inline: true },
{ name: '🏹 Bow Kills', value: stats.bowKills.toLocaleString(), inline: true },
// Additional stats
{ name: '🗡️ Melee Kills', value: stats.meleeKills.toLocaleString(), inline: true },
{ name: '🕳️ Void Kills', value: stats.voidKills.toLocaleString(), inline: true },
{
name: '🎯 Arrow Accuracy',
value:
stats.arrowsShot > 0
? `${((stats.arrowsHit / stats.arrowsShot) * 100).toFixed(1)}%`
: 'N/A',
inline: true,
}
)
.setFooter({
text: `Mode: ${modeNames[mode]} | Interval: ${interval.charAt(0).toUpperCase() + interval.slice(1)} | Requested by ${interaction.user.tag}`,
iconURL: interaction.user.displayAvatarURL(),
})
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
},
};

View File

@@ -0,0 +1,988 @@
/**
* Suggestions Command Module
* Advanced suggestion management system with voting
*/
import {
SlashCommandBuilder,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
type ChatInputCommandInteraction,
ComponentType,
type ButtonInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { SuggestionRepository, type Suggestion } from '../../database/repositories/SuggestionRepository.ts';
export const suggestionsCommand: Command = {
data: new SlashCommandBuilder()
.setName('suggestions')
.setDescription('Advanced suggestion management')
// User commands
.addSubcommand((sub) =>
sub
.setName('create')
.setDescription('Create a new suggestion')
)
.addSubcommand((sub) =>
sub
.setName('view')
.setDescription('View a suggestion')
.addIntegerOption((opt) =>
opt.setName('id').setDescription('Suggestion number').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('edit')
.setDescription('Edit your suggestion')
.addIntegerOption((opt) =>
opt.setName('id').setDescription('Suggestion number').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('delete')
.setDescription('Delete your suggestion')
.addIntegerOption((opt) =>
opt.setName('id').setDescription('Suggestion number').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('my')
.setDescription('View your suggestions')
)
// Staff commands
.addSubcommand((sub) =>
sub
.setName('approve')
.setDescription('Approve a suggestion')
.addIntegerOption((opt) =>
opt.setName('id').setDescription('Suggestion number').setRequired(true)
)
.addStringOption((opt) =>
opt.setName('response').setDescription('Staff response')
)
)
.addSubcommand((sub) =>
sub
.setName('deny')
.setDescription('Deny a suggestion')
.addIntegerOption((opt) =>
opt.setName('id').setDescription('Suggestion number').setRequired(true)
)
.addStringOption((opt) =>
opt.setName('reason').setDescription('Reason for denial').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('consider')
.setDescription('Mark suggestion as under consideration')
.addIntegerOption((opt) =>
opt.setName('id').setDescription('Suggestion number').setRequired(true)
)
.addStringOption((opt) =>
opt.setName('note').setDescription('Staff note')
)
)
.addSubcommand((sub) =>
sub
.setName('implement')
.setDescription('Mark suggestion as implemented')
.addIntegerOption((opt) =>
opt.setName('id').setDescription('Suggestion number').setRequired(true)
)
.addStringOption((opt) =>
opt.setName('note').setDescription('Implementation note')
)
)
.addSubcommand((sub) =>
sub
.setName('list')
.setDescription('List suggestions')
.addStringOption((opt) =>
opt
.setName('status')
.setDescription('Filter by status')
.addChoices(
{ name: 'All', value: 'all' },
{ name: 'Pending', value: 'pending' },
{ name: 'Approved', value: 'approved' },
{ name: 'Denied', value: 'denied' },
{ name: 'Considering', value: 'considering' },
{ name: 'Implemented', value: 'implemented' }
)
)
.addStringOption((opt) =>
opt
.setName('sort')
.setDescription('Sort order')
.addChoices(
{ name: 'Newest', value: 'newest' },
{ name: 'Oldest', value: 'oldest' },
{ name: 'Most Votes', value: 'votes' },
{ name: 'Most Controversial', value: 'controversial' }
)
)
)
.addSubcommand((sub) =>
sub
.setName('stats')
.setDescription('View suggestion statistics')
)
.addSubcommand((sub) =>
sub
.setName('top')
.setDescription('View top voted suggestions')
),
permission: PermissionLevel.User,
cooldown: 5,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const subcommand = interaction.options.getSubcommand();
const repo = new SuggestionRepository(client.database);
switch (subcommand) {
case 'create':
await handleCreate(interaction, client, repo);
break;
case 'view':
await handleView(interaction, client, repo);
break;
case 'edit':
await handleEdit(interaction, client, repo);
break;
case 'delete':
await handleDelete(interaction, client, repo);
break;
case 'my':
await handleMy(interaction, client, repo);
break;
case 'approve':
await handleApprove(interaction, client, repo);
break;
case 'deny':
await handleDeny(interaction, client, repo);
break;
case 'consider':
await handleConsider(interaction, client, repo);
break;
case 'implement':
await handleImplement(interaction, client, repo);
break;
case 'list':
await handleList(interaction, client, repo);
break;
case 'stats':
await handleStats(interaction, client, repo);
break;
case 'top':
await handleTop(interaction, client, repo);
break;
}
},
};
async function handleCreate(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: SuggestionRepository
): Promise<void> {
// Check blacklist
const blacklists = client.database.get<Array<{ userId: string; type: string }>>('blacklists') ?? [];
const isBlacklisted = blacklists.some(
(b) => b.userId === interaction.user.id && (b.type === 'suggestions' || b.type === 'bot')
);
if (isBlacklisted) {
await interaction.reply({
content: '❌ You are blacklisted from creating suggestions.',
ephemeral: true,
});
return;
}
// Show modal
const modal = new ModalBuilder()
.setCustomId('suggestion:create')
.setTitle('Create Suggestion')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('title')
.setLabel('Title')
.setPlaceholder('Brief title for your suggestion')
.setStyle(TextInputStyle.Short)
.setMaxLength(100)
.setRequired(true)
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('description')
.setLabel('Description')
.setPlaceholder('Describe your suggestion in detail...')
.setStyle(TextInputStyle.Paragraph)
.setMinLength(20)
.setMaxLength(2000)
.setRequired(true)
)
);
await interaction.showModal(modal);
try {
const modalInteraction = await interaction.awaitModalSubmit({
time: 600000,
filter: (i) => i.customId === 'suggestion:create' && i.user.id === interaction.user.id,
});
await modalInteraction.deferReply({ ephemeral: true });
const title = modalInteraction.fields.getTextInputValue('title');
const description = modalInteraction.fields.getTextInputValue('description');
// Create suggestion
const suggestion = await repo.create({
userId: interaction.user.id,
title,
description,
});
// Post to suggestions channel
const channel = client.channels_cache.suggestions;
if (!channel) {
await modalInteraction.editReply({
content: '❌ Suggestions channel not found.',
});
return;
}
const embed = createSuggestionEmbed(suggestion, interaction.user, client);
const row = createVoteButtons(suggestion.id, 0, 0);
const message = await channel.send({
embeds: [embed],
components: [row],
});
// Update with message ID
await repo.update(suggestion.id, { messageId: message.id, channelId: channel.id });
// Set up vote collector
setupVoteCollector(message, suggestion.id, client, repo);
await modalInteraction.editReply({
content: `✅ Your suggestion has been posted!\n\n**Suggestion #${suggestion.orderNum}**\n${message.url}`,
});
} catch {
// Modal timed out
}
}
async function handleView(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: SuggestionRepository
): Promise<void> {
const orderNum = interaction.options.getInteger('id', true);
const suggestion = await repo.getByOrderNum(orderNum);
if (!suggestion) {
await interaction.reply({
content: `❌ Suggestion #${orderNum} not found.`,
ephemeral: true,
});
return;
}
let author;
try {
author = await client.users.fetch(suggestion.userId);
} catch {
author = null;
}
const embed = createDetailedSuggestionEmbed(suggestion, author, client);
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleEdit(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: SuggestionRepository
): Promise<void> {
const orderNum = interaction.options.getInteger('id', true);
const suggestion = await repo.getByOrderNum(orderNum);
if (!suggestion) {
await interaction.reply({
content: `❌ Suggestion #${orderNum} not found.`,
ephemeral: true,
});
return;
}
if (suggestion.userId !== interaction.user.id) {
await interaction.reply({
content: '❌ You can only edit your own suggestions.',
ephemeral: true,
});
return;
}
if (suggestion.status !== 'pending') {
await interaction.reply({
content: '❌ You can only edit pending suggestions.',
ephemeral: true,
});
return;
}
// Show edit modal
const modal = new ModalBuilder()
.setCustomId(`suggestion:edit:${suggestion.id}`)
.setTitle('Edit Suggestion')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('title')
.setLabel('Title')
.setValue(suggestion.title)
.setStyle(TextInputStyle.Short)
.setMaxLength(100)
.setRequired(true)
),
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('description')
.setLabel('Description')
.setValue(suggestion.description)
.setStyle(TextInputStyle.Paragraph)
.setMaxLength(2000)
.setRequired(true)
)
);
await interaction.showModal(modal);
try {
const modalInteraction = await interaction.awaitModalSubmit({
time: 600000,
filter: (i) => i.customId === `suggestion:edit:${suggestion.id}`,
});
const title = modalInteraction.fields.getTextInputValue('title');
const description = modalInteraction.fields.getTextInputValue('description');
await repo.update(suggestion.id, { title, description });
// Update message if exists
if (suggestion.messageId && suggestion.channelId) {
try {
const channel = await client.channels.fetch(suggestion.channelId);
if (channel?.isTextBased()) {
const message = await channel.messages.fetch(suggestion.messageId);
const embed = EmbedBuilder.from(message.embeds[0])
.setTitle(`💡 Suggestion #${suggestion.orderNum}`)
.setDescription(`**${title}**\n\n${description}`)
.setFooter({ text: `Edited • ID: ${suggestion.id}` });
await message.edit({ embeds: [embed] });
}
} catch {
// Message might be deleted
}
}
await modalInteraction.reply({
content: '✅ Suggestion updated!',
ephemeral: true,
});
} catch {
// Modal timed out
}
}
async function handleDelete(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: SuggestionRepository
): Promise<void> {
const orderNum = interaction.options.getInteger('id', true);
const suggestion = await repo.getByOrderNum(orderNum);
if (!suggestion) {
await interaction.reply({
content: `❌ Suggestion #${orderNum} not found.`,
ephemeral: true,
});
return;
}
const member = interaction.guild?.members.cache.get(interaction.user.id);
const isStaff = member && client.permissions.hasPermission(member, PermissionLevel.Officer);
if (suggestion.userId !== interaction.user.id && !isStaff) {
await interaction.reply({
content: '❌ You can only delete your own suggestions.',
ephemeral: true,
});
return;
}
// Delete message if exists
if (suggestion.messageId && suggestion.channelId) {
try {
const channel = await client.channels.fetch(suggestion.channelId);
if (channel?.isTextBased()) {
const message = await channel.messages.fetch(suggestion.messageId);
await message.delete();
}
} catch {
// Message might already be deleted
}
}
await repo.delete(suggestion.id);
await interaction.reply({
content: `✅ Suggestion #${orderNum} has been deleted.`,
ephemeral: true,
});
}
async function handleMy(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: SuggestionRepository
): Promise<void> {
const suggestions = await repo.getByUserId(interaction.user.id);
if (suggestions.length === 0) {
await interaction.reply({
content: '📭 You have no suggestions.',
ephemeral: true,
});
return;
}
const statusEmoji: Record<string, string> = {
pending: '⏳',
approved: '✅',
denied: '❌',
considering: '🤔',
implemented: '🎉',
};
const list = suggestions.slice(0, 10).map((s) => {
const emoji = statusEmoji[s.status] ?? '❓';
const votes = (s.upvotes ?? 0) - (s.downvotes ?? 0);
const voteStr = votes >= 0 ? `+${votes}` : String(votes);
return `${emoji} **#${s.orderNum}** - ${s.title.substring(0, 40)}... (${voteStr} votes)`;
});
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📋 Your Suggestions')
.setDescription(list.join('\n'))
.setFooter({ text: `Total: ${suggestions.length} suggestions` })
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleApprove(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: SuggestionRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to approve suggestions.',
ephemeral: true,
});
return;
}
const orderNum = interaction.options.getInteger('id', true);
const response = interaction.options.getString('response');
const suggestion = await repo.getByOrderNum(orderNum);
if (!suggestion) {
await interaction.reply({
content: `❌ Suggestion #${orderNum} not found.`,
ephemeral: true,
});
return;
}
await repo.updateStatus(suggestion.id, 'approved', interaction.user.id, response ?? undefined);
await updateSuggestionMessage(suggestion, 'approved', client, repo, interaction.user.tag, response ?? undefined);
// Notify author
await notifyAuthor(client, suggestion.userId, suggestion, 'approved', response);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(0x57f287)
.setTitle('✅ Suggestion Approved')
.setDescription(`Suggestion #${orderNum} has been approved.`)
.setTimestamp(),
],
});
}
async function handleDeny(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: SuggestionRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to deny suggestions.',
ephemeral: true,
});
return;
}
const orderNum = interaction.options.getInteger('id', true);
const reason = interaction.options.getString('reason', true);
const suggestion = await repo.getByOrderNum(orderNum);
if (!suggestion) {
await interaction.reply({
content: `❌ Suggestion #${orderNum} not found.`,
ephemeral: true,
});
return;
}
await repo.updateStatus(suggestion.id, 'denied', interaction.user.id, reason);
await updateSuggestionMessage(suggestion, 'denied', client, repo, interaction.user.tag, reason);
// Notify author
await notifyAuthor(client, suggestion.userId, suggestion, 'denied', reason);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(0xed4245)
.setTitle('❌ Suggestion Denied')
.setDescription(`Suggestion #${orderNum} has been denied.`)
.addFields({ name: 'Reason', value: reason })
.setTimestamp(),
],
});
}
async function handleConsider(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: SuggestionRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission.',
ephemeral: true,
});
return;
}
const orderNum = interaction.options.getInteger('id', true);
const note = interaction.options.getString('note');
const suggestion = await repo.getByOrderNum(orderNum);
if (!suggestion) {
await interaction.reply({
content: `❌ Suggestion #${orderNum} not found.`,
ephemeral: true,
});
return;
}
await repo.updateStatus(suggestion.id, 'considering', interaction.user.id, note ?? undefined);
await updateSuggestionMessage(suggestion, 'considering', client, repo, interaction.user.tag, note ?? undefined);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(0xf39c12)
.setTitle('🤔 Suggestion Under Consideration')
.setDescription(`Suggestion #${orderNum} is now under consideration.`)
.setTimestamp(),
],
});
}
async function handleImplement(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: SuggestionRepository
): Promise<void> {
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission.',
ephemeral: true,
});
return;
}
const orderNum = interaction.options.getInteger('id', true);
const note = interaction.options.getString('note');
const suggestion = await repo.getByOrderNum(orderNum);
if (!suggestion) {
await interaction.reply({
content: `❌ Suggestion #${orderNum} not found.`,
ephemeral: true,
});
return;
}
await repo.updateStatus(suggestion.id, 'implemented', interaction.user.id, note ?? undefined);
await updateSuggestionMessage(suggestion, 'implemented', client, repo, interaction.user.tag, note ?? undefined);
// Notify author
await notifyAuthor(client, suggestion.userId, suggestion, 'implemented', note);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(0x9b59b6)
.setTitle('🎉 Suggestion Implemented')
.setDescription(`Suggestion #${orderNum} has been implemented!`)
.setTimestamp(),
],
});
}
async function handleList(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: SuggestionRepository
): Promise<void> {
const status = interaction.options.getString('status') ?? 'pending';
const sort = interaction.options.getString('sort') ?? 'newest';
let suggestions = await repo.getAll();
// Filter
if (status !== 'all') {
suggestions = suggestions.filter((s) => s.status === status);
}
// Sort
switch (sort) {
case 'oldest':
suggestions.sort((a, b) => a.createdAt - b.createdAt);
break;
case 'votes':
suggestions.sort((a, b) => ((b.upvotes ?? 0) - (b.downvotes ?? 0)) - ((a.upvotes ?? 0) - (a.downvotes ?? 0)));
break;
case 'controversial':
suggestions.sort((a, b) => Math.min(b.upvotes ?? 0, b.downvotes ?? 0) - Math.min(a.upvotes ?? 0, a.downvotes ?? 0));
break;
default:
suggestions.sort((a, b) => b.createdAt - a.createdAt);
}
if (suggestions.length === 0) {
await interaction.reply({
content: `📭 No ${status === 'all' ? '' : status} suggestions found.`,
ephemeral: true,
});
return;
}
const statusEmoji: Record<string, string> = {
pending: '⏳',
approved: '✅',
denied: '❌',
considering: '🤔',
implemented: '🎉',
};
const list = suggestions.slice(0, 15).map((s) => {
const emoji = statusEmoji[s.status] ?? '❓';
const votes = (s.upvotes ?? 0) - (s.downvotes ?? 0);
const voteStr = votes >= 0 ? `+${votes}` : String(votes);
return `${emoji} **#${s.orderNum}** - ${s.title.substring(0, 35)}... (${voteStr})`;
});
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle(`📋 ${status === 'all' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)} Suggestions`)
.setDescription(list.join('\n'))
.setFooter({ text: `Showing ${Math.min(15, suggestions.length)} of ${suggestions.length}` })
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleStats(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: SuggestionRepository
): Promise<void> {
const suggestions = await repo.getAll();
const stats = {
total: suggestions.length,
pending: suggestions.filter((s) => s.status === 'pending').length,
approved: suggestions.filter((s) => s.status === 'approved').length,
denied: suggestions.filter((s) => s.status === 'denied').length,
considering: suggestions.filter((s) => s.status === 'considering').length,
implemented: suggestions.filter((s) => s.status === 'implemented').length,
};
const totalVotes = suggestions.reduce((sum, s) => sum + (s.upvotes ?? 0) + (s.downvotes ?? 0), 0);
const avgVotes = suggestions.length > 0 ? Math.round(totalVotes / suggestions.length) : 0;
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📊 Suggestion Statistics')
.addFields(
{ name: '📋 Total', value: String(stats.total), inline: true },
{ name: '⏳ Pending', value: String(stats.pending), inline: true },
{ name: '✅ Approved', value: String(stats.approved), inline: true },
{ name: '❌ Denied', value: String(stats.denied), inline: true },
{ name: '🤔 Considering', value: String(stats.considering), inline: true },
{ name: '🎉 Implemented', value: String(stats.implemented), inline: true },
{ name: '👍 Total Votes', value: String(totalVotes), inline: true },
{ name: '📈 Avg Votes/Suggestion', value: String(avgVotes), inline: true }
)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleTop(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: SuggestionRepository
): Promise<void> {
const suggestions = await repo.getAll();
const sorted = suggestions
.map((s) => ({ ...s, score: (s.upvotes ?? 0) - (s.downvotes ?? 0) }))
.sort((a, b) => b.score - a.score)
.slice(0, 10);
if (sorted.length === 0) {
await interaction.reply({
content: '📭 No suggestions yet.',
ephemeral: true,
});
return;
}
const medals = ['🥇', '🥈', '🥉'];
const list = sorted.map((s, i) => {
const medal = medals[i] ?? `**${i + 1}.**`;
const scoreStr = s.score >= 0 ? `+${s.score}` : String(s.score);
return `${medal} **#${s.orderNum}** - ${s.title.substring(0, 35)}... (${scoreStr} votes)`;
});
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('🏆 Top Voted Suggestions')
.setDescription(list.join('\n'))
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
// Helper functions
function createSuggestionEmbed(
suggestion: Suggestion,
author: import('discord.js').User,
client: EllyClient
): EmbedBuilder {
return new EmbedBuilder()
.setColor(0x3498db)
.setTitle(`💡 Suggestion #${suggestion.orderNum}`)
.setDescription(`**${suggestion.title}**\n\n${suggestion.description}`)
.setAuthor({ name: author.tag, iconURL: author.displayAvatarURL() })
.setFooter({ text: `ID: ${suggestion.id} • Vote using the buttons below!` })
.setTimestamp();
}
function createDetailedSuggestionEmbed(
suggestion: Suggestion,
author: import('discord.js').User | null,
client: EllyClient
): EmbedBuilder {
const statusConfig: Record<string, { color: number; emoji: string }> = {
pending: { color: 0x3498db, emoji: '⏳' },
approved: { color: 0x57f287, emoji: '✅' },
denied: { color: 0xed4245, emoji: '❌' },
considering: { color: 0xf39c12, emoji: '🤔' },
implemented: { color: 0x9b59b6, emoji: '🎉' },
};
const config = statusConfig[suggestion.status] ?? statusConfig.pending;
const votes = (suggestion.upvotes ?? 0) - (suggestion.downvotes ?? 0);
const embed = new EmbedBuilder()
.setColor(config.color)
.setTitle(`${config.emoji} Suggestion #${suggestion.orderNum}`)
.setDescription(`**${suggestion.title}**\n\n${suggestion.description}`)
.addFields(
{ name: 'Status', value: suggestion.status.charAt(0).toUpperCase() + suggestion.status.slice(1), inline: true },
{ name: 'Author', value: author ? author.tag : `<@${suggestion.userId}>`, inline: true },
{ name: 'Votes', value: `👍 ${suggestion.upvotes ?? 0} | 👎 ${suggestion.downvotes ?? 0} (${votes >= 0 ? '+' : ''}${votes})`, inline: true },
{ name: 'Created', value: `<t:${Math.floor(suggestion.createdAt / 1000)}:R>`, inline: true }
);
if (suggestion.reviewedBy) {
embed.addFields({ name: 'Reviewed By', value: `<@${suggestion.reviewedBy}>`, inline: true });
}
if (suggestion.reviewReason) {
embed.addFields({ name: 'Staff Response', value: suggestion.reviewReason });
}
return embed;
}
function createVoteButtons(suggestionId: string, upvotes: number, downvotes: number): ActionRowBuilder<ButtonBuilder> {
return new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`suggestion:upvote:${suggestionId}`)
.setLabel(`${upvotes}`)
.setStyle(ButtonStyle.Success)
.setEmoji('👍'),
new ButtonBuilder()
.setCustomId(`suggestion:downvote:${suggestionId}`)
.setLabel(`${downvotes}`)
.setStyle(ButtonStyle.Danger)
.setEmoji('👎')
);
}
function setupVoteCollector(
message: import('discord.js').Message,
suggestionId: string,
client: EllyClient,
repo: SuggestionRepository
): void {
const collector = message.createMessageComponentCollector({
componentType: ComponentType.Button,
time: 30 * 24 * 60 * 60 * 1000, // 30 days
});
collector.on('collect', async (i: ButtonInteraction) => {
const [, action] = i.customId.split(':');
if (action === 'upvote' || action === 'downvote') {
const voteType = action === 'upvote' ? 'up' : 'down';
const result = await repo.vote(suggestionId, i.user.id, voteType);
if (!result) {
await i.reply({ content: '❌ Failed to record vote.', ephemeral: true });
return;
}
// Update buttons
const row = createVoteButtons(suggestionId, result.upvotes, result.downvotes);
await message.edit({ components: [row] });
await i.reply({
content: `✅ Vote recorded! (${voteType === 'up' ? '👍' : '👎'})`,
ephemeral: true,
});
}
});
}
async function updateSuggestionMessage(
suggestion: Suggestion,
status: string,
client: EllyClient,
repo: SuggestionRepository,
reviewerTag: string,
response?: string
): Promise<void> {
if (!suggestion.messageId || !suggestion.channelId) return;
try {
const channel = await client.channels.fetch(suggestion.channelId);
if (!channel?.isTextBased()) return;
const message = await channel.messages.fetch(suggestion.messageId);
const statusConfig: Record<string, { color: number; emoji: string }> = {
approved: { color: 0x57f287, emoji: '✅' },
denied: { color: 0xed4245, emoji: '❌' },
considering: { color: 0xf39c12, emoji: '🤔' },
implemented: { color: 0x9b59b6, emoji: '🎉' },
};
const config = statusConfig[status] ?? { color: 0x3498db, emoji: '💡' };
const embed = EmbedBuilder.from(message.embeds[0])
.setColor(config.color)
.setTitle(`${config.emoji} Suggestion #${suggestion.orderNum} - ${status.charAt(0).toUpperCase() + status.slice(1)}`)
.addFields({ name: 'Reviewed By', value: reviewerTag, inline: true });
if (response) {
embed.addFields({ name: 'Staff Response', value: response });
}
await message.edit({ embeds: [embed] });
} catch {
// Message might be deleted
}
}
async function notifyAuthor(
client: EllyClient,
userId: string,
suggestion: Suggestion,
status: string,
response?: string | null
): Promise<void> {
try {
const user = await client.users.fetch(userId);
const statusConfig: Record<string, { color: number; title: string }> = {
approved: { color: 0x57f287, title: '✅ Suggestion Approved!' },
denied: { color: 0xed4245, title: '❌ Suggestion Denied' },
implemented: { color: 0x9b59b6, title: '🎉 Suggestion Implemented!' },
};
const config = statusConfig[status];
if (!config) return;
const embed = new EmbedBuilder()
.setColor(config.color)
.setTitle(config.title)
.setDescription(`Your suggestion **#${suggestion.orderNum}** has been ${status}!`)
.addFields({ name: 'Suggestion', value: suggestion.title });
if (response) {
embed.addFields({ name: 'Staff Response', value: response });
}
await user.send({ embeds: [embed] });
} catch {
// User might have DMs disabled
}
}

View File

@@ -0,0 +1,354 @@
/**
* Away Command
* Manage away status for guild members
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
type GuildMember,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { AwayRepository } from '../../database/repositories/AwayRepository.ts';
import { parseTime, formatDuration, discordTimestamp } from '../../utils/time.ts';
export const awayCommand: Command = {
data: new SlashCommandBuilder()
.setName('away')
.setDescription('Manage away status')
.addSubcommand((subcommand) =>
subcommand
.setName('add')
.setDescription('Set a member as away')
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user to set as away')
.setRequired(true)
)
.addStringOption((option) =>
option
.setName('duration')
.setDescription('How long they will be away (e.g., 7d, 2w)')
.setRequired(true)
)
.addStringOption((option) =>
option
.setName('reason')
.setDescription('Reason for being away')
.setRequired(true)
.setMaxLength(500)
)
.addStringOption((option) =>
option
.setName('minecraft')
.setDescription('Their Minecraft username')
.setRequired(false)
)
)
.addSubcommand((subcommand) =>
subcommand
.setName('remove')
.setDescription('Remove away status from a member')
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user to remove away status from')
.setRequired(true)
)
)
.addSubcommand((subcommand) =>
subcommand
.setName('list')
.setDescription('List all members currently away')
)
.addSubcommand((subcommand) =>
subcommand
.setName('check')
.setDescription('Check away status of a member')
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user to check')
.setRequired(true)
)
),
permission: PermissionLevel.Officer,
cooldown: 5,
guildOnly: true,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const awayRepo = new AwayRepository(client.database);
const subcommand = interaction.options.getSubcommand();
switch (subcommand) {
case 'add':
await handleAdd(interaction, client, awayRepo);
break;
case 'remove':
await handleRemove(interaction, client, awayRepo);
break;
case 'list':
await handleList(interaction, client, awayRepo);
break;
case 'check':
await handleCheck(interaction, client, awayRepo);
break;
}
},
};
/**
* Handle adding away status
*/
async function handleAdd(
interaction: ChatInputCommandInteraction,
client: EllyClient,
awayRepo: AwayRepository
): Promise<void> {
const targetUser = interaction.options.getUser('user', true);
const durationStr = interaction.options.getString('duration', true);
const reason = interaction.options.getString('reason', true);
const minecraft = interaction.options.getString('minecraft');
// Parse duration
const durationMs = parseTime(durationStr);
if (!durationMs) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Invalid Duration')
.setDescription('Could not parse the duration. Use formats like `7d`, `2w`, `1mo`.'),
],
ephemeral: true,
});
return;
}
// Check max duration
const maxDurationMs = client.config.limits.away_max_days * 24 * 60 * 60 * 1000;
if (durationMs > maxDurationMs) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Duration Too Long')
.setDescription(
`Away status cannot exceed ${client.config.limits.away_max_days} days.`
),
],
ephemeral: true,
});
return;
}
// Check if already away
if (awayRepo.isAway(targetUser.id)) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.warning)
.setTitle('⚠️ Already Away')
.setDescription(`${targetUser} is already marked as away. Remove their status first.`),
],
ephemeral: true,
});
return;
}
// Create away status
const expiresAt = new Date(Date.now() + durationMs);
awayRepo.create({
userId: targetUser.id,
minecraftUsername: minecraft ?? undefined,
reason,
expiresAt: expiresAt.toISOString(),
});
// Try to add away role
if (interaction.guild && client.roles.away) {
try {
const member = await interaction.guild.members.fetch(targetUser.id);
await member.roles.add(client.roles.away);
} catch (error) {
client.logger.warn(`Could not add away role to ${targetUser.tag}`, error);
}
}
// Send to inactivity channel if configured
if (client.channels_cache.inactivity) {
const noticeEmbed = new EmbedBuilder()
.setColor(0xffa500)
.setTitle('📋 Inactivity Notice')
.setThumbnail(targetUser.displayAvatarURL())
.addFields(
{ name: 'Member', value: `${targetUser}`, inline: true },
{ name: 'Duration', value: formatDuration(durationMs), inline: true },
{ name: 'Returns', value: discordTimestamp(expiresAt, 'R'), inline: true },
{ name: 'Reason', value: reason, inline: false }
)
.setFooter({
text: `Set by ${interaction.user.tag}`,
iconURL: interaction.user.displayAvatarURL(),
})
.setTimestamp();
if (minecraft) {
noticeEmbed.addFields({ name: 'Minecraft', value: minecraft, inline: true });
}
await client.channels_cache.inactivity.send({ embeds: [noticeEmbed] });
}
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Away Status Set')
.setDescription(`${targetUser} has been marked as away.`)
.addFields(
{ name: 'Duration', value: formatDuration(durationMs), inline: true },
{ name: 'Returns', value: discordTimestamp(expiresAt, 'R'), inline: true },
{ name: 'Reason', value: reason, inline: false }
),
],
});
}
/**
* Handle removing away status
*/
async function handleRemove(
interaction: ChatInputCommandInteraction,
client: EllyClient,
awayRepo: AwayRepository
): Promise<void> {
const targetUser = interaction.options.getUser('user', true);
if (!awayRepo.isAway(targetUser.id)) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Not Away')
.setDescription(`${targetUser} is not marked as away.`),
],
ephemeral: true,
});
return;
}
awayRepo.delete(targetUser.id);
// Try to remove away role
if (interaction.guild && client.roles.away) {
try {
const member = await interaction.guild.members.fetch(targetUser.id);
await member.roles.remove(client.roles.away);
} catch (error) {
client.logger.warn(`Could not remove away role from ${targetUser.tag}`, error);
}
}
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Away Status Removed')
.setDescription(`${targetUser} is no longer marked as away.`),
],
});
}
/**
* Handle listing away members
*/
async function handleList(
interaction: ChatInputCommandInteraction,
client: EllyClient,
awayRepo: AwayRepository
): Promise<void> {
const allAway = awayRepo.getAll().filter((s) => new Date(s.expiresAt) > new Date());
if (allAway.length === 0) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.info)
.setTitle('📋 Away Members')
.setDescription('No members are currently away.'),
],
});
return;
}
// Sort by expiry date
allAway.sort((a, b) => new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime());
const awayList = allAway
.slice(0, 15)
.map((s) => {
const reason = s.reason.length > 30 ? s.reason.slice(0, 27) + '...' : s.reason;
return `<@${s.userId}> - Returns ${discordTimestamp(new Date(s.expiresAt), 'R')}\n└ ${reason}`;
})
.join('\n\n');
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.info)
.setTitle('📋 Away Members')
.setDescription(awayList)
.setFooter({
text: `Total: ${allAway.length} member${allAway.length !== 1 ? 's' : ''} away`,
}),
],
});
}
/**
* Handle checking away status
*/
async function handleCheck(
interaction: ChatInputCommandInteraction,
client: EllyClient,
awayRepo: AwayRepository
): Promise<void> {
const targetUser = interaction.options.getUser('user', true);
const status = awayRepo.getByUserId(targetUser.id);
if (!status || new Date(status.expiresAt) <= new Date()) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.info)
.setTitle(' Away Status')
.setDescription(`${targetUser} is not currently away.`),
],
});
return;
}
const embed = new EmbedBuilder()
.setColor(0xffa500)
.setTitle('📋 Away Status')
.setThumbnail(targetUser.displayAvatarURL())
.addFields(
{ name: 'Member', value: `${targetUser}`, inline: true },
{ name: 'Returns', value: discordTimestamp(new Date(status.expiresAt), 'R'), inline: true },
{ name: 'Reason', value: status.reason, inline: false }
)
.setTimestamp(new Date(status.createdAt));
if (status.minecraftUsername) {
embed.addFields({ name: 'Minecraft', value: status.minecraftUsername, inline: true });
}
await interaction.reply({ embeds: [embed] });
}

View File

@@ -0,0 +1,379 @@
/**
* Champion Command
* Manage champion role assignments
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
type GuildMember,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { ChampionRepository } from '../../database/repositories/ChampionRepository.ts';
import { parseDuration, formatDuration } from '../../utils/time.ts';
export const championCommand: Command = {
data: new SlashCommandBuilder()
.setName('champion')
.setDescription('Manage champion role')
.addSubcommand((sub) =>
sub
.setName('add')
.setDescription('Give champion role to a user')
.addUserOption((opt) =>
opt.setName('user').setDescription('User to give champion to').setRequired(true)
)
.addStringOption((opt) =>
opt.setName('duration').setDescription('Duration (e.g., 30d, 1w)').setRequired(true)
)
.addStringOption((opt) =>
opt.setName('reason').setDescription('Reason for champion').setRequired(false)
)
)
.addSubcommand((sub) =>
sub
.setName('remove')
.setDescription('Remove champion role from a user')
.addUserOption((opt) =>
opt.setName('user').setDescription('User to remove champion from').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('extend')
.setDescription('Extend champion duration')
.addUserOption((opt) =>
opt.setName('user').setDescription('User to extend champion for').setRequired(true)
)
.addStringOption((opt) =>
opt.setName('duration').setDescription('Additional duration (e.g., 7d)').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('check')
.setDescription('Check champion status')
.addUserOption((opt) =>
opt.setName('user').setDescription('User to check').setRequired(false)
)
)
.addSubcommand((sub) =>
sub
.setName('list')
.setDescription('List all active champions')
),
permission: PermissionLevel.User,
cooldown: 5,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const subcommand = interaction.options.getSubcommand();
const repo = new ChampionRepository(client.database);
switch (subcommand) {
case 'add':
await handleAdd(interaction, client, repo);
break;
case 'remove':
await handleRemove(interaction, client, repo);
break;
case 'extend':
await handleExtend(interaction, client, repo);
break;
case 'check':
await handleCheck(interaction, client, repo);
break;
case 'list':
await handleList(interaction, client, repo);
break;
}
},
};
/**
* Handle adding champion
*/
async function handleAdd(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ChampionRepository
): Promise<void> {
// Check permission
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to add champions.',
ephemeral: true,
});
return;
}
const targetUser = interaction.options.getUser('user', true);
const durationStr = interaction.options.getString('duration', true);
const reason = interaction.options.getString('reason') ?? 'No reason provided';
// Parse duration
const durationMs = parseDuration(durationStr);
if (!durationMs || durationMs <= 0) {
await interaction.reply({
content: '❌ Invalid duration. Use formats like `7d`, `2w`, `30d`.',
ephemeral: true,
});
return;
}
// Check max duration
const maxDays = client.config.limits.championMaxDays;
const maxMs = maxDays * 24 * 60 * 60 * 1000;
if (durationMs > maxMs) {
await interaction.reply({
content: `❌ Maximum champion duration is ${maxDays} days.`,
ephemeral: true,
});
return;
}
// Check if already champion
const existing = await repo.getByUserId(targetUser.id);
if (existing) {
await interaction.reply({
content: `${targetUser.tag} is already a champion. Use \`/champion extend\` to extend their duration.`,
ephemeral: true,
});
return;
}
await interaction.deferReply();
// Add to database
const champion = await repo.add({
oderId: targetUser.id,
assignedBy: interaction.user.id,
reason,
startDate: Date.now(),
endDate: Date.now() + durationMs,
});
// Add role
const targetMember = interaction.guild?.members.cache.get(targetUser.id);
const championRole = interaction.guild?.roles.cache.find(
(r) => r.name === client.config.roles.champion
);
if (targetMember && championRole) {
try {
await targetMember.roles.add(championRole);
} catch (error) {
console.error('Failed to add champion role:', error);
}
}
const embed = new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('🏆 Champion Added')
.setThumbnail(targetUser.displayAvatarURL())
.addFields(
{ name: '👤 User', value: targetUser.tag, inline: true },
{ name: '⏱️ Duration', value: formatDuration(durationMs), inline: true },
{ name: '📅 Expires', value: `<t:${Math.floor(champion.endDate / 1000)}:R>`, inline: true },
{ name: '📝 Reason', value: reason }
)
.setFooter({ text: `Added by ${interaction.user.tag}` })
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
}
/**
* Handle removing champion
*/
async function handleRemove(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ChampionRepository
): Promise<void> {
// Check permission
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to remove champions.',
ephemeral: true,
});
return;
}
const targetUser = interaction.options.getUser('user', true);
const removed = await repo.remove(targetUser.id);
if (!removed) {
await interaction.reply({
content: `${targetUser.tag} is not a champion.`,
ephemeral: true,
});
return;
}
// Remove role
const targetMember = interaction.guild?.members.cache.get(targetUser.id);
const championRole = interaction.guild?.roles.cache.find(
(r) => r.name === client.config.roles.champion
);
if (targetMember && championRole) {
try {
await targetMember.roles.remove(championRole);
} catch (error) {
console.error('Failed to remove champion role:', error);
}
}
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.warning)
.setTitle('🏆 Champion Removed')
.setDescription(`${targetUser.tag} is no longer a champion.`)
.setFooter({ text: `Removed by ${interaction.user.tag}` })
.setTimestamp(),
],
});
}
/**
* Handle extending champion
*/
async function handleExtend(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ChampionRepository
): Promise<void> {
// Check permission
const member = interaction.guild?.members.cache.get(interaction.user.id);
if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) {
await interaction.reply({
content: '❌ You need Officer permission to extend champions.',
ephemeral: true,
});
return;
}
const targetUser = interaction.options.getUser('user', true);
const durationStr = interaction.options.getString('duration', true);
// Parse duration
const durationMs = parseDuration(durationStr);
if (!durationMs || durationMs <= 0) {
await interaction.reply({
content: '❌ Invalid duration. Use formats like `7d`, `2w`, `30d`.',
ephemeral: true,
});
return;
}
const additionalDays = Math.ceil(durationMs / (24 * 60 * 60 * 1000));
const champion = await repo.extend(targetUser.id, additionalDays);
if (!champion) {
await interaction.reply({
content: `${targetUser.tag} is not a champion.`,
ephemeral: true,
});
return;
}
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('🏆 Champion Extended')
.setDescription(`${targetUser.tag}'s champion status has been extended.`)
.addFields(
{ name: '⏱️ Added', value: formatDuration(durationMs), inline: true },
{ name: '📅 New Expiry', value: `<t:${Math.floor(champion.endDate / 1000)}:R>`, inline: true }
)
.setFooter({ text: `Extended by ${interaction.user.tag}` })
.setTimestamp(),
],
});
}
/**
* Handle checking champion status
*/
async function handleCheck(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ChampionRepository
): Promise<void> {
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const champion = await repo.getByUserId(targetUser.id);
if (!champion) {
await interaction.reply({
content: `${targetUser.id === interaction.user.id ? 'You are' : `${targetUser.tag} is`} not a champion.`,
ephemeral: true,
});
return;
}
const remainingDays = await repo.getRemainingDays(targetUser.id);
const assigner = await client.users.fetch(champion.assignedBy).catch(() => null);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('🏆 Champion Status')
.setThumbnail(targetUser.displayAvatarURL())
.addFields(
{ name: '👤 User', value: targetUser.tag, inline: true },
{ name: '📅 Remaining', value: `${remainingDays} days`, inline: true },
{ name: '⏰ Expires', value: `<t:${Math.floor(champion.endDate / 1000)}:F>`, inline: false },
{ name: '👑 Assigned By', value: assigner?.tag ?? 'Unknown', inline: true },
{ name: '📝 Reason', value: champion.reason ?? 'No reason provided', inline: false }
)
.setTimestamp(),
],
});
}
/**
* Handle listing champions
*/
async function handleList(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ChampionRepository
): Promise<void> {
const champions = await repo.getActive();
if (champions.length === 0) {
await interaction.reply({
content: '📭 No active champions.',
ephemeral: true,
});
return;
}
const lines: string[] = [];
for (const champ of champions) {
const user = await client.users.fetch(champ.userId).catch(() => null);
const remaining = Math.ceil((champ.endDate - Date.now()) / (24 * 60 * 60 * 1000));
lines.push(`• **${user?.tag ?? 'Unknown'}** - ${remaining} days remaining`);
}
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('🏆 Active Champions')
.setDescription(lines.join('\n'))
.setFooter({ text: `Total: ${champions.length} champions` })
.setTimestamp(),
],
});
}

View File

@@ -0,0 +1,265 @@
/**
* Remind Command
* Set personal reminders
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { ReminderRepository } from '../../database/repositories/ReminderRepository.ts';
import { parseTime, formatDuration, discordTimestamp } from '../../utils/time.ts';
export const remindCommand: Command = {
data: new SlashCommandBuilder()
.setName('remind')
.setDescription('Set a personal reminder')
.addSubcommand((subcommand) =>
subcommand
.setName('set')
.setDescription('Set a new reminder')
.addStringOption((option) =>
option
.setName('duration')
.setDescription('When to remind you (e.g., 15m, 2h, 1d)')
.setRequired(true)
)
.addStringOption((option) =>
option
.setName('text')
.setDescription('What to remind you about')
.setRequired(true)
.setMaxLength(500)
)
)
.addSubcommand((subcommand) =>
subcommand.setName('list').setDescription('List your active reminders')
)
.addSubcommand((subcommand) =>
subcommand
.setName('cancel')
.setDescription('Cancel a reminder')
.addStringOption((option) =>
option
.setName('id')
.setDescription('The reminder ID to cancel')
.setRequired(true)
)
),
permission: PermissionLevel.User,
cooldown: 3,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const subcommand = interaction.options.getSubcommand();
const reminderRepo = new ReminderRepository(client.database);
switch (subcommand) {
case 'set':
await handleSet(interaction, client, reminderRepo);
break;
case 'list':
await handleList(interaction, client, reminderRepo);
break;
case 'cancel':
await handleCancel(interaction, client, reminderRepo);
break;
}
},
};
/**
* Handle setting a new reminder
*/
async function handleSet(
interaction: ChatInputCommandInteraction,
client: EllyClient,
reminderRepo: ReminderRepository
): Promise<void> {
const durationStr = interaction.options.getString('duration', true);
const text = interaction.options.getString('text', true);
// Parse duration
const durationMs = parseTime(durationStr);
if (!durationMs) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Invalid Duration')
.setDescription(
'Could not parse the duration. Use formats like:\n' +
'• `15m` - 15 minutes\n' +
'• `2h` - 2 hours\n' +
'• `1d` - 1 day\n' +
'• `1d 2h 30m` - 1 day, 2 hours, 30 minutes'
),
],
ephemeral: true,
});
return;
}
// Check max duration
const maxDurationMs = client.config.limits.reminder_max_duration_days * 24 * 60 * 60 * 1000;
if (durationMs > maxDurationMs) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Duration Too Long')
.setDescription(
`Reminders cannot be set for more than ${client.config.limits.reminder_max_duration_days} days.`
),
],
ephemeral: true,
});
return;
}
// Check reminder limit
const existingReminders = reminderRepo.countByUserId(interaction.user.id);
if (existingReminders >= 25) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Too Many Reminders')
.setDescription('You can only have up to 25 active reminders. Cancel some first.'),
],
ephemeral: true,
});
return;
}
// Create reminder
const remindAt = new Date(Date.now() + durationMs);
const reminder = reminderRepo.create({
id: ReminderRepository.generateId(),
userId: interaction.user.id,
channelId: interaction.channelId,
reminderText: text,
remindAt: remindAt.toISOString(),
isRecurring: false,
});
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('⏰ Reminder Set')
.setDescription(`I'll remind you about:\n\`\`\`${text}\`\`\``)
.addFields(
{ name: 'When', value: discordTimestamp(remindAt, 'R'), inline: true },
{ name: 'Duration', value: formatDuration(durationMs), inline: true },
{ name: 'ID', value: `\`${reminder.id}\``, inline: true }
)
.setFooter({ text: `Reminder ID: ${reminder.id}` })
.setTimestamp(),
],
});
}
/**
* Handle listing reminders
*/
async function handleList(
interaction: ChatInputCommandInteraction,
client: EllyClient,
reminderRepo: ReminderRepository
): Promise<void> {
const reminders = reminderRepo.getByUserId(interaction.user.id);
if (reminders.length === 0) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.info)
.setTitle('📋 Your Reminders')
.setDescription("You don't have any active reminders."),
],
ephemeral: true,
});
return;
}
// Sort by remind time
reminders.sort((a, b) => new Date(a.remindAt).getTime() - new Date(b.remindAt).getTime());
const reminderList = reminders
.slice(0, 10)
.map((r, i) => {
const remindAt = new Date(r.remindAt);
const text = r.reminderText.length > 50 ? r.reminderText.slice(0, 47) + '...' : r.reminderText;
return `**${i + 1}.** ${discordTimestamp(remindAt, 'R')}\n└ \`${r.id}\`: ${text}`;
})
.join('\n\n');
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.info)
.setTitle('📋 Your Reminders')
.setDescription(reminderList)
.setFooter({
text: `Showing ${Math.min(reminders.length, 10)} of ${reminders.length} reminders`,
}),
],
ephemeral: true,
});
}
/**
* Handle canceling a reminder
*/
async function handleCancel(
interaction: ChatInputCommandInteraction,
client: EllyClient,
reminderRepo: ReminderRepository
): Promise<void> {
const reminderId = interaction.options.getString('id', true);
const reminder = reminderRepo.getById(reminderId);
if (!reminder) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Reminder Not Found')
.setDescription(`Could not find a reminder with ID \`${reminderId}\`.`),
],
ephemeral: true,
});
return;
}
if (reminder.userId !== interaction.user.id) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Not Your Reminder')
.setDescription('You can only cancel your own reminders.'),
],
ephemeral: true,
});
return;
}
reminderRepo.delete(reminderId);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Reminder Cancelled')
.setDescription(`Cancelled reminder:\n\`\`\`${reminder.reminderText}\`\`\``),
],
ephemeral: true,
});
}

View File

@@ -0,0 +1,261 @@
/**
* Role Command
* Manage user roles (for officers)
*/
import {
SlashCommandBuilder,
EmbedBuilder,
type ChatInputCommandInteraction,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
export const roleCommand: Command = {
data: new SlashCommandBuilder()
.setName('role')
.setDescription('Manage user roles')
.addSubcommand((sub) =>
sub
.setName('add')
.setDescription('Add a role to a user')
.addUserOption((opt) =>
opt.setName('user').setDescription('User to add role to').setRequired(true)
)
.addRoleOption((opt) =>
opt.setName('role').setDescription('Role to add').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('remove')
.setDescription('Remove a role from a user')
.addUserOption((opt) =>
opt.setName('user').setDescription('User to remove role from').setRequired(true)
)
.addRoleOption((opt) =>
opt.setName('role').setDescription('Role to remove').setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('list')
.setDescription('List manageable roles')
),
permission: PermissionLevel.Officer,
cooldown: 3,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const subcommand = interaction.options.getSubcommand();
switch (subcommand) {
case 'add':
await handleAdd(interaction, client);
break;
case 'remove':
await handleRemove(interaction, client);
break;
case 'list':
await handleList(interaction, client);
break;
}
},
};
/**
* Handle adding a role
*/
async function handleAdd(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const targetUser = interaction.options.getUser('user', true);
const role = interaction.options.getRole('role', true);
// Check if role is manageable
const manageableIds = client.config.roles.manageable?.ids ?? [];
if (!manageableIds.includes(role.id)) {
await interaction.reply({
content: `❌ The role **${role.name}** is not in the list of manageable roles.`,
ephemeral: true,
});
return;
}
const member = interaction.guild?.members.cache.get(targetUser.id);
if (!member) {
await interaction.reply({
content: '❌ User not found in this server.',
ephemeral: true,
});
return;
}
// Check if user already has the role
if (member.roles.cache.has(role.id)) {
await interaction.reply({
content: `${targetUser.tag} already has the **${role.name}** role.`,
ephemeral: true,
});
return;
}
try {
await member.roles.add(role.id);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Role Added')
.setDescription(`Added **${role.name}** to ${targetUser.tag}`)
.setFooter({ text: `By ${interaction.user.tag}` })
.setTimestamp(),
],
});
// Log to development channel
await logRoleChange(client, interaction, targetUser.tag, role.name, 'added');
} catch (error) {
await interaction.reply({
content: `❌ Failed to add role. Make sure I have the required permissions.`,
ephemeral: true,
});
}
}
/**
* Handle removing a role
*/
async function handleRemove(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const targetUser = interaction.options.getUser('user', true);
const role = interaction.options.getRole('role', true);
// Check if role is manageable
const manageableIds = client.config.roles.manageable?.ids ?? [];
if (!manageableIds.includes(role.id)) {
await interaction.reply({
content: `❌ The role **${role.name}** is not in the list of manageable roles.`,
ephemeral: true,
});
return;
}
const member = interaction.guild?.members.cache.get(targetUser.id);
if (!member) {
await interaction.reply({
content: '❌ User not found in this server.',
ephemeral: true,
});
return;
}
// Check if user has the role
if (!member.roles.cache.has(role.id)) {
await interaction.reply({
content: `${targetUser.tag} doesn't have the **${role.name}** role.`,
ephemeral: true,
});
return;
}
try {
await member.roles.remove(role.id);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.warning)
.setTitle('✅ Role Removed')
.setDescription(`Removed **${role.name}** from ${targetUser.tag}`)
.setFooter({ text: `By ${interaction.user.tag}` })
.setTimestamp(),
],
});
// Log to development channel
await logRoleChange(client, interaction, targetUser.tag, role.name, 'removed');
} catch (error) {
await interaction.reply({
content: `❌ Failed to remove role. Make sure I have the required permissions.`,
ephemeral: true,
});
}
}
/**
* Handle listing manageable roles
*/
async function handleList(
interaction: ChatInputCommandInteraction,
client: EllyClient
): Promise<void> {
const manageableIds = client.config.roles.manageable?.ids ?? [];
if (manageableIds.length === 0) {
await interaction.reply({
content: '📭 No manageable roles configured.',
ephemeral: true,
});
return;
}
const roles = manageableIds
.map((id) => interaction.guild?.roles.cache.get(id))
.filter((r) => r !== undefined)
.map((r) => `• <@&${r!.id}> (\`${r!.id}\`)`);
await interaction.reply({
embeds: [
new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('📋 Manageable Roles')
.setDescription(roles.join('\n') || 'No roles found')
.setFooter({ text: `${roles.length} roles can be managed` })
.setTimestamp(),
],
ephemeral: true,
});
}
/**
* Log role change to development channel
*/
async function logRoleChange(
client: EllyClient,
interaction: ChatInputCommandInteraction,
targetTag: string,
roleName: string,
action: 'added' | 'removed'
): Promise<void> {
try {
const logChannelName = client.config.channels.developmentLogs;
const logChannel = interaction.guild?.channels.cache.find(
(c) => c.name === logChannelName && c.isTextBased()
);
if (logChannel && logChannel.isTextBased()) {
await logChannel.send({
embeds: [
new EmbedBuilder()
.setColor(action === 'added' ? 0x57f287 : 0xfee75c)
.setTitle(`📝 Role ${action === 'added' ? 'Added' : 'Removed'}`)
.addFields(
{ name: 'User', value: targetTag, inline: true },
{ name: 'Role', value: roleName, inline: true },
{ name: 'By', value: interaction.user.tag, inline: true }
)
.setTimestamp(),
],
});
}
} catch {
// Ignore logging errors
}
}

View File

@@ -0,0 +1,402 @@
/**
* Staff Simulator Command
* A fun game to simulate being a staff member
*/
import {
SlashCommandBuilder,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type ChatInputCommandInteraction,
type ButtonInteraction,
ComponentType,
} from 'discord.js';
import type { Command } from '../../types/index.ts';
import { PermissionLevel } from '../../types/index.ts';
import type { EllyClient } from '../../client/EllyClient.ts';
import { StaffRepository } from '../../database/repositories/StaffRepository.ts';
// Scenarios for the staff simulator
const SCENARIOS = {
appeal: [
{
title: 'Ban Appeal',
description: 'A player claims they were falsely banned for "hacking" but they say they were just using an FPS boost mod.',
correct: 'investigate',
options: ['Accept Appeal', 'Deny Appeal', 'Investigate Further'],
},
{
title: 'Mute Appeal',
description: 'Player was muted for spam but claims they only sent 3 messages. Logs show 15 messages in 10 seconds.',
correct: 'deny',
options: ['Accept Appeal', 'Deny Appeal', 'Reduce Punishment'],
},
{
title: 'Warning Appeal',
description: 'Player received a warning for advertising but says they were just sharing a YouTube video with friends.',
correct: 'accept',
options: ['Accept Appeal', 'Deny Appeal', 'Keep Warning'],
},
],
report: [
{
title: 'Hacker Report',
description: 'A player reports another for "flying" in BedWars. The video shows suspicious movement but could be lag.',
correct: 'investigate',
options: ['Ban Player', 'Dismiss Report', 'Investigate Further'],
},
{
title: 'Chat Report',
description: 'Report of a player using racial slurs in chat. Screenshots provided show clear evidence.',
correct: 'punish',
options: ['Mute Player', 'Warn Player', 'Dismiss Report'],
},
{
title: 'Teaming Report',
description: 'Player reports teaming in Solo SkyWars. Video shows two players not attacking each other.',
correct: 'warn',
options: ['Ban Both', 'Warn Both', 'Dismiss Report'],
},
],
assist: [
{
title: 'New Player Help',
description: 'A new player asks how to join a BedWars game. They seem confused about the lobby system.',
correct: 'guide',
options: ['Send Wiki Link', 'Guide Them Step-by-Step', 'Tell Them to Figure It Out'],
},
{
title: 'Bug Report',
description: 'Player reports items disappearing from their inventory. Could be a bug or user error.',
correct: 'escalate',
options: ['Dismiss as User Error', 'Escalate to Developers', 'Give Replacement Items'],
},
],
};
export const staffCommand: Command = {
data: new SlashCommandBuilder()
.setName('staff')
.setDescription('Staff simulator game')
.addSubcommand((sub) =>
sub
.setName('play')
.setDescription('Play a staff simulation scenario')
)
.addSubcommand((sub) =>
sub
.setName('stats')
.setDescription('View your staff simulator stats')
.addUserOption((opt) =>
opt.setName('user').setDescription('User to view stats for').setRequired(false)
)
)
.addSubcommand((sub) =>
sub
.setName('leaderboard')
.setDescription('View the staff simulator leaderboard')
),
permission: PermissionLevel.User,
cooldown: 5,
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const client = interaction.client as EllyClient;
const subcommand = interaction.options.getSubcommand();
const repo = new StaffRepository(client.database);
switch (subcommand) {
case 'play':
await handlePlay(interaction, client, repo);
break;
case 'stats':
await handleStats(interaction, client, repo);
break;
case 'leaderboard':
await handleLeaderboard(interaction, client, repo);
break;
}
},
};
/**
* Handle playing a scenario
*/
async function handlePlay(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: StaffRepository
): Promise<void> {
// Pick random scenario type and scenario
const types = Object.keys(SCENARIOS) as Array<keyof typeof SCENARIOS>;
const type = types[Math.floor(Math.random() * types.length)];
const scenarios = SCENARIOS[type];
const scenario = scenarios[Math.floor(Math.random() * scenarios.length)];
// Create buttons for options
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
...scenario.options.map((option, index) =>
new ButtonBuilder()
.setCustomId(`staff:${index}:${scenario.correct}`)
.setLabel(option)
.setStyle(ButtonStyle.Secondary)
)
);
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle(`🎮 Staff Simulator - ${scenario.title}`)
.setDescription(scenario.description)
.addFields({ name: '📋 Scenario Type', value: type.charAt(0).toUpperCase() + type.slice(1), inline: true })
.setFooter({ text: 'Choose the best action!' })
.setTimestamp();
const response = await interaction.reply({
embeds: [embed],
components: [row],
fetchReply: true,
});
// Wait for response
try {
const buttonInteraction = await response.awaitMessageComponent({
componentType: ComponentType.Button,
filter: (i) => i.user.id === interaction.user.id,
time: 30000,
});
await handleScenarioResponse(buttonInteraction, client, repo, type, scenario);
} catch {
// Timeout
const timeoutEmbed = new EmbedBuilder()
.setColor(client.config.colors.warning)
.setTitle('⏰ Time\'s Up!')
.setDescription('You took too long to respond. No points awarded.')
.setTimestamp();
await interaction.editReply({
embeds: [timeoutEmbed],
components: [],
});
}
}
/**
* Handle scenario response
*/
async function handleScenarioResponse(
interaction: ButtonInteraction,
client: EllyClient,
repo: StaffRepository,
type: string,
scenario: { title: string; correct: string; options: string[] }
): Promise<void> {
const [, choiceIndex, correctAnswer] = interaction.customId.split(':');
const choice = parseInt(choiceIndex);
// Determine if correct based on the option chosen
const isCorrect = determineCorrectness(choice, correctAnswer, scenario.options);
// Map type to action type
const actionTypeMap: Record<string, 'appeal' | 'punishment' | 'report' | 'assist'> = {
appeal: 'appeal',
report: 'report',
assist: 'assist',
};
const actionType = actionTypeMap[type] ?? 'assist';
let resultEmbed: EmbedBuilder;
if (isCorrect) {
// Award points
const result = await repo.addAction(
interaction.user.id,
interaction.user.tag,
actionType,
`Completed ${scenario.title} scenario correctly`
);
resultEmbed = new EmbedBuilder()
.setColor(client.config.colors.success)
.setTitle('✅ Correct!')
.setDescription(`Great job! You made the right call on this ${type}.`)
.addFields(
{ name: '🎯 Points Earned', value: `+${getPoints(actionType)}`, inline: true },
{ name: '📊 Total Points', value: String(result.progress.totalPoints), inline: true },
{ name: '🏆 Level', value: `${result.progress.level} (${repo.getLevelTitle(result.progress.level)})`, inline: true }
);
if (result.leveledUp) {
resultEmbed.addFields({
name: '🎉 Level Up!',
value: `Congratulations! You reached level ${result.newLevel} - ${repo.getLevelTitle(result.newLevel)}!`,
});
}
} else {
resultEmbed = new EmbedBuilder()
.setColor(client.config.colors.error)
.setTitle('❌ Incorrect')
.setDescription(`That wasn't the best choice for this ${type}. Try again!`)
.addFields({
name: '💡 Tip',
value: getHint(correctAnswer),
});
}
resultEmbed.setTimestamp();
await interaction.update({
embeds: [resultEmbed],
components: [],
});
}
/**
* Determine if the choice was correct
*/
function determineCorrectness(choice: number, correct: string, options: string[]): boolean {
const chosenOption = options[choice].toLowerCase();
switch (correct) {
case 'investigate':
return chosenOption.includes('investigate');
case 'deny':
return chosenOption.includes('deny');
case 'accept':
return chosenOption.includes('accept');
case 'punish':
return chosenOption.includes('mute') || chosenOption.includes('ban');
case 'warn':
return chosenOption.includes('warn');
case 'guide':
return chosenOption.includes('guide') || chosenOption.includes('step');
case 'escalate':
return chosenOption.includes('escalate') || chosenOption.includes('developer');
default:
return false;
}
}
/**
* Get points for action type
*/
function getPoints(actionType: string): number {
const points: Record<string, number> = {
appeal: 10,
punishment: 5,
report: 8,
assist: 3,
};
return points[actionType] ?? 5;
}
/**
* Get hint for correct answer
*/
function getHint(correct: string): string {
const hints: Record<string, string> = {
investigate: 'When evidence is unclear, it\'s best to investigate further before making a decision.',
deny: 'If the evidence clearly shows the player violated rules, the appeal should be denied.',
accept: 'If the punishment was unjustified or too harsh, consider accepting the appeal.',
punish: 'Clear rule violations with evidence should result in appropriate punishment.',
warn: 'For first-time or minor offenses, a warning is often the best approach.',
guide: 'New players benefit most from patient, step-by-step guidance.',
escalate: 'Technical issues should be escalated to the appropriate team.',
};
return hints[correct] ?? 'Consider all the evidence before making a decision.';
}
/**
* Handle viewing stats
*/
async function handleStats(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: StaffRepository
): Promise<void> {
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const progress = await repo.getByUserId(targetUser.id);
if (!progress) {
await interaction.reply({
content: `${targetUser.id === interaction.user.id ? 'You haven\'t' : `${targetUser.tag} hasn't`} played the staff simulator yet!`,
ephemeral: true,
});
return;
}
const rank = await repo.getRank(targetUser.id);
const nextLevelPoints = repo.getPointsForNextLevel(progress.level);
const progressToNext = nextLevelPoints > 0
? Math.round((progress.totalPoints / nextLevelPoints) * 100)
: 100;
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle(`📊 ${targetUser.tag}'s Staff Stats`)
.setThumbnail(targetUser.displayAvatarURL())
.addFields(
{ name: '🏆 Level', value: `${progress.level} (${repo.getLevelTitle(progress.level)})`, inline: true },
{ name: '⭐ Total Points', value: String(progress.totalPoints), inline: true },
{ name: '🏅 Rank', value: `#${rank}`, inline: true },
{ name: '📝 Appeals Handled', value: String(progress.appealsHandled), inline: true },
{ name: '⚖️ Punishments', value: String(progress.punishmentsIssued), inline: true },
{ name: '📋 Reports', value: String(progress.reportsHandled), inline: true },
{ name: '🤝 Assists', value: String(progress.assistsGiven), inline: true },
{
name: '📈 Progress to Next Level',
value: nextLevelPoints > 0
? `${progress.totalPoints}/${nextLevelPoints} (${progressToNext}%)`
: 'Max Level!',
inline: true
}
)
.setFooter({ text: `Last active: ${new Date(progress.lastActive).toLocaleDateString()}` })
.setTimestamp();
await interaction.reply({ embeds: [embed] });
}
/**
* Handle leaderboard
*/
async function handleLeaderboard(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: StaffRepository
): Promise<void> {
const leaderboard = await repo.getLeaderboard(10);
if (leaderboard.length === 0) {
await interaction.reply({
content: '📭 No one has played the staff simulator yet!',
ephemeral: true,
});
return;
}
const medals = ['🥇', '🥈', '🥉'];
const lines = leaderboard.map((entry, index) => {
const medal = medals[index] ?? `**${index + 1}.**`;
return `${medal} ${entry.username} - ${entry.totalPoints} pts (Lvl ${entry.level})`;
});
const userRank = await repo.getRank(interaction.user.id);
const userProgress = await repo.getByUserId(interaction.user.id);
const embed = new EmbedBuilder()
.setColor(client.config.colors.primary)
.setTitle('🏆 Staff Simulator Leaderboard')
.setDescription(lines.join('\n'))
.setFooter({
text: userProgress
? `Your rank: #${userRank} with ${userProgress.totalPoints} points`
: 'Play /staff play to get on the leaderboard!'
})
.setTimestamp();
await interaction.reply({ embeds: [embed] });
}