forked from syntaxbullet/AuroraBot-discord
feat: Introduce dynamic Aurora Observatory terminal with admin command, scheduled updates, and lootdrop event integration.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,3 +43,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
|
|
||||||
src/db/data
|
src/db/data
|
||||||
src/db/log
|
src/db/log
|
||||||
|
scratchpad/
|
||||||
|
|||||||
37
src/commands/admin/terminal.ts
Normal file
37
src/commands/admin/terminal.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
import { createCommand } from "@/lib/utils";
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, TextChannel } from "discord.js";
|
||||||
|
import { terminalService } from "@/modules/terminal/terminal.service";
|
||||||
|
import { createBaseEmbed, createErrorEmbed } from "@/lib/embeds";
|
||||||
|
|
||||||
|
export const terminal = createCommand({
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName("terminal")
|
||||||
|
.setDescription("Manage the Aurora Terminal")
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("init")
|
||||||
|
.setDescription("Initialize the terminal in the current channel")
|
||||||
|
)
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === "init") {
|
||||||
|
const channel = interaction.channel;
|
||||||
|
if (!channel || channel.type !== ChannelType.GuildText) {
|
||||||
|
await interaction.reply({ embeds: [createErrorEmbed("Terminal can only be initialized in text channels.")] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ ephemeral: true, content: "Initializing terminal..." });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await terminalService.init(channel as TextChannel);
|
||||||
|
await interaction.editReply({ content: "✅ Terminal initialized!" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
await interaction.editReply({ content: "❌ Failed to initialize terminal." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { Message, TextChannel } from "discord.js";
|
|||||||
import { getLootdropMessage } from "./lootdrop.view";
|
import { getLootdropMessage } from "./lootdrop.view";
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@/lib/config";
|
||||||
import { economyService } from "./economy.service";
|
import { economyService } from "./economy.service";
|
||||||
|
import { terminalService } from "@/modules/terminal/terminal.service";
|
||||||
|
|
||||||
|
|
||||||
import { lootdrops } from "@/db/schema";
|
import { lootdrops } from "@/db/schema";
|
||||||
@@ -109,6 +110,9 @@ class LootdropService {
|
|||||||
expiresAt: new Date(Date.now() + 600000)
|
expiresAt: new Date(Date.now() + 600000)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Trigger Terminal Update
|
||||||
|
terminalService.update();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to spawn lootdrop:", error);
|
console.error("Failed to spawn lootdrop:", error);
|
||||||
}
|
}
|
||||||
@@ -144,6 +148,9 @@ class LootdropService {
|
|||||||
`Claimed lootdrop in channel ${drop.channelId}`
|
`Claimed lootdrop in channel ${drop.channelId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Trigger Terminal Update
|
||||||
|
terminalService.update();
|
||||||
|
|
||||||
return { success: true, amount: drop.rewardAmount, currency: drop.currency };
|
return { success: true, amount: drop.rewardAmount, currency: drop.currency };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export const schedulerService = {
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
schedulerService.runJanitor();
|
schedulerService.runJanitor();
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
// Terminal Update Loop (every 60s)
|
||||||
|
const { terminalService } = require("@/modules/terminal/terminal.service");
|
||||||
|
setInterval(() => {
|
||||||
|
terminalService.update();
|
||||||
|
}, 60 * 1000);
|
||||||
},
|
},
|
||||||
|
|
||||||
runJanitor: async () => {
|
runJanitor: async () => {
|
||||||
|
|||||||
169
src/modules/terminal/terminal.service.ts
Normal file
169
src/modules/terminal/terminal.service.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { TextChannel, Message } from "discord.js";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
|
import { users, transactions, lootdrops } from "@/db/schema";
|
||||||
|
import { desc } from "drizzle-orm";
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Simple persistence for the terminal message ID
|
||||||
|
const TERMINAL_DATA_PATH = join(process.cwd(), 'terminal-data.json');
|
||||||
|
|
||||||
|
interface TerminalData {
|
||||||
|
channelId: string;
|
||||||
|
messageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const terminalService = {
|
||||||
|
data: null as TerminalData | null,
|
||||||
|
|
||||||
|
loadData: () => {
|
||||||
|
if (existsSync(TERMINAL_DATA_PATH)) {
|
||||||
|
try {
|
||||||
|
terminalService.data = JSON.parse(readFileSync(TERMINAL_DATA_PATH, 'utf-8'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load terminal data", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveData: (data: TerminalData) => {
|
||||||
|
terminalService.data = data;
|
||||||
|
writeFileSync(TERMINAL_DATA_PATH, JSON.stringify(data, null, 2));
|
||||||
|
},
|
||||||
|
|
||||||
|
init: async (channel: TextChannel) => {
|
||||||
|
// limit to one terminal for now
|
||||||
|
if (terminalService.data) {
|
||||||
|
try {
|
||||||
|
const oldChannel = await AuroraClient.channels.fetch(terminalService.data.channelId) as TextChannel;
|
||||||
|
if (oldChannel) {
|
||||||
|
const oldMsg = await oldChannel.messages.fetch(terminalService.data.messageId);
|
||||||
|
if (oldMsg) await oldMsg.delete();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore if old message doesn't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = await channel.send({ content: "🔄 Initializing Aurora Observatory..." });
|
||||||
|
terminalService.saveData({
|
||||||
|
channelId: channel.id,
|
||||||
|
messageId: msg.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await terminalService.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async () => {
|
||||||
|
if (!terminalService.data) {
|
||||||
|
terminalService.loadData();
|
||||||
|
}
|
||||||
|
if (!terminalService.data) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = await AuroraClient.channels.fetch(terminalService.data.channelId) as TextChannel;
|
||||||
|
if (!channel) return;
|
||||||
|
const message = await channel.messages.fetch(terminalService.data.messageId);
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
const content = await terminalService.buildMessage();
|
||||||
|
await message.edit({ content: content, embeds: [] });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update terminal:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
buildMessage: async () => {
|
||||||
|
// 1. Global Stats
|
||||||
|
const allUsers = await DrizzleClient.select().from(users);
|
||||||
|
const totalUsers = allUsers.length;
|
||||||
|
const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n);
|
||||||
|
|
||||||
|
// 2. 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);
|
||||||
|
|
||||||
|
const formatUser = (u: typeof users.$inferSelect, i: number) => {
|
||||||
|
const star = i === 0 ? "🌟" : i === 1 ? "⭐" : "✨";
|
||||||
|
return `${star} **${u.username}**`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelText = topLevels.map((u, i) => `> ${formatUser(u, i)} • Lvl ${u.level}`).join("\n") || "> *The sky is empty...*";
|
||||||
|
const wealthText = topWealth.map((u, i) => `> ${formatUser(u, i)} • ${u.balance} AU`).join("\n") || "> *The sky is empty...*";
|
||||||
|
|
||||||
|
// 3. Lootdrops (Active/Recent)
|
||||||
|
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
|
||||||
|
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
|
||||||
|
limit: 1,
|
||||||
|
orderBy: desc(lootdrops.createdAt)
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentDrops = await DrizzleClient.query.lootdrops.findMany({
|
||||||
|
where: (lootdrops, { isNotNull }) => isNotNull(lootdrops.claimedBy),
|
||||||
|
limit: 1,
|
||||||
|
orderBy: desc(lootdrops.createdAt)
|
||||||
|
});
|
||||||
|
|
||||||
|
let phenomenaSection = "";
|
||||||
|
if (activeDrops.length > 0 && activeDrops[0]) {
|
||||||
|
const drop = activeDrops[0];
|
||||||
|
phenomenaSection = `
|
||||||
|
## 🌠 **SHOOTING STAR DETECTED**
|
||||||
|
> **Radiance**: \`${drop.rewardAmount} ${drop.currency}\`
|
||||||
|
> **Coordinates**: <#${drop.channelId}>
|
||||||
|
> **Impact**: <t:${Math.floor(drop.expiresAt!.getTime() / 1000)}:R>`;
|
||||||
|
} else if (recentDrops.length > 0 && recentDrops[0]) {
|
||||||
|
const drop = recentDrops[0];
|
||||||
|
const claimer = allUsers.find(u => u.id === drop.claimedBy);
|
||||||
|
phenomenaSection = `
|
||||||
|
## 🌑 **RECENT CELESTIAL EVENTS**
|
||||||
|
> A star fell in <#${drop.channelId}> and was caught by **${claimer?.username || 'Unknown'}**
|
||||||
|
> **Yield**: \`${drop.rewardAmount} ${drop.currency}\``;
|
||||||
|
} else {
|
||||||
|
phenomenaSection = `
|
||||||
|
## 🌑 **THE NIGHT IS QUIET**
|
||||||
|
> *Scanning the horizon for falling stars...*`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Recent Activity
|
||||||
|
const recentTx = await DrizzleClient.query.transactions.findMany({
|
||||||
|
limit: 5,
|
||||||
|
orderBy: [desc(transactions.createdAt)]
|
||||||
|
});
|
||||||
|
|
||||||
|
const activityLines = recentTx.map(tx => {
|
||||||
|
const time = Math.floor(tx.createdAt!.getTime() / 1000);
|
||||||
|
let icon = "<22>";
|
||||||
|
if (tx.type.includes("LOOT")) icon = "<22>";
|
||||||
|
if (tx.type.includes("GIFT")) icon = "🌕";
|
||||||
|
|
||||||
|
const user = allUsers.find(u => u.id === tx.userId);
|
||||||
|
return `\`[<t:${time}:T>]\` ${icon} **${user?.username || 'Unknown'}**: ${tx.description}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activityText = activityLines.join("\n") || "*Silence in the cosmos...*";
|
||||||
|
|
||||||
|
// Construct the "Container" style message
|
||||||
|
return `
|
||||||
|
# 🌌 **AURORA OBSERVATORY**
|
||||||
|
*Current Moon Phase: Waxing Crescent 🌒*
|
||||||
|
|
||||||
|
## 🔭 **OBSERVATION LOG**
|
||||||
|
> **Enrolled Stargazers**: \`${totalUsers}\`
|
||||||
|
> **Total Astral Wealth**: \`${totalWealth.toLocaleString()} AU\`
|
||||||
|
${phenomenaSection}
|
||||||
|
|
||||||
|
## ✨ **CONSTELLATION LEADERS**
|
||||||
|
**Brightest Stars (Level)**
|
||||||
|
${levelText}
|
||||||
|
|
||||||
|
**Gilded Nebulas (Wealth)**
|
||||||
|
${wealthText}
|
||||||
|
|
||||||
|
## 📡 **COSMIC ECHOES**
|
||||||
|
${activityText}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user