├── .gitignore ├── Examples ├── console_cog.py └── test.py ├── README.md ├── dpyConsole ├── __init__.py ├── console.py ├── converter.py └── errors.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | requirements.txt 2 | dist 3 | discord.py_Console.egg-info 4 | build -------------------------------------------------------------------------------- /Examples/console_cog.py: -------------------------------------------------------------------------------- 1 | from dpyConsole import Cog 2 | from dpyConsole import console 3 | import discord 4 | 5 | 6 | class ConsoleCog(Cog): 7 | def __init__(self, console): 8 | super(ConsoleCog, self).__init__() 9 | self.console = console 10 | 11 | @console.command() 12 | async def cog_test(self, user: discord.User): 13 | await user.send("Hello from Console \n" 14 | f"This command operates in a Cog and my name is {self.console.client.user.name}") 15 | 16 | 17 | def setup(console): 18 | """ 19 | Loads Cog 20 | This function must be present. 21 | As you can see the implementation is just like in discord.py 22 | :param console: 23 | :return: 24 | """ 25 | console.add_console_cog(ConsoleCog(console)) 26 | -------------------------------------------------------------------------------- /Examples/test.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from dpyConsole import Console 3 | 4 | client = discord.Client(intents=discord.Intents.all()) 5 | console = Console(client) 6 | console.load_extension("console_cog") # Loads extension (use doted path) 7 | 8 | 9 | @client.event 10 | async def on_ready(): 11 | pass 12 | 13 | 14 | @console.command() 15 | async def hey(user: discord.User): # Library automatically converts type annotations, just like in discord.py 16 | """ 17 | Library can handle both synchronous or asynchronous functions 18 | """ 19 | print(f"Sending message to {user.name} id: = {user.id}") 20 | await user.send("Hello from Console") 21 | 22 | 23 | console.start() 24 | client.run("Token") 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discord.py-Console 2 | Discord.py Console is a command line tool that allows you to control your bot and execute commands, 3 | so you can **use your Bot in the terminal/console** and run Discord commands as usual. 4 | 5 | **(Works with all discord.py forks like py-cord/pycord or nextcord)** 6 | 7 | 8 | ### Installation 9 | ---------- 10 | 11 | #### Windows 12 | `py -3 -m pip install discord.py-Console` 13 | 14 | #### Linux/macOS 15 | `python3 -m pip install discord.py-Console` 16 | 17 | 18 | ### Usage and Example 19 | ---------- 20 | 21 | The implementation is similar to the regular commands in discord.py. 22 | Just implement the discord.py-Console like this: 23 | 24 | ```python 25 | import discord 26 | from dpyConsole import Console 27 | 28 | client = discord.Client(intents=discord.Intents.all()) 29 | my_console = Console(client) 30 | 31 | @client.event 32 | async def on_ready(): 33 | print("I'm Ready") 34 | 35 | 36 | @my_console.command() 37 | async def hey(user: discord.User): # Library automatically converts type annotations, just like in discord.py 38 | """ 39 | Library can handle both synchronous or asynchronous functions 40 | """ 41 | print(f"Sending message to {user.name} id: = {user.id}") 42 | await user.send(f"Hello from Console Im {client.user.name}") 43 | 44 | 45 | my_console.start() # Starts console listener (opens new Thread to be nonblocking) 46 | client.run("Token") 47 | ``` 48 | To execute the mentioned command run ``hey exampleUser#0001`` or ``hey ``. 49 | 50 | 51 | ### Links and Infos 52 | ---------- 53 | 54 | Note: You can split up discord.py-Console commands into cogs view an example in the Example folder. 55 | - [PyPI Download](https://pypi.org/project/discord.py-Console) 56 | -------------------------------------------------------------------------------- /dpyConsole/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | dpy-Console 3 | """ 4 | 5 | try: 6 | import discord 7 | except ImportError: 8 | RuntimeError("Cannot find discord namespace please use:\n" 9 | "pip install discord.py-Console[discord.py] or pip install discord.py-Console[py-cord] " 10 | "depending on what library you want to use or install it manually") 11 | 12 | from dpyConsole.console import Console, Cog 13 | from dpyConsole.converter import Converter 14 | -------------------------------------------------------------------------------- /dpyConsole/console.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import sys 3 | from asyncio import Future 4 | from inspect import iscoroutinefunction 5 | import asyncio 6 | from dpyConsole.converter import Converter 7 | import inspect 8 | import logging 9 | import traceback 10 | import shlex 11 | import threading 12 | 13 | from dpyConsole.errors import CommandNotFound, ExtensionError 14 | 15 | logger = logging.getLogger("dpyConsole") 16 | 17 | 18 | class Console: 19 | """ 20 | Handles console input and Command invocations. 21 | It also holds the converter in 22 | """ 23 | 24 | def __init__(self, client, **kwargs): 25 | self.client = client 26 | self.input = kwargs.get("input", sys.stdin) 27 | self.out = kwargs.get("out", sys.stdout) 28 | self.__commands__ = dict() 29 | self.converter = kwargs.get("converter", Converter(client)) 30 | self.__extensions = {} 31 | self.__cogs = {} 32 | 33 | def add_console_cog(self, obj): 34 | if isinstance(obj, Cog): 35 | self.__cogs.update({obj.__class__.__name__: obj}) 36 | obj.load(self) 37 | return 38 | raise Exception 39 | 40 | def remove_console_cog(self, name): 41 | cog = self.__cogs.pop(name, None) 42 | cog.unload(self) 43 | 44 | def load_extension(self, path): 45 | """ 46 | Loads an extension just like in discord.py 47 | :param path: 48 | :return: 49 | """ 50 | if path in self.__extensions: 51 | raise ExtensionError(f"Extension {path} already loaded") 52 | module = importlib.import_module(path) 53 | # noinspection PyUnresolvedReferences 54 | module.setup(self) 55 | self.__extensions.update({path: module}) 56 | 57 | def unload_extension(self, path): 58 | """ 59 | Unloads an extension 60 | :param path: 61 | :return: 62 | """ 63 | module = self.__extensions.pop(path, None) 64 | if module is None: # raise if ext is not loaded 65 | raise ExtensionError(f"This extension is not loaded ({path})") 66 | for name, cog in self.__cogs.copy().items(): 67 | if _is_submodule(module.__name__, cog.__module__): 68 | self.remove_console_cog(name) 69 | 70 | sys.modules.pop(module.__name__, None) # Remove "cached" module 71 | 72 | def reload_extension(self, path): 73 | module = self.__extensions.get(path, None) 74 | sys.modules.pop(module.__name__, None) 75 | if module is None: 76 | raise ExtensionError(f"This extension is not loaded ({path})") 77 | old_modules = {} 78 | cached = [] 79 | """ 80 | Store old module state to fallback if exception occurs 81 | """ 82 | for name, mod in sys.modules.items(): 83 | if _is_submodule(mod.__name__, path): 84 | cached.append(mod) 85 | old_modules.update({path: cached}) 86 | 87 | try: 88 | self.unload_extension(path) 89 | self.load_extension(path) 90 | except Exception: 91 | # Rollback 92 | module.setup(self) 93 | self.__extensions[path] = module 94 | sys.modules.update(old_modules) 95 | raise 96 | 97 | def listen(self): 98 | """ 99 | Console starts listening for inputs. 100 | This is a blocking call. To avoid the bot being stopped this has to run in an executor (New Thread) 101 | :return: 102 | """ 103 | logger.info("Console is ready and is listening for commands\n") 104 | while True: 105 | try: 106 | console_in = shlex.split(self.input.readline()) 107 | if len(console_in) == 0: 108 | continue 109 | try: 110 | command = self.__commands__.get(console_in[0], None) 111 | 112 | if not command: 113 | raise CommandNotFound(console_in[0]) 114 | 115 | if len(command.__subcommands__) == 0: 116 | self.prepare(command, console_in[1:]) 117 | else: 118 | try: 119 | sub_command = command.__subcommands__.get(console_in[1], None) 120 | except IndexError: 121 | sub_command = None 122 | if not sub_command: 123 | self.prepare(command, console_in[1:]) 124 | continue 125 | self.prepare(sub_command, console_in[2:]) 126 | 127 | except (IndexError, KeyError): 128 | traceback.print_exc() 129 | except Exception: 130 | traceback.print_exc() 131 | 132 | def prepare(self, command, args): 133 | args_ = args.copy() 134 | logger.info(f"Invoking command {command.name} with args {args}") 135 | if getattr(command, "cog", None): 136 | args_.insert(0, command.cog) 137 | 138 | converted_args = command.convert(self.converter, args_) 139 | if iscoroutinefunction(command.__callback__): 140 | command.invoke(converted_args, loop=self.client.loop) 141 | else: 142 | command.invoke(converted_args) 143 | 144 | def command(self, **kwargs): 145 | cls = Command 146 | 147 | def decorator(func): 148 | name = kwargs.get("name", func.__name__) 149 | command = cls(name, func) 150 | self.add_command(command) 151 | return command 152 | 153 | return decorator 154 | 155 | def add_command(self, command): 156 | self.__commands__.update( 157 | { 158 | command.name: command 159 | } 160 | ) 161 | 162 | def remove_command(self, command): 163 | self.__commands__.pop(command.name, None) 164 | 165 | def start(self): 166 | """ 167 | Abstracts Thread initialization away from user. 168 | :return: 169 | """ 170 | thread = threading.Thread(None, self.listen, daemon=True) 171 | thread.start() 172 | 173 | 174 | def _is_submodule(parent, child): 175 | return parent == child or child.startswith(parent + ".") 176 | 177 | 178 | class Command: 179 | """ 180 | The class every command uses 181 | """ 182 | 183 | def __init__(self, name, callback, parent=None): 184 | self.name = name 185 | self.__callback__: type = callback 186 | self.__subcommands__ = dict() 187 | self.parent = parent 188 | 189 | def subcommand(self, **kwargs): 190 | """ 191 | Decorator to add subcommands 192 | :param kwargs: 193 | :return: 194 | """ 195 | cls = Command 196 | 197 | def decorator(func): 198 | name = kwargs.get("name", func.__name__) 199 | subcommand = cls(name, func, self) 200 | self.add_sub_command(subcommand) 201 | return subcommand 202 | 203 | return decorator 204 | 205 | def add_sub_command(self, command): 206 | """ 207 | Adds a subcommand to an existing command 208 | :param command: 209 | :return: 210 | """ 211 | self.__subcommands__.update( 212 | {command.name: command} 213 | ) 214 | 215 | def invoke(self, args, loop: asyncio.AbstractEventLoop = None): 216 | """ 217 | Invokes command callback. 218 | :param args: 219 | :param loop: 220 | :return: 221 | """ 222 | if loop: 223 | def done_callback(future: Future): 224 | error = future.exception() 225 | if error: 226 | traceback.print_tb(error.__traceback__) 227 | 228 | fut = asyncio.run_coroutine_threadsafe(self.__callback__(*args), loop=loop) 229 | fut.add_done_callback(done_callback) 230 | else: 231 | self.__callback__(*args) 232 | 233 | def convert(self, converter: Converter, args: list): 234 | """ 235 | Convertes the parameters before invoke 236 | :param converter: 237 | :param args: 238 | :return: 239 | """ 240 | args_ = args.copy() 241 | signature = inspect.signature(self.__callback__) 242 | count = 0 243 | for key, value in signature.parameters.items(): 244 | if value.annotation != inspect.Parameter.empty: 245 | converter_ = converter.get_converter(value.annotation) 246 | try: 247 | new_param = converter_(args_[count]) 248 | except IndexError: 249 | continue 250 | args_[count] = new_param 251 | count += 1 252 | else: 253 | count += 1 254 | return args_ 255 | 256 | 257 | def command(**kwargs): 258 | """ 259 | Decorator to register a command 260 | :param kwargs: 261 | :return: 262 | """ 263 | cls = Command 264 | 265 | def decorator(func): 266 | name = kwargs.get("name", func.__name__) 267 | return cls(name, func) 268 | 269 | return decorator 270 | 271 | 272 | class Cog: 273 | def __new__(cls, *args, **kwargs): 274 | commands = [] 275 | # noinspection PyUnresolvedReferences 276 | for base in reversed(cls.__mro__): 277 | for elem, value in base.__dict__.items(): 278 | if isinstance(value, Command): 279 | commands.append(value) 280 | cls.commands = commands 281 | return super().__new__(cls) 282 | 283 | def load(self, console: Console): 284 | """ 285 | Gets called everytime when the Cog gets loaded from console 286 | :param console: 287 | :return: 288 | """ 289 | for cmd in self.__class__.commands: 290 | cmd.cog = self 291 | for c in cmd.__subcommands__.values(): 292 | c.cog = self 293 | console.add_command(cmd) 294 | 295 | def unload(self, console: Console): 296 | """ 297 | Gets called when unloaded from console. 298 | Cleans up all commands 299 | :param console: 300 | :return: 301 | """ 302 | for cmd in self.__class__.commands: 303 | console.remove_command(cmd) 304 | -------------------------------------------------------------------------------- /dpyConsole/converter.py: -------------------------------------------------------------------------------- 1 | """ 2 | Class to convert function parameters 3 | """ 4 | 5 | import discord 6 | from discord.utils import get 7 | import re 8 | 9 | 10 | class Converter: 11 | """ 12 | Holds conversion logic. 13 | To modify its behavior Subclass it or add converters using the add_converter method. 14 | """ 15 | def __init__(self, client): 16 | self.client: discord.Client = client 17 | self.covert_mapping = { 18 | bool: self.bool_converter, 19 | discord.User: self.user_converter, 20 | int: self.int_converter, 21 | discord.Guild: self.guild_converter 22 | } 23 | 24 | def get_id_match(self, id): 25 | match = re.search(r"[0-9]{15,21}", id) 26 | return True if match else False 27 | 28 | def bool_converter(self, param: str): 29 | if param.lower() in ["y", "yes", "on", "true", "1"]: 30 | return True 31 | elif param.lower() in ["n", "no", "off", "false", "0"]: 32 | return False 33 | raise TypeError(f"Cannot convert {param} into Bool") 34 | 35 | def user_converter(self, param): 36 | if self.get_id_match(param): 37 | return self.client.get_user(int(param)) 38 | else: 39 | for user in self.client.users: 40 | if user.name + "#" + user.discriminator == param: 41 | return user 42 | else: 43 | raise TypeError(f"Cannot convert {param} to User") 44 | 45 | def int_converter(self, param): 46 | try: 47 | return int(param) 48 | except TypeError: 49 | raise TypeError(f"Cannot convert {param} to int") 50 | 51 | def guild_converter(self, param): 52 | if self.get_id_match(param): 53 | return self.client.get_guild(int(param)) 54 | else: 55 | guild = get(self.client.guilds, name=param) 56 | if guild: 57 | return guild 58 | else: 59 | raise TypeError(f"Cannot convert {param} to Guild") 60 | 61 | def get_converter(self, type): 62 | try: 63 | return self.covert_mapping[type] 64 | except KeyError: 65 | raise TypeError(f"{type} can not be converted.\n" 66 | f"Add a conversion behavior using the add_converter method.") 67 | 68 | def add_converter(self, type_, func): 69 | """ 70 | Adds converter to internal mapping. 71 | Converter callback must take one positional argument. 72 | :param type_: 73 | :param func: 74 | :return: 75 | """ 76 | self.covert_mapping.update({type_: func}) 77 | -------------------------------------------------------------------------------- /dpyConsole/errors.py: -------------------------------------------------------------------------------- 1 | class CommandNotFound(Exception): 2 | def __init__(self, command_name): 3 | self.name = command_name 4 | 5 | def __str__(self): 6 | return f"Command with name {self.name} not found" 7 | 8 | 9 | class ExtensionError(Exception): 10 | def __init__(self, message): 11 | self.message = message 12 | 13 | def __str__(self): 14 | return self.message 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools~=54.0.0 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="discord.py-Console", 5 | version="0.1.2", 6 | description="Executes commands from console while your bot is running.", 7 | long_description=open("README.md").read(), 8 | url="https://github.com/Mihitoko/discord.py-Console", 9 | long_description_content_type="text/markdown", 10 | author="Mihito", 11 | license="MIT", 12 | classifiers=[ 13 | "Programming Language :: Python :: 3.7" 14 | ], 15 | packages=["dpyConsole"], 16 | include_package_data=True, 17 | extras_require={ 18 | "py-cord": ["py-cord"], 19 | "discord.py": ["discord.py"] 20 | } 21 | ) 22 | --------------------------------------------------------------------------------