├── .env.example ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── bot ├── __init__.py ├── __main__.py ├── app.py ├── config.py ├── constants.py ├── lang_defaults.py ├── manager.py ├── models.py ├── plugins │ ├── __init__.py │ ├── events.py │ ├── help.py │ ├── instance.py │ ├── languages.py │ ├── message_commands.py │ └── tasks.py ├── providers │ ├── __init__.py │ ├── godbolt.py │ ├── piston.py │ └── provider.py └── utils │ ├── __init__.py │ ├── display.py │ ├── fixes.py │ └── parse.py ├── noxfile.py └── pyproject.toml /.env.example: -------------------------------------------------------------------------------- 1 | TOKEN="YOUR_BOT_TOKEN" 2 | PISTON_URL="https://emkc.org/api/v2/piston/" 3 | GODBOLT_URL="https://godbolt.org/api/" 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | lint: 6 | name: lint 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-python@v4 11 | with: 12 | python-version: "3.11" 13 | - name: Install Poetry 14 | run: | 15 | pip install poetry 16 | - name: run 17 | run: | 18 | poetry install --only=dev 19 | poetry run nox -s lint 20 | 21 | typecheck: 22 | name: "typecheck" 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Setup Python 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: "3.11" 30 | - name: Install Poetry 31 | run: | 32 | pip install poetry 33 | - name: run 34 | run: | 35 | poetry install --only=dev 36 | poetry run nox -s typecheck 37 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 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 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 CircuitSacul 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 | # io 2 | A flexible code-running bot, inspired by [lunarmagpie/io](https://github.com/lunarmagpie/io). Click [here](https://discord.com/api/oauth2/authorize?client_id=1073771658906701954&permissions=346176&scope=bot) to invite the bot. 3 | 4 | ## Usage 5 | Use `io/run` or `io/asm` in the message to execute or compile code blocks. You can also use the menu command (right-click > apps > create instance) to create an instance from any message. 6 | 7 | ## Hosting 8 | Copy `.env.example` to a new file called `.env`. Fill out the environment variables. 9 | The bot can then be run with these commands: 10 | ```sh 11 | poetry install 12 | poetry run python -m bot 13 | ``` 14 | 15 | ## Credits 16 | - [@Lunarmagpie](https://github.com/lunarmagpie) for creating the [original io](https://github.com/lunarmagpie/io), which this project was based on 17 | - [@Enderchief](https://github.com/Enderchief) for hosting a [piston fork](https://github.com/Endercheif/piston) with additional languages 18 | - [Compiler Explorer](https://github.com/compiler-explorer/compiler-explorer) and [piston](https://github.com/engineer-man/piston) for creating free-to-use code execution tools 19 | - [@trag1c](https://github.com/trag1c) for coming up with the name 20 | - [@isFakeAccount](https://github.com/isFakeAccount) for adding support for compiler and runtime args 21 | -------------------------------------------------------------------------------- /bot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circuitsacul/io/203b9bde1863faa2ffb1b9b41c9eb07fc5ec13cc/bot/__init__.py -------------------------------------------------------------------------------- /bot/__main__.py: -------------------------------------------------------------------------------- 1 | from bot.app import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /bot/app.py: -------------------------------------------------------------------------------- 1 | import crescent 2 | import hikari 3 | 4 | from bot.config import CONFIG 5 | from bot.manager import Manager 6 | 7 | Plugin = crescent.Plugin[hikari.GatewayBot, "Model"] 8 | INTENTS = hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT 9 | 10 | 11 | class Model: 12 | def __init__(self) -> None: 13 | self.manager = Manager(self) 14 | 15 | async def startup(self) -> None: 16 | await self.manager.startup() 17 | 18 | async def shutdown(self) -> None: 19 | await self.manager.shutdown() 20 | 21 | 22 | def main() -> None: 23 | app = hikari.GatewayBot(CONFIG.TOKEN, intents=INTENTS) 24 | client = crescent.Client(app, model := Model()) 25 | 26 | @app.listen(hikari.StartingEvent) 27 | async def _(_: hikari.StartingEvent) -> None: 28 | await model.startup() 29 | 30 | @app.listen(hikari.StoppingEvent) 31 | async def _(_: hikari.StoppingEvent) -> None: 32 | await model.shutdown() 33 | 34 | client.plugins.load_folder("bot.plugins") 35 | app.run() 36 | -------------------------------------------------------------------------------- /bot/config.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing as t 3 | 4 | import dotenv 5 | 6 | 7 | @dataclasses.dataclass 8 | class Config: 9 | TOKEN: str 10 | PISTON_URL: str = "https://emkc.org/api/v2/piston/" 11 | GODBOLT_URL: str = "https://godbolt.org/api/" 12 | 13 | 14 | env_vars: dict[str, str] = t.cast(dict[str, str], dotenv.dotenv_values()) 15 | try: 16 | CONFIG = Config(**env_vars) 17 | except TypeError as e: 18 | raise Exception( 19 | "You have an error in your .env file. Check the README for more info." 20 | ) from e 21 | -------------------------------------------------------------------------------- /bot/constants.py: -------------------------------------------------------------------------------- 1 | import hikari 2 | 3 | EMBED_COLOR = hikari.Color(0xF7B159) 4 | -------------------------------------------------------------------------------- /bot/lang_defaults.py: -------------------------------------------------------------------------------- 1 | DEFAULTS = { 2 | "python": "piston", 3 | "rust": {"amd64": "rust"}, 4 | "go": {"amd64": "none"}, 5 | "typescript": "piston", 6 | "c": { 7 | "amd64": { 8 | "clang-intel": "16.0.0", 9 | "clang": "16.0.0", 10 | }, 11 | "aarch64": {"clang": "16.0.0"}, 12 | }, 13 | "c++": { 14 | "amd64": { 15 | "clang-intel": "16.0.0", 16 | "clang": "16.0.0", 17 | }, 18 | "aarch64": { 19 | "clang": "16.0.0", 20 | }, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /bot/manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import typing as t 5 | 6 | from bot import models 7 | from bot.providers.godbolt import GodBolt 8 | from bot.providers.piston import Piston 9 | from bot.providers.provider import Provider 10 | 11 | if t.TYPE_CHECKING: 12 | from bot.app import Model 13 | 14 | 15 | class Manager: 16 | def __init__(self, model: Model) -> None: 17 | self.piston_provider = Piston(model) 18 | self.providers: list[Provider] = [GodBolt(model), self.piston_provider] 19 | self.runtimes = models.RuntimeTree() 20 | self.model = model 21 | 22 | async def startup(self) -> None: 23 | await asyncio.gather( 24 | *(asyncio.create_task(p.startup()) for p in self.providers) 25 | ) 26 | 27 | async def shutdown(self) -> None: 28 | await asyncio.gather( 29 | *(asyncio.create_task(p.shutdown()) for p in self.providers) 30 | ) 31 | 32 | async def update_data(self) -> None: 33 | await asyncio.gather( 34 | *(asyncio.create_task(p.update_data()) for p in self.providers) 35 | ) 36 | 37 | runtimes = models.RuntimeTree() 38 | for provider in self.providers: 39 | runtimes.extend(provider.runtimes) 40 | runtimes.sort() 41 | self.runtimes = runtimes 42 | 43 | def unalias(self, language: str) -> str | None: 44 | if language in self.runtimes.run or language in self.runtimes.asm: 45 | return language 46 | 47 | return self.piston_provider.aliases.get(language) 48 | -------------------------------------------------------------------------------- /bot/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import re 5 | import typing as t 6 | from collections import defaultdict 7 | from dataclasses import dataclass, field 8 | from enum import Enum, auto 9 | 10 | import dahlia 11 | 12 | from bot.providers.provider import Provider 13 | from bot.utils.display import format_text 14 | 15 | 16 | class Action(Enum): 17 | RUN = auto() 18 | ASM = auto() 19 | 20 | 21 | @dataclass 22 | class Runtime: 23 | id: str 24 | name: str 25 | description: str 26 | provider: Provider 27 | 28 | 29 | @dataclass 30 | class Code: 31 | code: str 32 | filename: t.Optional[str] = None 33 | language: t.Optional[str] = None 34 | 35 | 36 | @dataclass 37 | class Result: 38 | output: str 39 | 40 | def format(self) -> str: 41 | out = format_text(self.output) 42 | try: 43 | out = dahlia.quantize_ansi(out, to=3) 44 | except Exception: 45 | pass 46 | return out 47 | 48 | 49 | TREE_T = t.Dict[ 50 | str | None, # language 51 | t.Dict[ 52 | str | None, # Instruction Set 53 | t.Dict[ 54 | str | None, # Compiler Type 55 | t.Dict[ 56 | str | None, # Version 57 | Runtime, 58 | ], 59 | ], 60 | ], 61 | ] 62 | 63 | 64 | def make_tree() -> TREE_T: 65 | return defaultdict(lambda: defaultdict(lambda: defaultdict(dict))) 66 | 67 | 68 | @dataclass 69 | class RuntimeTree: 70 | asm: TREE_T = field(default_factory=make_tree) 71 | run: TREE_T = field(default_factory=make_tree) 72 | 73 | def extend(self, other: RuntimeTree) -> None: 74 | for this_tree, other_tree in [(self.asm, other.asm), (self.run, other.run)]: 75 | for lang, tree in other_tree.items(): 76 | for compiler, tree2 in tree.items(): 77 | for instruction, tree3 in tree2.items(): 78 | for version, runtime in tree3.items(): 79 | this_tree[lang][compiler][instruction][version] = runtime 80 | 81 | def sort(self) -> None: 82 | self.asm = sort(self.asm) 83 | self.run = sort(self.run) 84 | 85 | 86 | K = t.TypeVar("K") 87 | T = t.TypeVar("T") 88 | SEMVER_RE = re.compile(r"(\d+)(\.(\d+))+") 89 | 90 | 91 | @dataclass 92 | @functools.total_ordering 93 | class Key: 94 | semver: t.Optional[list[int]] 95 | string: str 96 | 97 | def __lt__(self, other: object) -> bool: 98 | assert isinstance(other, Key) 99 | if self.semver and other.semver: 100 | return self.semver < other.semver 101 | 102 | if self.string == "piston": 103 | return True 104 | elif other.string == "piston": 105 | return False 106 | 107 | return self.string > other.string 108 | 109 | def __eq__(self, other: object) -> bool: 110 | return False 111 | 112 | 113 | def sort(item: dict[K, T]) -> dict[K, T]: 114 | def key(item: tuple[K, object]) -> Key: 115 | key = item[0] 116 | if isinstance(key, str): 117 | if m := SEMVER_RE.search(key): 118 | version = [int(i) for i in m.group().split(".")] 119 | return Key(version, key) 120 | return Key([], str(key)) 121 | 122 | item = {k: v for k, v in sorted(item.items(), key=key, reverse=True)} 123 | for k, v in item.items(): 124 | if isinstance(v, dict): 125 | item[k] = t.cast(T, sort(v)) 126 | 127 | return item 128 | -------------------------------------------------------------------------------- /bot/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circuitsacul/io/203b9bde1863faa2ffb1b9b41c9eb07fc5ec13cc/bot/plugins/__init__.py -------------------------------------------------------------------------------- /bot/plugins/events.py: -------------------------------------------------------------------------------- 1 | import crescent 2 | import hikari 3 | 4 | from bot import models 5 | from bot.app import Plugin 6 | from bot.plugins.instance import Instance 7 | 8 | plugin = Plugin() 9 | 10 | 11 | @plugin.include 12 | @crescent.event 13 | async def on_message(event: hikari.MessageCreateEvent) -> None: 14 | if not event.is_human: 15 | return None 16 | if not (ct := event.message.content): 17 | return None 18 | if not ct.startswith("io/"): 19 | return None 20 | try: 21 | cmd = ct[3:].splitlines()[0].split(" ", 1)[0] 22 | except KeyError: 23 | return None 24 | match cmd: 25 | case "run": 26 | action = models.Action.RUN 27 | case "asm": 28 | action = models.Action.ASM 29 | case _: 30 | return None 31 | 32 | instance = await Instance.from_original(event.message, event.author_id) 33 | if not instance: 34 | return 35 | 36 | instance.action = action 37 | await instance.execute() 38 | -------------------------------------------------------------------------------- /bot/plugins/help.py: -------------------------------------------------------------------------------- 1 | import crescent 2 | import hikari 3 | 4 | from bot.app import Plugin 5 | from bot.constants import EMBED_COLOR 6 | 7 | plugin = Plugin() 8 | 9 | HELP_EMBEDS = [ 10 | hikari.Embed( 11 | description=( 12 | "Hi! My name is io, and my job is to run code." 13 | "\n I can run any code inside of code blocks:" 14 | "\n```" 15 | "\n`\u200b`\u200b`\u200b" 16 | "\n" 17 | "\n`\u200b`\u200b`\u200b" 18 | "\n```" 19 | ), 20 | color=EMBED_COLOR, 21 | ), 22 | hikari.Embed( 23 | description=( 24 | "\n- Running code - Start your message with `io/run`." 25 | "\n- View Assembly - Start your message with `io/asm`." 26 | "\nYou can use message commands by right-clicking on a message, " 27 | "selecting the `Apps` subcategory, then clicking on the `Create Instance` " 28 | "command." 29 | ), 30 | color=EMBED_COLOR, 31 | ), 32 | ] 33 | 34 | 35 | @plugin.include 36 | @crescent.command 37 | async def help(ctx: crescent.Context) -> None: 38 | await ctx.respond(embeds=HELP_EMBEDS) 39 | 40 | 41 | @plugin.include 42 | @crescent.event 43 | async def on_message(message: hikari.MessageCreateEvent) -> None: 44 | me = plugin.app.get_me() 45 | 46 | if not me: 47 | return 48 | 49 | if not message.is_human: 50 | return 51 | 52 | if not message.content: 53 | return 54 | 55 | if not ( 56 | message.content == me.mention 57 | or message.content == "io/help" 58 | or ( 59 | message.content.startswith(me.mention) 60 | and message.content.removeprefix(me.mention).strip() == "help" 61 | ) 62 | ): 63 | return 64 | 65 | await message.message.respond(embeds=HELP_EMBEDS, reply=True) 66 | -------------------------------------------------------------------------------- /bot/plugins/instance.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import enum 5 | import typing as t 6 | from dataclasses import dataclass, field 7 | 8 | import crescent 9 | import hikari 10 | from hikari.api import special_endpoints 11 | 12 | from bot import models 13 | from bot.app import Plugin 14 | from bot.lang_defaults import DEFAULTS 15 | from bot.utils import parse 16 | 17 | plugin = Plugin() 18 | instances: dict[hikari.Snowflake, Instance] = {} 19 | 20 | 21 | K = t.TypeVar("K") 22 | V = t.TypeVar("V") 23 | 24 | 25 | class ComponentID(enum.StrEnum): 26 | DELETE = "delete" 27 | REFRESH_CODE = "refresh_code" 28 | CODE_BLOCK = "code_block" 29 | LANGUAGE = "language" 30 | TOGGLE_MODE = "toggle_mode" 31 | INSTRUCTION_SET = "instruction_set" 32 | COMPILER_TYPE = "compiler_type" 33 | VERSION = "version" 34 | INPUTS = "inputs" 35 | 36 | 37 | class ModalID(enum.StrEnum): 38 | LANGUAGE = "language" 39 | INPUTS = "inputs" 40 | 41 | 42 | def next_in_default_chain(path: list[str | None]) -> str | None: 43 | tree = DEFAULTS 44 | for k in path: 45 | if isinstance(tree, str): 46 | return None 47 | tree = tree.get(k) # type: ignore 48 | if not tree: 49 | return None 50 | 51 | if isinstance(tree, str): 52 | return tree 53 | elif isinstance(tree, dict): 54 | return next(iter(tree.keys())) 55 | return None 56 | 57 | 58 | def get_or_first( 59 | dct: dict[str | None, V], key: str | None, path: list[str | None] 60 | ) -> tuple[str | None, V] | None: 61 | with contextlib.suppress(KeyError): 62 | return (key, dct[key]) 63 | 64 | if k := next_in_default_chain(path): 65 | with contextlib.suppress(KeyError): 66 | return (k, dct[k]) 67 | 68 | with contextlib.suppress(StopIteration): 69 | return next(iter(dct.items())) 70 | 71 | return None 72 | 73 | 74 | @plugin.include 75 | @crescent.event 76 | async def on_modal_interaction(event: hikari.InteractionCreateEvent) -> None: 77 | if not isinstance(event.interaction, hikari.ModalInteraction): 78 | return 79 | 80 | if not (message := event.interaction.message): 81 | return 82 | 83 | if not (inst := instances.get(message.id)): 84 | return 85 | 86 | id = event.interaction.custom_id 87 | 88 | match id: 89 | case ModalID.LANGUAGE: 90 | lang: str | None = event.interaction.components[0].components[0].value 91 | if not lang: 92 | if inst.code: 93 | lang = inst.code.language 94 | else: 95 | lang = None 96 | inst.update_language(lang, False) 97 | else: 98 | inst.update_language(lang, True) 99 | case ModalID.INPUTS: 100 | inst.stdin = event.interaction.components[0].components[0].value 101 | inst.comptime_args = event.interaction.components[1].components[0].value 102 | inst.runtime_args = event.interaction.components[2].components[0].value 103 | 104 | await event.app.rest.create_interaction_response( 105 | event.interaction, 106 | event.interaction.token, 107 | hikari.ResponseType.MESSAGE_UPDATE, 108 | components=inst.components(), 109 | content="Working...", 110 | attachment=None, 111 | ) 112 | await inst.execute() 113 | 114 | 115 | @plugin.include 116 | @crescent.event 117 | async def on_component_interaction(event: hikari.InteractionCreateEvent) -> None: 118 | if not isinstance(event.interaction, hikari.ComponentInteraction): 119 | return 120 | 121 | if not (inst := instances.get(event.interaction.message.id)): 122 | return 123 | 124 | if event.interaction.user.id != inst.requester: 125 | await event.app.rest.create_interaction_response( 126 | event.interaction, 127 | event.interaction.token, 128 | hikari.ResponseType.MESSAGE_CREATE, 129 | flags=hikari.MessageFlag.EPHEMERAL, 130 | content="Only the person who created this instance can edit it.", 131 | ) 132 | return 133 | 134 | id = event.interaction.custom_id 135 | 136 | match id: 137 | case ComponentID.DELETE: 138 | await inst.delete() 139 | return 140 | case ComponentID.REFRESH_CODE: 141 | message = await plugin.app.rest.fetch_message(inst.channel, inst.message) 142 | await inst.update(message) 143 | case ComponentID.CODE_BLOCK: 144 | if v := event.interaction.values: 145 | for x, block in enumerate(inst.codes): 146 | if str(x) == v[0]: 147 | inst.update_code(block) 148 | break 149 | else: 150 | if inst.codes: 151 | inst.update_code(inst.codes[0]) 152 | case ComponentID.LANGUAGE: 153 | await event.app.rest.create_modal_response( 154 | event.interaction, 155 | event.interaction.token, 156 | title="Select Language", 157 | custom_id=ModalID.LANGUAGE, 158 | component=event.app.rest.build_modal_action_row().add_text_input( 159 | ModalID.LANGUAGE, 160 | "Language", 161 | placeholder="Leave empty to use the default.", 162 | required=False, 163 | ), 164 | ) 165 | return 166 | case ComponentID.TOGGLE_MODE: 167 | if inst.action is models.Action.RUN: 168 | inst.update_action(models.Action.ASM) 169 | else: 170 | inst.update_action(models.Action.RUN) 171 | case ComponentID.INSTRUCTION_SET: 172 | if v := event.interaction.values: 173 | inst.instruction_set = v[0] 174 | else: 175 | inst.instruction_set = None 176 | inst.compiler_type = None 177 | inst.version = None 178 | case ComponentID.COMPILER_TYPE: 179 | if v := event.interaction.values: 180 | inst.compiler_type = v[0] 181 | else: 182 | inst.compiler_type = None 183 | inst.version = None 184 | case ComponentID.VERSION: 185 | if v := event.interaction.values: 186 | inst.version = v[0] 187 | else: 188 | inst.version = None 189 | case ComponentID.INPUTS: 190 | await event.app.rest.create_modal_response( 191 | event.interaction, 192 | event.interaction.token, 193 | title="Set Inputs", 194 | custom_id=ModalID.INPUTS, 195 | components=create_input_args_modal(event, inst), 196 | ) 197 | return 198 | 199 | await event.app.rest.create_interaction_response( 200 | event.interaction, 201 | event.interaction.token, 202 | hikari.ResponseType.MESSAGE_UPDATE, 203 | content="Working...", 204 | attachment=None, 205 | components=inst.components(), 206 | ) 207 | await inst.execute() 208 | 209 | 210 | def create_input_args_modal( 211 | event: hikari.InteractionCreateEvent, 212 | msg_instance: Instance, 213 | ) -> list[special_endpoints.ModalActionRowBuilder]: 214 | stdin_modal_row = event.app.rest.build_modal_action_row() 215 | stdin_modal_row.add_text_input( 216 | "stdin", 217 | "Set STDIN", 218 | value=msg_instance.stdin or hikari.UNDEFINED, 219 | required=False, 220 | style=hikari.TextInputStyle.PARAGRAPH, 221 | max_length=1024, 222 | placeholder="Standard Input", 223 | ) 224 | 225 | comptime_modal_row = event.app.rest.build_modal_action_row() 226 | comptime_modal_row.add_text_input( 227 | "comptime", 228 | "Compiler Args", 229 | value=msg_instance.comptime_args or hikari.UNDEFINED, 230 | required=False, 231 | style=hikari.TextInputStyle.PARAGRAPH, 232 | max_length=255, 233 | placeholder="Compiler Args - 1 per line", 234 | ) 235 | 236 | runtime_modal_row = event.app.rest.build_modal_action_row() 237 | runtime_modal_row.add_text_input( 238 | "runtime", 239 | "Runtime Args", 240 | value=msg_instance.runtime_args or hikari.UNDEFINED, 241 | required=False, 242 | style=hikari.TextInputStyle.PARAGRAPH, 243 | max_length=255, 244 | placeholder="Runtime Args - 1 per line", 245 | ) 246 | 247 | return [stdin_modal_row, comptime_modal_row, runtime_modal_row] 248 | 249 | 250 | T = t.TypeVar("T") 251 | 252 | 253 | @dataclass 254 | class Setting(t.Generic[T]): 255 | v: T 256 | overwritten: bool = False 257 | 258 | def update(self, v: T) -> None: 259 | self.v = v 260 | self.overwritten = False 261 | 262 | def user_update(self, v: T) -> None: 263 | self.v = v 264 | self.overwritten = True 265 | 266 | 267 | class Selector(t.NamedTuple): 268 | id: ComponentID 269 | """The custom ID for the component used to select this.""" 270 | selected: str | None 271 | """The currently selected runtime.""" 272 | options: list[str | None] 273 | """A list of all runtimes the user can select.""" 274 | 275 | 276 | @dataclass 277 | class Instance: 278 | channel: hikari.Snowflake 279 | message: hikari.Snowflake 280 | requester: hikari.Snowflake 281 | codes: list[models.Code] 282 | 283 | code: t.Optional[models.Code] = None 284 | stdin: str | None = None 285 | comptime_args: str | None = None 286 | runtime_args: str | None = None 287 | language: Setting[t.Optional[str]] = field(default_factory=lambda: Setting(None)) 288 | action: models.Action = models.Action.RUN 289 | instruction_set: str | None = None 290 | compiler_type: str | None = None 291 | version: str | None = None 292 | 293 | response: t.Optional[hikari.Snowflake] = None 294 | 295 | def update_code(self, code: models.Code | None) -> None: 296 | self.code = code 297 | if ( 298 | code 299 | and code.language 300 | and plugin.model.manager.unalias(code.language.lower()) != self.language.v 301 | and not self.language.overwritten 302 | ): 303 | self.update_language(code.language, False) 304 | 305 | def update_language(self, language: str | None, user: bool) -> None: 306 | if language: 307 | language = plugin.model.manager.unalias(language.lower()) 308 | if user: 309 | self.language.user_update(language) 310 | else: 311 | self.language.update(language) 312 | 313 | self.reset_selectors() 314 | 315 | def update_action(self, action: models.Action) -> None: 316 | self.action = action 317 | 318 | def reset_selectors(self) -> None: 319 | self.instruction_set = None 320 | self.compiler_type = None 321 | self.version = None 322 | 323 | def selectors(self) -> list[Selector]: 324 | if self.language.v is None: 325 | return [] 326 | 327 | selectors: list[Selector] = [] 328 | 329 | runtimes = plugin.model.manager.runtimes 330 | match self.action: 331 | case models.Action.RUN: 332 | tree = runtimes.run 333 | case models.Action.ASM: 334 | tree = runtimes.asm 335 | 336 | path: list[str | None] = [] 337 | if instructions := tree.get(self.language.v): 338 | path.append(self.language.v) 339 | if instruction_set_select := get_or_first( 340 | instructions, self.instruction_set, path 341 | ): 342 | instruction_set, compilers = instruction_set_select 343 | if len(instructions) > 1: 344 | selectors.append( 345 | Selector( 346 | id=ComponentID.INSTRUCTION_SET, 347 | selected=instruction_set, 348 | options=list(instructions), 349 | ) 350 | ) 351 | path.append(instruction_set) 352 | 353 | if compiler_type_select := get_or_first( 354 | compilers, self.compiler_type, path 355 | ): 356 | compiler, versions = compiler_type_select 357 | if len(compilers) > 1: 358 | selectors.append( 359 | Selector( 360 | id=ComponentID.COMPILER_TYPE, 361 | selected=compiler, 362 | options=list(compilers), 363 | ) 364 | ) 365 | path.append(compiler) 366 | 367 | if version_select := get_or_first(versions, self.version, path): 368 | version, _ = version_select 369 | selectors.append( 370 | Selector( 371 | id=ComponentID.VERSION, 372 | selected=version, 373 | options=list(versions), 374 | ) 375 | ) 376 | 377 | return selectors 378 | 379 | @property 380 | def runtime(self) -> models.Runtime | None: 381 | lang = self.language.v 382 | 383 | match self.action: 384 | case models.Action.RUN: 385 | tree = plugin.model.manager.runtimes.run 386 | case models.Action.ASM: 387 | tree = plugin.model.manager.runtimes.asm 388 | 389 | path: list[str | None] = [lang] 390 | if not (tree2 := tree.get(lang)): 391 | return None 392 | 393 | if not (tree3 := get_or_first(tree2, self.instruction_set, path)): 394 | return None 395 | self.instruction_set = tree3[0] 396 | path.append(tree3[0]) 397 | if not (tree4 := get_or_first(tree3[1], self.compiler_type, path)): 398 | return None 399 | self.compiler_type = tree4[0] 400 | path.append(tree4[0]) 401 | tree5 = get_or_first(tree4[1], self.version, path) 402 | if tree5: 403 | self.version = tree5[0] 404 | return tree5[1] 405 | else: 406 | return None 407 | 408 | @staticmethod 409 | async def from_original( 410 | message: hikari.Message, 411 | requester: hikari.Snowflake, 412 | ) -> Instance | None: 413 | codes = await parse.get_codes(message) 414 | if not codes: 415 | return None 416 | 417 | instance = Instance(message.channel_id, message.id, requester, codes) 418 | instance.update_code(codes[0]) 419 | 420 | return instance 421 | 422 | async def delete(self) -> None: 423 | if not self.response: 424 | return 425 | try: 426 | await plugin.app.rest.delete_message(self.channel, self.response) 427 | except hikari.NotFoundError: 428 | pass 429 | else: 430 | del instances[self.response] 431 | 432 | async def update(self, message: hikari.Message) -> None: 433 | if not (codes := await parse.get_codes(message)): 434 | await self.delete() 435 | return 436 | 437 | self.codes = codes 438 | self.update_code(codes[0]) 439 | 440 | def components(self) -> list[hikari.api.MessageActionRowBuilder]: 441 | rows = [] 442 | 443 | # basic buttons 444 | rows.append( 445 | plugin.app.rest.build_message_action_row() 446 | .add_interactive_button( 447 | hikari.ButtonStyle.SECONDARY, ComponentID.DELETE, label="Delete" 448 | ) 449 | .add_interactive_button( 450 | hikari.ButtonStyle.SECONDARY, 451 | ComponentID.REFRESH_CODE, 452 | label="Refresh Code", 453 | ) 454 | .add_interactive_button( 455 | hikari.ButtonStyle.SECONDARY, 456 | ComponentID.TOGGLE_MODE, 457 | label=( 458 | "Mode: Execute" if self.action is models.Action.RUN else "Mode: ASM" 459 | ), 460 | ) 461 | .add_interactive_button( 462 | hikari.ButtonStyle.SECONDARY, 463 | ComponentID.LANGUAGE, 464 | label=f"Language: {self.language.v or 'Unknown'}", 465 | ) 466 | .add_interactive_button( 467 | hikari.ButtonStyle.SECONDARY, 468 | ComponentID.INPUTS, 469 | label="Set Inputs", 470 | ) 471 | ) 472 | 473 | # code block selection 474 | if len(self.codes) > 1: 475 | select = plugin.app.rest.build_message_action_row().add_text_menu( 476 | ComponentID.CODE_BLOCK, 477 | placeholder="Select the code block to run", 478 | ) 479 | for x, block in enumerate(self.codes): 480 | if block.filename: 481 | label = f"Attachment {block.filename}" 482 | else: 483 | label = f'Code Block: "{block.code[0:32]}..."' 484 | select.add_option(label, str(x), is_default=block == self.code) 485 | rows.append(select.parent) 486 | 487 | # version 488 | for id, selected, options in self.selectors(): 489 | select = ( 490 | plugin.app.rest.build_message_action_row() 491 | .add_text_menu(id) 492 | .set_is_disabled(len(options) == 1) 493 | ) 494 | for option in options[0:25]: 495 | select.add_option( 496 | str(option), str(option), is_default=option == selected 497 | ) 498 | rows.append(select.parent) 499 | 500 | return rows 501 | 502 | async def execute(self) -> None: 503 | if not self.response: 504 | await plugin.app.rest.trigger_typing(self.channel) 505 | 506 | # try to execute code 507 | code_file: hikari.Bytes | None = None 508 | out: list[str] = [f"<@{self.requester}>"] 509 | 510 | code_output: str = "" 511 | if self.runtime: 512 | ret = await self.runtime.provider.execute(self) 513 | code_output = ret.format() 514 | 515 | if not code_output.strip(): 516 | out.append("Your code ran with no output.") 517 | code_output = "" 518 | else: 519 | out.append("No runtime selected.") 520 | 521 | stdin = self.stdin or "" 522 | comptime_args = self.comptime_args or "" 523 | runtime_args = self.runtime_args or "" 524 | 525 | message_length = ( 526 | len(code_output) + len(stdin) + len(comptime_args) + len(runtime_args) 527 | ) 528 | 529 | if code_output: 530 | if message_length > 1_950: 531 | code_file = hikari.Bytes(code_output, "code.ansi") 532 | else: 533 | out.append(f"```ansi\n{code_output}```") 534 | 535 | if stdin: 536 | out.append(f"STDIN:\n```ansi\n{stdin}```") 537 | 538 | if comptime_args: 539 | warn = "" 540 | if self.runtime and not self.runtime.provider.supports_compiler_args: 541 | warn = " (Not supported by current runtime)" 542 | out.append(f"Compiler Args{warn}:\n```ansi\n{comptime_args}```") 543 | 544 | if runtime_args: 545 | warn = "" 546 | if self.runtime and not self.runtime.provider.supports_runtime_args: 547 | warn = " (Not supported by current runtime)" 548 | out.append(f"Runtime Args{warn}:\n```ansi\n{runtime_args}```") 549 | 550 | # send message 551 | out_str = "\n".join(out) 552 | rows = self.components() 553 | attachments = [code_file] if code_file else [] 554 | if self.response: 555 | await plugin.app.rest.edit_message( 556 | self.channel, 557 | self.response, 558 | out_str, 559 | components=rows, 560 | attachments=attachments or None, 561 | ) 562 | else: 563 | resp = await plugin.app.rest.create_message( 564 | self.channel, 565 | out_str, 566 | reply=self.message, 567 | components=rows, 568 | attachments=attachments, 569 | user_mentions=[self.requester], 570 | ) 571 | self.response = resp.id 572 | instances[resp.id] = self 573 | -------------------------------------------------------------------------------- /bot/plugins/languages.py: -------------------------------------------------------------------------------- 1 | import crescent 2 | import hikari 3 | 4 | from bot.app import Plugin 5 | from bot.constants import EMBED_COLOR 6 | 7 | plugin = Plugin() 8 | 9 | 10 | @plugin.include 11 | @crescent.command 12 | async def languages(ctx: crescent.Context) -> None: 13 | lang_names = list( 14 | f"`{runtime}`" for runtime in plugin.model.manager.runtimes.run if runtime 15 | ) 16 | embed = hikari.Embed( 17 | title="Supported Languages", description=",".join(lang_names), color=EMBED_COLOR 18 | ) 19 | await ctx.respond(embed=embed) 20 | -------------------------------------------------------------------------------- /bot/plugins/message_commands.py: -------------------------------------------------------------------------------- 1 | import crescent 2 | import hikari 3 | 4 | from bot.app import Plugin 5 | 6 | from .instance import Instance 7 | 8 | plugin = Plugin() 9 | 10 | 11 | @plugin.include 12 | @crescent.message_command(name="Create Instance") 13 | async def create_instance(ctx: crescent.Context, msg: hikari.Message) -> None: 14 | inst = await Instance.from_original(msg, ctx.user.id) 15 | if not inst: 16 | await ctx.respond("No code blocks to run could be found.", ephemeral=True) 17 | return 18 | 19 | await ctx.respond("Instance created.", ephemeral=True) 20 | await inst.execute() 21 | -------------------------------------------------------------------------------- /bot/plugins/tasks.py: -------------------------------------------------------------------------------- 1 | from crescent.ext import tasks 2 | 3 | from bot.app import Plugin 4 | 5 | plugin = Plugin() 6 | 7 | 8 | @plugin.include 9 | @tasks.loop(minutes=30) 10 | async def update_languages() -> None: 11 | await plugin.model.manager.update_data() 12 | -------------------------------------------------------------------------------- /bot/providers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circuitsacul/io/203b9bde1863faa2ffb1b9b41c9eb07fc5ec13cc/bot/providers/__init__.py -------------------------------------------------------------------------------- /bot/providers/godbolt.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import aiohttp 6 | 7 | from bot import models 8 | from bot.config import CONFIG 9 | from bot.providers.provider import Provider 10 | from bot.utils.fixes import transform_code 11 | 12 | if t.TYPE_CHECKING: 13 | from bot.plugins.instance import Instance 14 | 15 | 16 | def parse_response(data: dict[str, t.Any]) -> str: 17 | berr = get_text(data, "buildResult", "stderr") 18 | bout = get_text(data, "buildResult", "stdout") 19 | err = get_text(data, "stderr") 20 | out = get_text(data, "stdout") 21 | asm = get_text(data, "asm") 22 | 23 | if berr == err: 24 | berr = None 25 | if bout == out: 26 | bout = None 27 | 28 | return join_text(berr, bout, err, out, asm) 29 | 30 | 31 | def join_text(*texts: str | None) -> str: 32 | return "\n".join(t for t in texts if t) 33 | 34 | 35 | def get_text(obj: object, *path: str) -> str | None: 36 | for k in path: 37 | assert isinstance(obj, dict) 38 | try: 39 | obj = obj[k] 40 | except (KeyError, TypeError): 41 | return None 42 | assert isinstance(obj, list) 43 | return "\n".join(line["text"] for line in obj) 44 | 45 | 46 | class GodBolt(Provider): 47 | URL = CONFIG.GODBOLT_URL 48 | 49 | @property 50 | def supports_compiler_args(self) -> bool: 51 | return True 52 | 53 | @property 54 | def supports_runtime_args(self) -> bool: 55 | return True 56 | 57 | async def startup(self) -> None: 58 | self._session = aiohttp.ClientSession(headers={"Accept": "application/json"}) 59 | 60 | async def update_data(self) -> None: 61 | runtimes = models.RuntimeTree() 62 | 63 | async with self.session.get(self.URL + "compilers/") as resp: 64 | resp.raise_for_status() 65 | for data in await resp.json(): 66 | runtime = models.Runtime( 67 | id=data["id"], 68 | name=data["name"], 69 | description="{}/{}/{}/{}".format( 70 | data["lang"], 71 | data["instructionSet"] or "none", 72 | data["compilerType"] or "none", 73 | data["semver"] or "none", 74 | ), 75 | provider=self, 76 | ) 77 | for tree in [runtimes.asm, runtimes.run]: 78 | # godbolt supports execution and compilation 79 | # fmt: off 80 | ( 81 | tree 82 | [data["lang"]] 83 | [data["instructionSet"] or "none"] 84 | [data["compilerType"] or "none"] 85 | [data["semver"] or "none"] 86 | ) = runtime 87 | # fmt: on 88 | 89 | self.runtimes = runtimes 90 | 91 | async def _run(self, instance: Instance) -> models.Result: 92 | if not (rt := instance.runtime): 93 | return models.Result("No runtime selected.") 94 | if not (code := instance.code): 95 | return models.Result("No code to run.") 96 | if not (lang := instance.language.v): 97 | return models.Result("No language.") 98 | 99 | url = self.URL + f"compiler/{rt.id}/compile" 100 | post_data = { 101 | "source": transform_code(lang, code.code), 102 | "lang": lang.lower(), 103 | "options": { 104 | "compilerOptions": {"executorRequest": True}, 105 | "executeParameters": { 106 | "stdin": instance.stdin, 107 | "args": ( 108 | instance.runtime_args.splitlines() 109 | if instance.runtime_args is not None 110 | else [] 111 | ), 112 | }, 113 | "filters": {"execute": True}, 114 | "userArguments": instance.comptime_args.replace("\n", " ") 115 | if instance.comptime_args is not None 116 | else "", 117 | }, 118 | } 119 | 120 | async with self.session.post(url, json=post_data) as resp: 121 | resp.raise_for_status() 122 | data = await resp.json() 123 | 124 | return models.Result(parse_response(data)) 125 | 126 | async def _asm(self, instance: Instance) -> models.Result: 127 | assert instance.runtime 128 | assert instance.language.v 129 | assert instance.code 130 | 131 | rt = instance.runtime 132 | lang = instance.language.v 133 | code = instance.code 134 | 135 | url = self.URL + f"compiler/{rt.id}/compile" 136 | post_data = { 137 | "source": transform_code(lang, code.code), 138 | "lang": lang.lower(), 139 | "options": { 140 | "compilerOptions": { 141 | "executorRequest": False, 142 | }, 143 | "filters": { 144 | "binary": False, 145 | "binaryObject": False, 146 | "commentOnly": True, 147 | "demangle": True, 148 | "directives": True, 149 | "execute": False, 150 | "intel": True, 151 | "labels": True, 152 | "libraryCode": False, 153 | "trim": False, 154 | }, 155 | "userArguments": instance.comptime_args.replace("\n", " ") 156 | if instance.comptime_args is not None 157 | else "", 158 | }, 159 | } 160 | 161 | async with self.session.post(url, json=post_data) as resp: 162 | resp.raise_for_status() 163 | data = await resp.json() 164 | 165 | return models.Result(parse_response(data)) 166 | 167 | async def execute(self, instance: Instance) -> models.Result: 168 | match instance.action: 169 | case models.Action.RUN: 170 | return await self._run(instance) 171 | case models.Action.ASM: 172 | return await self._asm(instance) 173 | -------------------------------------------------------------------------------- /bot/providers/piston.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import aiohttp 6 | 7 | from bot import models 8 | from bot.config import CONFIG 9 | from bot.providers.provider import Provider 10 | from bot.utils.fixes import transform_code 11 | 12 | if t.TYPE_CHECKING: 13 | from bot.app import Model 14 | from bot.plugins.instance import Instance 15 | 16 | 17 | class Piston(Provider): 18 | URL = CONFIG.PISTON_URL 19 | 20 | @property 21 | def supports_compiler_args(self) -> bool: 22 | return False 23 | 24 | @property 25 | def supports_runtime_args(self) -> bool: 26 | return True 27 | 28 | def __init__(self, model: Model) -> None: 29 | self.aliases: dict[str, str] = {} 30 | super().__init__(model) 31 | 32 | async def startup(self) -> None: 33 | self._session = aiohttp.ClientSession( 34 | headers={"content-type": "application/json"} 35 | ) 36 | 37 | async def update_data(self) -> None: 38 | runtimes = models.RuntimeTree() 39 | aliases: dict[str, str] = {} 40 | 41 | async with self.session.get(self.URL + "runtimes/") as resp: 42 | resp.raise_for_status() 43 | for data in await resp.json(): 44 | if "runtime" in data: 45 | version = "{}@{}".format(data["runtime"], data["version"]) 46 | else: 47 | version = data["version"] 48 | runtime = models.Runtime( 49 | id="{}@{}".format(data["language"], data["version"]), 50 | name=data["language"], 51 | description="{} {}".format(data["language"], version), 52 | provider=self, 53 | ) 54 | 55 | for alias in data["aliases"]: 56 | aliases[alias] = runtime.name 57 | 58 | runtimes.run[data["language"]]["piston"]["piston"][version] = runtime 59 | 60 | self.aliases = aliases 61 | self.runtimes = runtimes 62 | 63 | async def execute(self, instance: Instance) -> models.Result: 64 | assert instance.runtime 65 | assert instance.language.v 66 | assert instance.code 67 | 68 | lang, version = instance.runtime.id.split("@") 69 | post_data = { 70 | "language": lang, 71 | "version": version, 72 | "files": [{"content": transform_code(lang, instance.code.code)}], 73 | "stdin": instance.stdin, 74 | "args": instance.runtime_args.splitlines() 75 | if instance.runtime_args is not None 76 | else [], 77 | } 78 | 79 | async with self.session.post(self.URL + "execute", json=post_data) as resp: 80 | resp.raise_for_status() 81 | data = await resp.json() 82 | 83 | return models.Result( 84 | "\n".join( 85 | [ 86 | data.get("compile", {}).get("output", ""), 87 | data.get("run", {}).get("output", ""), 88 | ] 89 | ) 90 | ) 91 | -------------------------------------------------------------------------------- /bot/providers/provider.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import typing as t 5 | 6 | import aiohttp 7 | 8 | from bot import models 9 | 10 | if t.TYPE_CHECKING: 11 | from bot.app import Model 12 | from bot.plugins.instance import Instance 13 | 14 | 15 | class Provider(abc.ABC): 16 | def __init__(self, model: Model) -> None: 17 | self.model = model 18 | self.runtimes = models.RuntimeTree() 19 | self._session: aiohttp.ClientSession | None = None 20 | 21 | @abc.abstractproperty 22 | def supports_compiler_args(self) -> bool: 23 | ... 24 | 25 | @abc.abstractproperty 26 | def supports_runtime_args(self) -> bool: 27 | ... 28 | 29 | @property 30 | def session(self) -> aiohttp.ClientSession: 31 | assert self._session, "aiohttp session not initialized" 32 | return self._session 33 | 34 | async def shutdown(self) -> None: 35 | if self._session and not self._session.closed: 36 | await self._session.close() 37 | 38 | @abc.abstractmethod 39 | async def startup(self) -> None: 40 | ... 41 | 42 | @abc.abstractmethod 43 | async def execute(self, instance: Instance) -> models.Result: 44 | ... 45 | 46 | @abc.abstractmethod 47 | async def update_data(self) -> None: 48 | ... 49 | 50 | def __str__(self) -> str: 51 | return self.__class__.__name__.lower() 52 | -------------------------------------------------------------------------------- /bot/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circuitsacul/io/203b9bde1863faa2ffb1b9b41c9eb07fc5ec13cc/bot/utils/__init__.py -------------------------------------------------------------------------------- /bot/utils/display.py: -------------------------------------------------------------------------------- 1 | def format_text(text: str) -> str: 2 | return ( 3 | # GCC is stupid. 4 | text.replace("\x1b[K", "") 5 | # Discord doesn't understand this alias. 6 | .replace("\x1b[m", "\x1b[0m") 7 | # Discord doesn't understand this either. 8 | .replace("\x1b[01m", "\x1b[1m") 9 | ) 10 | -------------------------------------------------------------------------------- /bot/utils/fixes.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | JAVA_PUBLIC_CLASS_REGEX = re.compile(r"public\s+class") 4 | RUST_FN_REGEX = re.compile(r"fn\s+main\s*\(\s*\)") 5 | 6 | ZIG_STD_REGEX = re.compile(r"std\s*=") 7 | ZIG_MAIN_FN_REGEX = re.compile(r"fn\s+main\s*\(\s*\)") 8 | 9 | 10 | def transform_code(lang: str, code: str) -> str: 11 | """ 12 | Converts the code into new code based on the language and some rules. 13 | """ 14 | 15 | match lang: 16 | case "java": 17 | return JAVA_PUBLIC_CLASS_REGEX.sub("class", code, count=1) 18 | 19 | case "rust": 20 | if not RUST_FN_REGEX.search(code): 21 | return "fn main() {\n" f"{code}\n" "}" 22 | return code 23 | 24 | case "zig": 25 | if not ZIG_STD_REGEX.search(code): 26 | header = 'const std = @import("std");' 27 | else: 28 | header = "" 29 | 30 | if not ZIG_MAIN_FN_REGEX.search(code): 31 | return f"{header}\n" "pub fn main() !void { " f"{code}" "}" 32 | 33 | return f"{header}\n{code}" 34 | 35 | case _: 36 | return code 37 | -------------------------------------------------------------------------------- /bot/utils/parse.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import typing as t 5 | 6 | from hikari import Attachment, Message 7 | 8 | from bot import models 9 | 10 | CODE_BLOCK_REGEX = re.compile(r"```(?P\w*)[\n\s]*(?P(.|\n)*?)```") 11 | CODE_LINE_REGEX = re.compile(r"`(?P[^`\n]+)`") 12 | 13 | 14 | async def get_codes(message: Message) -> list[models.Code]: 15 | return [ 16 | *_get_code_blocks(message.content), 17 | *await _get_code_attachments(message.attachments), 18 | ] 19 | 20 | 21 | def _get_code_blocks(content: str | None) -> list[models.Code]: 22 | if not content: 23 | return [] 24 | 25 | blocks: list[models.Code] = [] 26 | 27 | for block in CODE_BLOCK_REGEX.finditer(content): 28 | dct = block.groupdict() 29 | code = models.Code(code=dct["code"]) 30 | if language := dct.get("lang"): 31 | code.language = language 32 | blocks.append(code) 33 | 34 | content = CODE_BLOCK_REGEX.sub("", content) 35 | for line in CODE_LINE_REGEX.finditer(content): 36 | blocks.append(models.Code(code=line.groupdict()["code"])) 37 | 38 | return blocks 39 | 40 | 41 | async def _get_code_attachments(files: t.Sequence[Attachment]) -> list[models.Code]: 42 | codes: list[models.Code] = [] 43 | 44 | for file in files: 45 | content = await file.read() 46 | code = models.Code(code=content.decode(), filename=file.filename) 47 | if extension := file.extension: 48 | code.language = extension 49 | codes.append(code) 50 | 51 | return codes 52 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | 4 | def install_poetry_groups(session: nox.Session, *groups: str) -> None: 5 | session.run("poetry", "install", f"--with={','.join(groups)}") 6 | 7 | 8 | @nox.session 9 | def typecheck(session: nox.Session) -> None: 10 | install_poetry_groups(session, "typing") 11 | session.run("mypy", ".") 12 | 13 | 14 | @nox.session 15 | def lint(session: nox.Session) -> None: 16 | install_poetry_groups(session, "linting") 17 | session.run("black", "--check", ".") 18 | session.run("ruff", "check", ".") 19 | session.run("codespell", ".") 20 | 21 | 22 | @nox.session 23 | def fix(session: nox.Session) -> None: 24 | install_poetry_groups(session, "linting") 25 | session.run("black", ".") 26 | session.run("ruff", "check", "--fix", ".") 27 | session.run("codespell", "-w", "-i2", ".") 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.mypy] 2 | strict = true 3 | 4 | [tool.ruff] 5 | extend-select = [ 6 | # Enable isort 7 | "I", 8 | # Prevent dangling tasks 9 | "RUF006" 10 | ] 11 | 12 | [tool.poetry] 13 | name = "io" 14 | packages = [{ include = "bot" }] 15 | version = "0" 16 | description = "A flexible code-running bot." 17 | authors = ["CircuitSacul "] 18 | license = "MIT" 19 | readme = "README.md" 20 | 21 | [tool.poetry.dependencies] 22 | python = ">=3.11,<3.12" 23 | hikari = "^2.0.0.dev120" 24 | hikari-crescent = "^0.6.1" 25 | python-dotenv = "^1.0.0" 26 | aiohttp = "^3.8.4" 27 | dahlia = "^2.3.2" 28 | 29 | [tool.poetry.group.dev.dependencies] 30 | nox = "^2023.4.22" 31 | 32 | [tool.poetry.group.typing.dependencies] 33 | mypy = "^1.1.1" 34 | 35 | [tool.poetry.group.linting.dependencies] 36 | black = "^23.1.0" 37 | ruff = "^0.1.9" 38 | codespell = "^2.2.5" 39 | 40 | [build-system] 41 | requires = ["poetry-core"] 42 | build-backend = "poetry.core.masonry.api" 43 | --------------------------------------------------------------------------------