├── .editorconfig ├── .env.sample ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── helloworld.png ├── requirements.txt ├── src ├── bot.py └── cogs │ ├── error_handler.py │ ├── extra │ └── help.py │ ├── management.py │ ├── run.py │ └── utils │ ├── codeswap.py │ └── errors.py └── state └── config.json.sample /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | 3 | [*] 4 | charset=utf-8 5 | end_of_line=lf 6 | indent_style=space 7 | indent_size=4 8 | insert_final_newline=true 9 | trim_trailing_whitespace=true 10 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | COMPOSE_PROJECT_NAME=piston_bot 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | *.json 138 | .gitkeep 139 | .vscode 140 | .env 141 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | ADD requirements.txt /app/ 3 | RUN pip install -U -r /app/requirements.txt 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Brian Seymour and EMKC Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Important Change/Update (May 2022) 2 | **Discord changed their client to prevent sending messages 3 | that are preceeded by a slash (/) 4 | To run code you can use `"./run"` or `" /run"` until further notice** 5 | 6 | # I Run Code 7 | Source code for the "I Run Code" Discord Bot used to run code snippets inside Discord chats. 8 | 9 | ![helloworld picture](helloworld.png) 10 | 11 | You can add me to your server [here](https://emkc.org/run) 12 | 13 | To see a list of supported languages click [here](https://github.com/engineer-man/piston#Supported-Languages) 14 | 15 | If you have questions check by our [Discord Server](https://discord.com/invite/engineerman) 16 | 17 | [engineer-man/piston](https://github.com/engineer-man/piston) is the api that actually runs the code. 18 | 19 | # How to use 20 | ## Basic Syntax 21 | 22 | ```` 23 | /run [language] (-> [output syntax] (optional)) 24 | [args (optional)] 25 | ```[syntax] 26 | 27 | ``` 28 | [stdin (optional)] 29 | ```` 30 | 31 | * You have to provide either `language` or `syntax` 32 | * You can provide command line arguments by specifying them before the codeblock 33 | * Each line corresponds to one argument 34 | * You can provide standard input by specifying it after the codeblock 35 | 36 | ## Editing and deleting messages 37 | * You can edit your last `/run` message if you make a mistake and the bot will edit it's initial response. 38 | * You can delete the last output message (that was caused by you) with `/delete` or `/del` or by deleting your most recent `/run` message 39 | 40 | ## Use a source file instead of a codeblock 41 | 42 | ```` 43 | /run [language] (-> [output syntax] (optional)) 44 | [args1] 45 | [args2] 46 | 47 | [stdin1] 48 | [stdin2] 49 | 50 | 51 | ```` 52 | * You can attach a file with source code instead of providing a codeblock (Maximum file size is 65535 bytes) 53 | * If you don't specify the language the bot will use the file extension 54 | * You can specify command line arguments and stdin in the command. 55 | * command line arguments should directly follow the `/run` line 56 | * stdin is everything that follows after the first double newline 57 | * Please note that attachments can not be edited therefore you can not use the edit functionality if you provide a source file 58 | 59 | # Contributing 60 | If you want to contribute you can just submit a pull request. 61 | ### Code styling / IDE Settings 62 | Please style your code according to these guidelines: 63 | * maximum line length is 99 columns 64 | * use 4 spaces for indentation 65 | * files end with a newline 66 | * lines should not have trailing whitespace 67 | 68 | If you want to use an auto formatter please use `autopep8` 69 | 70 | Example config for VSCode: 71 | ``` 72 | "[python]": { 73 | "editor.rulers": [ 74 | 99 75 | ], 76 | "editor.tabSize": 4, 77 | }, 78 | "files.insertFinalNewline": true, 79 | "files.trimTrailingWhitespace": true, 80 | "editor.trimAutoWhitespace": true, 81 | "python.formatting.provider": "autopep8", 82 | "python.formatting.autopep8Args": ["--max-line-length", "99"], 83 | ``` 84 | 85 | 86 | # What's new 87 | 88 | ## 2021-09-26 89 | Added `output syntax` functionality. 90 | * The `/run` command will now take an additional output syntax highlighting code on the first line after a `->` 91 | For example 92 | ``` 93 | /run cs -> json 94 | ``` 95 | will highlight the resulting output in discord as json. 96 | 97 | ## 2020-11-29 98 | Added `delete` functionality. 99 | * The `/delete` or `/del` command will make the bot delete it's last output (that was caused by the caller) 100 | * If you delete your most recent `/run` message the corresponding output will also be deleted. 101 | 102 | ## 2020-08-04 103 | Made writing rust code "snippets" easier (Thanks https://github.com/UsairimIsani) 104 | ```rust 105 | fn main() { } 106 | ``` 107 | will be automatically created if it is not present in the provided code. 108 | 109 | ## 2020-07-16 110 | Made writing java code "snippets" easier (Thanks https://github.com/Minecraftian14) 111 | 112 | When typing Java code the boilerplate code for `public class` will be added automatically. 113 | ````java 114 | /run java 115 | ``` 116 | import java.util.List; 117 | List.of(args).forEach(System.out::println); 118 | ``` 119 | ```` 120 | will be interpreted as 121 | ````java 122 | /run java 123 | ``` 124 | import java.util.List; 125 | public class temp extends Object { 126 | public static void main(String[] args) { 127 | List.of(args).forEach(System.out::println); 128 | } 129 | } 130 | ``` 131 | ```` 132 | 133 | 134 | ## 2020-07-15 135 | Added optional command line parameters 136 | You can use them by specifying them before the codeblock (1 per line) 137 | 138 | Example: 139 | ```` 140 | /run 141 | parameter 1 142 | parameter 2 143 | ``` 144 | 145 | ``` 146 | ```` 147 | 148 | ## 2021-02-05 149 | Added optional standard input 150 | You can use standard input by specifying it after the codeblock 151 | 152 | Example: 153 | ```` 154 | /run 155 | ``` 156 | 157 | ``` 158 | standard input 159 | ```` 160 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | piston: 4 | container_name: piston_bot 5 | build: . 6 | volumes: 7 | - ./:/opt/piston-bot 8 | command: bash -c 'cd /opt/piston-bot/src && python3 -u bot.py' 9 | restart: always 10 | network_mode: bridge 11 | -------------------------------------------------------------------------------- /helloworld.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/engineer-man/piston-bot/04305f142abbff0733fec02844773158c153c2be/helloworld.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py 2 | -------------------------------------------------------------------------------- /src/bot.py: -------------------------------------------------------------------------------- 1 | """PistonBot 2 | 3 | """ 4 | import json 5 | import sys 6 | import traceback 7 | from datetime import datetime, timezone 8 | from os import path, listdir 9 | from discord.ext.commands import AutoShardedBot, Context 10 | from discord import Activity, AllowedMentions, Intents 11 | from aiohttp import ClientSession, ClientTimeout 12 | from discord.ext.commands.bot import when_mentioned_or 13 | 14 | 15 | class PistonBot(AutoShardedBot): 16 | def __init__(self, *args, **options): 17 | super().__init__(*args, **options) 18 | self.session = None 19 | with open('../state/config.json') as conffile: 20 | self.config = json.load(conffile) 21 | self.last_errors = [] 22 | self.recent_guilds_joined = [] 23 | self.recent_guilds_left = [] 24 | self.default_activity = Activity(name='emkc.org/run | ./run', type=0) 25 | self.error_activity = Activity(name='!emkc.org/run | ./run', type=0) 26 | self.maintenance_activity = Activity(name='undergoing maintenance', type=0) 27 | self.error_string = 'Sorry, something went wrong. We will look into it.' 28 | self.maintenance_mode = False 29 | 30 | async def start(self, *args, **kwargs): 31 | self.session = ClientSession(timeout=ClientTimeout(total=15)) 32 | await super().start(*args, **kwargs) 33 | 34 | async def close(self): 35 | await self.session.close() 36 | await super().close() 37 | 38 | async def setup_hook(self): 39 | print('Loading Extensions:') 40 | STARTUP_EXTENSIONS = [] 41 | for file in listdir(path.join(path.dirname(__file__), 'cogs/')): 42 | filename, ext = path.splitext(file) 43 | if '.py' in ext: 44 | STARTUP_EXTENSIONS.append(f'cogs.{filename}') 45 | 46 | for extension in reversed(STARTUP_EXTENSIONS): 47 | try: 48 | print('loading', extension) 49 | await self.load_extension(f'{extension}') 50 | except Exception as e: 51 | await self.log_error(e, 'Cog INIT') 52 | exc = f'{type(e).__name__}: {e}' 53 | print(f'Failed to load extension {extension}\n{exc}') 54 | 55 | 56 | def user_is_admin(self, user): 57 | return user.id in self.config['admins'] 58 | 59 | async def log_error(self, error, error_source=None): 60 | is_context = isinstance(error_source, Context) 61 | has_attachment = bool(error_source.message.attachments) if is_context else False 62 | self.last_errors.append(( 63 | error, 64 | datetime.now(tz=timezone.utc), 65 | error_source, 66 | error_source.message.content if is_context else None, 67 | error_source.message.attachments[0] if has_attachment else None, 68 | )) 69 | await self.change_presence(activity=self.error_activity) 70 | 71 | 72 | intents = Intents.default() 73 | intents.message_content = True 74 | 75 | client = PistonBot( 76 | command_prefix=when_mentioned_or('./', '/'), 77 | description='Hello, I can run code!', 78 | max_messages=15000, 79 | allowed_mentions=AllowedMentions(everyone=False, users=True, roles=False), 80 | intents=intents 81 | ) 82 | client.remove_command('help') 83 | 84 | 85 | @client.event 86 | async def on_ready(): 87 | print('PistonBot started successfully') 88 | return True 89 | 90 | 91 | @client.event 92 | async def on_message(msg): 93 | prefixes = await client.get_prefix(msg) 94 | for prefix in prefixes: 95 | if msg.content.lower().startswith(f'{prefix}run'): 96 | msg.content = msg.content.replace(f'{prefix}run', f'/run', 1) 97 | break 98 | await client.process_commands(msg) 99 | 100 | 101 | @client.event 102 | async def on_error(event_method, *args, **kwargs): 103 | """|coro| 104 | 105 | The default error handler provided by the client. 106 | 107 | By default this prints to :data:`sys.stderr` however it could be 108 | overridden to have a different implementation. 109 | Check :func:`~discord.on_error` for more details. 110 | """ 111 | print('Default Handler: Ignoring exception in {}'.format(event_method), file=sys.stderr) 112 | traceback.print_exc() 113 | # --------------- custom code below ------------------------------- 114 | # Saving the error to be inspected later 115 | await client.log_error(sys.exc_info()[1], 'DEFAULT HANDLER:' + event_method) 116 | 117 | 118 | client.run(client.config["bot_key"]) 119 | print('PistonBot has exited') 120 | -------------------------------------------------------------------------------- /src/cogs/error_handler.py: -------------------------------------------------------------------------------- 1 | """This is a cog for a discord.py bot. 2 | It will add error handling and inspecting commands 3 | 4 | Commands: 5 | error List unhandled errors 6 | - traceback print traceback of stored error 7 | 8 | """ 9 | # pylint: disable=E0402 10 | import traceback 11 | import typing 12 | from datetime import datetime, timezone 13 | from asyncio import TimeoutError as AsyncTimeoutError 14 | from discord import Embed, DMChannel, errors as discord_errors 15 | from discord.ext import commands 16 | from .utils.errors import PistonError 17 | 18 | 19 | class ErrorHandler(commands.Cog, name='ErrorHandler'): 20 | def __init__(self, client): 21 | self.client = client 22 | 23 | # ---------------------------------------------- 24 | # Error handler 25 | # ---------------------------------------------- 26 | @commands.Cog.listener() 27 | async def on_command_error(self, ctx, error): 28 | if isinstance(error, commands.CommandNotFound): 29 | return 30 | 31 | if not isinstance(ctx.channel, DMChannel): 32 | perms = ctx.channel.permissions_for(ctx.guild.get_member(self.client.user.id)) 33 | try: 34 | if not perms.send_messages: 35 | await ctx.author.send("I don't have permission to write in this channel.") 36 | return 37 | except discord_errors.Forbidden: 38 | return 39 | 40 | if not perms.embed_links: 41 | await ctx.send("I don't have permission to post embeds in this channel.") 42 | return 43 | 44 | usr = ctx.author.mention 45 | 46 | if isinstance(error, commands.CommandOnCooldown): 47 | await ctx.send(error) 48 | return 49 | 50 | if isinstance(error, commands.MissingRequiredArgument): 51 | par = str(error.param) 52 | missing = par.split(": ")[0] 53 | if ':' in par: 54 | missing_type = ' (' + str(par).split(": ")[1] + ')' 55 | else: 56 | missing_type = '' 57 | await ctx.send( 58 | f'{usr} Missing parameter: `{missing}{missing_type}`' 59 | ) 60 | return 61 | 62 | if isinstance(error, commands.CheckFailure): 63 | await ctx.send(f'Sorry {usr}, you are not allowed to run this command.') 64 | return 65 | 66 | if isinstance(error, commands.BadArgument): 67 | # It's in an embed to prevent mentions from working 68 | embed = Embed( 69 | title='Error', 70 | description=str(error), 71 | color=0x2ECC71 72 | ) 73 | await ctx.send(usr, embed=embed) 74 | return 75 | 76 | if isinstance(error, commands.UnexpectedQuoteError): 77 | await ctx.send(f'{usr} Unexpected quote encountered') 78 | return 79 | 80 | if isinstance(error, commands.InvalidEndOfQuotedStringError): 81 | await ctx.send(f'{usr} Invalid character after quote') 82 | return 83 | 84 | if isinstance(error, commands.CommandInvokeError): 85 | if isinstance(error.original, PistonError): 86 | error_message = str(error.original) 87 | if error_message: 88 | error_message = f'`{error_message}` ' 89 | await ctx.send(f'{usr} API Error {error_message}- Please try again later') 90 | await self.client.log_error(error, ctx) 91 | return 92 | 93 | if isinstance(error.original, AsyncTimeoutError): 94 | await ctx.send(f'{usr} API Timeout - Please try again later') 95 | await self.client.log_error(error, ctx) 96 | return 97 | 98 | # In case of an unhandled error -> Save the error so it can be accessed later 99 | await self.client.log_error(error, ctx) 100 | 101 | try: 102 | await ctx.send(f'{usr} {self.client.error_string}') 103 | except discord_errors.Forbidden: 104 | pass 105 | except Exception as e: 106 | print('EXCEPTION during sending of fallthrough error message\n', '='*80) 107 | traceback.print_exception( 108 | type(e), e, e.__traceback__ 109 | ) 110 | print('='*80) 111 | 112 | 113 | print(f'Ignoring exception in command {ctx.command}:', flush=True) 114 | traceback.print_exception( 115 | type(error), error, error.__traceback__ 116 | ) 117 | print('-'*80, flush=True) 118 | 119 | # ---------------------------------------------- 120 | # Error command Group 121 | # ---------------------------------------------- 122 | @commands.group( 123 | invoke_without_command=True, 124 | name='error', 125 | hidden=True, 126 | aliases=['errors'] 127 | ) 128 | async def error(self, ctx, n: typing.Optional[int] = None): 129 | """Show a concise list of stored errors""" 130 | 131 | if n is not None: 132 | await self.print_traceback(ctx, n) 133 | return 134 | 135 | NUM_ERRORS_PER_PAGE = 10 136 | 137 | error_log = self.client.last_errors 138 | 139 | if not error_log: 140 | await ctx.send('Error log is empty') 141 | return 142 | 143 | response = [f'```css\nNumber of stored errors: {len(error_log)}'] 144 | for i, exc_tuple in enumerate(error_log): 145 | exc, date, error_source, *_ = exc_tuple 146 | call_info = f'{"CMD:" + error_source.invoked_with if isinstance(error_source, commands.Context) else error_source}' 147 | response.append( 148 | f'{i}: [' 149 | + date.isoformat().split('.')[0] 150 | + '] - [' 151 | + call_info 152 | + f']\nException: {str(exc)[:200]}' 153 | ) 154 | if i % NUM_ERRORS_PER_PAGE == NUM_ERRORS_PER_PAGE-1: 155 | response.append('```') 156 | await ctx.send('\n'.join(response)) 157 | response = [f'```css'] 158 | if len(response) > 1: 159 | response.append('```') 160 | await ctx.send('\n'.join(response)) 161 | 162 | @error.command( 163 | name='clear', 164 | aliases=['delete'], 165 | ) 166 | async def error_clear(self, ctx, n: int = None): 167 | """Clear error with index [n]""" 168 | if n is None: 169 | self.client.last_errors = [] 170 | await ctx.send('Error log cleared') 171 | else: 172 | self.client.last_errors.pop(n) 173 | await ctx.send(f'Deleted error #{n}') 174 | await self.client.change_presence( 175 | activity=self.client.default_activity 176 | ) 177 | 178 | @error.command( 179 | name='traceback', 180 | aliases=['tb'], 181 | ) 182 | async def error_traceback(self, ctx, n: int = None): 183 | """Print the traceback of error [n] from the error log""" 184 | await self.print_traceback(ctx, n) 185 | 186 | async def print_traceback(self, ctx, n): 187 | error_log = self.client.last_errors 188 | 189 | if not error_log: 190 | await ctx.send('Error log is empty') 191 | return 192 | 193 | if n is None: 194 | await ctx.send('Please specify an error index') 195 | await self.client.get_command('error').invoke(ctx) 196 | return 197 | 198 | if n >= len(error_log) or n < 0: 199 | await ctx.send('Error index does not exist') 200 | return 201 | 202 | exc, date, error_source, orig_content, orig_attachment = error_log[n] 203 | delta = (datetime.now(tz=timezone.utc) - date).total_seconds() 204 | hours = int(delta // 3600) 205 | seconds = int(delta - (hours * 3600)) 206 | delta_str = f'{hours} hours and {seconds} seconds ago' 207 | tb = ''.join( 208 | traceback.format_exception(type(exc), exc, exc.__traceback__) 209 | ) 210 | response_header = [f'`Error occured {delta_str}`'] 211 | response_error = [] 212 | 213 | if isinstance(error_source, commands.Context): 214 | guild = error_source.guild 215 | channel = error_source.channel 216 | response_header.append( 217 | f'`Server:{guild.name} | Channel: {channel.name}`' if guild else '`In DMChannel`' 218 | ) 219 | response_header.append( 220 | f'`User: {error_source.author.name}#{error_source.author.discriminator}`' 221 | ) 222 | response_header.append(f'`Command: {error_source.invoked_with}`') 223 | response_header.append(error_source.message.jump_url) 224 | e = Embed(title='Full command that caused the error:', 225 | description=orig_content) 226 | avatar = error_source.author.avatar 227 | if avatar: 228 | e.set_footer(text=error_source.author.display_name, icon_url=avatar.url) 229 | else: 230 | e.set_footer(text=error_source.author.display_name) 231 | else: 232 | response_header.append(f'`Error caught in {error_source}`') 233 | e = None 234 | 235 | for line in tb.split('\n'): 236 | while len(line) > 1800: 237 | response_error.append(line[:1800]) 238 | line = line[1800:] 239 | response_error.append(line) 240 | 241 | to_send = '\n'.join(response_header) + '\n```python' 242 | 243 | for line in response_error: 244 | if len(to_send) + len(line) + 1 > 1800: 245 | await ctx.send(to_send + '\n```') 246 | to_send = '```python' 247 | to_send += '\n' + line 248 | await ctx.send(to_send + '\n```', embed=e) 249 | 250 | 251 | if orig_attachment: 252 | await ctx.send( 253 | 'Attached file:', 254 | file=await orig_attachment.to_file() 255 | ) 256 | 257 | # @commands.command() 258 | # async def error_mock(self, ctx, n=1): 259 | # print('MOCKING') 260 | # raise IndexError('Mocked Test Error'*n) 261 | 262 | # @commands.Cog.listener() 263 | # async def on_message_edit(self, before, after): 264 | # if 'mock' in after.content: 265 | # await self.client.process_commands(after) 266 | # # ctx = await self.client.get_context(after) 267 | # # await self.client.get_command('error_mock').invoke(ctx) 268 | 269 | 270 | async def setup(client): 271 | await client.add_cog(ErrorHandler(client)) 272 | -------------------------------------------------------------------------------- /src/cogs/extra/help.py: -------------------------------------------------------------------------------- 1 | """This is a cog for a discord.py bot. 2 | It hides the help command and channges it's look 3 | """ 4 | 5 | import itertools 6 | from discord import Embed 7 | from discord.ext import commands 8 | from discord.ext.commands import HelpCommand, DefaultHelpCommand 9 | 10 | #pylint: disable=E1101 11 | 12 | 13 | class myHelpCommand(HelpCommand): 14 | def __init__(self, **options): 15 | super().__init__(**options) 16 | self.paginator = None 17 | self.spacer = "\u1160 " # Invisible Unicode Character to indent lines 18 | 19 | async def send_pages(self, header=False, footer=False): 20 | destination = self.get_destination() 21 | embed = Embed( 22 | color=0x2ECC71 23 | ) 24 | if header: 25 | embed.set_author( 26 | name=self.context.bot.description, 27 | icon_url=self.context.bot.user.avatar_url 28 | ) 29 | for category, entries in self.paginator: 30 | embed.add_field( 31 | name=category, 32 | value=entries, 33 | inline=False 34 | ) 35 | if footer: 36 | embed.set_footer( 37 | text='Use /help for more information.' 38 | ) 39 | await destination.send(embed=embed) 40 | 41 | async def send_bot_help(self, mapping): 42 | ctx = self.context 43 | bot = ctx.bot 44 | 45 | def get_category(command): 46 | cog = command.cog 47 | return cog.qualified_name + ':' if cog is not None else 'Help:' 48 | 49 | filtered = await self.filter_commands( 50 | bot.commands, 51 | sort=True, 52 | key=get_category 53 | ) 54 | to_iterate = itertools.groupby(filtered, key=get_category) 55 | for cog_name, command_grouper in to_iterate: 56 | cmds = sorted(command_grouper, key=lambda c: c.name) 57 | category = f'► {cog_name}' 58 | if len(cmds) == 1: 59 | entries = f'{self.spacer}{cmds[0].name} → {cmds[0].short_doc}' 60 | else: 61 | entries = '' 62 | while len(cmds) > 0: 63 | entries += self.spacer 64 | entries += ' | '.join([cmd.name for cmd in cmds[0:8]]) 65 | cmds = cmds[8:] 66 | entries += '\n' if cmds else '' 67 | self.paginator.append((category, entries)) 68 | await self.send_pages(header=True, footer=True) 69 | 70 | async def send_cog_help(self, cog): 71 | filtered = await self.filter_commands(cog.get_commands(), sort=True) 72 | if not filtered: 73 | await self.context.send( 74 | 'No public commands in this cog.' 75 | ) 76 | return 77 | category = f'▼ {cog.qualified_name}' 78 | entries = '\n'.join( 79 | self.spacer + 80 | f'**{command.name}** → {command.short_doc or command.description}' 81 | for command in filtered 82 | ) 83 | self.paginator.append((category, entries)) 84 | await self.send_pages(footer=True) 85 | 86 | async def send_group_help(self, group): 87 | filtered = await self.filter_commands(group.commands, sort=True) 88 | if not filtered: 89 | await self.context.send( 90 | 'No public commands in group.' 91 | ) 92 | return 93 | category = f'**{group.name}** - {group.description or group.short_doc}' 94 | entries = '\n'.join( 95 | self.spacer + f'**{command.name}** → {command.short_doc}' 96 | for command in filtered 97 | ) 98 | self.paginator.append((category, entries)) 99 | await self.send_pages(footer=True) 100 | 101 | async def send_command_help(self, command): 102 | signature = self.get_command_signature(command) 103 | helptext = command.help or command.description or 'No help Text' 104 | self.paginator.append( 105 | (signature, helptext) 106 | ) 107 | await self.send_pages() 108 | 109 | async def prepare_help_command(self, ctx, command=None): 110 | self.paginator = [] 111 | await super().prepare_help_command(ctx, command) 112 | 113 | 114 | class Help(commands.Cog): 115 | def __init__(self, client): 116 | self.client = client 117 | self.client.help_command = myHelpCommand( 118 | command_attrs={ 119 | 'aliases': ['halp'], 120 | 'help': 'Shows help about the bot, a command, or a category', 121 | 'hidden': True, 122 | }, 123 | ) 124 | 125 | def cog_unload(self): 126 | self.client.get_command('help').hidden = False 127 | self.client.help_command = DefaultHelpCommand() 128 | 129 | 130 | def setup(client): 131 | client.add_cog(Help(client)) 132 | -------------------------------------------------------------------------------- /src/cogs/management.py: -------------------------------------------------------------------------------- 1 | """This is a cog for a discord.py bot. 2 | It will add some management commands to a bot. 3 | 4 | Commands: 5 | load load an extension / cog 6 | unload unload an extension / cog 7 | reload reload an extension / cog 8 | cogs show currently active extensions / cogs 9 | error print the traceback of the last unhandled error to chat 10 | """ 11 | import json 12 | import typing 13 | import subprocess 14 | import re 15 | from io import BytesIO 16 | from datetime import datetime, timezone 17 | from os import path, listdir 18 | from discord import File, errors as discord_errors 19 | from discord.ext import commands 20 | 21 | 22 | class Management(commands.Cog, name='Management'): 23 | def __init__(self, client): 24 | self.client = client 25 | self.reload_config() 26 | self.cog_re = re.compile(r'\s*src\/cogs\/(.+)\.py\s*\|\s*\d+\s*[+-]+') 27 | 28 | async def cog_check(self, ctx): 29 | return self.client.user_is_admin(ctx.author) 30 | 31 | @commands.Cog.listener() 32 | async def on_ready(self): 33 | loaded = self.client.extensions 34 | unloaded = [x for x in self.crawl_cogs() if x not in loaded and 'extra.' not in x] 35 | activity = self.client.error_activity if unloaded else self.client.default_activity 36 | await self.client.change_presence(activity=activity) 37 | 38 | @commands.Cog.listener() 39 | async def on_guild_join(self, guild): 40 | self.client.recent_guilds_joined.append( 41 | (datetime.now(tz=timezone.utc).isoformat()[:19], guild) 42 | ) 43 | self.client.recent_guilds_joined = self.client.recent_guilds_joined[-10:] 44 | 45 | @commands.Cog.listener() 46 | async def on_guild_remove(self, guild): 47 | self.client.recent_guilds_left.append( 48 | (datetime.now(tz=timezone.utc).isoformat()[:19], guild) 49 | ) 50 | self.client.recent_guilds_left = self.client.recent_guilds_left[-10:] 51 | 52 | def reload_config(self): 53 | with open("../state/config.json") as conffile: 54 | self.client.config = json.load(conffile) 55 | 56 | def crawl_cogs(self, directory='cogs'): 57 | cogs = [] 58 | for element in listdir(directory): 59 | if element in ('samples', 'utils'): 60 | continue 61 | abs_el = path.join(directory, element) 62 | if path.isdir(abs_el): 63 | cogs += self.crawl_cogs(abs_el) 64 | else: 65 | filename, ext = path.splitext(element) 66 | if ext == '.py': 67 | dot_dir = directory.replace('\\', '.') 68 | dot_dir = dot_dir.replace('/', '.') 69 | cogs.append(f'{dot_dir}.' + filename) 70 | return cogs 71 | 72 | # ---------------------------------------------- 73 | # Function to load extensions 74 | # ---------------------------------------------- 75 | @commands.command( 76 | name='load', 77 | brief='Load bot extension', 78 | description='Load bot extension', 79 | hidden=True, 80 | ) 81 | async def load_extension(self, ctx, extension_name): 82 | for cog_name in self.crawl_cogs(): 83 | if extension_name in cog_name: 84 | target_extension = cog_name 85 | break 86 | try: 87 | await self.client.load_extension(target_extension) 88 | except Exception as e: 89 | await self.client.log_error(e, ctx) 90 | await ctx.send(f'```py\n{type(e).__name__}: {str(e)}\n```') 91 | return 92 | await ctx.send(f'```css\nExtension [{target_extension}] loaded.```') 93 | 94 | # ---------------------------------------------- 95 | # Function to unload extensions 96 | # ---------------------------------------------- 97 | @commands.command( 98 | name='unload', 99 | brief='Unload bot extension', 100 | description='Unload bot extension', 101 | hidden=True, 102 | ) 103 | async def unload_extension(self, ctx, extension_name): 104 | for cog_name in self.client.extensions: 105 | if extension_name in cog_name: 106 | target_extension = cog_name 107 | break 108 | if target_extension.lower() in 'cogs.management': 109 | await ctx.send( 110 | f"```diff\n- {target_extension} can't be unloaded" + 111 | f"\n+ try reload instead```" 112 | ) 113 | return 114 | if self.client.extensions.get(target_extension) is None: 115 | return 116 | await self.client.unload_extension(target_extension) 117 | await ctx.send(f'```css\nExtension [{target_extension}] unloaded.```') 118 | 119 | # ---------------------------------------------- 120 | # Function to reload extensions 121 | # ---------------------------------------------- 122 | @commands.command( 123 | name='reload', 124 | brief='Reload bot extension', 125 | description='Reload bot extension', 126 | hidden=True, 127 | aliases=['re'] 128 | ) 129 | async def reload_extension(self, ctx, extension_name): 130 | target_extensions = [] 131 | if extension_name == 'all': 132 | target_extensions = [__name__] + \ 133 | [x for x in self.client.extensions if not x == __name__] 134 | else: 135 | for cog_name in self.client.extensions: 136 | if extension_name in cog_name: 137 | target_extensions = [cog_name] 138 | break 139 | if not target_extensions: 140 | return 141 | result = [] 142 | for ext in target_extensions: 143 | try: 144 | await self.client.reload_extension(ext) 145 | result.append(f'Extension [{ext}] reloaded.') 146 | except Exception as e: 147 | await self.client.log_error(e, ctx) 148 | result.append(f'#ERROR loading [{ext}]') 149 | continue 150 | result = '\n'.join(result) 151 | await ctx.send(f'```css\n{result}```') 152 | 153 | # ---------------------------------------------- 154 | # Function to get bot extensions 155 | # ---------------------------------------------- 156 | @commands.command( 157 | name='cogs', 158 | brief='Get loaded cogs', 159 | description='Get loaded cogs', 160 | aliases=['extensions'], 161 | hidden=True, 162 | ) 163 | async def print_cogs(self, ctx): 164 | loaded = self.client.extensions 165 | unloaded = [x for x in self.crawl_cogs() if x not in loaded] 166 | response = ['\n[Loaded extensions]'] + ['\n ' + x for x in loaded] 167 | response += ['\n[Unloaded extensions]'] + \ 168 | ['\n ' + x for x in unloaded] 169 | await ctx.send(f'```css{"".join(response)}```') 170 | return True 171 | 172 | @commands.command( 173 | name='servers', 174 | hidden=True, 175 | ) 176 | async def show_servers(self, ctx, include_txt: bool = False): 177 | to_send = '\n'.join(str(guild) for guild in self.client.guilds) 178 | file = File( 179 | fp=BytesIO(to_send.encode()), 180 | filename=f'servers_{datetime.now(tz=timezone.utc).isoformat()}.txt' 181 | ) if include_txt else None 182 | j = '\n'.join(f'{time} | {guild.name}' for time, guild in self.client.recent_guilds_joined) 183 | l = '\n'.join(f'{time} | {guild.name}' for time, guild in self.client.recent_guilds_left) 184 | await ctx.send( 185 | f'**I am active in {len(self.client.guilds)} Servers ' + 186 | f'| # of Shards: {len(self.client.shards)}** ' + 187 | f'```\nJoined recently:\n{j}```\n```\nLeft Recently:\n{l}```', 188 | file=file 189 | ) 190 | 191 | # ---------------------------------------------- 192 | # Command to pull the latest changes from github 193 | # ---------------------------------------------- 194 | @commands.group( 195 | name='git', 196 | hidden=True, 197 | ) 198 | async def git(self, ctx): 199 | """Commands to run git commands on the local repo""" 200 | pass 201 | 202 | @git.command( 203 | name='pull', 204 | ) 205 | async def pull(self, ctx, noreload: typing.Optional[str] = None): 206 | """Pull the latest changes from github""" 207 | try: 208 | await ctx.typing() 209 | except discord_errors.Forbidden: 210 | pass 211 | try: 212 | output = subprocess.check_output( 213 | ['git', 'pull']).decode() 214 | await ctx.send('```git\n' + output + '\n```') 215 | except Exception as e: 216 | return await ctx.send(str(e)) 217 | 218 | if noreload is not None: 219 | return 220 | 221 | _cogs = [f'cogs.{i}' for i in self.cog_re.findall(output)] 222 | active_cogs = [i for i in _cogs if i in self.client.extensions] 223 | if active_cogs: 224 | for cog_name in active_cogs: 225 | await ctx.invoke(self.client.get_command('reload'), cog_name) 226 | 227 | # ---------------------------------------------- 228 | # Command to reset the repo to a previous commit 229 | # ---------------------------------------------- 230 | @git.command( 231 | name='reset', 232 | ) 233 | async def reset(self, ctx, n: int): 234 | """Reset repo to HEAD~[n]""" 235 | if not n > 0: 236 | raise commands.BadArgument('Please specify n>0') 237 | try: 238 | await ctx.typing() 239 | except discord_errors.Forbidden: 240 | pass 241 | try: 242 | output = subprocess.check_output( 243 | ['git', 'reset', '--hard', f'HEAD~{n}']).decode() 244 | await ctx.send('```git\n' + output + '\n```') 245 | except Exception as e: 246 | await ctx.send(str(e)) 247 | 248 | # ---------------------------------------------- 249 | # Command to stop the bot 250 | # ---------------------------------------------- 251 | @commands.command( 252 | name='restart', 253 | aliases=['shutdown'], 254 | hidden=True 255 | ) 256 | async def shutdown(self, ctx): 257 | """Stop/Restart the bot""" 258 | await self.client.close() 259 | 260 | # ---------------------------------------------- 261 | # Command to toggle maintenance mode 262 | # ---------------------------------------------- 263 | @commands.command( 264 | name='maintenance', 265 | hidden=True 266 | ) 267 | async def maintenance(self, ctx): 268 | """Toggle maintenance mode""" 269 | if self.client.maintenance_mode: 270 | self.client.maintenance_mode = False 271 | await self.client.change_presence(activity=self.client.default_activity) 272 | else: 273 | self.client.maintenance_mode = True 274 | await self.client.change_presence(activity=self.client.maintenance_activity) 275 | 276 | 277 | async def setup(client): 278 | await client.add_cog(Management(client)) 279 | -------------------------------------------------------------------------------- /src/cogs/run.py: -------------------------------------------------------------------------------- 1 | """This is a cog for a discord.py bot. 2 | It will add the run command for everyone to use 3 | 4 | Commands: 5 | run Run code using the Piston API 6 | 7 | """ 8 | # pylint: disable=E0402 9 | import json 10 | import re, sys 11 | from dataclasses import dataclass 12 | from discord import Embed, Message, errors as discord_errors 13 | from discord.ext import commands, tasks 14 | from discord.utils import escape_mentions 15 | from aiohttp import ContentTypeError 16 | from .utils.codeswap import add_boilerplate 17 | from .utils.errors import PistonInvalidContentType, PistonInvalidStatus, PistonNoOutput 18 | #pylint: disable=E1101 19 | 20 | 21 | @dataclass 22 | class RunIO: 23 | input: Message 24 | output: Message 25 | 26 | def get_size(obj, seen=None): 27 | """Recursively finds size of objects""" 28 | size = sys.getsizeof(obj) 29 | if seen is None: 30 | seen = set() 31 | obj_id = id(obj) 32 | if obj_id in seen: 33 | return 0 34 | # Important mark as seen *before* entering recursion to gracefully handle 35 | # self-referential objects 36 | seen.add(obj_id) 37 | if isinstance(obj, dict): 38 | size += sum([get_size(v, seen) for v in obj.values()]) 39 | size += sum([get_size(k, seen) for k in obj.keys()]) 40 | elif hasattr(obj, '__dict__'): 41 | size += get_size(obj.__dict__, seen) 42 | elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes, bytearray)): 43 | size += sum([get_size(i, seen) for i in obj]) 44 | return size 45 | 46 | class Run(commands.Cog, name='CodeExecution'): 47 | def __init__(self, client): 48 | self.client = client 49 | self.run_IO_store = dict() # Store the most recent /run message for each user.id 50 | self.languages = dict() # Store the supported languages and aliases 51 | self.versions = dict() # Store version for each language 52 | self.run_regex_code = re.compile( 53 | r'(?s)/(?:edit_last_)?run' 54 | r'(?: +(?P\S*?)\s*|\s*)' 55 | r'(?:-> *(?P\S*)\s*|\s*)' 56 | r'(?:\n(?P(?:[^\n\r\f\v]*\n)*?)\s*|\s*)' 57 | r'```(?:(?P\S+)\n\s*|\s*)(?P.*)```' 58 | r'(?:\n?(?P(?:[^\n\r\f\v]\n?)+)+|)' 59 | ) 60 | self.run_regex_file = re.compile( 61 | r'/run(?: *(?P\S*)\s*?|\s*?)?' 62 | r'(?: *-> *(?P\S*)\s*?|\s*?)?' 63 | r'(?:\n(?P(?:[^\n\r\f\v]+\n?)*)\s*|\s*)?' 64 | r'(?:\n*(?P(?:[^\n\r\f\v]\n*)+)+|)?' 65 | ) 66 | self.get_available_languages.start() 67 | 68 | @tasks.loop(count=1) 69 | async def get_available_languages(self): 70 | async with self.client.session.get( 71 | 'https://emkc.org/api/v2/piston/runtimes' 72 | ) as response: 73 | runtimes = await response.json() 74 | for runtime in runtimes: 75 | language = runtime['language'] 76 | self.languages[language] = language 77 | self.versions[language] = runtime['version'] 78 | for alias in runtime['aliases']: 79 | self.languages[alias] = language 80 | self.versions[alias] = runtime['version'] 81 | 82 | async def send_to_log(self, ctx, language, source): 83 | logging_data = { 84 | 'server': ctx.guild.name if ctx.guild else 'DMChannel', 85 | 'server_id': str(ctx.guild.id) if ctx.guild else '0', 86 | 'user': f'{ctx.author.name}#{ctx.author.discriminator}', 87 | 'user_id': str(ctx.author.id), 88 | 'language': language, 89 | 'source': source 90 | } 91 | headers = {'Authorization': self.client.config["emkc_key"]} 92 | 93 | async with self.client.session.post( 94 | 'https://emkc.org/api/internal/piston/log', 95 | headers=headers, 96 | data=json.dumps(logging_data) 97 | ) as response: 98 | if response.status != 200: 99 | await self.client.log_error( 100 | commands.CommandError(f'Error sending log. Status: {response.status}'), 101 | ctx 102 | ) 103 | return False 104 | 105 | return True 106 | 107 | async def get_api_parameters_with_codeblock(self, ctx): 108 | if ctx.message.content.count('```') != 2: 109 | raise commands.BadArgument('Invalid command format (missing codeblock?)') 110 | 111 | match = self.run_regex_code.search(ctx.message.content) 112 | 113 | if not match: 114 | raise commands.BadArgument('Invalid command format') 115 | 116 | language, output_syntax, args, syntax, source, stdin = match.groups() 117 | 118 | if not language: 119 | language = syntax 120 | 121 | if language: 122 | language = language.lower() 123 | 124 | if language not in self.languages: 125 | raise commands.BadArgument( 126 | f'Unsupported language: **{str(language)[:1000]}**\n' 127 | '[Request a new language](https://github.com/engineer-man/piston/issues)' 128 | ) 129 | 130 | return language, output_syntax, source, args, stdin 131 | 132 | async def get_api_parameters_with_file(self, ctx): 133 | if len(ctx.message.attachments) != 1: 134 | raise commands.BadArgument('Invalid number of attachments') 135 | 136 | file = ctx.message.attachments[0] 137 | 138 | MAX_BYTES = 65535 139 | if file.size > MAX_BYTES: 140 | raise commands.BadArgument(f'Source file is too big ({file.size}>{MAX_BYTES})') 141 | 142 | filename_split = file.filename.split('.') 143 | 144 | if len(filename_split) < 2: 145 | raise commands.BadArgument('Please provide a source file with a file extension') 146 | 147 | match = self.run_regex_file.search(ctx.message.content) 148 | 149 | if not match: 150 | raise commands.BadArgument('Invalid command format') 151 | 152 | language, output_syntax, args, stdin = match.groups() 153 | 154 | if not language: 155 | language = filename_split[-1] 156 | 157 | if language: 158 | language = language.lower() 159 | 160 | if language not in self.languages: 161 | raise commands.BadArgument( 162 | f'Unsupported file extension: **{language}**\n' 163 | '[Request a new language](https://github.com/engineer-man/piston/issues)' 164 | ) 165 | 166 | source = await file.read() 167 | try: 168 | source = source.decode('utf-8') 169 | except UnicodeDecodeError as e: 170 | raise commands.BadArgument(str(e)) 171 | 172 | return language, output_syntax, source, args, stdin 173 | 174 | async def get_run_output(self, ctx): 175 | # Get parameters to call api depending on how the command was called (file <> codeblock) 176 | if ctx.message.attachments: 177 | alias, output_syntax, source, args, stdin = await self.get_api_parameters_with_file(ctx) 178 | else: 179 | alias, output_syntax, source, args, stdin = await self.get_api_parameters_with_codeblock(ctx) 180 | 181 | # Resolve aliases for language 182 | language = self.languages[alias] 183 | 184 | version = self.versions[alias] 185 | 186 | # Add boilerplate code to supported languages 187 | source = add_boilerplate(language, source) 188 | 189 | # Split args at newlines 190 | if args: 191 | args = [arg for arg in args.strip().split('\n') if arg] 192 | 193 | if not source: 194 | raise commands.BadArgument(f'No source code found') 195 | 196 | # Call piston API 197 | data = { 198 | 'language': alias, 199 | 'version': version, 200 | 'files': [{'content': source}], 201 | 'args': args, 202 | 'stdin': stdin or "", 203 | 'log': 0 204 | } 205 | headers = {'Authorization': self.client.config["emkc_key"]} 206 | async with self.client.session.post( 207 | 'https://emkc.org/api/v2/piston/execute', 208 | headers=headers, 209 | json=data 210 | ) as response: 211 | try: 212 | r = await response.json() 213 | except ContentTypeError: 214 | raise PistonInvalidContentType('invalid content type') 215 | if not response.status == 200: 216 | raise PistonInvalidStatus(f'status {response.status}: {r.get("message", "")}') 217 | 218 | comp_stderr = r['compile']['stderr'] if 'compile' in r else '' 219 | run = r['run'] 220 | 221 | if run['output'] is None: 222 | raise PistonNoOutput('no output') 223 | 224 | # Logging 225 | await self.send_to_log(ctx, language, source) 226 | 227 | language_info=f'{alias}({version})' 228 | 229 | # Return early if no output was received 230 | if len(run['output'] + comp_stderr) == 0: 231 | return f'Your {language_info} code ran without output {ctx.author.mention}' 232 | 233 | # Limit output to 30 lines maximum 234 | output = '\n'.join((comp_stderr + run['output']).split('\n')[:30]) 235 | 236 | # Prevent mentions in the code output 237 | output = escape_mentions(output) 238 | 239 | # Prevent code block escaping by adding zero width spaces to backticks 240 | output = output.replace("`", "`\u200b") 241 | 242 | # Truncate output to be below 2000 char discord limit. 243 | if len(comp_stderr) > 0: 244 | introduction = f'{ctx.author.mention} I received {language_info} compile errors\n' 245 | elif len(run['stdout']) == 0 and len(run['stderr']) > 0: 246 | introduction = f'{ctx.author.mention} I only received {language_info} error output\n' 247 | else: 248 | introduction = f'Here is your {language_info} output {ctx.author.mention}\n' 249 | truncate_indicator = '[...]' 250 | len_codeblock = 7 # 3 Backticks + newline + 3 Backticks 251 | available_chars = 2000-len(introduction)-len_codeblock 252 | if len(output) > available_chars: 253 | output = output[:available_chars-len(truncate_indicator)] + truncate_indicator 254 | 255 | # Use an empty string if no output language is selected 256 | return ( 257 | introduction 258 | + f'```{output_syntax or ""}\n' 259 | + output.replace('\0', '') 260 | + '```' 261 | ) 262 | 263 | async def delete_last_output(self, user_id): 264 | try: 265 | msg_to_delete = self.run_IO_store[user_id].output 266 | del self.run_IO_store[user_id] 267 | await msg_to_delete.delete() 268 | except KeyError: 269 | # Message does not exist in store dicts 270 | return 271 | except discord_errors.NotFound: 272 | # Message no longer exists in discord (deleted by server admin) 273 | return 274 | 275 | @commands.command(aliases=['del']) 276 | async def delete(self, ctx): 277 | """Delete the most recent output message you caused 278 | Type "./run" or "./help" for instructions""" 279 | await self.delete_last_output(ctx.author.id) 280 | 281 | @commands.command() 282 | async def run(self, ctx, *, source=None): 283 | """Run some code 284 | Type "./run" or "./help" for instructions""" 285 | if self.client.maintenance_mode: 286 | await ctx.send('Sorry - I am currently undergoing maintenance.') 287 | return 288 | banned_users = [ 289 | #473160828502409217, # em 290 | 501851143203454986 291 | ] 292 | if ctx.author.id in banned_users: 293 | await ctx.send('You have been banned from using I Run Code.') 294 | return 295 | try: 296 | await ctx.typing() 297 | except discord_errors.Forbidden: 298 | pass 299 | if not source and not ctx.message.attachments: 300 | await self.send_howto(ctx) 301 | return 302 | try: 303 | run_output = await self.get_run_output(ctx) 304 | msg = await ctx.send(run_output) 305 | except commands.BadArgument as error: 306 | embed = Embed( 307 | title='Error', 308 | description=str(error), 309 | color=0x2ECC71 310 | ) 311 | msg = await ctx.send(ctx.author.mention, embed=embed) 312 | self.run_IO_store[ctx.author.id] = RunIO(input=ctx.message, output=msg) 313 | 314 | @commands.command(hidden=True) 315 | async def edit_last_run(self, ctx, *, content=None): 316 | """Run some edited code and edit previous message""" 317 | if self.client.maintenance_mode: 318 | return 319 | if (not content) or ctx.message.attachments: 320 | return 321 | try: 322 | msg_to_edit = self.run_IO_store[ctx.author.id].output 323 | run_output = await self.get_run_output(ctx) 324 | await msg_to_edit.edit(content=run_output, embed=None) 325 | except KeyError: 326 | # Message no longer exists in output store 327 | # (can only happen if smartass user calls this command directly instead of editing) 328 | return 329 | except discord_errors.NotFound: 330 | # Message no longer exists in discord 331 | if ctx.author.id in self.run_IO_store: 332 | del self.run_IO_store[ctx.author.id] 333 | return 334 | except commands.BadArgument as error: 335 | # Edited message probably has bad formatting -> replace previous message with error 336 | embed = Embed( 337 | title='Error', 338 | description=str(error), 339 | color=0x2ECC71 340 | ) 341 | try: 342 | await msg_to_edit.edit(content=ctx.author.mention, embed=embed) 343 | except discord_errors.NotFound: 344 | # Message no longer exists in discord 345 | del self.run_IO_store[ctx.author.id] 346 | return 347 | 348 | @commands.command(hidden=True) 349 | async def size(self, ctx): 350 | if ctx.author.id != 98488345952256000: 351 | return False 352 | await ctx.send( 353 | f'```\nIO Cache {len(self.run_IO_store)} / {get_size(self.run_IO_store) // 1000} kb' 354 | f'\nMessage Cache {len(self.client.cached_messages)} / {get_size(self.client.cached_messages) // 1000} kb\n```') 355 | 356 | @commands.Cog.listener() 357 | async def on_message_edit(self, before, after): 358 | if self.client.maintenance_mode: 359 | return 360 | if after.author.bot: 361 | return 362 | if before.author.id not in self.run_IO_store: 363 | return 364 | if before.id != self.run_IO_store[before.author.id].input.id: 365 | return 366 | prefixes = await self.client.get_prefix(after) 367 | if isinstance(prefixes, str): 368 | prefixes = [prefixes, ] 369 | if any(after.content in (f'{prefix}delete', f'{prefix}del') for prefix in prefixes): 370 | await self.delete_last_output(after.author.id) 371 | return 372 | for prefix in prefixes: 373 | if after.content.lower().startswith(f'{prefix}run'): 374 | after.content = after.content.replace(f'{prefix}run', f'/edit_last_run', 1) 375 | await self.client.process_commands(after) 376 | break 377 | 378 | @commands.Cog.listener() 379 | async def on_message_delete(self, message): 380 | if self.client.maintenance_mode: 381 | return 382 | if message.author.bot: 383 | return 384 | if message.author.id not in self.run_IO_store: 385 | return 386 | if message.id != self.run_IO_store[message.author.id].input.id: 387 | return 388 | await self.delete_last_output(message.author.id) 389 | 390 | async def send_howto(self, ctx): 391 | languages = sorted(set(self.languages.values())) 392 | 393 | run_instructions = ( 394 | '**Update: Discord changed their client to prevent sending messages**\n' 395 | '**that are preceeded by a slash (/)**\n' 396 | '**To run code you can use `"./run"` or `" /run"` until further notice**\n\n' 397 | '**Here are my supported languages:**\n' 398 | + ', '.join(languages) + 399 | '\n\n**You can run code like this:**\n' 400 | './run \ncommand line parameters (optional) - 1 per line\n' 401 | '\\`\\`\\`\nyour code\n\\`\\`\\`\nstandard input (optional)\n' 402 | '\n**Provided by the Engineer Man Discord Server - visit:**\n' 403 | '• https://emkc.org/run to get it in your own server\n' 404 | '• https://discord.gg/engineerman for more info\n' 405 | ) 406 | 407 | e = Embed(title='I can execute code right here in Discord! (click here for instructions)', 408 | description=run_instructions, 409 | url='https://github.com/engineer-man/piston-bot', 410 | color=0x2ECC71) 411 | 412 | await ctx.send(embed=e) 413 | 414 | @commands.command(name='help') 415 | async def send_help(self, ctx): 416 | await self.send_howto(ctx) 417 | 418 | 419 | async def setup(client): 420 | await client.add_cog(Run(client)) 421 | -------------------------------------------------------------------------------- /src/cogs/utils/codeswap.py: -------------------------------------------------------------------------------- 1 | def add_boilerplate(language, source): 2 | if language == 'java': 3 | return for_java(source) 4 | if language == 'scala': 5 | return for_scala(source) 6 | if language == 'rust': 7 | return for_rust(source) 8 | if language == 'c' or language == 'c++': 9 | return for_c_cpp(source) 10 | if language == 'go': 11 | return for_go(source) 12 | if language in ['csharp', 'dotnet', 'c#.net']: 13 | return for_csharp(source) 14 | return source 15 | 16 | def for_go(source): 17 | if 'main' in source: 18 | return source 19 | 20 | package = ['package main'] 21 | imports = [] 22 | code = ['func main() {'] 23 | 24 | lines = source.split('\n') 25 | for line in lines: 26 | if line.lstrip().startswith('import'): 27 | imports.append(line) 28 | else: 29 | code.append(line) 30 | 31 | code.append('}') 32 | return '\n'.join(package + imports + code) 33 | 34 | def for_c_cpp(source): 35 | if 'main' in source: 36 | return source 37 | 38 | imports = [] 39 | code = ['int main() {'] 40 | 41 | lines = source.replace(';', ';\n').split('\n') 42 | for line in lines: 43 | if line.lstrip().startswith('#include'): 44 | imports.append(line) 45 | else: 46 | code.append(line) 47 | 48 | code.append('}') 49 | return '\n'.join(imports + code) 50 | 51 | def for_csharp(source): 52 | if 'class' in source: 53 | return source 54 | 55 | imports=[] 56 | code = ['class Program{'] 57 | if not 'static void Main' in source: 58 | code.append('static void Main(string[] args){') 59 | 60 | lines = source.replace(';', ';\n').split('\n') 61 | for line in lines: 62 | if line.lstrip().startswith('using'): 63 | imports.append(line) 64 | else: 65 | code.append(line) 66 | 67 | if not 'static void Main' in source: 68 | code.append('}') 69 | code.append('}') 70 | 71 | return '\n'.join(imports + code).replace(';\n', ';') 72 | 73 | def for_java(source): 74 | if 'class' in source: 75 | return source 76 | 77 | imports = [] 78 | code = [ 79 | 'public class temp extends Object {public static void main(String[] args) {'] 80 | 81 | lines = source.replace(';', ';\n').split('\n') 82 | for line in lines: 83 | if line.lstrip().startswith('import'): 84 | imports.append(line) 85 | else: 86 | code.append(line) 87 | 88 | code.append('}}') 89 | return '\n'.join(imports + code).replace(';\n',';') 90 | 91 | def for_scala(source): 92 | if any(s in source for s in ('extends App', 'def main', '@main def', '@main() def')): 93 | return source 94 | 95 | # Scala will complain about indentation so just indent source 96 | indented_source = ' ' + source.replace('\n', '\n ').rstrip() + '\n' 97 | 98 | return f'@main def run(): Unit = {{\n{indented_source}}}\n' 99 | 100 | def for_rust(source): 101 | if 'fn main' in source: 102 | return source 103 | imports = [] 104 | code = ['fn main() {'] 105 | 106 | lines = source.replace(';', ';\n').split('\n') 107 | for line in lines: 108 | if line.lstrip().startswith('use'): 109 | imports.append(line) 110 | else: 111 | code.append(line) 112 | 113 | code.append('}') 114 | return '\n'.join(imports + code) 115 | -------------------------------------------------------------------------------- /src/cogs/utils/errors.py: -------------------------------------------------------------------------------- 1 | class PistonError(Exception): 2 | """The base exception type for all Piston related errors.""" 3 | pass 4 | 5 | class PistonNoOutput(PistonError): 6 | """Exception raised when no output was received from the API""" 7 | pass 8 | 9 | class PistonInvalidStatus(PistonError): 10 | """Exception raised when the API request returns a non 200 status""" 11 | pass 12 | 13 | class PistonInvalidContentType(PistonError): 14 | """Exception raised when the API request returns a non JSON content type""" 15 | pass 16 | -------------------------------------------------------------------------------- /state/config.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "bot_key": "", 3 | "emkc_key": "", 4 | "admins": [ 5 | 123456789 6 | ] 7 | } 8 | --------------------------------------------------------------------------------