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