diff --git a/src/bot.py b/src/bot.py index e9d6151..ac77088 100644 --- a/src/bot.py +++ b/src/bot.py @@ -7,10 +7,10 @@ from datetime import datetime, timezone from os import path, listdir from discord.ext.commands import AutoShardedBot, Context -from discord import Activity, AllowedMentions, Intents +from discord import Activity, AllowedMentions, Intents, Interaction from aiohttp import ClientSession, ClientTimeout from discord.ext.commands.bot import when_mentioned_or - +from cogs.utils.runner import Runner class PistonBot(AutoShardedBot): def __init__(self, *args, **options): @@ -29,6 +29,7 @@ def __init__(self, *args, **options): async def start(self, *args, **kwargs): self.session = ClientSession(timeout=ClientTimeout(total=15)) + self.runner = Runner(self.config['emkc_key'], self.session) await super().start(*args, **kwargs) async def close(self): @@ -52,7 +53,6 @@ async def setup_hook(self): exc = f'{type(e).__name__}: {e}' print(f'Failed to load extension {extension}\n{exc}') - def user_is_admin(self, user): return user.id in self.config['admins'] diff --git a/src/cogs/error_handler.py b/src/cogs/error_handler.py index d629c12..e6f9b99 100644 --- a/src/cogs/error_handler.py +++ b/src/cogs/error_handler.py @@ -13,7 +13,7 @@ from asyncio import TimeoutError as AsyncTimeoutError from discord import Embed, DMChannel, errors as discord_errors from discord.ext import commands -from .utils.errors import PistonError +from .utils.errors import PistonError, NoLanguageFoundError class ErrorHandler(commands.Cog, name='ErrorHandler'): @@ -63,7 +63,7 @@ async def on_command_error(self, ctx, error): await ctx.send(f'Sorry {usr}, you are not allowed to run this command.') return - if isinstance(error, commands.BadArgument): + if isinstance(error, commands.BadArgument) or isinstance(error, NoLanguageFoundError): # It's in an embed to prevent mentions from working embed = Embed( title='Error', diff --git a/src/cogs/management.py b/src/cogs/management.py index da9def0..a5e932d 100644 --- a/src/cogs/management.py +++ b/src/cogs/management.py @@ -273,6 +273,14 @@ async def maintenance(self, ctx): self.client.maintenance_mode = True await self.client.change_presence(activity=self.client.maintenance_activity) + # ---------------------------------------------- + # Command to sync slash commands + # ---------------------------------------------- + @commands.command(name='synccmds', hidden=True) + async def sync_commands(self, ctx: commands.Context): + await self.client.tree.sync() + await ctx.send('Commands synced.') + async def setup(client): await client.add_cog(Management(client)) diff --git a/src/cogs/run.py b/src/cogs/run.py index 91ae91b..5022624 100644 --- a/src/cogs/run.py +++ b/src/cogs/run.py @@ -6,16 +6,11 @@ """ # pylint: disable=E0402 -import json -import re, sys +import sys from dataclasses import dataclass from discord import Embed, Message, errors as discord_errors -from discord.ext import commands, tasks -from discord.utils import escape_mentions -from aiohttp import ContentTypeError -from .utils.codeswap import add_boilerplate -from .utils.errors import PistonInvalidContentType, PistonInvalidStatus, PistonNoOutput -#pylint: disable=E1101 +from discord.ext import commands +# pylint: disable=E1101 @dataclass @@ -46,218 +41,45 @@ def get_size(obj, seen=None): class Run(commands.Cog, name='CodeExecution'): def __init__(self, client): self.client = client - self.run_IO_store = dict() # Store the most recent /run message for each user.id - self.languages = dict() # Store the supported languages and aliases - self.versions = dict() # Store version for each language - self.run_regex_code = re.compile( - r'(?s)/(?:edit_last_)?run' - r'(?: +(?P\S*?)\s*|\s*)' - r'(?:-> *(?P\S*)\s*|\s*)' - r'(?:\n(?P(?:[^\n\r\f\v]*\n)*?)\s*|\s*)' - r'```(?:(?P\S+)\n\s*|\s*)(?P.*)```' - r'(?:\n?(?P(?:[^\n\r\f\v]\n?)+)+|)' - ) - self.run_regex_file = re.compile( - r'/run(?: *(?P\S*)\s*?|\s*?)?' - r'(?: *-> *(?P\S*)\s*?|\s*?)?' - r'(?:\n(?P(?:[^\n\r\f\v]+\n?)*)\s*|\s*)?' - r'(?:\n*(?P(?:[^\n\r\f\v]\n*)+)+|)?' - ) - self.get_available_languages.start() - - @tasks.loop(count=1) - async def get_available_languages(self): - async with self.client.session.get( - 'https://emkc.org/api/v2/piston/runtimes' - ) as response: - runtimes = await response.json() - for runtime in runtimes: - language = runtime['language'] - self.languages[language] = language - self.versions[language] = runtime['version'] - for alias in runtime['aliases']: - self.languages[alias] = language - self.versions[alias] = runtime['version'] - - async def send_to_log(self, ctx, language, source): - logging_data = { - 'server': ctx.guild.name if ctx.guild else 'DMChannel', - 'server_id': str(ctx.guild.id) if ctx.guild else '0', - 'user': f'{ctx.author.name}#{ctx.author.discriminator}', - 'user_id': str(ctx.author.id), - 'language': language, - 'source': source - } - headers = {'Authorization': self.client.config["emkc_key"]} - - async with self.client.session.post( - 'https://emkc.org/api/internal/piston/log', - headers=headers, - data=json.dumps(logging_data) - ) as response: - if response.status != 200: - await self.client.log_error( - commands.CommandError(f'Error sending log. Status: {response.status}'), - ctx - ) - return False - - return True - - async def get_api_parameters_with_codeblock(self, ctx): - if ctx.message.content.count('```') != 2: - raise commands.BadArgument('Invalid command format (missing codeblock?)') - - match = self.run_regex_code.search(ctx.message.content) - - if not match: - raise commands.BadArgument('Invalid command format') - - language, output_syntax, args, syntax, source, stdin = match.groups() - - if not language: - language = syntax - - if language: - language = language.lower() - - if language not in self.languages: - raise commands.BadArgument( - f'Unsupported language: **{str(language)[:1000]}**\n' - '[Request a new language](https://github.com/engineer-man/piston/issues)' - ) - - return language, output_syntax, source, args, stdin - - async def get_api_parameters_with_file(self, ctx): - if len(ctx.message.attachments) != 1: - raise commands.BadArgument('Invalid number of attachments') - - file = ctx.message.attachments[0] - - MAX_BYTES = 65535 - if file.size > MAX_BYTES: - raise commands.BadArgument(f'Source file is too big ({file.size}>{MAX_BYTES})') - - filename_split = file.filename.split('.') - - if len(filename_split) < 2: - raise commands.BadArgument('Please provide a source file with a file extension') - - match = self.run_regex_file.search(ctx.message.content) - - if not match: - raise commands.BadArgument('Invalid command format') - - language, output_syntax, args, stdin = match.groups() - - if not language: - language = filename_split[-1] - - if language: - language = language.lower() + self.run_IO_store: dict[int, RunIO] = dict() + # Store the most recent /run message for each user.id - if language not in self.languages: - raise commands.BadArgument( - f'Unsupported file extension: **{language}**\n' - '[Request a new language](https://github.com/engineer-man/piston/issues)' - ) - - source = await file.read() - try: - source = source.decode('utf-8') - except UnicodeDecodeError as e: - raise commands.BadArgument(str(e)) - - return language, output_syntax, source, args, stdin - - async def get_run_output(self, ctx): + async def get_run_output(self, ctx: commands.Context): # Get parameters to call api depending on how the command was called (file <> codeblock) if ctx.message.attachments: - alias, output_syntax, source, args, stdin = await self.get_api_parameters_with_file(ctx) - else: - alias, output_syntax, source, args, stdin = await self.get_api_parameters_with_codeblock(ctx) - - # Resolve aliases for language - language = self.languages[alias] - - version = self.versions[alias] - - # Add boilerplate code to supported languages - source = add_boilerplate(language, source) - - # Split args at newlines - if args: - args = [arg for arg in args.strip().split('\n') if arg] - - if not source: - raise commands.BadArgument(f'No source code found') - - # Call piston API - data = { - 'language': alias, - 'version': version, - 'files': [{'content': source}], - 'args': args, - 'stdin': stdin or "", - 'log': 0 - } - headers = {'Authorization': self.client.config["emkc_key"]} - async with self.client.session.post( - 'https://emkc.org/api/v2/piston/execute', - headers=headers, - json=data - ) as response: - try: - r = await response.json() - except ContentTypeError: - raise PistonInvalidContentType('invalid content type') - if not response.status == 200: - raise PistonInvalidStatus(f'status {response.status}: {r.get("message", "")}') - - comp_stderr = r['compile']['stderr'] if 'compile' in r else '' - run = r['run'] - - if run['output'] is None: - raise PistonNoOutput('no output') - - # Logging - await self.send_to_log(ctx, language, source) - - language_info=f'{alias}({version})' - - # Return early if no output was received - if len(run['output'] + comp_stderr) == 0: - return f'Your {language_info} code ran without output {ctx.author.mention}' - - # Limit output to 30 lines maximum - output = '\n'.join((comp_stderr + run['output']).split('\n')[:30]) - - # Prevent mentions in the code output - output = escape_mentions(output) - - # Prevent code block escaping by adding zero width spaces to backticks - output = output.replace("`", "`\u200b") - - # Truncate output to be below 2000 char discord limit. - if len(comp_stderr) > 0: - introduction = f'{ctx.author.mention} I received {language_info} compile errors\n' - elif len(run['stdout']) == 0 and len(run['stderr']) > 0: - introduction = f'{ctx.author.mention} I only received {language_info} error output\n' - else: - introduction = f'Here is your {language_info} output {ctx.author.mention}\n' - truncate_indicator = '[...]' - len_codeblock = 7 # 3 Backticks + newline + 3 Backticks - available_chars = 2000-len(introduction)-len_codeblock - if len(output) > available_chars: - output = output[:available_chars-len(truncate_indicator)] + truncate_indicator + source, language, output_syntax, args, stdin = self.client.runner.get_api_params_with_file( + input_language="", + output_syntax="", + args="", + stdin="", + content=ctx.message.content, + file=ctx.message.attachments[0], + ) + return await self.client.runner.get_run_output( + ctx.guild, + ctx.author, + source=source, + language=language, + output_syntax=output_syntax, + args=args, + stdin=stdin, + mention_author=True, + ) - # Use an empty string if no output language is selected - return ( - introduction - + f'```{output_syntax or ""}\n' - + output.replace('\0', '') - + '```' + source, language, output_syntax, args, stdin = self.client.runner.get_api_params_with_codeblock( + content=ctx.message.content, + mention_author=True, + needs_strict_re=True, + ) + return await self.client.runner.get_run_output( + ctx.guild, + ctx.author, + source=source, + language=language, + output_syntax=output_syntax, + args=args, + stdin=stdin, + mention_author=True, ) async def delete_last_output(self, user_id): @@ -300,7 +122,7 @@ async def run(self, ctx, *, source=None): await self.send_howto(ctx) return try: - run_output = await self.get_run_output(ctx) + run_output, _ = await self.get_run_output(ctx) msg = await ctx.send(run_output) except commands.BadArgument as error: embed = Embed( @@ -320,7 +142,7 @@ async def edit_last_run(self, ctx, *, content=None): return try: msg_to_edit = self.run_IO_store[ctx.author.id].output - run_output = await self.get_run_output(ctx) + run_output, _ = await self.get_run_output(ctx) await msg_to_edit.edit(content=run_output, embed=None) except KeyError: # Message no longer exists in output store @@ -388,7 +210,7 @@ async def on_message_delete(self, message): await self.delete_last_output(message.author.id) async def send_howto(self, ctx): - languages = sorted(set(self.languages.values())) + languages = self.client.runner.get_languages() run_instructions = ( '**Update: Discord changed their client to prevent sending messages**\n' diff --git a/src/cogs/user_commands.py b/src/cogs/user_commands.py new file mode 100644 index 0000000..01b1ea8 --- /dev/null +++ b/src/cogs/user_commands.py @@ -0,0 +1,256 @@ +import discord +from discord import app_commands, Interaction, Attachment +from discord.ext import commands +from .utils.errors import PistonError, NoLanguageFoundError +from asyncio import TimeoutError as AsyncTimeoutError +from io import BytesIO + +class SourceCodeModal(discord.ui.Modal, title="Run Code"): + def __init__(self, get_run_output, log_error, language): + super().__init__() + self.get_run_output = get_run_output + self.log_error = log_error + self.language = language + + self.lang = discord.ui.TextInput( + label="Language", + placeholder="The language", + max_length=50, + default=self.language or "", + ) + + self.code = discord.ui.TextInput( + label="Code", + style=discord.TextStyle.long, + placeholder="The source code", + max_length=1900, + ) + + self.add_item(self.lang) + self.add_item(self.code) + + async def on_submit(self, interaction: discord.Interaction): + await interaction.response.defer() + output = await self.get_run_output( + guild=interaction.guild, + author=interaction.user, + content=self.code.value, + input_lang=self.lang.value, + output_syntax=None, + args=None, + stdin=None, + mention_author=False, + ) + + if len(self.code.value) > 1000: + file = discord.File(filename=f"source_code.{self.lang.value}", fp=BytesIO(self.code.value.encode('utf-8'))) + await interaction.followup.send("Here is your input:", file=file) + await interaction.followup.send(output) + return + formatted_src = f"```{self.lang.value}\n{self.code.value}\n```" + await interaction.followup.send("Here is your input:" + formatted_src) + await interaction.followup.send(output) + + async def on_error( + self, interaction: discord.Interaction, error: Exception + ) -> None: + await interaction.followup.send( + "Oops! Something went wrong.", ephemeral=True + ) + + await self.log_error(error, error_source="SourceCodeModal") + +class NoLang(discord.ui.Modal, title="Give language"): + def __init__(self, get_api_params_with_codeblock, get_run_output, log_error, message): + super().__init__() + self.get_api_params_with_codeblock = get_api_params_with_codeblock + self.get_run_output = get_run_output + self.log_error = log_error + self.message = message + + lang = discord.ui.TextInput( + label="Language", + placeholder="The language", + max_length=50, + ) + + async def on_submit(self, interaction: discord.Interaction): + source, language, output_syntax, args, stdin = await self.get_api_params_with_codeblock( + guild=interaction.guild, + author=interaction.user, + content=self.message.content, + mention_author=False, + needs_strict_re=False, + input_lang=self.lang.value, + jump_url=self.message.jump_url, + ) + + await interaction.response.defer() + output = await self.get_run_output( + guild=interaction.guild, + author=interaction.user, + content=source, + lang=language, + output_syntax=output_syntax, + args=args, + stdin=stdin, + mention_author=False, + ) + await interaction.followup.send(output) + + async def on_error( + self, interaction: discord.Interaction, error: Exception + ) -> None: + await interaction.followup.send( + "Oops! Something went wrong.", ephemeral=True + ) + + await self.log_error(error, error_source="NoLangModal") + + +class UserCommands(commands.Cog, name="UserCommands"): + def __init__(self, client): + self.client = client + self.ctx_menu = app_commands.ContextMenu( + name="Run Code", + callback=self.run_code_ctx_menu, + ) + self.client.tree.add_command(self.ctx_menu) + + async def cog_app_command_error(self, interaction: Interaction, error: app_commands.AppCommandError): + if isinstance(error.original, PistonError): + error_message = str(error.original) + if error_message: + error_message = f'`{error_message}` ' + await interaction.followup.send(f'API Error {error_message}- Please try again later', ephemeral=True) + await self.client.log_error(error, Interaction) + return + + if isinstance(error.original, AsyncTimeoutError): + await interaction.followup.send(f'API Timeout - Please try again later', ephemeral=True) + await self.client.log_error(error, Interaction) + return + await self.client.log_error(error, Interaction) + await interaction.followup.send(f'{error.original}', ephemeral=True) + + @app_commands.command(name="run_code", description="Open a modal to input code") + @app_commands.user_install() + @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) + async def run_code(self, interaction: Interaction, language: str = None): + if language not in self.client.runner.get_languages(inlude_aliases=True): + await interaction.response.send_modal( + SourceCodeModal( + self.client.runner.get_run_output, + self.client.log_error, + "", + ) + ) + return + await interaction.response.send_modal( + SourceCodeModal( + self.client.runner.get_run_output, + self.client.log_error, + language, + ) + ) + + @run_code.autocomplete('language') + async def autocomplete_callback(self, _: discord.Interaction, current: str): + langs = self.client.runner.get_languages(inlude_aliases=True) + if current: + langs = [lang for lang in langs if lang.startswith(current)] + return [app_commands.Choice(name=lang, value=lang) for lang in langs[:25]] + + @app_commands.command(name="run_file", description="Run a file") + @app_commands.user_install() + @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) + async def run_file( + self, + interaction: Interaction, + file: Attachment, + language: str = "", + output_syntax: str = "None", + args: str = "", + stdin: str = "", + ): + source, language, output_syntax, args, stdin = await self.client.runner.get_api_params_with_file( + guild=interaction.guild, + author=interaction.user, + content="", + file=file, + input_language=language, + output_syntax=output_syntax, + args=args, + stdin=stdin, + mention_author=False, + ) + await interaction.response.defer() + output = await self.client.runner.get_run_output( + guild=interaction.guild, + author=interaction.user, + content=source, + lang=language, + output_syntax=output_syntax, + args=args, + stdin=stdin, + mention_author=False, + jump_url=None, + ) + + + if len(source) > 1000: + output_file = discord.File(filename=file.filename, fp=BytesIO(source)) + await interaction.followup.send("Here is your input:", file=output_file) + await interaction.followup.send(output) + return + + formatted_src = f"```{language}\n{source}\n```" + await interaction.followup.send("Here is your input:" + formatted_src) + await interaction.followup.send(output) + + @app_commands.user_install() + @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) + async def run_code_ctx_menu(self, interaction: Interaction, message: discord.Message): + try: + if len(message.attachments) > 0: + source, language, output_syntax, args, stdin = await self.client.runner.get_api_params_with_file( + content=message.content, + file=message.attachments[0], + input_language="", + output_syntax="", + args="", + stdin="" + ) + else: + source, language, output_syntax, args, stdin = await self.client.runner.get_api_params_with_codeblock( + content=message.content, + needs_strict_re=False, + input_lang=None, + ) + await interaction.response.defer() + output = await self.client.runner.get_run_output( + guild=interaction.guild, + author=interaction.user, + content=source, + lang=language, + output_syntax=output_syntax, + args=args, + stdin=stdin, + mention_author=False, + jump_url=message.jump_url, + ) + await interaction.followup.send(output) + except NoLanguageFoundError: + await interaction.response.send_modal( + NoLang( + self.client.runner.get_api_params_with_codeblock, + self.client.runner.get_run_output, + self.client.log_error, + message, + ) + ) + except commands.BadArgument as e: + await interaction.followup.send(str(e), ephemeral=True) + +async def setup(client): + await client.add_cog(UserCommands(client)) diff --git a/src/cogs/utils/errors.py b/src/cogs/utils/errors.py index ede1dae..7ac06da 100644 --- a/src/cogs/utils/errors.py +++ b/src/cogs/utils/errors.py @@ -13,3 +13,7 @@ class PistonInvalidStatus(PistonError): class PistonInvalidContentType(PistonError): """Exception raised when the API request returns a non JSON content type""" pass + +class NoLanguageFoundError(Exception): + """Exception raised when no language is found""" + pass diff --git a/src/cogs/utils/runner.py b/src/cogs/utils/runner.py new file mode 100644 index 0000000..0cc740c --- /dev/null +++ b/src/cogs/utils/runner.py @@ -0,0 +1,249 @@ +import re +import json +from .errors import PistonInvalidContentType, PistonInvalidStatus, PistonNoOutput, NoLanguageFoundError +from discord.ext import commands, tasks +from discord.utils import escape_mentions +from aiohttp import ContentTypeError +from .codeswap import add_boilerplate + + +class Runner: + def __init__(self, emkc_key, session): + self.languages = dict() # Store the supported languages and aliases + self.versions = dict() # Store version for each language + self.emkc_key = emkc_key + self.base_re = ( + r'(?: +(?P\S*?)\s*|\s*)' + r'(?:-> *(?P\S*)\s*|\s*)' + r'(?:\n(?P(?:[^\n\r\f\v]*\n)*?)\s*|\s*)' + r'```(?:(?P\S+)\n\s*|\s*)(?P.*)```' + r'(?:\n?(?P(?:[^\n\r\f\v]\n?)+)+|)' + ) + self.run_regex_code = re.compile(self.base_re, re.DOTALL) + self.run_regex_code_strict = re.compile( + r'/(?:edit_last_)?run' + self.base_re, re.DOTALL + ) + + self.run_regex_file = re.compile( + r'/run(?: *(?P\S*)\s*?|\s*?)?' + r'(?: *-> *(?P\S*)\s*?|\s*?)?' + r'(?:\n(?P(?:[^\n\r\f\v]+\n?)*)\s*|\s*)?' + r'(?:\n*(?P(?:[^\n\r\f\v]\n*)+)+|)?' + ) + + self.session = session + + self.update_available_languages.start() + + @tasks.loop(count=1) + async def update_available_languages(self): + async with self.session.get( + 'https://emkc.org/api/v2/piston/runtimes' + ) as response: + runtimes = await response.json() + for runtime in runtimes: + language = runtime['language'] + self.languages[language] = language + self.versions[language] = runtime['version'] + for alias in runtime['aliases']: + self.languages[alias] = language + self.versions[alias] = runtime['version'] + + def get_languages(self, inlude_aliases=False): + return sorted(set(self.languages.keys() if inlude_aliases else self.languages.values())) + + def get_language(self, language): + return self.languages.get(language, None) + + async def send_to_log(self, guild, author, language, source): + logging_data = { + 'server': guild.name if guild else 'DMChannel', + 'server_id': f'{guild.id}' if guild else '0', + 'user': f'{author.name}', + 'user_id': f'{author.id}', + 'language': language, + 'source': source, + } + headers = {'Authorization': self.emkc_key} + + async with self.session.post( + 'https://emkc.org/api/internal/piston/log', + headers=headers, + data=json.dumps(logging_data), + ) as response: + if response.status != 200: + pass + return True + + async def get_api_params_with_codeblock( + self, + content, + needs_strict_re, + input_lang=None, + ): + if content.count('```') != 2: + raise commands.BadArgument('Invalid command format (missing codeblock?)') + if needs_strict_re: + match = self.run_regex_code_strict.search(content) + else: + match = self.run_regex_code.search(content) + + if not match: + raise commands.BadArgument('Invalid command format') + + language, output_syntax, args, syntax, source, stdin = match.groups() + + if not language: + language = syntax + + if input_lang: + language = input_lang + + if language: + language = language.lower() + + if language not in self.languages: + raise NoLanguageFoundError( + f'Unsupported language: **{str(language)[:1000]}**\n' + '[Request a new language](https://github.com/engineer-man/piston/issues)' + ) + + return source, language, output_syntax, args, stdin + + async def get_api_params_with_file( + self, + file, + input_language, + output_syntax, + args, + stdin, + content, + ): + MAX_BYTES = 65535 + if file.size > MAX_BYTES: + return f'Source file is too big ({file.size}>{MAX_BYTES})', True + + filename_split = file.filename.split('.') + if len(filename_split) < 2: + return 'Please provide a source file with a file extension', True + + match = self.run_regex_file.search(content) + if content and not match: + raise commands.BadArgument('Invalid command format') + + language = input_language or filename_split[-1] + if match: + matched_language, output_syntax, args, stdin = match.groups() # type: ignore + if matched_language: + language = matched_language + + language = language.lower() + + if language not in self.languages: + raise NoLanguageFoundError( + f'Unsupported language: **{str(language)[:1000]}**\n' + '[Request a new language](https://github.com/engineer-man/piston/issues)' + ) + + source = await file.read() + try: + source = source.decode('utf-8') + except UnicodeDecodeError as e: + return str(e) + return source, language, output_syntax, args, stdin + + + async def get_run_output( + self, + guild, + author, + content, + lang, + output_syntax, + args, + stdin, + mention_author, + jump_url=None, + ): + version = self.versions[lang] + + # Add boilerplate code to supported languages + source = add_boilerplate(lang, content) + + # Split args at newlines + if args: + args = [arg for arg in args.strip().split('\n') if arg] + + if not source: + raise commands.BadArgument('No source code found') + + # Call piston API + data = { + 'language': lang, + 'version': version, + 'files': [{'content': source}], + 'args': args or '', + 'stdin': stdin or '', + 'log': 0, + } + headers = {'Authorization': self.emkc_key} + async with self.session.post( + 'https://emkc.org/api/v2/piston/execute', headers=headers, json=data + ) as response: + try: + r = await response.json() + except ContentTypeError: + raise PistonInvalidContentType('invalid content type') + if not response.status == 200: + raise PistonInvalidStatus( + f'status {response.status}: {r.get("message", "")}' + ) + + comp_stderr = r['compile']['stderr'] if 'compile' in r else '' + run = r['run'] + + if run['output'] is None: + raise PistonNoOutput('no output') + + # Logging + await self.send_to_log(guild, author, lang, source) + + language_info = f'{lang}({version})' + + mention = author.mention + '' if mention_author else '' + + # Return early if no output was received + if len(run['output'] + comp_stderr) == 0: + return f'Your {language_info} code ran without output {mention}' + + # Limit output to 30 lines maximum + output = '\n'.join((comp_stderr + run['output']).split('\n')[:30]) + + # Prevent mentions in the code output + output = escape_mentions(output) + + # Prevent code block escaping by adding zero width spaces to backticks + output = output.replace('`', '`\u200b') + + # Truncate output to be below 2000 char discord limit. + if len(comp_stderr) > 0: + introduction = f'{mention}I received {language_info} compile errors' + elif len(run['stdout']) == 0 and len(run['stderr']) > 0: + introduction = f'{mention}I only received {language_info} error output' + else: + introduction = f'Here is your {language_info} output {mention}' + truncate_indicator = '[...]' + len_codeblock = 7 # 3 Backticks + newline + 3 Backticks + available_chars = 2000 - len(introduction) - len_codeblock + if len(output) > available_chars: + output = ( + output[: available_chars - len(truncate_indicator)] + truncate_indicator + ) + + if jump_url: + jump_url = f'from running: {jump_url}' + introduction = f'{introduction}{jump_url or ""}\n' + #source = f'```{lang}\n'+ source+ '```\n' + # Use an empty string if no output language is selected + output_content = f'```{output_syntax or ""}\n' + output.replace('\0', "")+ '```' + return introduction + output_content