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

@@ -82,92 +82,81 @@ describe("lootdropService", () => {
mockModifyUserBalance.mockRestore();
});
describe("processMessage", () => {
it("should track activity but not spawn if minMessages not reached", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
describe("trackActivity", () => {
it("should track activity but not spawn if minMessages not reached", () => {
const result1 = lootdropService.trackActivity("chan1");
const result2 = lootdropService.trackActivity("chan1");
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
// Expect no spawn attempt
expect(mockChannel.send).not.toHaveBeenCalled();
// Internal state check if possible, or just behavior
expect(result1.shouldSpawn).toBe(false);
expect(result2.shouldSpawn).toBe(false);
});
it("should spawn lootdrop if minMessages reached and chance hits", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
mockChannel.send.mockResolvedValue({ id: "msg1" });
it("should spawn lootdrop if minMessages reached and chance hits", () => {
Math.random = () => 0.01; // Force hit (0.01 < 0.5)
// Send 3 messages
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
lootdropService.trackActivity("chan1");
lootdropService.trackActivity("chan1");
const result = lootdropService.trackActivity("chan1");
expect(mockChannel.send).toHaveBeenCalled();
expect(mockInsert).toHaveBeenCalledWith(lootdrops);
// Verify DB insert
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
channelId: "chan1",
messageId: "msg1",
currency: "GOLD"
}));
expect(result.shouldSpawn).toBe(true);
});
it("should not spawn if chance fails", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
it("should not spawn if chance fails", () => {
Math.random = () => 0.99; // Force fail (0.99 > 0.5)
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
lootdropService.trackActivity("chan1");
lootdropService.trackActivity("chan1");
const result = lootdropService.trackActivity("chan1");
expect(mockChannel.send).not.toHaveBeenCalled();
expect(result.shouldSpawn).toBe(false);
});
it("should respect cooldowns", async () => {
const mockChannel = { id: "chan1", send: mock() };
const mockMessage = {
author: { bot: false },
guild: {},
channel: mockChannel
};
mockChannel.send.mockResolvedValue({ id: "msg1" });
it("should respect cooldowns", () => {
Math.random = () => 0.01; // Force hit
// Trigger spawn
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
lootdropService.trackActivity("chan1");
lootdropService.trackActivity("chan1");
const result1 = lootdropService.trackActivity("chan1");
expect(mockChannel.send).toHaveBeenCalledTimes(1);
mockChannel.send.mockClear();
expect(result1.shouldSpawn).toBe(true);
// Try again immediately (cooldown active)
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
await lootdropService.processMessage(mockMessage as any);
lootdropService.trackActivity("chan1");
lootdropService.trackActivity("chan1");
const result2 = lootdropService.trackActivity("chan1");
expect(mockChannel.send).not.toHaveBeenCalled();
expect(result2.shouldSpawn).toBe(false);
});
});
describe("calculateReward", () => {
it("should return override values when provided", () => {
const result = lootdropService.calculateReward(500, "SILVER");
expect(result.reward).toBe(500);
expect(result.currency).toBe("SILVER");
});
it("should return random reward within range when no override", () => {
const result = lootdropService.calculateReward();
expect(result.reward).toBeGreaterThanOrEqual(10);
expect(result.reward).toBeLessThanOrEqual(100);
expect(result.currency).toBe("GOLD");
});
});
describe("persistLootdrop", () => {
it("should insert lootdrop into database", async () => {
await lootdropService.persistLootdrop("msg1", "chan1", 50, "GOLD");
expect(mockInsert).toHaveBeenCalledWith(lootdrops);
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
messageId: "msg1",
channelId: "chan1",
rewardAmount: 50,
currency: "GOLD"
}));
});
});

View File

@@ -1,8 +1,5 @@
import { Message, TextChannel } from "discord.js";
import { getLootdropMessage } from "@/modules/economy/lootdrop.view";
import { config } from "@shared/lib/config";
import { economyService } from "./economy.service";
import { terminalService } from "@shared/modules/terminal/terminal.service";
import { lootdrops } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { eq, and, isNull, lt } from "drizzle-orm";
@@ -53,15 +50,16 @@ async function cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise
}
}
async function processMessage(message: Message) {
if (message.author.bot || !message.guild) return;
const channelId = message.channel.id;
/**
* Track channel activity and determine if a lootdrop should spawn.
* Returns shouldSpawn: true if conditions are met (activity threshold + random chance).
*/
function trackActivity(channelId: string): { shouldSpawn: boolean } {
const now = Date.now();
// Check cooldown
const cooldown = channelCooldowns.get(channelId);
if (cooldown && now < cooldown) return;
if (cooldown && now < cooldown) return { shouldSpawn: false };
// Track activity
const timestamps = channelActivity.get(channelId) || [];
@@ -75,41 +73,61 @@ async function processMessage(message: Message) {
if (recentActivity.length >= config.lootdrop.minMessages) {
// Chance to spawn
if (Math.random() < config.lootdrop.spawnChance) {
await spawnLootdrop(message.channel as TextChannel);
// Set cooldown
channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
channelActivity.set(channelId, []);
return { shouldSpawn: true };
}
}
return { shouldSpawn: false };
}
async function spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) {
/**
* Calculate lootdrop reward amount and currency.
*/
function calculateReward(overrideReward?: number, overrideCurrency?: string): { reward: number; currency: string } {
const min = config.lootdrop.reward.min;
const max = config.lootdrop.reward.max;
const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min);
const currency = overrideCurrency ?? config.lootdrop.reward.currency;
return { reward, currency };
}
const { content, files, components } = await getLootdropMessage(reward, currency);
/**
* Persist a spawned lootdrop to the database.
*/
async function persistLootdrop(messageId: string, channelId: string, reward: number, currency: string): Promise<void> {
await DrizzleClient.insert(lootdrops).values({
messageId,
channelId,
rewardAmount: reward,
currency: currency,
createdAt: new Date(),
// Expire after 10 mins
expiresAt: new Date(Date.now() + 600000)
});
}
/**
* Remove a lootdrop from the database. Returns the channelId for Discord cleanup.
*/
async function removeLootdrop(messageId: string): Promise<{ channelId: string } | null> {
try {
const message = await channel.send({ content, files, components });
// Persist to DB
await DrizzleClient.insert(lootdrops).values({
messageId: message.id,
channelId: channel.id,
rewardAmount: reward,
currency: currency,
createdAt: new Date(),
// Expire after 10 mins
expiresAt: new Date(Date.now() + 600000)
// First fetch it to get channel info
const drop = await DrizzleClient.query.lootdrops.findFirst({
where: eq(lootdrops.messageId, messageId)
});
// Trigger Terminal Update
terminalService.update(channel.guildId);
if (!drop) return null;
// Delete from DB
await DrizzleClient.delete(lootdrops).where(eq(lootdrops.messageId, messageId));
return { channelId: drop.channelId };
} catch (error) {
console.error("Failed to spawn lootdrop:", error);
console.error("Error removing lootdrop:", error);
return null;
}
}
@@ -143,9 +161,6 @@ async function tryClaim(messageId: string, userId: string, username: string): Pr
`Claimed lootdrop in channel ${drop.channelId}`
);
// Trigger Terminal Update (uses primary guild from env)
terminalService.update();
return { success: true, amount: drop.rewardAmount, currency: drop.currency };
} catch (error) {
@@ -197,43 +212,13 @@ async function clearCaches() {
console.log("[LootdropService] Caches cleared via administrative action.");
}
async function deleteLootdrop(messageId: string): Promise<boolean> {
try {
// First fetch it to get channel info so we can delete the message
const drop = await DrizzleClient.query.lootdrops.findFirst({
where: eq(lootdrops.messageId, messageId)
});
if (!drop) return false;
// Delete from DB
await DrizzleClient.delete(lootdrops).where(eq(lootdrops.messageId, messageId));
// Try to delete from Discord
try {
const { AuroraClient } = await import("../../../bot/lib/BotClient");
const channel = await AuroraClient.channels.fetch(drop.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;
} catch (error) {
console.error("Error deleting lootdrop:", error);
return false;
}
}
export const lootdropService = {
cleanupExpiredLootdrops,
processMessage,
spawnLootdrop,
trackActivity,
calculateReward,
persistLootdrop,
removeLootdrop,
tryClaim,
getLootdropState,
clearCaches,
deleteLootdrop,
};

View File

@@ -0,0 +1,163 @@
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

@@ -0,0 +1,41 @@
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

@@ -0,0 +1,71 @@
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

@@ -170,7 +170,7 @@ export const inventoryService = {
const results: any[] = [];
// 2. Apply Effects
const { validateAndExecuteEffect } = await import("@/modules/inventory/effect.registry");
const { validateAndExecuteEffect } = await import("./effect.registry");
for (const effect of usageData.effects) {
const result = await validateAndExecuteEffect(effect, userId, txFn);

View File

@@ -1,199 +0,0 @@
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,325 +0,0 @@
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];
}
};