@zerospacegg/vynthra
Version:
Discord bot for ZeroSpace.gg data
344 lines (291 loc) ⢠11 kB
text/typescript
import {
ChatInputCommandInteraction,
Client,
Collection,
Events,
GatewayIntentBits,
SlashCommandBuilder,
} from "discord.js";
import { EventEmitter } from "events";
import { deployCommands } from "./deploy.js";
// import { statsSubcommand } from "./subcommands/stats.js";
import util from "util";
import type { BotClient, BotCommand, BotConfig, BotSubcommand } from "./types.js";
import { validateSubcommands } from "./utils.js";
export interface CommandUsageEvent {
commandName: string;
subcommand?: string;
userTag: string;
userId: string;
guildId?: string;
guildName?: string;
channelType: string;
success: boolean;
errorMessage?: string;
responseTime: number;
isEphemeral: boolean;
timestamp: Date;
}
export class VynthraBot {
private client: BotClient;
private config: BotConfig;
private eventEmitter: EventEmitter;
constructor(config: BotConfig) {
this.config = config;
this.eventEmitter = new EventEmitter();
// Validate custom subcommands if provided
if (config.subcommands && config.subcommands.length > 0) {
validateSubcommands(config.subcommands);
}
// Create Discord client with necessary intents for member access
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers, // Required for member fetching (privileged intent)
],
}) as BotClient;
// Initialize commands collection
this.client.commands = new Collection();
this.setupEventHandlers();
this.loadCommands();
}
private setupEventHandlers() {
// Ready event
this.client.once(Events.ClientReady, readyClient => {
console.log(`ā
Vynthra bot is online as ${readyClient.user.tag}`);
const guilds = readyClient.guilds.cache;
const guildNames = guilds.map(guild => guild.name).join(", ");
console.log(`š Serving ${guilds.size} guilds: ${guildNames}`);
});
// Guild join event - automatically register commands for new servers
this.client.on(Events.GuildCreate, async guild => {
console.log(`š Bot added to new guild: ${guild.name} (${guild.id})`);
try {
// Deploy commands to the new guild specifically for instant availability
await this.deployCommandsToGuild(guild.id);
console.log(`ā
Commands deployed to guild: ${guild.name}`);
} catch (error) {
console.error(`ā Failed to deploy commands to guild ${guild.name}:`, error);
}
});
// Interaction handler
this.client.on(Events.InteractionCreate, async interaction => {
if (!interaction.isChatInputCommand()) return;
const command = this.client.commands.get(interaction.commandName);
if (!command) {
console.error(`ā No command matching ${interaction.commandName} was found.`);
return;
}
const startTime = Date.now();
let commandExecutionSucceeded = false;
let commandExecutionError: string | undefined;
try {
await command.execute(interaction);
commandExecutionSucceeded = true;
const commandDetails = this.formatCommandDetails(interaction);
console.log(`ā
Executed command: ${commandDetails}`);
} catch (error) {
commandExecutionSucceeded = false;
commandExecutionError = error instanceof Error ? error.message : "Unknown error";
const commandDetails = this.formatCommandDetails(interaction);
console.error(`ā Error executing command: ${commandDetails}:`, error);
const errorMessage = {
content: "ā There was an error while executing this command!",
ephemeral: true,
};
try {
if (interaction.replied || interaction.deferred) {
await interaction.followUp(errorMessage);
} else {
await interaction.reply(errorMessage);
}
} catch (followUpError) {
console.error("ā Failed to send error message:", followUpError);
}
}
// Emit command usage event for analytics
const responseTime = Date.now() - startTime;
const subcommand = interaction.options.getSubcommand(false);
// Determine if response was ephemeral
let isEphemeral = false;
try {
// Check if the interaction was deferred/replied as ephemeral
if (interaction.replied || interaction.deferred) {
isEphemeral = interaction.ephemeral || false;
}
} catch {
// Default to false if we can't determine
isEphemeral = false;
}
const usageEvent: CommandUsageEvent = {
commandName: interaction.commandName,
subcommand: subcommand || undefined,
userTag: interaction.user.tag,
userId: interaction.user.id,
guildId: interaction.guild?.id,
guildName: interaction.guild?.name,
channelType: interaction.channel?.type.toString() || "unknown",
success: commandExecutionSucceeded,
errorMessage: commandExecutionError,
responseTime,
isEphemeral,
timestamp: new Date(),
};
this.eventEmitter.emit("commandUsage", usageEvent);
});
// Error handlers
this.client.on(Events.Error, error => {
console.error("ā Discord client error:", error);
});
this.client.on(Events.Warn, warning => {
console.warn("ā ļø Discord client warning:", warning);
});
// Graceful shutdown
process.on("SIGINT", () => {
console.log("\nš Received SIGINT, shutting down gracefully...");
this.shutdown();
});
process.on("SIGTERM", () => {
console.log("\nš Received SIGTERM, shutting down gracefully...");
this.shutdown();
});
}
private loadCommands() {
// Build the main command with configurable name and subcommands
const rootCommandName = this.config.rootCommandName || "zsgg";
const rootCommandDescription = this.config.rootCommandDescription || "Search ZeroSpace.gg game data";
// Collect all subcommands (built-in + user-provided)
const allSubcommands: BotSubcommand[] = [...(this.config.subcommands || [])];
// Build the main slash command
const commandBuilder = new SlashCommandBuilder().setName(rootCommandName).setDescription(rootCommandDescription);
// Add all subcommands
for (const subcommand of allSubcommands) {
commandBuilder.addSubcommand(subcommand.builder);
}
// Create the main command handler
const mainCommand: BotCommand = {
data: commandBuilder,
async execute(interaction) {
const subcommandName = interaction.options.getSubcommand();
const subcommand = allSubcommands.find(sc => sc.name === subcommandName);
if (!subcommand) {
console.error(`ā No subcommand matching ${subcommandName} was found.`);
await interaction.reply({
content: `ā Unknown subcommand: ${subcommandName}`,
ephemeral: true,
});
return;
}
await subcommand.execute(interaction);
},
};
// Register the main command
this.client.commands.set(rootCommandName, mainCommand);
console.log(`š Loaded command: ${rootCommandName} with ${allSubcommands.length} subcommands`);
for (const subcommand of allSubcommands) {
console.log(` āā ${subcommand.name}: ${subcommand.description}`);
}
}
public async start() {
try {
console.log("š Starting Vynthra Discord bot...");
await this.client.login(this.config.token);
} catch (error) {
console.error("ā Failed to start bot:", error);
if (typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "test") {
process.exit(1);
}
throw error;
}
}
public async shutdown() {
try {
console.log("š Shutting down Vynthra bot...");
this.client.destroy();
// Don't exit process when used as a library - only when running standalone
if (typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "test" && require.main === module) {
process.exit(0);
}
} catch (error) {
console.error("ā Error during shutdown:", error);
// Don't exit process when used as a library - only when running standalone
if (typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "test" && require.main === module) {
process.exit(1);
}
throw error;
}
}
/**
* Format command details for logging
*/
private formatCommandDetails(interaction: ChatInputCommandInteraction): string {
const subcommand = interaction.options.getSubcommand(false);
const args = [];
// Collect all options/arguments
for (const option of interaction.options.data) {
if (option.type === 1) {
// SUB_COMMAND
// For subcommands, collect their options
if (option.options) {
for (const subOption of option.options) {
args.push(`${subOption.name}:${subOption.value}`);
}
}
} else {
// For direct command options
args.push(`${option.name}:${option.value}`);
}
}
const argsString = args.length > 0 ? ` [${args.join(", ")}]` : "";
const subcommandString = subcommand ? ` ${subcommand}` : "";
return `/${interaction.commandName}${subcommandString}${argsString} (User: ${interaction.user.tag}, Guild: ${
interaction.guild?.name || "DM"
})`;
}
getClient(): BotClient {
return this.client;
}
/**
* Get the event emitter for listening to bot events
*/
getEventEmitter(): EventEmitter {
return this.eventEmitter;
}
/**
* Add a listener for command usage events
*/
onCommandUsage(listener: (event: CommandUsageEvent) => void): void {
this.eventEmitter.on("commandUsage", listener);
}
/**
* Remove a command usage listener
*/
offCommandUsage(listener: (event: CommandUsageEvent) => void): void {
this.eventEmitter.off("commandUsage", listener);
}
public async deployCommandsToGuild(guildId: string) {
try {
console.log(`š Deploying commands to guild: ${guildId}`);
const guildConfig = {
...this.config,
guildId: guildId,
};
console.log(util.inspect(guildConfig, { depth: null, colors: true }));
await deployCommands(guildConfig);
console.log(`ā
Commands successfully deployed to guild: ${guildId}`);
} catch (error) {
console.error(`ā Failed to deploy commands to guild ${guildId}:`, error);
throw error;
}
}
}
export function createBot(config: BotConfig): VynthraBot {
// Validate required configuration fields
if (!config.token) {
throw new Error("Bot configuration is missing required 'token' field");
}
if (!config.clientId) {
throw new Error("Bot configuration is missing required 'clientId' field");
}
return new VynthraBot(config);
}