forked from syntaxbullet/aurorabot
refactor: convert LootdropService from class to singleton object pattern
- Move instance properties to module-level state (channelActivity, channelCooldowns) - Convert constructor cleanup interval to module-level initialization - Export state variables for testing - Update tests to use direct state access instead of (service as any) - Maintains same behavior while following project service pattern Closes #4
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
|
import { lootdropService, channelActivity, channelCooldowns } from "@shared/modules/economy/lootdrop.service";
|
||||||
import { lootdrops } from "@db/schema";
|
import { lootdrops } from "@db/schema";
|
||||||
import { economyService } from "@shared/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
|
|
||||||
@@ -67,8 +67,8 @@ describe("lootdropService", () => {
|
|||||||
mockFrom.mockClear();
|
mockFrom.mockClear();
|
||||||
|
|
||||||
// Reset internal state
|
// Reset internal state
|
||||||
(lootdropService as any).channelActivity = new Map();
|
channelActivity.clear();
|
||||||
(lootdropService as any).channelCooldowns = new Map();
|
channelCooldowns.clear();
|
||||||
|
|
||||||
// Mock Math.random
|
// Mock Math.random
|
||||||
originalRandom = Math.random;
|
originalRandom = Math.random;
|
||||||
|
|||||||
@@ -7,234 +7,233 @@ import { lootdrops } from "@db/schema";
|
|||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { eq, and, isNull, lt } from "drizzle-orm";
|
import { eq, and, isNull, lt } from "drizzle-orm";
|
||||||
|
|
||||||
interface Lootdrop {
|
// Module-level state (exported for testing)
|
||||||
messageId: string;
|
export const channelActivity: Map<string, number[]> = new Map();
|
||||||
channelId: string;
|
export const channelCooldowns: Map<string, number> = new Map();
|
||||||
rewardAmount: number;
|
|
||||||
currency: string;
|
// Private helper function
|
||||||
claimedBy?: string;
|
function cleanupActivity() {
|
||||||
createdAt: Date;
|
const now = Date.now();
|
||||||
|
const window = config.lootdrop.activityWindowMs;
|
||||||
|
|
||||||
|
for (const [channelId, timestamps] of channelActivity.entries()) {
|
||||||
|
const validTimestamps = timestamps.filter(t => now - t < window);
|
||||||
|
if (validTimestamps.length === 0) {
|
||||||
|
channelActivity.delete(channelId);
|
||||||
|
} else {
|
||||||
|
channelActivity.set(channelId, validTimestamps);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class LootdropService {
|
// Initialize cleanup interval on module load
|
||||||
private channelActivity: Map<string, number[]> = new Map();
|
setInterval(() => {
|
||||||
private channelCooldowns: Map<string, number> = new Map();
|
cleanupActivity();
|
||||||
|
cleanupExpiredLootdrops(true);
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
constructor() {
|
async function cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
|
||||||
// Cleanup interval for activity tracking and expired lootdrops
|
try {
|
||||||
setInterval(() => {
|
const now = new Date();
|
||||||
this.cleanupActivity();
|
const whereClause = includeClaimed
|
||||||
this.cleanupExpiredLootdrops(true);
|
? lt(lootdrops.expiresAt, now)
|
||||||
}, 60000);
|
: and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now));
|
||||||
|
|
||||||
|
const result = await DrizzleClient.delete(lootdrops)
|
||||||
|
.where(whereClause)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`);
|
||||||
|
}
|
||||||
|
return result.length;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to cleanup lootdrops:", error);
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private cleanupActivity() {
|
async function processMessage(message: Message) {
|
||||||
const now = Date.now();
|
if (message.author.bot || !message.guild) return;
|
||||||
const window = config.lootdrop.activityWindowMs;
|
|
||||||
|
|
||||||
for (const [channelId, timestamps] of this.channelActivity.entries()) {
|
const channelId = message.channel.id;
|
||||||
const validTimestamps = timestamps.filter(t => now - t < window);
|
const now = Date.now();
|
||||||
if (validTimestamps.length === 0) {
|
|
||||||
this.channelActivity.delete(channelId);
|
// Check cooldown
|
||||||
} else {
|
const cooldown = channelCooldowns.get(channelId);
|
||||||
this.channelActivity.set(channelId, validTimestamps);
|
if (cooldown && now < cooldown) return;
|
||||||
}
|
|
||||||
|
// Track activity
|
||||||
|
const timestamps = channelActivity.get(channelId) || [];
|
||||||
|
timestamps.push(now);
|
||||||
|
channelActivity.set(channelId, timestamps);
|
||||||
|
|
||||||
|
// Filter for window
|
||||||
|
const window = config.lootdrop.activityWindowMs;
|
||||||
|
const recentActivity = timestamps.filter(t => now - t < window);
|
||||||
|
|
||||||
|
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, []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
|
async function spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) {
|
||||||
try {
|
const min = config.lootdrop.reward.min;
|
||||||
const now = new Date();
|
const max = config.lootdrop.reward.max;
|
||||||
const whereClause = includeClaimed
|
const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min);
|
||||||
? lt(lootdrops.expiresAt, now)
|
const currency = overrideCurrency ?? config.lootdrop.reward.currency;
|
||||||
: and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now));
|
|
||||||
|
|
||||||
const result = await DrizzleClient.delete(lootdrops)
|
const { content, files, components } = await getLootdropMessage(reward, currency);
|
||||||
.where(whereClause)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
try {
|
||||||
console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`);
|
const message = await channel.send({ content, files, components });
|
||||||
}
|
|
||||||
return result.length;
|
// Persist to DB
|
||||||
} catch (error) {
|
await DrizzleClient.insert(lootdrops).values({
|
||||||
console.error("Failed to cleanup lootdrops:", error);
|
messageId: message.id,
|
||||||
return 0;
|
channelId: channel.id,
|
||||||
}
|
rewardAmount: reward,
|
||||||
|
currency: currency,
|
||||||
|
createdAt: new Date(),
|
||||||
|
// Expire after 10 mins
|
||||||
|
expiresAt: new Date(Date.now() + 600000)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger Terminal Update
|
||||||
|
terminalService.update(channel.guildId);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to spawn lootdrop:", error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async processMessage(message: Message) {
|
async function tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> {
|
||||||
if (message.author.bot || !message.guild) return;
|
try {
|
||||||
|
// Atomic update: Try to set claimedBy where it is currently null
|
||||||
|
// This acts as a lock and check in one query
|
||||||
|
const result = await DrizzleClient.update(lootdrops)
|
||||||
|
.set({ claimedBy: BigInt(userId) })
|
||||||
|
.where(and(
|
||||||
|
eq(lootdrops.messageId, messageId),
|
||||||
|
isNull(lootdrops.claimedBy)
|
||||||
|
))
|
||||||
|
.returning();
|
||||||
|
|
||||||
const channelId = message.channel.id;
|
if (result.length === 0 || !result[0]) {
|
||||||
const now = Date.now();
|
// If update affected 0 rows, check if it was because it doesn't exist or is already claimed
|
||||||
|
const check = await DrizzleClient.select().from(lootdrops).where(eq(lootdrops.messageId, messageId));
|
||||||
|
if (check.length === 0) {
|
||||||
|
return { success: false, error: "This lootdrop has expired." };
|
||||||
|
}
|
||||||
|
return { success: false, error: "This lootdrop has already been claimed." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const drop = result[0];
|
||||||
|
|
||||||
|
await economyService.modifyUserBalance(
|
||||||
|
userId,
|
||||||
|
BigInt(drop.rewardAmount),
|
||||||
|
"LOOTDROP_CLAIM",
|
||||||
|
`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) {
|
||||||
|
console.error("Error claiming lootdrop:", error);
|
||||||
|
return { success: false, error: "An error occurred while processing the reward." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLootdropState() {
|
||||||
|
let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null;
|
||||||
|
let maxMessages = -1;
|
||||||
|
|
||||||
|
const window = config.lootdrop.activityWindowMs;
|
||||||
|
const now = Date.now();
|
||||||
|
const required = config.lootdrop.minMessages;
|
||||||
|
|
||||||
|
for (const [channelId, timestamps] of channelActivity.entries()) {
|
||||||
|
// Filter valid just to be sure we are reporting accurate numbers
|
||||||
|
const validCount = timestamps.filter(t => now - t < window).length;
|
||||||
|
|
||||||
// Check cooldown
|
// Check cooldown
|
||||||
const cooldown = this.channelCooldowns.get(channelId);
|
const cooldownUntil = channelCooldowns.get(channelId);
|
||||||
if (cooldown && now < cooldown) return;
|
const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
|
||||||
|
|
||||||
// Track activity
|
if (validCount > maxMessages) {
|
||||||
const timestamps = this.channelActivity.get(channelId) || [];
|
maxMessages = validCount;
|
||||||
timestamps.push(now);
|
hottestChannel = {
|
||||||
this.channelActivity.set(channelId, timestamps);
|
id: channelId,
|
||||||
|
messages: validCount,
|
||||||
// Filter for window
|
progress: Math.min(100, (validCount / required) * 100),
|
||||||
const window = config.lootdrop.activityWindowMs;
|
cooldown: isOnCooldown
|
||||||
const recentActivity = timestamps.filter(t => now - t < window);
|
};
|
||||||
|
|
||||||
if (recentActivity.length >= config.lootdrop.minMessages) {
|
|
||||||
// Chance to spawn
|
|
||||||
if (Math.random() < config.lootdrop.spawnChance) {
|
|
||||||
await this.spawnLootdrop(message.channel as TextChannel);
|
|
||||||
// Set cooldown
|
|
||||||
this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
|
|
||||||
this.channelActivity.set(channelId, []);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async spawnLootdrop(channel: TextChannel, overrideReward?: number, overrideCurrency?: string) {
|
return {
|
||||||
const min = config.lootdrop.reward.min;
|
monitoredChannels: channelActivity.size,
|
||||||
const max = config.lootdrop.reward.max;
|
hottestChannel,
|
||||||
const reward = overrideReward ?? (Math.floor(Math.random() * (max - min + 1)) + min);
|
config: {
|
||||||
const currency = overrideCurrency ?? config.lootdrop.reward.currency;
|
requiredMessages: required,
|
||||||
|
dropChance: config.lootdrop.spawnChance
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const { content, files, components } = await getLootdropMessage(reward, currency);
|
async function clearCaches() {
|
||||||
|
channelActivity.clear();
|
||||||
|
channelCooldowns.clear();
|
||||||
|
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 {
|
try {
|
||||||
const message = await channel.send({ content, files, components });
|
const { AuroraClient } = await import("../../../bot/lib/BotClient");
|
||||||
|
const channel = await AuroraClient.channels.fetch(drop.channelId) as TextChannel;
|
||||||
// Persist to DB
|
if (channel) {
|
||||||
await DrizzleClient.insert(lootdrops).values({
|
const message = await channel.messages.fetch(messageId);
|
||||||
messageId: message.id,
|
if (message) await message.delete();
|
||||||
channelId: channel.id,
|
|
||||||
rewardAmount: reward,
|
|
||||||
currency: currency,
|
|
||||||
createdAt: new Date(),
|
|
||||||
// Expire after 10 mins
|
|
||||||
expiresAt: new Date(Date.now() + 600000)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger Terminal Update
|
|
||||||
terminalService.update(channel.guildId);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to spawn lootdrop:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async tryClaim(messageId: string, userId: string, username: string): Promise<{ success: boolean; amount?: number; currency?: string; error?: string }> {
|
|
||||||
try {
|
|
||||||
// Atomic update: Try to set claimedBy where it is currently null
|
|
||||||
// This acts as a lock and check in one query
|
|
||||||
const result = await DrizzleClient.update(lootdrops)
|
|
||||||
.set({ claimedBy: BigInt(userId) })
|
|
||||||
.where(and(
|
|
||||||
eq(lootdrops.messageId, messageId),
|
|
||||||
isNull(lootdrops.claimedBy)
|
|
||||||
))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (result.length === 0 || !result[0]) {
|
|
||||||
// If update affected 0 rows, check if it was because it doesn't exist or is already claimed
|
|
||||||
const check = await DrizzleClient.select().from(lootdrops).where(eq(lootdrops.messageId, messageId));
|
|
||||||
if (check.length === 0) {
|
|
||||||
return { success: false, error: "This lootdrop has expired." };
|
|
||||||
}
|
|
||||||
return { success: false, error: "This lootdrop has already been claimed." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const drop = result[0];
|
|
||||||
|
|
||||||
await economyService.modifyUserBalance(
|
|
||||||
userId,
|
|
||||||
BigInt(drop.rewardAmount),
|
|
||||||
"LOOTDROP_CLAIM",
|
|
||||||
`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) {
|
|
||||||
console.error("Error claiming lootdrop:", error);
|
|
||||||
return { success: false, error: "An error occurred while processing the reward." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public getLootdropState() {
|
|
||||||
let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null;
|
|
||||||
let maxMessages = -1;
|
|
||||||
|
|
||||||
const window = config.lootdrop.activityWindowMs;
|
|
||||||
const now = Date.now();
|
|
||||||
const required = config.lootdrop.minMessages;
|
|
||||||
|
|
||||||
for (const [channelId, timestamps] of this.channelActivity.entries()) {
|
|
||||||
// Filter valid just to be sure we are reporting accurate numbers
|
|
||||||
const validCount = timestamps.filter(t => now - t < window).length;
|
|
||||||
|
|
||||||
// Check cooldown
|
|
||||||
const cooldownUntil = this.channelCooldowns.get(channelId);
|
|
||||||
const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
|
|
||||||
|
|
||||||
if (validCount > maxMessages) {
|
|
||||||
maxMessages = validCount;
|
|
||||||
hottestChannel = {
|
|
||||||
id: channelId,
|
|
||||||
messages: validCount,
|
|
||||||
progress: Math.min(100, (validCount / required) * 100),
|
|
||||||
cooldown: isOnCooldown
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Could not delete lootdrop message from Discord:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return true;
|
||||||
monitoredChannels: this.channelActivity.size,
|
} catch (error) {
|
||||||
hottestChannel,
|
console.error("Error deleting lootdrop:", error);
|
||||||
config: {
|
return false;
|
||||||
requiredMessages: required,
|
|
||||||
dropChance: config.lootdrop.spawnChance
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async clearCaches() {
|
|
||||||
this.channelActivity.clear();
|
|
||||||
this.channelCooldowns.clear();
|
|
||||||
console.log("[LootdropService] Caches cleared via administrative action.");
|
|
||||||
}
|
|
||||||
public async 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 = new LootdropService();
|
export const lootdropService = {
|
||||||
|
cleanupExpiredLootdrops,
|
||||||
|
processMessage,
|
||||||
|
spawnLootdrop,
|
||||||
|
tryClaim,
|
||||||
|
getLootdropState,
|
||||||
|
clearCaches,
|
||||||
|
deleteLootdrop,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user