From 89bea28b033301a390dd84b05b50b9e537bac8a3 Mon Sep 17 00:00:00 2001 From: Esteve Autet Alexe Date: Sun, 24 Mar 2024 10:20:59 +0100 Subject: [PATCH] feat: code interpreter (#31) --- .idea/inspectionProfiles/Project_Default.xml | 31 ++++ .../code-interpreter/slash/RunCode.ts | 136 ++++++++++++++++++ .../code-interpreter/slash/SessionResult.ts | 49 +++++++ .../code-interpreter/utils/ApiTypes.ts | 19 +++ .../code-interpreter/utils/RunnerUtils.ts | 59 ++++++++ src/modules/handlers/HandlerParameters.ts | 12 +- 6 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 src/commands/code-interpreter/slash/RunCode.ts create mode 100644 src/commands/code-interpreter/slash/SessionResult.ts create mode 100644 src/commands/code-interpreter/utils/ApiTypes.ts create mode 100644 src/commands/code-interpreter/utils/RunnerUtils.ts 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/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