diff --git a/.github/workflows/deploy-docker.yml b/.github/workflows/deploy-docker.yml
index 2fc20c3..0cc6c52 100644
--- a/.github/workflows/deploy-docker.yml
+++ b/.github/workflows/deploy-docker.yml
@@ -1,4 +1,4 @@
-name: Publish Docker Image
+name: Publish Docker Image and update bot's server
on:
pull_request:
@@ -35,4 +35,11 @@ jobs:
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
\ No newline at end of file
+ labels: ${{ steps.meta.outputs.labels }}
+
+ - name: Update container in bot's server
+ if: success()
+ run: |
+ curl -X POST -H "Content-Type: application/json" -d '{"image": "alexcrav/saasbot"}' $WEBHOOK_SERVER
+ env:
+ WEBHOOK_SERVER: ${{ secrets.WEBHOOK_SERVER }}
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 0a8a18b..e2d69cd 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -2,6 +2,37 @@
+
+
+
\ No newline at end of file
diff --git a/src/commands/code-interpreter/slash/RunCode.ts b/src/commands/code-interpreter/slash/RunCode.ts
new file mode 100644
index 0000000..465f6c2
--- /dev/null
+++ b/src/commands/code-interpreter/slash/RunCode.ts
@@ -0,0 +1,136 @@
+import {SlashCommand} from "../../../modules/handlers/HandlerBuilders.js";
+import {Attachment, EmbedBuilder, SlashCommandBuilder} from "discord.js";
+import {CreateRunnerResponse, GetRunnerDetails} from "../utils/ApiTypes.js";
+import {checkRunnerDetails, checkRunnerStatus, getResultEmbed, extensions} from "../utils/RunnerUtils.js";
+
+export default new SlashCommand({
+ builder: new SlashCommandBuilder()
+ .setName("run-code")
+ .setDescription("Code running category")
+ .addSubcommand(c => c
+ .setName("from-file")
+ .setDescription("Run code from a file you upload")
+ .addAttachmentOption(o => o
+ .setName("file")
+ .setDescription("The file to run the code from, the language will be inferred from the file extension")
+ .setRequired(true)
+ )
+ .addStringOption(o => o
+ .setName("stdin")
+ .setDescription("Pass input to the code interpreter.")
+ )
+ )
+ .addSubcommand(c => c
+ .setName("from-input")
+ .setDescription("Run code directly from your command input")
+ .addStringOption(o => o
+ .setName("code")
+ .setDescription("The script to run.")
+ .setRequired(true)
+ )
+ .addStringOption(o => o
+ .setName("language")
+ .setDescription("The coding language the script is written on")
+ .setRequired(true)
+ .addChoices(
+ {name: "C | C17/clang10", value: "c"},
+ {name: "C++ | C++17/clang10", value: "cpp"},
+ {name: "Java | OpenJDK18", value: "java"},
+ {name: "Kotlin | 1.7.10/JRE18", value: "kotlin"},
+ {name: "Swift | 5.6.2", value: "swift"},
+ {name: "C# | Mono 6", value: "csharp"},
+ {name: "Go | 1.19", value: "go"},
+ {name: "Python 3 | 3.8.10", value: "python3"},
+ {name: "Ruby | 3.1.2p20", value: "ruby"},
+ {name: "PHP | 8.1.9 cli", value: "php"},
+ {name: "BASH | 5.0.17", value: "bash"},
+ {name: "RLang | 3.6.3", value: "r"},
+ {name: "JavaScript | NodeJS 16.17.0", value: "javascript"},
+ {name: "Visual Basic | Mono 6", value: "vb"},
+ {name: "BrainFuck", value: "brainfuck"},
+ {name: "Cobol | cobc 2.2.0", value: "cobol"},
+ {name: "F# | F# Interactive 4.0", value: "fsharp"},
+ {name: "Elixir | 1.12.3", value: "elixir"},
+ {name: "Rust | rustc 1.59.0", value: "rust"},
+ {name: "TypeScript | 4.8.2", value: "typescript"}
+ )
+ )
+ .addStringOption(o => o
+ .setName("stdin")
+ .setDescription("Pass input to the code interpreter.")
+ )
+ ),
+
+ async handler(): Promise {
+ let code: string;
+ let language: string;
+ const stdin: string | null = this.context.options.getString("stdin");
+
+ if (this.context.options.getSubcommand() === "from-file") {
+ const file: Attachment = this.context.options.getAttachment("file")!;
+ code = await fetch(file.url).then(f => f.text());
+
+ const splitName: string[] = file.name.split('.');
+
+ if (splitName.length < 2
+ || !Object.keys(extensions).includes(splitName[splitName.length - 1])) {
+
+ const unknownLangEmbed: EmbedBuilder = new EmbedBuilder()
+ .setTitle("Invalid language")
+ .setDescription("Inferred language is not valid, valid languages include:\n\n```" +
+ Object.keys(extensions).map(e => `*.${e}`).join(", ") +
+ "```"
+ )
+ .setColor("#FF0000");
+
+ await this.context.reply({
+ embeds: [unknownLangEmbed],
+ ephemeral: true
+ });
+ return;
+ }
+
+ language = extensions[splitName[splitName.length - 1]];
+ } else {
+ code = this.context.options.getString("code")!;
+ language = this.context.options.getString("language")!;
+ }
+
+ await this.context.deferReply();
+
+ const createRunnerResult: CreateRunnerResponse = await fetch("http://api.paiza.io/runners/create?"
+ + `source_code=${encodeURIComponent(code)}&`
+ + `language=${encodeURIComponent(language)}&`
+ + "api_key=guest&"
+ + (stdin !== null ? `input=${encodeURIComponent(stdin)}` : ""),
+ { method: "POST" }
+ )
+ .then(r => r.json());
+
+ setTimeout(async (): Promise => {
+ const checkRunnerResult: CreateRunnerResponse = await checkRunnerStatus(createRunnerResult.id);
+
+ if (checkRunnerResult.status === "running") {
+ const timeoutEmbed: EmbedBuilder = new EmbedBuilder()
+ .setTitle("Long running session!")
+ .setDescription(
+ "Looks like this session is going to take some time," +
+ "but don't worry you can still use the " +
+ "command and check the output for this session once it's finished!"
+ )
+ .addFields(
+ {name: "Session ID", value: checkRunnerResult.id, inline: true},
+ {name: "Status", value: checkRunnerResult.status, inline: true}
+ )
+ .setColor("#FFA500");
+
+ await this.context.editReply({embeds: [timeoutEmbed]});
+ return;
+ }
+
+ const getRunnerResult: GetRunnerDetails = await checkRunnerDetails(createRunnerResult.id);
+
+ await this.context.editReply({embeds: [getResultEmbed(getRunnerResult)]});
+ }, 3000);
+ }
+});
\ No newline at end of file
diff --git a/src/commands/code-interpreter/slash/SessionResult.ts b/src/commands/code-interpreter/slash/SessionResult.ts
new file mode 100644
index 0000000..5e33722
--- /dev/null
+++ b/src/commands/code-interpreter/slash/SessionResult.ts
@@ -0,0 +1,49 @@
+import {SlashCommand} from "../../../modules/handlers/HandlerBuilders.js";
+import {EmbedBuilder, SlashCommandBuilder} from "discord.js";
+import {CreateRunnerResponse} from "../utils/ApiTypes.js";
+import {checkRunnerDetails, checkRunnerStatus, getResultEmbed} from "../utils/RunnerUtils.js";
+
+export default new SlashCommand({
+ builder: new SlashCommandBuilder()
+ .setName("code-session")
+ .setDescription("Get data from code sessions")
+ .addSubcommand(c => c
+ .setName("get-result")
+ .setDescription("Get result from a long running session")
+ .addStringOption(o => o
+ .setName("id")
+ .setDescription("The session ID")
+ .setRequired(true)
+ )
+ ),
+
+ async handler(): Promise {
+ await this.context.deferReply();
+ const sessionId: string = this.context.options.getString("id")!;
+
+ const status: CreateRunnerResponse = await checkRunnerStatus(sessionId);
+
+ if (status.status === "running") {
+ const timeoutEmbed: EmbedBuilder = new EmbedBuilder()
+ .setTitle("On it!")
+ .setDescription(
+ "This runner is still running," +
+ "please, wait and run this command again in a few seconds"
+ )
+ .addFields(
+ {name: "Session ID", value: status.id, inline: true},
+ {name: "Status", value: status.status, inline: true}
+ )
+ .setColor("#FFA500")
+
+ await this.context.editReply({
+ embeds: [timeoutEmbed]
+ });
+ return;
+ }
+
+ await this.context.editReply({
+ embeds: [getResultEmbed(await checkRunnerDetails(sessionId))]
+ });
+ }
+})
\ No newline at end of file
diff --git a/src/commands/code-interpreter/utils/ApiTypes.ts b/src/commands/code-interpreter/utils/ApiTypes.ts
new file mode 100644
index 0000000..c60a615
--- /dev/null
+++ b/src/commands/code-interpreter/utils/ApiTypes.ts
@@ -0,0 +1,19 @@
+
+export interface CreateRunnerResponse {
+ id: string;
+ status: "running" | "completed";
+}
+
+export interface GetRunnerDetails {
+ id: string;
+
+ build_stderr: string | null;
+ build_stdout: string | null;
+ build_exit_code: number;
+ build_result: "success" | "failure" | "error";
+
+ stdout: string | null;
+ stderr: string | null;
+ result: "success" | "failure" | "error";
+ exit_code: number;
+}
\ No newline at end of file
diff --git a/src/commands/code-interpreter/utils/RunnerUtils.ts b/src/commands/code-interpreter/utils/RunnerUtils.ts
new file mode 100644
index 0000000..7fb36ab
--- /dev/null
+++ b/src/commands/code-interpreter/utils/RunnerUtils.ts
@@ -0,0 +1,59 @@
+import {CreateRunnerResponse, GetRunnerDetails} from "./ApiTypes.js";
+import {EmbedBuilder} from "discord.js";
+
+export const extensions: Record = {
+ "c": "c",
+ "cpp": "cpp",
+ "java": "java",
+ "kt": "kotlin",
+ "swift": "swift",
+ "cs": "csharp",
+ "go": "go",
+ "py": "python3",
+ "rb": "ruby",
+ "php": "php",
+ "sh": "bash",
+ "r": "r",
+ "js": "javascript",
+ "vb": "vb",
+ "bf": "brainfuck",
+ "cob": "cobol",
+ "fs": "fsharp",
+ "ex": "elixir",
+ "rs": "rust",
+ "ts": "typescript"
+}
+
+export async function checkRunnerDetails(id: string): Promise {
+ return await fetch(`http://api.paiza.io/runners/get_details?id=${encodeURIComponent(id)}&api_key=guest`)
+ .then(r => r.json());
+}
+
+export async function checkRunnerStatus(id: string): Promise {
+ return await fetch(`http://api.paiza.io/runners/get_status?id=${encodeURIComponent(id)}&api_key=guest`)
+ .then(r => r.json());
+}
+
+export function getResultEmbed(details: GetRunnerDetails): EmbedBuilder {
+ if (details.build_result !== "success" && details.build_result !== null) {
+ return new EmbedBuilder()
+ .setTitle("Build error")
+ .setDescription(`Session ID: \`${details.id}\`\n\`\`\`${details.build_stderr ?? details.build_stdout}\`\`\``)
+ .setColor("#FF0000")
+ .setFooter({text: `Exited with code: ${details.build_exit_code}`});
+ }
+
+ if (details.result !== "success") {
+ return new EmbedBuilder()
+ .setTitle("Runtime error")
+ .setDescription(`Session ID: \`${details.id}\`\n\`\`\`${details.stderr ?? details.stdout}\`\`\``)
+ .setColor("#FF0000")
+ .setFooter({text: `Exited with code: ${details.exit_code}`});
+ }
+
+ return new EmbedBuilder()
+ .setTitle("Success")
+ .setDescription(`Session ID: \`${details.id}\`\n\`\`\`${details.stdout}\`\`\``)
+ .setColor("#00B000")
+ .setFooter({text: `Exited with code: ${details.exit_code}`});
+}
\ No newline at end of file
diff --git a/src/commands/slash/setup-stats.ts b/src/commands/slash/SetupStats.ts
similarity index 95%
rename from src/commands/slash/setup-stats.ts
rename to src/commands/slash/SetupStats.ts
index d5135a8..04a4d91 100644
--- a/src/commands/slash/setup-stats.ts
+++ b/src/commands/slash/SetupStats.ts
@@ -41,6 +41,7 @@ export default new SlashCommand({
}
const totalMembers = guild.memberCount
+ await guild.members.fetch();
const totalUsers = guild.members.cache.filter(member => !member.user.bot).size
const totalBots = guild.members.cache.filter(member => member.user.bot).size
@@ -80,6 +81,6 @@ export default new SlashCommand({
]
})
- await this.context.reply(`Canal de estadísticas creado en la categoría ${category}`)
+ await this.context.reply(`Canal de estadísticas creado correctamente`)
}
});
\ No newline at end of file
diff --git a/src/commands/slash/test.ts b/src/commands/slash/Test.ts
similarity index 100%
rename from src/commands/slash/test.ts
rename to src/commands/slash/Test.ts
diff --git a/src/events/InteractionCreate.ts b/src/events/InteractionCreate.ts
index 7f2638f..9e6e70b 100644
--- a/src/events/InteractionCreate.ts
+++ b/src/events/InteractionCreate.ts
@@ -8,18 +8,21 @@ import {
} from "../modules/handlers/HandlerBuilders.js";
import {
ButtonInteraction,
- ChatInputCommandInteraction,
+ ChatInputCommandInteraction, EmbedBuilder,
Events,
Interaction,
SelectMenuInteraction,
UserContextMenuCommandInteraction
} from "discord.js";
+import {logger, notifyError} from "../modules/utils/logger.js";
export default new Event({
event: Events.InteractionCreate,
handler(interaction: Interaction): void {
let command: CommandTypes | ComponentTypes | undefined = undefined;
+ let interactionIdentifier: string = "unknown";
+ let interactionType: string = "unknown";
if (interaction instanceof ChatInputCommandInteraction) {
command = this.client.commands
@@ -32,6 +35,11 @@ export default new Event({
context: interaction,
client: this.client
}
+
+ interactionIdentifier = `/${interaction.commandName} `
+ + `${interaction.options.getSubcommandGroup ?? ""} `
+ + interaction.options.getSubcommand ?? "";
+ interactionType = "slash command";
} else if (interaction instanceof UserContextMenuCommandInteraction) {
command = this.client.commands
.find((i: CommandTypes): boolean =>
@@ -43,6 +51,9 @@ export default new Event({
context: interaction,
client: this.client
}
+
+ interactionIdentifier = interaction.commandName;
+ interactionType = "user command";
} else if (
interaction instanceof SelectMenuInteraction
|| interaction instanceof ButtonInteraction
@@ -51,8 +62,65 @@ export default new Event({
.find((i: ComponentTypes): boolean =>
i.parameters.componentId === interaction.customId
)
+
+ interactionIdentifier = interaction.customId;
+ interactionType = "message component"
}
- command?.parameters.handler.bind(command?.context)();
+ new Promise((resolve, reject) => {
+ try {
+ const handlerResult: Promise | void
+ = command?.parameters.handler.bind(command?.context)();
+
+ if (handlerResult instanceof Promise) {
+ handlerResult
+ .then(resolve)
+ .catch(reject);
+
+ return;
+ }
+
+ resolve();
+ } catch (error: any) {
+ reject(error);
+ }
+ })
+ .then((): void => {
+ logger.info(
+ `${interactionType} triggered by ${interaction.user.globalName} |> ${interactionIdentifier}`
+ );
+ })
+ .catch(async (error: Error) => {
+ logger.error(
+ `${interactionType} triggered by ${interaction.user.globalName} caught an error`
+ +` |> ${interactionIdentifier}\n${error}`
+ );
+
+ // required for type guarding...
+ if (!(interaction instanceof ChatInputCommandInteraction
+ || interaction instanceof UserContextMenuCommandInteraction
+ || interaction instanceof SelectMenuInteraction
+ || interaction instanceof ButtonInteraction))
+ return;
+
+ const errorEmbed: EmbedBuilder = new EmbedBuilder()
+ .setTitle("Internal error")
+ .setDescription("Whoops, looks like there was an error while replying to your interaction, " +
+ "don't worry this error has been notified and we are doing everything in our hands to solve it.")
+ .setColor("#FF0000");
+
+ if (!interaction.replied && !interaction.deferred) {
+ await interaction.reply({
+ embeds: [errorEmbed]
+ });
+ return;
+ }
+
+ await interaction.followUp({
+ embeds: [errorEmbed]
+ });
+
+ notifyError(error);
+ });
}
});
\ No newline at end of file
diff --git a/src/events/Ready.ts b/src/events/Ready.ts
index 903a2b1..8c502ff 100644
--- a/src/events/Ready.ts
+++ b/src/events/Ready.ts
@@ -16,7 +16,7 @@ export default new Event({
let currentStatus: number = 0;
function changeStatus(this: EventContext): void {
this.client.user!.setActivity(
-presences[++currentStatus === presences.length ? 0 : currentStatus]
+ presences[++currentStatus === presences.length ? 0 : currentStatus]
);
}
diff --git a/src/index.ts b/src/index.ts
index e81334b..b0219f1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,6 +11,7 @@ import {
ComponentTypes,
UserCommand
} from "./modules/handlers/HandlerBuilders.js";
+import {logger, notifyError} from "./modules/utils/logger.js";
(await import("dotenv")).config({path: ".env"});
@@ -46,13 +47,36 @@ await (new REST()
));
await registerFiles("events", (imported: Event): void => {
- imported.context = {
- client
+ function eventWrapper(...params: any[]): void {
+ new Promise((resolve, reject) => {
+ try {
+ const handlerResult: Promise | void
+ = imported.parameters.handler.call(imported.context, ...params);
+
+ if (handlerResult instanceof Promise) {
+ handlerResult
+ .then(resolve)
+ .catch(reject);
+
+ return;
+ }
+
+ resolve();
+ } catch (error: any) {
+ reject(error);
+ }
+ })
+ .catch((error: Error): void => {
+ logger.error(`Error caught in ${imported.parameters.event} event:\n${error}`);
+ notifyError(error);
+ })
}
+ imported.context = { client };
+
client.on(
imported.parameters.event,
- imported.parameters.handler.bind(imported.context)
+ eventWrapper
);
});
diff --git a/src/modules/handlers/HandlerParameters.ts b/src/modules/handlers/HandlerParameters.ts
index 59668d3..a769061 100644
--- a/src/modules/handlers/HandlerParameters.ts
+++ b/src/modules/handlers/HandlerParameters.ts
@@ -1,5 +1,11 @@
-import {Events, Interaction, SlashCommandBuilder} from "discord.js";
-import {BaseContext, ButtonContext, CommandContext, EventContext, SelectMenuContext} from "./HandlerContext.js";
+import {Events, Interaction, SlashCommandSubcommandsOnlyBuilder} from "discord.js";
+import {
+ BaseContext,
+ ButtonContext,
+ CommandContext,
+ EventContext,
+ SelectMenuContext
+} from "./HandlerContext.js";
export interface BaseParameters {
handler: (this: TThis, ...params: any[]) => Promise | void;
@@ -8,7 +14,7 @@ export interface BaseParameters {
export interface CommandParameters
extends BaseParameters> {
- builder: SlashCommandBuilder;
+ builder: SlashCommandSubcommandsOnlyBuilder;
}
export interface EventParameters
diff --git a/src/modules/utils/logger.ts b/src/modules/utils/logger.ts
index 8cea28a..5a0fa73 100644
--- a/src/modules/utils/logger.ts
+++ b/src/modules/utils/logger.ts
@@ -64,3 +64,10 @@ export const logger = createLogger({
})
]
});
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export function notifyError(error: Error): void {
+ // add a notification system and remove the eslint override.
+ // the console logging is already done, this should send a message
+ // to someone who can take care about the bot throwing an error.
+}
\ No newline at end of file