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:
@@ -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"
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
163
shared/modules/inventory/effect.handlers.ts
Normal file
163
shared/modules/inventory/effect.handlers.ts
Normal 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.";
|
||||
};
|
||||
41
shared/modules/inventory/effect.registry.ts
Normal file
41
shared/modules/inventory/effect.registry.ts
Normal 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);
|
||||
}
|
||||
71
shared/modules/inventory/effect.types.ts
Normal file
71
shared/modules/inventory/effect.types.ts
Normal 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>;
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user