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:
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
60
bot/modules/economy/lootdrop.handler.ts
Normal file
60
bot/modules/economy/lootdrop.handler.ts
Normal 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;
|
||||
}
|
||||
@@ -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}**!`
|
||||
});
|
||||
|
||||
@@ -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.";
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
199
bot/modules/moderation/prune.service.ts
Normal file
199
bot/modules/moderation/prune.service.ts
Normal 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
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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: () => {
|
||||
|
||||
325
bot/modules/system/terminal.service.ts
Normal file
325
bot/modules/system/terminal.service.ts
Normal 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];
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user