refactor: extract Discord.js code from shared services into bot layer

Move terminal.service.ts and prune.service.ts entirely to bot/modules/
since they are Discord-specific. Split lootdrop.service.ts: pure logic
(activity tracking, DB ops, claim) stays in shared/, Discord operations
(message sending, channel interactions) move to bot/modules/economy/
lootdrop.handler.ts. Move effect registry/handlers/types from bot/ to
shared/modules/inventory/ since they contain no Discord.js imports and
are needed by inventory.service.ts in shared.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-03-18 13:15:29 +01:00
parent 5a20ed23f4
commit abe25e0ceb
15 changed files with 175 additions and 137 deletions

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
import { config } from "@shared/lib/config";
import { pruneService } from "@shared/modules/moderation/prune.service";
import { pruneService } from "@modules/moderation/prune.service";
import {
getConfirmationMessage,
getProgressEmbed,

View File

@@ -1,7 +1,7 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
import { terminalService } from "@shared/modules/terminal/terminal.service";
import { terminalService } from "@modules/system/terminal.service";
import { createErrorEmbed } from "@/lib/embeds";
import { withCommandErrorHandling } from "@lib/commandUtils";

View File

@@ -15,7 +15,7 @@ const event: Event<Events.MessageCreate> = {
levelingService.processChatXp(message.author.id);
// Activity Tracking for Lootdrops
import("@shared/modules/economy/lootdrop.service").then(m => m.lootdropService.processMessage(message));
import("@modules/economy/lootdrop.handler").then(m => m.processLootdropMessage(message));
},
};

View File

@@ -0,0 +1,60 @@
import { Message, TextChannel } from "discord.js";
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { getLootdropMessage } from "./lootdrop.view";
import { terminalService } from "@modules/system/terminal.service";
/**
* Process a Discord message for lootdrop activity tracking.
* Called from messageCreate event handler.
*/
export async function processLootdropMessage(message: Message): Promise<void> {
if (message.author.bot || !message.guild) return;
const { shouldSpawn } = lootdropService.trackActivity(message.channel.id);
if (shouldSpawn) {
await spawnLootdrop(message.channel as TextChannel);
}
}
/**
* Spawn a lootdrop in a Discord channel.
* Used by both bot events and API routes.
*/
export async function spawnLootdrop(
channel: TextChannel,
overrideReward?: number,
overrideCurrency?: string
): Promise<void> {
const { reward, currency } = lootdropService.calculateReward(overrideReward, overrideCurrency);
const { content, files, components } = await getLootdropMessage(reward, currency);
try {
const sentMessage = await channel.send({ content, files, components });
await lootdropService.persistLootdrop(sentMessage.id, channel.id, reward, currency);
terminalService.update(channel.guildId);
} catch (error) {
console.error("Failed to spawn lootdrop:", error);
}
}
/**
* Delete a lootdrop from DB and Discord.
*/
export async function deleteLootdrop(messageId: string): Promise<boolean> {
const result = await lootdropService.removeLootdrop(messageId);
if (!result) return false;
try {
const { AuroraClient } = await import("@/lib/BotClient");
const channel = await AuroraClient.channels.fetch(result.channelId) as TextChannel;
if (channel) {
const message = await channel.messages.fetch(messageId);
if (message) await message.delete();
}
} catch (e) {
console.warn("Could not delete lootdrop message from Discord:", e);
}
return true;
}

View File

@@ -2,6 +2,7 @@ import { ButtonInteraction } from "discord.js";
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { UserError } from "@shared/lib/errors";
import { getLootdropClaimedMessage } from "./lootdrop.view";
import { terminalService } from "@modules/system/terminal.service";
export async function handleLootdropInteraction(interaction: ButtonInteraction) {
if (interaction.customId === "lootdrop_claim") {
@@ -13,6 +14,9 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction)
throw new UserError(result.error || "Failed to claim.");
}
// Update terminal display after successful claim
terminalService.update();
await interaction.editReply({
content: `🎉 You successfully claimed **${result.amount} ${result.currency}**!`
});

View File

@@ -1,163 +0,0 @@
import { levelingService } from "@shared/modules/leveling/leveling.service";
import { economyService } from "@shared/modules/economy/economy.service";
import { userTimers } from "@db/schema";
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
import { EffectType } from "@shared/lib/constants";
import type { LootTableItem } from "@shared/lib/types";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { inventory, items } from "@db/schema";
import { TimerType, TransactionType, LootType } from "@shared/lib/constants";
// Helper to extract duration in seconds
const getDuration = (effect: any): number => {
if (effect.durationHours) return effect.durationHours * 3600;
if (effect.durationMinutes) return effect.durationMinutes * 60;
return effect.durationSeconds || 60; // Default to 60s if nothing provided
};
export const handleAddXp: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_XP }>, txFn) => {
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
return `Gained ${effect.amount} XP`;
};
export const handleAddBalance: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.ADD_BALANCE }>, txFn) => {
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
return `Gained ${effect.amount} 🪙`;
};
export const handleReplyMessage: EffectHandler = async (_userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.REPLY_MESSAGE }>, _txFn) => {
return effect.message;
};
export const handleXpBoost: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.XP_BOOST }>, txFn) => {
const boostDuration = getDuration(effect);
const expiresAt = new Date(Date.now() + boostDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: TimerType.EFFECT,
key: 'xp_boost',
expiresAt: expiresAt,
metadata: { multiplier: effect.multiplier }
}).onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: expiresAt, metadata: { multiplier: effect.multiplier } }
});
return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`;
};
export const handleTempRole: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.TEMP_ROLE }>, txFn) => {
const roleDuration = getDuration(effect);
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: TimerType.ACCESS,
key: `role_${effect.roleId}`,
expiresAt: roleExpiresAt,
metadata: { roleId: effect.roleId }
}).onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: roleExpiresAt }
});
// Actual role assignment happens in the Command layer
return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`;
};
export const handleColorRole: EffectHandler = async (_userId, _effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.COLOR_ROLE }>, _txFn) => {
return "Color Role Equipped";
};
export const handleLootbox: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.LOOTBOX }>, txFn) => {
const pool = effect.pool as LootTableItem[];
if (!pool || pool.length === 0) return "The box is empty...";
const totalWeight = pool.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * totalWeight;
let winner: LootTableItem | null = null;
for (const item of pool) {
if (random < item.weight) {
winner = item;
break;
}
random -= item.weight;
}
if (!winner) return "The box is empty..."; // Should not happen
// Process Winner
if (winner.type === LootType.NOTHING) {
return {
type: 'LOOTBOX_RESULT',
rewardType: 'NOTHING',
message: winner.message || "You found nothing inside."
};
}
if (winner.type === LootType.CURRENCY) {
let amount = winner.amount || 0;
if (winner.minAmount && winner.maxAmount) {
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
}
if (amount > 0) {
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
return {
type: 'LOOTBOX_RESULT',
rewardType: 'CURRENCY',
amount: amount,
message: winner.message || `You found ${amount} 🪙!`
};
}
}
if (winner.type === LootType.XP) {
let amount = winner.amount || 0;
if (winner.minAmount && winner.maxAmount) {
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
}
if (amount > 0) {
await levelingService.addXp(userId, BigInt(amount), txFn);
return {
type: 'LOOTBOX_RESULT',
rewardType: 'XP',
amount: amount,
message: winner.message || `You gained ${amount} XP!`
};
}
}
if (winner.type === LootType.ITEM) {
if (winner.itemId) {
const quantity = BigInt(winner.amount || 1);
await inventoryService.addItem(userId, winner.itemId, quantity, txFn);
// Try to fetch item name for the message
try {
const item = await txFn.query.items.findFirst({
where: (items: any, { eq }: any) => eq(items.id, winner.itemId!)
});
if (item) {
return {
type: 'LOOTBOX_RESULT',
rewardType: 'ITEM',
amount: Number(quantity),
item: {
name: item.name,
rarity: item.rarity,
description: item.description,
image: item.imageUrl || item.iconUrl
},
message: winner.message || `You found ${quantity > 1 ? quantity + 'x ' : ''}**${item.name}**!`
};
}
} catch (e) {
console.error("Failed to fetch item name for lootbox message", e);
}
return winner.message || `You found an item! (ID: ${winner.itemId})`;
}
}
return "You found nothing suitable inside.";
};

View File

@@ -1,41 +0,0 @@
import {
handleAddXp,
handleAddBalance,
handleReplyMessage,
handleXpBoost,
handleTempRole,
handleColorRole,
handleLootbox
} from "./effect.handlers";
import type { EffectHandler, ValidatedEffectPayload } from "./effect.types";
import { EffectPayloadSchema } from "./effect.types";
import { UserError } from "@shared/lib/errors";
import type { Transaction } from "@shared/lib/types";
export const effectHandlers: Record<string, EffectHandler> = {
'ADD_XP': handleAddXp,
'ADD_BALANCE': handleAddBalance,
'REPLY_MESSAGE': handleReplyMessage,
'XP_BOOST': handleXpBoost,
'TEMP_ROLE': handleTempRole,
'COLOR_ROLE': handleColorRole,
'LOOTBOX': handleLootbox
};
export async function validateAndExecuteEffect(
effect: unknown,
userId: string,
tx: Transaction
) {
const result = EffectPayloadSchema.safeParse(effect);
if (!result.success) {
throw new UserError(`Invalid effect configuration: ${result.error.message}`);
}
const handler = effectHandlers[result.data.type];
if (!handler) {
throw new UserError(`Unknown effect type: ${result.data.type}`);
}
return handler(userId, result.data, tx);
}

View File

@@ -1,71 +0,0 @@
import type { Transaction } from "@shared/lib/types";
import { z } from "zod";
import { EffectType, LootType } from "@shared/lib/constants";
// Helper Schemas
const LootTableItemSchema = z.object({
type: z.nativeEnum(LootType),
weight: z.number(),
amount: z.number().optional(),
itemId: z.number().optional(),
minAmount: z.number().optional(),
maxAmount: z.number().optional(),
message: z.string().optional(),
});
const DurationSchema = z.object({
durationSeconds: z.number().optional(),
durationMinutes: z.number().optional(),
durationHours: z.number().optional(),
});
// Effect Schemas
const AddXpSchema = z.object({
type: z.literal(EffectType.ADD_XP),
amount: z.number().positive(),
});
const AddBalanceSchema = z.object({
type: z.literal(EffectType.ADD_BALANCE),
amount: z.number(),
});
const ReplyMessageSchema = z.object({
type: z.literal(EffectType.REPLY_MESSAGE),
message: z.string(),
});
const XpBoostSchema = DurationSchema.extend({
type: z.literal(EffectType.XP_BOOST),
multiplier: z.number(),
});
const TempRoleSchema = DurationSchema.extend({
type: z.literal(EffectType.TEMP_ROLE),
roleId: z.string(),
});
const ColorRoleSchema = z.object({
type: z.literal(EffectType.COLOR_ROLE),
roleId: z.string(),
});
const LootboxSchema = z.object({
type: z.literal(EffectType.LOOTBOX),
pool: z.array(LootTableItemSchema),
});
// Union Schema
export const EffectPayloadSchema = z.discriminatedUnion('type', [
AddXpSchema,
AddBalanceSchema,
ReplyMessageSchema,
XpBoostSchema,
TempRoleSchema,
ColorRoleSchema,
LootboxSchema,
]);
export type ValidatedEffectPayload = z.infer<typeof EffectPayloadSchema>;
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;

View File

@@ -0,0 +1,199 @@
import { Collection, Message, PermissionFlagsBits } from "discord.js";
import type { TextBasedChannel } from "discord.js";
import type { PruneOptions, PruneResult, PruneProgress } from "@/modules/moderation/prune.types";
import { config } from "@shared/lib/config";
import { UserError, SystemError } from "@shared/lib/errors";
/**
* Fetch messages from a channel
*/
async function fetchMessages(
channel: TextBasedChannel,
limit: number,
before?: string
): Promise<Collection<string, Message>> {
if (!('messages' in channel)) {
return new Collection();
}
return await channel.messages.fetch({
limit,
before
});
}
/**
* Process a batch of messages for deletion
*/
async function processBatch(
channel: TextBasedChannel,
messages: Collection<string, Message>,
userId?: string
): Promise<{ deleted: number; skipped: number }> {
if (!('bulkDelete' in channel)) {
throw new UserError("This channel type does not support bulk deletion");
}
// Filter by user if specified
let messagesToDelete = messages;
if (userId) {
messagesToDelete = messages.filter(msg => msg.author.id === userId);
}
if (messagesToDelete.size === 0) {
return { deleted: 0, skipped: 0 };
}
try {
// bulkDelete with filterOld=true will automatically skip messages >14 days
const deleted = await channel.bulkDelete(messagesToDelete, true);
const skipped = messagesToDelete.size - deleted.size;
return {
deleted: deleted.size,
skipped
};
} catch (error) {
console.error("Error during bulk delete:", error);
throw new SystemError("Failed to delete messages");
}
}
/**
* Helper to delay execution
*/
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const pruneService = {
/**
* Delete messages from a channel based on provided options
*/
async deleteMessages(
channel: TextBasedChannel,
options: PruneOptions,
progressCallback?: (progress: PruneProgress) => Promise<void>
): Promise<PruneResult> {
// Validate channel permissions
if (!('permissionsFor' in channel)) {
throw new UserError("Cannot check permissions for this channel type");
}
const permissions = channel.permissionsFor(channel.client.user!);
if (!permissions?.has(PermissionFlagsBits.ManageMessages)) {
throw new UserError("Missing permission to manage messages in this channel");
}
const { amount, userId, all } = options;
const batchSize = config.moderation.prune.batchSize;
const batchDelay = config.moderation.prune.batchDelayMs;
let totalDeleted = 0;
let totalSkipped = 0;
let requestedCount = amount || 10;
let lastMessageId: string | undefined;
let username: string | undefined;
if (all) {
// Delete all messages in batches
const estimatedTotal = await this.estimateMessageCount(channel);
requestedCount = estimatedTotal;
while (true) {
const messages = await fetchMessages(channel, batchSize, lastMessageId);
if (messages.size === 0) break;
const { deleted, skipped } = await processBatch(
channel,
messages,
userId
);
totalDeleted += deleted;
totalSkipped += skipped;
// Update progress
if (progressCallback) {
await progressCallback({
current: totalDeleted,
total: estimatedTotal
});
}
// If we deleted fewer than we fetched, we've hit old messages
if (deleted < messages.size) {
break;
}
// Get the ID of the last message for pagination
const lastMessage = Array.from(messages.values()).pop();
lastMessageId = lastMessage?.id;
// Delay to avoid rate limits
if (messages.size >= batchSize) {
await delay(batchDelay);
}
}
} else {
// Delete specific amount
const limit = Math.min(amount || 10, config.moderation.prune.maxAmount);
const messages = await fetchMessages(channel, limit, undefined);
const { deleted, skipped } = await processBatch(
channel,
messages,
userId
);
totalDeleted = deleted;
totalSkipped = skipped;
requestedCount = limit;
}
// Get username if filtering by user
if (userId && totalDeleted > 0) {
try {
const user = await channel.client.users.fetch(userId);
username = user.username;
} catch {
username = "Unknown User";
}
}
return {
deletedCount: totalDeleted,
requestedCount,
filtered: !!userId,
username,
skippedOld: totalSkipped > 0 ? totalSkipped : undefined
};
},
/**
* Estimate the total number of messages in a channel
*/
async estimateMessageCount(channel: TextBasedChannel): Promise<number> {
if (!('messages' in channel)) {
return 0;
}
try {
// Fetch a small sample to get the oldest message
const sample = await channel.messages.fetch({ limit: 1 });
if (sample.size === 0) return 0;
// This is a rough estimate - Discord doesn't provide exact counts
// We'll return a conservative estimate
const oldestMessage = sample.first();
const channelAge = Date.now() - (oldestMessage?.createdTimestamp || Date.now());
const estimatedRate = 100; // messages per day (conservative)
const daysOld = channelAge / (1000 * 60 * 60 * 24);
return Math.max(100, Math.round(daysOld * estimatedRate));
} catch {
return 100; // Default estimate
}
},
};

View File

@@ -1,5 +1,5 @@
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
import { terminalService } from "@shared/modules/terminal/terminal.service";
import { terminalService } from "./terminal.service";
export const schedulerService = {
start: () => {

View File

@@ -0,0 +1,325 @@
import {
TextChannel,
ContainerBuilder,
TextDisplayBuilder,
SectionBuilder,
SeparatorBuilder,
ThumbnailBuilder,
MessageFlags,
SeparatorSpacingSize
} from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, transactions, lootdrops, inventory } from "@db/schema";
import { desc, sql } from "drizzle-orm";
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { env } from "@shared/lib/env";
const COLORS = {
HEADER: 0x9B59B6,
LEADERS: 0xF1C40F,
ACTIVITY: 0x3498DB,
ALERT: 0xE74C3C
};
function getPrimaryGuildId(): string | null {
return env.DISCORD_GUILD_ID ?? null;
}
export const terminalService = {
init: async (channel: TextChannel) => {
const guildId = channel.guildId;
if (!guildId) {
console.error("Cannot initialize terminal: no guild ID");
return;
}
// Clean up old terminal if exists
const currentConfig = await getGuildConfig(guildId);
if (currentConfig.terminal?.channelId && currentConfig.terminal?.messageId) {
try {
const oldChannel = await AuroraClient.channels.fetch(currentConfig.terminal.channelId).catch(() => null) as TextChannel | null;
if (oldChannel) {
const oldMsg = await oldChannel.messages.fetch(currentConfig.terminal.messageId).catch(() => null);
if (oldMsg) await oldMsg.delete();
}
} catch (e) {
// Ignore if old message doesn't exist
}
}
const msg = await channel.send({ content: "🔄 Initializing Aurora Station..." });
// Save to database
await guildSettingsService.upsertSettings({
guildId,
terminalChannelId: channel.id,
terminalMessageId: msg.id,
});
invalidateGuildConfigCache(guildId);
await terminalService.update(guildId);
},
update: async (guildId?: string) => {
const effectiveGuildId = guildId ?? getPrimaryGuildId();
if (!effectiveGuildId) {
console.warn("No guild ID available for terminal update");
return;
}
const guildConfig = await getGuildConfig(effectiveGuildId);
if (!guildConfig.terminal?.channelId || !guildConfig.terminal?.messageId) {
return;
}
try {
const channel = await AuroraClient.channels.fetch(guildConfig.terminal.channelId).catch(() => null) as TextChannel | null;
if (!channel) {
console.warn("Terminal channel not found");
return;
}
const message = await channel.messages.fetch(guildConfig.terminal.messageId).catch(() => null);
if (!message) {
console.warn("Terminal message not found");
return;
}
const containers = await terminalService.buildMessage();
await message.edit({
content: null,
embeds: null as any,
components: containers as any,
flags: MessageFlags.IsComponentsV2,
allowedMentions: { parse: [] }
});
} catch (error) {
console.error("Failed to update terminal:", error);
}
},
buildMessage: async () => {
// ═══════════════════════════════════════════════════════════
// DATA FETCHING
// ═══════════════════════════════════════════════════════════
const allUsers = await DrizzleClient.select().from(users);
const totalUsers = allUsers.length;
const totalWealth = allUsers.reduce((acc: bigint, u: any) => acc + (u.balance || 0n), 0n);
// System stats
const uptime = process.uptime();
const uptimeHours = Math.floor(uptime / 3600);
const uptimeMinutes = Math.floor((uptime % 3600) / 60);
const ping = AuroraClient.ws.ping;
const now = Math.floor(Date.now() / 1000);
// Guild member count (if available)
const guild = AuroraClient.guilds.cache.first();
const memberCount = guild?.memberCount ?? totalUsers;
// Additional metrics
const avgLevel = totalUsers > 0
? Math.round(allUsers.reduce((acc: number, u: any) => acc + (u.level || 1), 0) / totalUsers)
: 1;
const topStreak = allUsers.reduce((max: number, u: any) => Math.max(max, u.dailyStreak || 0), 0);
// Items in circulation
const itemsResult = await DrizzleClient
.select({ total: sql<string>`COALESCE(SUM(${inventory.quantity}), 0)` })
.from(inventory);
const totalItems = Number(itemsResult[0]?.total || 0);
// Last command timestamp
const lastCmd = AuroraClient.lastCommandTimestamp
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
: "*Never*";
// Leaderboards
const topLevels = [...allUsers]
.sort((a, b) => (b.level || 0) - (a.level || 0))
.slice(0, 3);
const topWealth = [...allUsers]
.sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n))
.slice(0, 3);
// Lootdrops
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
where: (lootdrops: any, { isNull }: any) => isNull(lootdrops.claimedBy),
limit: 1,
orderBy: desc(lootdrops.createdAt)
});
const recentDrops = await DrizzleClient.query.lootdrops.findMany({
where: (lootdrops: any, { isNotNull }: any) => isNotNull(lootdrops.claimedBy),
limit: 1,
orderBy: desc(lootdrops.createdAt)
});
// Recent transactions
const recentTx = await DrizzleClient.query.transactions.findMany({
limit: 3,
orderBy: [desc(transactions.createdAt)]
});
// ═══════════════════════════════════════════════════════════
// HELPER FORMATTERS
// ═══════════════════════════════════════════════════════════
const getMedal = (i: number) => i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉";
const formatLeaderEntry = (u: typeof users.$inferSelect, i: number, type: 'level' | 'wealth') => {
const medal = getMedal(i);
const value = type === 'level'
? `Lvl ${u.level ?? 1}`
: `${Number(u.balance ?? 0).toLocaleString()} AU`;
return `${medal} **${u.username}** — ${value}`;
};
const getActivityIcon = (type: string) => {
if (type.includes("LOOT")) return "🌠";
if (type.includes("GIFT")) return "🎁";
if (type.includes("SHOP")) return "🛒";
if (type.includes("DAILY")) return "☀️";
if (type.includes("QUEST")) return "📜";
return "💫";
};
// ═══════════════════════════════════════════════════════════
// CONTAINER 1: HEADER - Station Overview
// ═══════════════════════════════════════════════════════════
const botAvatar = AuroraClient.user?.displayAvatarURL({ size: 64 }) ?? "";
const headerSection = new SectionBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 🔮 AURORA STATION"),
new TextDisplayBuilder().setContent("-# Real-time server observatory")
)
.setThumbnailAccessory(
new ThumbnailBuilder().setURL(botAvatar)
);
const statsText = [
`📡 **Uptime** ${uptimeHours}h ${uptimeMinutes}m`,
`🏓 **Ping** ${ping}ms`,
`👥 **Students** ${totalUsers}`,
`🪙 **Economy** ${totalWealth.toLocaleString()} AU`
].join(" • ");
const secondaryStats = [
`📦 **Items** ${totalItems.toLocaleString()}`,
`📈 **Avg Lvl** ${avgLevel}`,
`🔥 **Top Streak** ${topStreak}d`,
`⚡ **Last Cmd** ${lastCmd}`
].join(" • ");
const headerContainer = new ContainerBuilder()
.setAccentColor(COLORS.HEADER)
.addSectionComponents(headerSection)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(statsText),
new TextDisplayBuilder().setContent(secondaryStats),
new TextDisplayBuilder().setContent(`-# Updated <t:${now}:R>`)
);
// ═══════════════════════════════════════════════════════════
// CONTAINER 2: LEADERBOARDS
// ═══════════════════════════════════════════════════════════
const levelLeaderText = topLevels.length > 0
? topLevels.map((u, i) => formatLeaderEntry(u, i, 'level')).join("\n")
: "*No data yet*";
const wealthLeaderText = topWealth.length > 0
? topWealth.map((u, i) => formatLeaderEntry(u, i, 'wealth')).join("\n")
: "*No data yet*";
const leadersContainer = new ContainerBuilder()
.setAccentColor(COLORS.LEADERS)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## ✨ CONSTELLATION LEADERS")
)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`**Brightest Stars**\n${levelLeaderText}`),
new TextDisplayBuilder().setContent(`**Gilded Nebulas**\n${wealthLeaderText}`)
);
// ═══════════════════════════════════════════════════════════
// CONTAINER 3: LIVE ACTIVITY
// ═══════════════════════════════════════════════════════════
// Determine if there's an active lootdrop
const hasActiveDrop = activeDrops.length > 0 && activeDrops[0];
const activityColor = hasActiveDrop ? COLORS.ALERT : COLORS.ACTIVITY;
const activityContainer = new ContainerBuilder()
.setAccentColor(activityColor)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("## 🌠 LIVE ACTIVITY")
)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
// Active lootdrop or recent event
if (hasActiveDrop) {
const drop = activeDrops[0]!;
const expiresTimestamp = Math.floor(drop.expiresAt!.getTime() / 1000);
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`🚨 **SHOOTING STAR ACTIVE**\n` +
`> **Reward:** \`${drop.rewardAmount} ${drop.currency}\`\n` +
`> **Location:** <#${drop.channelId}>\n` +
`> **Expires:** <t:${expiresTimestamp}:R>`
)
);
} else if (recentDrops.length > 0 && recentDrops[0]) {
const drop = recentDrops[0];
const claimer = allUsers.find((u: any) => u.id === drop.claimedBy);
const claimedTimestamp = drop.createdAt ? Math.floor(drop.createdAt.getTime() / 1000) : now;
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(
`✅ **Last Star Claimed**\n` +
`> **${claimer?.username ?? 'Unknown'}** collected \`${drop.rewardAmount} ${drop.currency}\` <t:${claimedTimestamp}:R>`
)
);
} else {
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`-# The sky is quiet... waiting for the next star.`)
);
}
// Recent transactions
if (recentTx.length > 0) {
activityContainer.addSeparatorComponents(
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
);
const txLines = recentTx.map((tx: any) => {
const time = Math.floor(tx.createdAt!.getTime() / 1000);
const icon = getActivityIcon(tx.type);
const user = allUsers.find((u: any) => u.id === tx.userId);
// Clean description (remove trailing channel IDs)
let desc = tx.description || "Unknown";
desc = desc.replace(/\s*\d{17,19}\s*$/, "").trim();
return `${icon} **${user?.username ?? 'Unknown'}**: ${desc} · <t:${time}:R>`;
});
activityContainer.addTextDisplayComponents(
new TextDisplayBuilder().setContent("**Recent Echoes**"),
new TextDisplayBuilder().setContent(txLines.join("\n"))
);
}
return [headerContainer, leadersContainer, activityContainer];
}
};