Skip to content

Commit

Permalink
Add online command and make it easier to run commands
Browse files Browse the repository at this point in the history
This is a big commit (I probably should have split it up into multiple,
but oh well). This adds the !online command to retrieve the players
currently online on every server.

I also added a `short_description` to each command which will be shown
in the !help command. The command's description will be shown (in
addition to the short description) when !help <command> is used.

This commit also adds an easier way to run commands. A command context
will be used that contains all necessary information for a Command class
to process the command. I put control of sending the command results in
the command class itself because it allows for more
extensibility--specifically when sending more than one embed, which
allows for waiting for reactions from the user to scroll through more of
the output.
  • Loading branch information
Syntro42 committed Jan 7, 2024
1 parent 044f4f2 commit 34ad796
Show file tree
Hide file tree
Showing 17 changed files with 349 additions and 115 deletions.
17 changes: 12 additions & 5 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { existsSync } from "fs";
export class Bridge {
channel: TextChannel;
name: string;
rcon: Rcon;
serverLogPath: string;

private rcon: Rcon;
private serverLogPath: string;

constructor(server: IServer, channel: TextChannel) {
this.channel = channel;
Expand Down Expand Up @@ -48,16 +49,22 @@ export class Bridge {
}

async sendToMinecraft(message: Message): Promise<void> {
// TODO: Handle errors in send
const minecraftMsg = MinecraftMessage.format(message);
await this.sendMinecraftCommand(`tellraw @a ${minecraftMsg}`);
}

async sendMinecraftCommand(command: string): Promise<string> {
try {
await this.rcon.send(`tellraw @a ${minecraftMsg}`);
// TODO: Handle errors in send
return await this.rcon.send(command);
} catch (e) {
if (e instanceof PacketTooBigError) {
console.error("Input too big to send to Minecraft. Skipping...");
} else {
console.error("Unknown error while sending message to Minecraft: " + e);
console.error("Unknown error while sending command to Minecraft: " + e);
}
}

return "";
}
}
8 changes: 5 additions & 3 deletions src/commands/command.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { EmbedBuilder, GuildMember } from "discord.js";
import { ICommandInfo } from "../lib/interfaces.js";
import { ICommandContext, ICommandInfo } from "../lib/interfaces.js";
import { GuildMember } from "discord.js";

export class Command {
name: string;
aliases: string[];
short_description: string;
description: string;
usage: string;
roles_allowed: string[];
Expand All @@ -12,14 +13,15 @@ export class Command {
constructor(info: ICommandInfo) {
this.name = info.name;
this.aliases = info.aliases || [];
this.short_description = info.short_description;
this.description = info.description;
this.usage = info.usage;
this.roles_allowed = info.roles_allowed || [];
this.users_allowed = info.users_allowed || [];
}

/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
async run(args: string[]): Promise<EmbedBuilder> {
async run(cmdContext: ICommandContext): Promise<void> {
throw new Error("This command has no functionality!");
}

Expand Down
34 changes: 22 additions & 12 deletions src/commands/commandHandler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CommandNotFoundEmbed, InvalidPermissionsEmbed } from "../lib/embeds.js";
import { EmbedBuilder, GuildMember } from "discord.js";
import { CommandNotFoundEmbed, ErrorEmbed, InvalidPermissionsEmbed } from "../lib/embeds.js";
import { Command } from "./command.js";
import { ICommandContext } from "../lib/interfaces.js";

export class CommandHandler {
private commands: Command[] = [];
Expand All @@ -9,20 +9,30 @@ export class CommandHandler {
this.commands.push(command);
}

async runCommand(
commandName: string,
args: string[],
author: GuildMember,
): Promise<EmbedBuilder> {
async handleCommand(cmdContext: ICommandContext): Promise<void> {
const cmdName = cmdContext.cmdName;
for (const command of this.commands) {
if (command.name === commandName || command.aliases.includes(commandName)) {
return (await command.hasPermission(author))
? await command.run(args)
: new InvalidPermissionsEmbed(commandName);
if (command.name === cmdName || command.aliases.includes(cmdName)) {
await this.runCommand(command, cmdContext);
return;
}
}

return new CommandNotFoundEmbed(commandName);
cmdContext.channel.send({ embeds: [new CommandNotFoundEmbed(cmdName)] });
}

private async runCommand(command: Command, cmdContext: ICommandContext): Promise<void> {
if (!(await command.hasPermission(cmdContext.author))) {
cmdContext.channel.send({ embeds: [new InvalidPermissionsEmbed(command.name)] });
return;
}

try {
await command.run(cmdContext);
} catch (e: any) {
const errorMsg = `Error running command '${command.name}': ${e.message}`;
cmdContext.channel.send({ embeds: [new ErrorEmbed(errorMsg)] });
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/commands/discord/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./online.js";
83 changes: 83 additions & 0 deletions src/commands/discord/online.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Embed, ErrorEmbed, InfoEmbed } from "../../lib/embeds.js";
import { TextChannel, escapeMarkdown } from "discord.js";
import { Bridge } from "../../bridge.js";
import { Command } from "../command.js";
import { ICommandContext } from "../../lib/interfaces.js";
import { discordCommand } from "../commandHandler.js";

@discordCommand
export class OnlineCommand extends Command {
constructor() {
const defaultCmdInfo = {
name: "online",
aliases: ["o"],
short_description: "Get a list of all online players on the server",
description:
"By default, a list will be shown for each server. Provide a server " +
"name to list players online on a specific server",
usage: "online [serverName]",
};

super(defaultCmdInfo);
}

async run(ctx: ICommandContext): Promise<void> {
const queriedBridges = this.getQueriedBridges(ctx.args, ctx.bridges);

const embeds = [];
if (queriedBridges.length === 0) {
// One bridge will always be set up if this point is reached, so zero queried bridges
// always means at least one argument was provided.
const embed = new ErrorEmbed(`No server named '${ctx.args[0]}'`);
embeds.push(embed);
} else {
for (const bridge of queriedBridges) {
const playerInfo = await this.getOnlinePlayers(bridge);
const embed = this.formatEmbed(playerInfo, bridge, ctx.channel);
embeds.push(embed);
}
}

ctx.channel.send({ embeds: embeds });
}

private getQueriedBridges(args: string[], bridges: Bridge[]): Bridge[] {
if (args.length <= 0) {
return bridges;
}

return bridges.filter((bridge) => bridge.name === args[0]);
}

private async getOnlinePlayers(bridge: Bridge): Promise<IPlayerInfo> {
const data = await bridge.sendMinecraftCommand("list");
const response = data.split(" ");
return {
onlineCount: Number(response[2]),
maxCount: Number(response[7]),
playerList: response.slice(10),
};
}

private formatEmbed(playerInfo: IPlayerInfo, bridge: Bridge, channel: TextChannel): Embed {
if (playerInfo.onlineCount === 0) {
return new ErrorEmbed(`No players online on \`${bridge.name}\``);
}

const guildName = channel.guild.name;
const guildIcon = channel.guild.iconURL();
const description =
`\`${playerInfo.onlineCount}/${playerInfo.maxCount}\` players online ` +
`on: \`${bridge.name}\``;
return new InfoEmbed(guildName, description).setThumbnail(guildIcon).addFields({
name: "Player list:",
value: escapeMarkdown(playerInfo.playerList.map((name) => `- ${name}`).join("\n")),
});
}
}

interface IPlayerInfo {
onlineCount: number;
maxCount: number;
playerList: string[];
}
39 changes: 27 additions & 12 deletions src/endbot.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Client, GatewayIntentBits, Message, TextChannel } from "discord.js";
import { CommandHandler, commandHandler } from "./commands/commandHandler.js";
import { Bridge } from "./bridge.js";
import { CommandHandler } from "./commands/commandHandler.js";
import { Config } from "./config.js";
import { ICommandContext } from "./lib/interfaces.js";

export class Endbot extends Client {
private bridges: Bridge[] = [];
Expand All @@ -19,7 +20,7 @@ export class Endbot extends Client {
});

this.config = new Config();
this.handler = new CommandHandler();
this.handler = commandHandler;
// TODO: Handle failed logins better
this.login(this.config.token).catch(console.error);

Expand Down Expand Up @@ -48,17 +49,20 @@ export class Endbot extends Client {
}

private async handleDiscordMessage(message: Message): Promise<void> {
if (message.author.bot) return;
if (this.isInvalidMessage(message) || this.bridges.length === 0) return;

this.sendMessageToMinecraft(message);

const { cmdName, args } = this.extractCommand(message.content);
if (cmdName) {
const resultEmbed = await this.handler.runCommand(cmdName, args, message.member!);
message.channel.send({ embeds: [resultEmbed] });
const cmdContext = this.createCmdContext(message);
if (cmdContext.cmdName) {
await this.handler.handleCommand(cmdContext);
}
}

private isInvalidMessage(message: Message): boolean {
return message.author.bot || !message.member || !(message.channel instanceof TextChannel);
}

private sendMessageToMinecraft(message: Message): void {
for (const bridge of this.bridges) {
if (bridge.channel.id === message.channelId) {
Expand All @@ -67,12 +71,23 @@ export class Endbot extends Client {
}
}

private extractCommand(content: string): { cmdName: string; args: string[] } {
if (!this.config.prefixes.includes(content[0])) {
return { cmdName: "", args: [] };
private createCmdContext(message: Message): ICommandContext {
let cmdName = "";
let args: string[] = [];
if (this.config.prefixes.includes(message.content[0])) {
// This message is a valid command
const words = message.content.split(" ");
cmdName = words[0].slice(1);
args = words.slice(1);
}

const words = content.split(" ");
return { cmdName: words[0].slice(1), args: words.slice(1) };
return {
cmdName: cmdName,
args: args,
author: message.member!,
bridges: this.bridges,
// Will always be a TextChannel as the isInvalidMessage check passed
channel: message.channel as TextChannel,
};
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import "./commands/discord/index.js";

import { Endbot } from "./endbot.js";

new Endbot();
31 changes: 23 additions & 8 deletions src/lib/embeds.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,40 @@
import { EmbedBuilder } from "discord.js";

export type Embed = ErrorEmbed | InfoEmbed;

const Colors = {
ERROR: 0xfc251e,
INFO: 0x86ff40,
};

export class CommandNotFoundEmbed extends EmbedBuilder {
constructor(command: string) {
export class ErrorEmbed extends EmbedBuilder {
constructor(errorMsg: string, title: string = "") {
super({
title: "Command Not Found",
description: `Unable to find command: '${command}'`,
title: title,
description: errorMsg,
color: Colors.ERROR,
});
}
}

export class InvalidPermissionsEmbed extends EmbedBuilder {
export class CommandNotFoundEmbed extends ErrorEmbed {
constructor(command: string) {
super(`Unable to find command: '${command}'`, "Command Not Found");
}
}

export class InvalidPermissionsEmbed extends ErrorEmbed {
constructor(command: string) {
super(`You don't have permission to run the command: '${command}'`, "Invalid Permissions");
}
}

export class InfoEmbed extends EmbedBuilder {
constructor(title: string, description: string) {
super({
title: "Invalid Permissions",
description: `You don't have permission to run the command: '${command}'`,
color: Colors.ERROR,
title: title,
description: description,
color: Colors.INFO,
});
}
}
12 changes: 12 additions & 0 deletions src/lib/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import { GuildMember, TextChannel } from "discord.js";
import { Bridge } from "../bridge.js";

export interface IConfig {
token: string;
prefixes: string[];
servers?: IServer[];
}

export interface ICommandContext {
cmdName: string;
args: string[];
author: GuildMember;
bridges: Bridge[];
channel: TextChannel;
}

export interface ICommandInfo {
name: string;
aliases?: string[];
short_description: string;
description: string;
usage: string;
roles_allowed?: string[];
Expand Down
2 changes: 1 addition & 1 deletion src/lib/rcon/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export class Queue {
private queue: QueueItem[] = [];
private draining = false;

async add(getData: () => Promise<string>): Promise<void> {
async add(getData: () => Promise<string>): Promise<string> {
return new Promise((resolve, reject) => {
this.queue.push({ getData, resolve, reject });
this.drain();
Expand Down
2 changes: 1 addition & 1 deletion src/lib/rcon/rcon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class Rcon {
}
}

async send(data: string, type = PacketType.COMMAND): Promise<void> {
async send(data: string, type = PacketType.COMMAND): Promise<string> {
const packet = await Packet.create(data, type);

// This function is added to the queue each time send() is called. This
Expand Down
Loading

0 comments on commit 34ad796

Please sign in to comment.