(Feat): Added a minimal pikanetwork client
This commit is contained in:
276
src/commands/applications/handlers/admin.ts
Normal file
276
src/commands/applications/handlers/admin.ts
Normal 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: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
708
src/commands/applications/handlers/apply.ts
Normal file
708
src/commands/applications/handlers/apply.ts
Normal 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
|
||||
}
|
||||
}
|
||||
299
src/commands/applications/handlers/list.ts
Normal file
299
src/commands/applications/handlers/list.ts
Normal 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] });
|
||||
}
|
||||
278
src/commands/applications/handlers/review.ts
Normal file
278
src/commands/applications/handlers/review.ts
Normal 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
|
||||
}
|
||||
}
|
||||
89
src/commands/applications/handlers/settings.ts
Normal file
89
src/commands/applications/handlers/settings.ts
Normal 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 });
|
||||
}
|
||||
219
src/commands/applications/handlers/stats.ts
Normal file
219
src/commands/applications/handlers/stats.ts
Normal 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`;
|
||||
}
|
||||
159
src/commands/applications/handlers/view.ts
Normal file
159
src/commands/applications/handlers/view.ts
Normal 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;
|
||||
}
|
||||
271
src/commands/applications/index.ts
Normal file
271
src/commands/applications/index.ts
Normal 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 });
|
||||
}
|
||||
292
src/commands/developer/blacklist.ts
Normal file
292
src/commands/developer/blacklist.ts
Normal 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 });
|
||||
}
|
||||
315
src/commands/developer/database.ts
Normal file
315
src/commands/developer/database.ts
Normal 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]}`;
|
||||
}
|
||||
240
src/commands/developer/debug.ts
Normal file
240
src/commands/developer/debug.ts
Normal 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]}`;
|
||||
}
|
||||
101
src/commands/developer/emit.ts
Normal file
101
src/commands/developer/emit.ts
Normal 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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
134
src/commands/developer/eval.ts
Normal file
134
src/commands/developer/eval.ts
Normal 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] });
|
||||
},
|
||||
};
|
||||
119
src/commands/developer/reload.ts
Normal file
119
src/commands/developer/reload.ts
Normal 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.'),
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
167
src/commands/developer/shell.ts
Normal file
167
src/commands/developer/shell.ts
Normal 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] });
|
||||
}
|
||||
},
|
||||
};
|
||||
174
src/commands/developer/sync.ts
Normal file
174
src/commands/developer/sync.ts
Normal 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.`),
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
192
src/commands/family/adopt.ts
Normal file
192
src/commands/family/adopt.ts
Normal 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: [],
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
125
src/commands/family/divorce.ts
Normal file
125
src/commands/family/divorce.ts
Normal 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: [],
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
196
src/commands/family/marry.ts
Normal file
196
src/commands/family/marry.ts
Normal 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: [],
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
114
src/commands/family/relationship.ts
Normal file
114
src/commands/family/relationship.ts
Normal 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] });
|
||||
},
|
||||
};
|
||||
360
src/commands/moderation/filter.ts
Normal file
360
src/commands/moderation/filter.ts
Normal 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(),
|
||||
],
|
||||
});
|
||||
}
|
||||
180
src/commands/moderation/purge.ts
Normal file
180
src/commands/moderation/purge.ts
Normal 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
611
src/commands/qotd/index.ts
Normal 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);
|
||||
}
|
||||
173
src/commands/statistics/bedwars.ts
Normal file
173
src/commands/statistics/bedwars.ts
Normal 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] });
|
||||
},
|
||||
};
|
||||
128
src/commands/statistics/guild.ts
Normal file
128
src/commands/statistics/guild.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
96
src/commands/statistics/server.ts
Normal file
96
src/commands/statistics/server.ts
Normal 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] });
|
||||
},
|
||||
};
|
||||
176
src/commands/statistics/skywars.ts
Normal file
176
src/commands/statistics/skywars.ts
Normal 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] });
|
||||
},
|
||||
};
|
||||
988
src/commands/suggestions/index.ts
Normal file
988
src/commands/suggestions/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
354
src/commands/utility/away.ts
Normal file
354
src/commands/utility/away.ts
Normal 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] });
|
||||
}
|
||||
379
src/commands/utility/champion.ts
Normal file
379
src/commands/utility/champion.ts
Normal 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(),
|
||||
],
|
||||
});
|
||||
}
|
||||
265
src/commands/utility/remind.ts
Normal file
265
src/commands/utility/remind.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
261
src/commands/utility/role.ts
Normal file
261
src/commands/utility/role.ts
Normal 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
|
||||
}
|
||||
}
|
||||
402
src/commands/utility/staff.ts
Normal file
402
src/commands/utility/staff.ts
Normal 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] });
|
||||
}
|
||||
Reference in New Issue
Block a user