feat: Add web admin page for quest management and refactor Discord bot's quest UI to use new components.
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import { createCommand } from "@shared/lib/utils";
|
||||
import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js";
|
||||
import { SlashCommandBuilder, MessageFlags } from "discord.js";
|
||||
import { questService } from "@shared/modules/quest/quest.service";
|
||||
import { createSuccessEmbed, createWarningEmbed } from "@lib/embeds";
|
||||
import { getQuestListEmbed, getAvailableQuestsEmbed, getQuestActionRows } from "@/modules/quest/quest.view";
|
||||
import { createSuccessEmbed } from "@lib/embeds";
|
||||
import {
|
||||
getQuestListComponents,
|
||||
getAvailableQuestsComponents,
|
||||
getQuestActionRows
|
||||
} from "@/modules/quest/quest.view";
|
||||
|
||||
export const quests = createCommand({
|
||||
data: new SlashCommandBuilder()
|
||||
@@ -17,15 +21,18 @@ export const quests = createCommand({
|
||||
const userQuests = await questService.getUserQuests(userId);
|
||||
const availableQuests = await questService.getAvailableQuests(userId);
|
||||
|
||||
const embed = viewType === 'active'
|
||||
? getQuestListEmbed(userQuests)
|
||||
: getAvailableQuestsEmbed(availableQuests);
|
||||
|
||||
const components = getQuestActionRows(viewType, availableQuests);
|
||||
const containers = viewType === 'active'
|
||||
? getQuestListComponents(userQuests)
|
||||
: getAvailableQuestsComponents(availableQuests);
|
||||
|
||||
const actionRows = getQuestActionRows(viewType);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [embed],
|
||||
components: components
|
||||
content: null,
|
||||
embeds: null as any,
|
||||
components: [...containers, ...actionRows] as any,
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
};
|
||||
|
||||
@@ -33,8 +40,8 @@ export const quests = createCommand({
|
||||
await updateView('active');
|
||||
|
||||
const collector = response.createMessageComponentCollector({
|
||||
time: 60000,
|
||||
componentType: undefined // Allow both buttons and select menu
|
||||
time: 120000, // 2 minutes
|
||||
componentType: undefined // Allow buttons
|
||||
});
|
||||
|
||||
collector.on('collect', async (i) => {
|
||||
@@ -47,22 +54,24 @@ export const quests = createCommand({
|
||||
} else if (i.customId === "quest_view_available") {
|
||||
await i.deferUpdate();
|
||||
await updateView('available');
|
||||
} else if (i.customId === "quest_accept_select") {
|
||||
const questId = parseInt((i as any).values[0]);
|
||||
} else if (i.customId.startsWith("quest_accept:")) {
|
||||
const questIdStr = i.customId.split(":")[1];
|
||||
if (!questIdStr) return;
|
||||
const questId = parseInt(questIdStr);
|
||||
await questService.assignQuest(userId, questId);
|
||||
|
||||
await i.reply({
|
||||
embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
|
||||
await i.reply({
|
||||
embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
|
||||
|
||||
await updateView('active');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Quest interaction error:", error);
|
||||
await i.followUp({
|
||||
content: "Something went wrong while processing your quest interaction.",
|
||||
flags: MessageFlags.Ephemeral
|
||||
await i.followUp({
|
||||
content: "Something went wrong while processing your quest interaction.",
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
|
||||
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "@shared/lib/types";
|
||||
import { env } from "@shared/lib/env";
|
||||
@@ -74,6 +74,27 @@ export class Client extends DiscordClient {
|
||||
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
||||
this.maintenanceMode = enabled;
|
||||
});
|
||||
|
||||
systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => {
|
||||
const { userId, quest, rewards } = data;
|
||||
try {
|
||||
const user = await this.users.fetch(userId);
|
||||
if (!user) return;
|
||||
|
||||
const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view");
|
||||
const components = getQuestCompletionComponents(quest, rewards);
|
||||
|
||||
// Try to send to the user's DM
|
||||
await user.send({
|
||||
components: components as any,
|
||||
flags: [MessageFlags.IsComponentsV2]
|
||||
}).catch(async () => {
|
||||
console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send quest completion notification:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadCommands(reload: boolean = false) {
|
||||
@@ -176,4 +197,4 @@ export class Client extends DiscordClient {
|
||||
}
|
||||
}
|
||||
|
||||
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });
|
||||
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] });
|
||||
@@ -1,4 +1,13 @@
|
||||
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from "discord.js";
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ContainerBuilder,
|
||||
TextDisplayBuilder,
|
||||
SeparatorBuilder,
|
||||
SeparatorSpacingSize,
|
||||
MessageFlags
|
||||
} from "discord.js";
|
||||
|
||||
/**
|
||||
* Quest entry with quest details and progress
|
||||
@@ -27,6 +36,13 @@ interface AvailableQuest {
|
||||
requirements: any;
|
||||
}
|
||||
|
||||
// Color palette for containers
|
||||
const COLORS = {
|
||||
ACTIVE: 0x3498db, // Blue - in progress
|
||||
AVAILABLE: 0x2ecc71, // Green - available
|
||||
COMPLETED: 0xf1c40f // Gold - completed
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats quest rewards object into a human-readable string
|
||||
*/
|
||||
@@ -34,14 +50,7 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string
|
||||
const rewardStr: string[] = [];
|
||||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||
return rewardStr.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the quest status display string
|
||||
*/
|
||||
function getQuestStatus(completedAt: Date | null): string {
|
||||
return completedAt ? "✅ Completed" : "📝 In Progress";
|
||||
return rewardStr.join(" • ") || "None";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,108 +64,155 @@ function renderProgressBar(current: number, total: number, size: number = 10): s
|
||||
const progressText = "▰".repeat(progress);
|
||||
const emptyText = "▱".repeat(empty);
|
||||
|
||||
return `${progressText}${emptyText} ${Math.round(percentage * 100)}% (${current}/${total})`;
|
||||
return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an embed displaying a user's quest log
|
||||
* Creates Components v2 containers for the quest list (active quests only)
|
||||
*/
|
||||
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("📜 Quest Log")
|
||||
.setDescription("Your active and completed quests.")
|
||||
.setColor(0x3498db); // Blue
|
||||
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
|
||||
// Filter to only show in-progress quests (not completed)
|
||||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||
|
||||
if (userQuests.length === 0) {
|
||||
embed.setDescription("You have no active quests. Check available quests!");
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.ACTIVE)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
||||
new TextDisplayBuilder().setContent("-# Your active quests")
|
||||
);
|
||||
|
||||
if (activeQuests.length === 0) {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*")
|
||||
);
|
||||
return [container];
|
||||
}
|
||||
|
||||
userQuests.forEach(entry => {
|
||||
const status = getQuestStatus(entry.completedAt);
|
||||
activeQuests.forEach((entry) => {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
|
||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||
const rewardsText = formatQuestRewards(rewards);
|
||||
|
||||
|
||||
const requirements = entry.quest.requirements as { target?: number };
|
||||
const target = requirements?.target || 1;
|
||||
const progress = entry.progress || 0;
|
||||
const progressBar = renderProgressBar(progress, target);
|
||||
|
||||
const progressBar = entry.completedAt ? "✅ Fully completed" : renderProgressBar(progress, target);
|
||||
|
||||
embed.addFields({
|
||||
name: `${entry.quest.name} (${status})`,
|
||||
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${progressBar}`,
|
||||
inline: false
|
||||
});
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`**${entry.quest.name}**`),
|
||||
new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"),
|
||||
new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`)
|
||||
);
|
||||
});
|
||||
|
||||
return embed;
|
||||
return [container];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an embed for available quests
|
||||
* Creates Components v2 containers for available quests with inline accept buttons
|
||||
*/
|
||||
export function getAvailableQuestsEmbed(availableQuests: AvailableQuest[]): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("🗺️ Available Quests")
|
||||
.setDescription("Quests you can accept right now.")
|
||||
.setColor(0x2ecc71); // Green
|
||||
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.AVAILABLE)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
||||
new TextDisplayBuilder().setContent("-# Quests you can accept")
|
||||
);
|
||||
|
||||
if (availableQuests.length === 0) {
|
||||
embed.setDescription("There are no new quests available for you at the moment.");
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("*No new quests available at the moment.*")
|
||||
);
|
||||
return [container];
|
||||
}
|
||||
|
||||
availableQuests.forEach(quest => {
|
||||
// Limit to 10 quests (5 action rows max with 2 added for navigation)
|
||||
const questsToShow = availableQuests.slice(0, 10);
|
||||
|
||||
questsToShow.forEach((quest) => {
|
||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||
|
||||
const rewards = quest.rewards as { xp?: number, balance?: number };
|
||||
const rewardsText = formatQuestRewards(rewards);
|
||||
|
||||
|
||||
const requirements = quest.requirements as { target?: number };
|
||||
const target = requirements?.target || 1;
|
||||
|
||||
embed.addFields({
|
||||
name: quest.name,
|
||||
value: `${quest.description}\n**Goal:** Reach ${target} for this activity.\n**Rewards:** ${rewardsText}`,
|
||||
inline: false
|
||||
});
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`**${quest.name}**`),
|
||||
new TextDisplayBuilder().setContent(quest.description || "*No description*"),
|
||||
new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` • 🎁 ${rewardsText}`)
|
||||
);
|
||||
|
||||
// Add accept button inline within the container
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`quest_accept:${quest.id}`)
|
||||
.setLabel("Accept Quest")
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji("✅")
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return embed;
|
||||
return [container];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns action rows for the quest view
|
||||
* Returns action rows for navigation only
|
||||
*/
|
||||
export function getQuestActionRows(viewType: 'active' | 'available', availableQuests: AvailableQuest[] = []): ActionRowBuilder<any>[] {
|
||||
const rows: ActionRowBuilder<any>[] = [];
|
||||
|
||||
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
|
||||
// Navigation row
|
||||
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId("quest_view_active")
|
||||
.setLabel("Active Quests")
|
||||
.setLabel("📜 Active")
|
||||
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||
.setDisabled(viewType === 'active'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId("quest_view_available")
|
||||
.setLabel("Available Quests")
|
||||
.setLabel("🗺️ Available")
|
||||
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||
.setDisabled(viewType === 'available')
|
||||
);
|
||||
rows.push(navRow);
|
||||
|
||||
if (viewType === 'available' && availableQuests.length > 0) {
|
||||
const selectMenu = new StringSelectMenuBuilder()
|
||||
.setCustomId("quest_accept_select")
|
||||
.setPlaceholder("Select a quest to accept")
|
||||
.addOptions(
|
||||
availableQuests.slice(0, 25).map(q =>
|
||||
new StringSelectMenuOptionBuilder()
|
||||
.setLabel(q.name)
|
||||
.setDescription(q.description?.substring(0, 100) || "")
|
||||
.setValue(q.id.toString())
|
||||
)
|
||||
);
|
||||
|
||||
rows.push(new ActionRowBuilder().addComponents(selectMenu));
|
||||
}
|
||||
|
||||
return rows;
|
||||
return [navRow];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Components v2 celebratory message for quest completion
|
||||
*/
|
||||
export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] {
|
||||
const rewardsText = formatQuestRewards({
|
||||
xp: Number(rewards.xp),
|
||||
balance: Number(rewards.balance)
|
||||
});
|
||||
|
||||
const container = new ContainerBuilder()
|
||||
.setAccentColor(COLORS.COMPLETED)
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent("# 🎉 Quest Completed!"),
|
||||
new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`)
|
||||
)
|
||||
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
|
||||
.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(`📝 ${quest.description || "No description provided."}`),
|
||||
new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`)
|
||||
);
|
||||
|
||||
return [container];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MessageFlags and allowedMentions for Components v2 messages
|
||||
*/
|
||||
export function getComponentsV2MessageFlags() {
|
||||
return {
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
allowedMentions: { parse: [] as const }
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user