forked from syntaxbullet/AuroraBot-discord
feat: persistent lootbox states, update command now runs db migrations
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"generate": "docker compose run --rm app drizzle-kit generate",
|
||||
"migrate": "docker compose run --rm app drizzle-kit migrate",
|
||||
"db:push": "docker compose run --rm app drizzle-kit push",
|
||||
"db:push:local": "drizzle-kit push",
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"db:studio": "drizzle-kit studio --host 0.0.0.0"
|
||||
},
|
||||
|
||||
@@ -76,9 +76,27 @@ export const update = createCommand({
|
||||
|
||||
const { stdout } = await execAsync(`git reset --hard origin/${branch}`);
|
||||
|
||||
// Run DB Migrations
|
||||
await confirmation.editReply({
|
||||
embeds: [createWarningEmbed("Updating database schema...", "Running Migrations")],
|
||||
components: []
|
||||
});
|
||||
|
||||
let migrationOutput = "";
|
||||
try {
|
||||
const { stdout: dbOut } = await execAsync("bun run db:push:local");
|
||||
migrationOutput = dbOut;
|
||||
} catch (dbErr: any) {
|
||||
migrationOutput = `Migration Failed: ${dbErr.message}`;
|
||||
console.error("Migration Error:", dbErr);
|
||||
// We continue with restart even if migration fails?
|
||||
// Probably safer to try, or should we abort?
|
||||
// For now, let's log it and proceed, as code might depend on it but maybe it was a no-op partial fail.
|
||||
}
|
||||
|
||||
await interaction.followUp({
|
||||
flags: MessageFlags.Ephemeral,
|
||||
embeds: [createSuccessEmbed(`Git Reset Output:\n\`\`\`\n${stdout}\n\`\`\`\nRestarting process...`, "Update Successful")]
|
||||
embeds: [createSuccessEmbed(`Git Reset Output:\n\`\`\`\n${stdout}\n\`\`\`\nDB Migration Output:\n\`\`\`\n${migrationOutput}\n\`\`\`\nRestarting process...`, "Update Successful")]
|
||||
});
|
||||
|
||||
// Write context for post-restart notification
|
||||
|
||||
@@ -130,8 +130,17 @@ export const userTimers = pgTable('user_timers', {
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.userId, table.type, table.key] })
|
||||
]);
|
||||
// 9. Lootdrops
|
||||
export const lootdrops = pgTable('lootdrops', {
|
||||
messageId: varchar('message_id', { length: 255 }).primaryKey(),
|
||||
channelId: varchar('channel_id', { length: 255 }).notNull(),
|
||||
rewardAmount: integer('reward_amount').notNull(),
|
||||
currency: varchar('currency', { length: 50 }).notNull(),
|
||||
claimedBy: bigint('claimed_by', { mode: 'bigint' }).references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
// --- RELATIONS ---
|
||||
|
||||
export const classesRelations = relations(classes, ({ many }) => ({
|
||||
users: many(users),
|
||||
|
||||
@@ -3,6 +3,10 @@ import { Message, TextChannel, EmbedBuilder, ActionRowBuilder, ButtonBuilder, Bu
|
||||
import { config } from "@/lib/config";
|
||||
import { economyService } from "./economy.service";
|
||||
|
||||
import { lootdrops } from "@/db/schema";
|
||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { eq, and, isNull, lt } from "drizzle-orm";
|
||||
|
||||
interface Lootdrop {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
@@ -15,11 +19,13 @@ interface Lootdrop {
|
||||
class LootdropService {
|
||||
private channelActivity: Map<string, number[]> = new Map();
|
||||
private channelCooldowns: Map<string, number> = new Map();
|
||||
private activeLootdrops: Map<string, Lootdrop> = new Map(); // key: messageId
|
||||
|
||||
constructor() {
|
||||
// Cleanup interval for activity tracking
|
||||
setInterval(() => this.cleanupActivity(), 60000);
|
||||
// Cleanup interval for activity tracking and expired lootdrops
|
||||
setInterval(() => {
|
||||
this.cleanupActivity();
|
||||
this.cleanupExpiredLootdrops();
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
private cleanupActivity() {
|
||||
@@ -36,6 +42,19 @@ class LootdropService {
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupExpiredLootdrops() {
|
||||
try {
|
||||
const now = new Date();
|
||||
await DrizzleClient.delete(lootdrops)
|
||||
.where(and(
|
||||
isNull(lootdrops.claimedBy),
|
||||
lt(lootdrops.expiresAt, now)
|
||||
));
|
||||
} catch (error) {
|
||||
console.error("Failed to cleanup lootdrops:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public async processMessage(message: Message) {
|
||||
if (message.author.bot || !message.guild) return;
|
||||
|
||||
@@ -61,8 +80,6 @@ class LootdropService {
|
||||
await this.spawnLootdrop(message.channel as TextChannel);
|
||||
// Set cooldown
|
||||
this.channelCooldowns.set(channelId, now + config.lootdrop.cooldownMs);
|
||||
// Reset activity for this channel to prevent immediate double spawn?
|
||||
// Maybe not strictly necessary if cooldown handles it, but good practice.
|
||||
this.channelActivity.set(channelId, []);
|
||||
}
|
||||
}
|
||||
@@ -92,44 +109,45 @@ class LootdropService {
|
||||
try {
|
||||
const message = await channel.send({ embeds: [embed], components: [row] });
|
||||
|
||||
this.activeLootdrops.set(message.id, {
|
||||
// Persist to DB
|
||||
await DrizzleClient.insert(lootdrops).values({
|
||||
messageId: message.id,
|
||||
channelId: channel.id,
|
||||
rewardAmount: reward,
|
||||
currency: currency,
|
||||
createdAt: new Date()
|
||||
createdAt: new Date(),
|
||||
// Expire after 10 mins
|
||||
expiresAt: new Date(Date.now() + 600000)
|
||||
});
|
||||
|
||||
// Auto-cleanup unclaimable drops after 10 minutes (optional, prevents memory leak)
|
||||
setTimeout(() => {
|
||||
const drop = this.activeLootdrops.get(message.id);
|
||||
if (drop && !drop.claimedBy) {
|
||||
this.activeLootdrops.delete(message.id);
|
||||
// Optionally edit message to say expired
|
||||
}
|
||||
}, 600000);
|
||||
|
||||
} 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 }> {
|
||||
const drop = this.activeLootdrops.get(messageId);
|
||||
|
||||
if (!drop) {
|
||||
return { success: false, error: "This lootdrop has expired or already been fully processed." };
|
||||
}
|
||||
|
||||
if (drop.claimedBy) {
|
||||
return { success: false, error: "This lootdrop has already been claimed." };
|
||||
}
|
||||
|
||||
// Lock it
|
||||
drop.claimedBy = userId;
|
||||
this.activeLootdrops.set(messageId, drop);
|
||||
|
||||
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),
|
||||
@@ -137,15 +155,10 @@ class LootdropService {
|
||||
`Claimed lootdrop in channel ${drop.channelId}`
|
||||
);
|
||||
|
||||
// Clean up from memory
|
||||
this.activeLootdrops.delete(messageId);
|
||||
|
||||
return { success: true, amount: drop.rewardAmount, currency: drop.currency };
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error claiming lootdrop:", error);
|
||||
// Unlock if failed?
|
||||
drop.claimedBy = undefined;
|
||||
this.activeLootdrops.set(messageId, drop);
|
||||
return { success: false, error: "An error occurred while processing the reward." };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user