├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.md │ ├── FEATURE_REQUEST.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── README.md └── assets │ ├── command-examples │ ├── dshell-exec-command-example.png │ └── dshell-root-command-example.png │ └── readme │ ├── dshell-example.gif │ └── dshell-logo.png ├── .gitignore ├── Documentation ├── Basics.md ├── Examples.md ├── FAQ.md ├── Message Commands.md ├── README.md └── Shell Commands.md ├── LICENSE ├── dshell ├── __init__.py ├── cog.py └── jskshell.py └── setup.py /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing to DShell 2 | 3 | First off, thanks for taking the time to contribute. It makes the library substantially better :+1: 4 | 5 | The following is a set of guidelines for contributing to the repository. These are guidelines, not hard rules. 6 | 7 | ## This is too much to read! I want to ask a question! 8 | 9 | Generally speaking questions are better suited in our resources below. 10 | 11 | - [The official support server](https://discord.gg/FcxqdJ7AQq) 12 | - [The FAQ in the documentation](https://github.com/ImNimboss/dshell/blob/main/Documentation/FAQ.md) 13 | 14 | Please try your best not to ask questions in our issue tracker. Most of them don't belong there unless they provide value to a larger audience. 15 | 16 | ## Good Bug Reports 17 | 18 | Please be aware of the following things when filing bug reports. 19 | 20 | 1. Don't open duplicate issues. Please search your issue to see if it has been asked already. Duplicate issues will be closed. 21 | 2. When filing a bug about exceptions or tracebacks, please include the *complete* traceback. Without the complete traceback the issue might be **unsolvable** and you will be asked to provide more information. 22 | 3. Make sure to provide enough information to make the issue workable. The issue template will generally walk you through the process but they are enumerated here as well: 23 | - A **summary** of your bug report. This is generally a quick sentence or two to describe the issue in human terms. 24 | - Guidance on **how to reproduce the issue**. Ideally, this should have a small code sample that allows us to run and see the issue for ourselves to debug. **Please make sure that any bot tokens are not displayed**. If you cannot provide a code snippet, then let us know what the steps were, how often it happens, etc. 25 | - Tell us **what you expected to happen**. That way we can meet that expectation. 26 | - Tell us **what actually happens**. What ends up happening in reality? It's not helpful to say "it fails" or "it doesn't work". Say *how* it failed, do you get an exception? Does it hang? How are the expectations different from reality? 27 | - Tell us **information about your environment**. What version of DShell are you using? How was it installed? What operating system are you running on? These are valuable questions and information that we use. 28 | 29 | If the bug report is missing this information then it'll take us longer to fix the issue. We will probably ask for clarification, and barring that if no response was given then the issue will be closed. 30 | 31 | ## Submitting a Pull Request 32 | 33 | Submitting a pull request is fairly simple, just make sure it focuses on a single aspect and doesn't manage to have [scope creep](https://www.projectmanagementqualification.com/blog/2019/03/07/manage-scope-creep/) and it's probably good to go. It would be incredibly lovely if the style is consistent to that found in the project. 34 | 35 | ### Git Commit Guidelines 36 | 37 | - Use present tense (e.g. "Add feature" not "Added feature") 38 | - Reference issues or pull requests outside of the first line. 39 | - Please use the shorthand `#123` and not the full URL. 40 | - There's no strict limit on line length as long as the line is readable. 41 | 42 | If you do not meet any of these guidelines don't fret, but please do try to meet them to remove some of the workload. 43 | 44 | ## CONTRIBUTING.md credits - [The discord.py repository](https://github.com/Rapptz/discord.py) -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: nimboss -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug so it can be noticed and patched for the next version 4 | labels: Bug 5 | --- 6 | 7 | ## Description 8 | 9 | 10 | 11 | ## Reproduction steps 12 | 13 | 14 | 15 | ## Expected result 16 | 17 | 18 | 19 | ## Actual result 20 | 21 | 22 | 23 | ## DShell version 24 | 25 | 26 | 27 | ## discord.py version 28 | 29 | 30 | 31 | 32 | ## Operating system 33 | 34 | 35 | 36 | ## Checklist 37 | 38 | 39 | 40 | - [] - This bug was found on the latest version of DShell 41 | - [] - You have checked that there is no issue already mentioning this bug, open or closed 42 | - [] - Full details of the bug have been provided/shown, including full tracebacks in case of exceptions 43 | - [] - All bot tokens have been hidden/omitted from any screenshots or code, if any 44 | 45 | ## Additional context 46 | 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Propose a feature to be added to DShell 4 | labels: Feature 5 | --- 6 | 7 | ## Description 8 | 9 | 10 | 11 | ## The problem 12 | 13 | 14 | 15 | ## The solution 16 | 17 | 18 | 19 | ## The current solution, if any 20 | 21 | 22 | 23 | ## Checklist 24 | 25 | 26 | 27 | - [] - You have checked that there is no issue already mentioning this feature, open or closed 28 | - [] - Full details of the requested feature have been provided/shown 29 | 30 | ## Additional context 31 | 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Checklist 6 | 7 | 8 | 9 | - [] - All code changes have been tested, if any 10 | - [] - The documentation has been updated accordingly for any code changes 11 | - [] - This PR fixes an already existing issue 12 | - [] - This PR adds a new feature (new functions, methods, classes, parameters, etc.) 13 | - [] - This PR fixes a bug 14 | - [] - This PR changes the main DShell cog 15 | - [] - This PR is a breaking change (removes/renames functions, parameters etc.) 16 | - [] - This PR is NOT a code change (changes Documentation files, README.md, CONTRIBUTING.md etc.) 17 | 18 | ## Additional context 19 | 20 | -------------------------------------------------------------------------------- /.github/README.md: -------------------------------------------------------------------------------- 1 |

DShell Logo

2 | 3 | # DShell 4 | 5 | 6 | PyPI DShell version number 7 | PyPI downloads per month 8 | PyPI supported Python versions 9 | 10 | 11 | Number of open GitHub issues 12 | 13 | 14 | Number of contributors 15 | 16 | 17 | Discord server 18 | 19 | 20 | ## Description 21 | 22 | Discord shell or dshell for short is a Python package that combines with [discord.py](https://github.com/Rapptz/discord.py) and [Jishaku](https://github.com/Gorialis/jishaku) to transform an ordinary Discord channel into one capable of running bash commands using a Discord bot. 23 | 24 | ## Typical usage 25 | 26 | 27 |

28 | 29 | ## When would I use this? 30 | 31 | * When you want to use shell commands on your virtual private server (VPS) without logging into it 32 | * When you want a collaborative terminal with other developers of your team 33 | * When you're simply too lazy to open your shell and would rather do it in an open Discord app 34 | * When you want to run shell commands from your Discord bot's directory easily 35 | * When you want to run shell commands on something that simply looks better than your shell 36 | 37 | ## Main Features 38 | 39 | - [x] - Customizable 40 | - [x] - Easy to use 41 | - [x] - Asynchronous 42 | - [x] - Compatible with discord.py v2.0 43 | - [x] - Lightweight 44 | - [x] - Regularly maintained 45 | 46 | ## Links 47 | * [Documentation](https://github.com/ImNimboss/dshell/tree/main/Documentation) 48 | * [PyPI](https://pypi.org) 49 | * [Issue Tracking](https://github.com/ImNimboss/dshell/issues) 50 | * [Discord server](https://discord.gg/FcxqdJ7AQq) 51 | 52 | ## Installation and upgrades 53 | 54 | ``` 55 | pip install discordshell 56 | ``` 57 | for the stable version (recommended). 58 | 59 | ``` 60 | pip install -U discordshell 61 | ``` 62 | to update your stable version. 63 | 64 | ``` 65 | pip install git+https://github.com/ImNimboss/dshell 66 | ``` 67 | to install it straight off of GitHub (you need git installed for this). 68 | 69 | ``` 70 | pip install -U git+https://github.com/ImNimboss/dshell 71 | ``` 72 | to upgrade your version that you got from GitHub. 73 | 74 | ## How to use 75 | 76 | Check [Documentation/Basics.md](https://github.com/ImNimboss/dshell/blob/main/Documentation/Basics.md) 77 | 78 | ## Examples 79 | 80 | Check [Documentation/Examples.md](https://github.com/ImNimboss/dshell/blob/main/Documentation/Examples.md) 81 | 82 | ## Changelog 83 | 84 | * `v0.0.1` - Initial release, but quite rough/in testing phase. 85 | * `v0.0.2` - Smooth out the library. 86 | * `v0.0.3` - Make library compatible with discord.py v2.0 and above. -------------------------------------------------------------------------------- /.github/assets/command-examples/dshell-exec-command-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImNimboss/dshell/95bbec4a4d4ba0f4fbd9627f2ada4395873cb501/.github/assets/command-examples/dshell-exec-command-example.png -------------------------------------------------------------------------------- /.github/assets/command-examples/dshell-root-command-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImNimboss/dshell/95bbec4a4d4ba0f4fbd9627f2ada4395873cb501/.github/assets/command-examples/dshell-root-command-example.png -------------------------------------------------------------------------------- /.github/assets/readme/dshell-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImNimboss/dshell/95bbec4a4d4ba0f4fbd9627f2ada4395873cb501/.github/assets/readme/dshell-example.gif -------------------------------------------------------------------------------- /.github/assets/readme/dshell-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImNimboss/dshell/95bbec4a4d4ba0f4fbd9627f2ada4395873cb501/.github/assets/readme/dshell-logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.tmp* 4 | *.mo 5 | *.egg 6 | *.EGG 7 | *.egg-info 8 | *.EGG-INFO 9 | .*.cfg 10 | bin/ 11 | build/ 12 | develop-eggs/ 13 | downloads/ 14 | eggs/ 15 | fake-eggs/ 16 | parts/ 17 | dist/ 18 | var/ 19 | dshell/__pycache__ 20 | .DS_Store 21 | private* -------------------------------------------------------------------------------- /Documentation/Basics.md: -------------------------------------------------------------------------------- 1 | # Basics 2 | 3 | ## Loading the extension 4 | 5 | You need to have an instance of [discord.ext.commands.Bot](https://discordpy.readthedocs.io/) 6 | 7 | Before your bot.run() statement, put this line in an async function: 8 | ```python 9 | await bot.load_extension('dshell') 10 | ``` 11 | Or import asyncio and use: 12 | ```python 13 | asyncio.run(load_extension('dshell')) 14 | ``` 15 | This will load the main DShell cog into your bot. 16 | 17 | ## Configuring the bot 18 | 19 | Loading the extension will also set an attribute to your bot named `dshell_config`. You can access this module's configuration via `bot.dshell_config` now. 20 | 21 | This attribute is a dictionary, and by default it looks like this: 22 | ```python 23 | { 24 | 'show_cd_command_output': True, 25 | 'give_clear_command_confirmation_warning': True, 26 | 'shell_channels': [], 27 | 'shell_in_dms': False, 28 | 'shell_whitelisted_users': [r"A list of all the bot's owners' IDs"] 29 | } 30 | ``` 31 | 32 | ### What these values mean: 33 | 34 | #### `give_clear_command_confirmation_warning`: 35 | 36 | This is a boolean property. When you run the `clear` command in your shell channel and this setting is set to `True`, it asks you to confirm whether you really want to delete the channel and make another shell channel copy. 37 | 38 | When it is set to `False`, it skips the confirmation altogether and simply deletes the current shell channel and makes another copy it. 39 | 40 | By default this is set to `True`. 41 | 42 | #### `shell_channels`: 43 | 44 | This is a list and simply contains all the channels that the shell can be run in. 45 | 46 | By default, this is an empty list. 47 | 48 | #### `shell_in_dms`: 49 | 50 | This is a boolean property. If it is set to `True` then the shell will work in Direct Messages with the bot. It essentially makes your DMs a shell channel too. 51 | 52 | If it is set to `False` the bot will not process any Direct Messages from you as shell commands. 53 | 54 | By default this is set to `False`. 55 | 56 | #### `show_cd_command_output`: 57 | 58 | If it is set to `True`, when you run `cd` in the shell, shows some output details like your current process directory's path, your home directory's path and your new process directory's path. 59 | 60 | If it is set to `False` then it won't do that. 61 | 62 | By default this is set to `True`. 63 | 64 | #### `shell_whitelisted_users`: 65 | 66 | This is a list and it contains all the people with the permissions to use the shell. By default this contains all the owners of the bot. 67 | 68 | **WARNING: change this list with caution as granting someone access to your bot's shell could literally give them access to do whatever they want with whatever computer/system your bot is running on. They could get every private file and even destroy your bot's computer/system. This package wields a LOT of power. Make sure you only give this to people you fully trust.** 69 | 70 | ## How to change the default configuration 71 | 72 | Since `bot.dshell_config` is a dictionary, you can change its values just like you would with any other normal dictionary. 73 | 74 | Eg. if you wanted to enable the shell to work in DMs you would type 75 | ```python 76 | bot.dshell_config["shell_in_dms"] = True 77 | ``` 78 | Easy as that. 79 | 80 | If you wanted to add some shell channels to use the shell in (which is something you probably will do) then you would type 81 | ```python 82 | bot.dshell_config["shell_channels"].append(930143692545744896) 83 | # OR 84 | bot.dshell_config["shell_channels"] = [930143692545744896, 849233992586362979] 85 | ``` 86 | etc...you get the point. 87 | 88 | # If you're still confused, [check out some examples in Examples.md](https://github.com/ImNimboss/dshell/blob/main/Documentation/Examples.md). -------------------------------------------------------------------------------- /Documentation/Examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## A basic bot 4 | 5 | ```python 6 | from discord.ext import commands 7 | import discord 8 | from asyncio import run 9 | 10 | bot = commands.Bot( 11 | command_prefix = 'e!', 12 | intents = discord.Intents.all() 13 | ) 14 | 15 | @bot.command() 16 | async def hi(ctx): 17 | await ctx.send(f'Hello, {ctx.author}!') 18 | 19 | @bot.command() 20 | async def ping(ctx): 21 | await ctx.send(f'Pong! {round(bot.latency * 1000)}ms.') 22 | 23 | # this is where dshell stuff starts 24 | run(bot.load_extension('dshell')) 25 | bot.dshell_config['shell_channels'] = [930143692545744896, 808404030380441600] # put your own channel IDs here. all the channels that you've put will become shell channels 26 | bot.dshell_config['shell_in_dms'] = True 27 | bot.dshell_config['give_clear_command_confirmation_warning'] = False 28 | 29 | # after you're done with whatever configuration you had to do, run the bot 30 | bot.run(TOKEN_GOES_HERE) 31 | ``` 32 | 33 | ## A bot specifically dedicated to DShell, whose only purpose is to serve as a shell bot 34 | 35 | ```python 36 | from discord.ext import commands 37 | from discord import Intents 38 | from asyncio import run 39 | 40 | bot = commands.Bot( 41 | command_prefix = 's!', 42 | intents = Intents.all() 43 | ) 44 | 45 | run(bot.load_extension('dshell')) 46 | bot.dshell_config['shell_channels'] = [930143692545744896, 808404030380441600] # again, use your own channel IDs 47 | bot.dshell_config['shell_in_dms'] = True 48 | 49 | bot.run(TOKEN_GOES_HERE) 50 | ``` 51 | 52 | ## A typical public multipurpose bot with cogs 53 | 54 | ```python 55 | import discord 56 | import asyncio 57 | from discord.ext import commands, tasks 58 | from os import listdir 59 | from bot_config import BotHelpCommand, get_prefix, TOKEN 60 | 61 | bot = commands.Bot( 62 | command_prefix = get_prefix, 63 | help_command = BotHelpCommand(), 64 | description = 'A multipurpose bot that can do anything your heart desires!', 65 | intents = discord.Intents.all(), 66 | allowed_mentions = discord.AllowedMentions(roles = False, everyone = False), 67 | case_insensitive = True, 68 | strip_after_prefix = True, 69 | owner_ids = [123123, 6969420] 70 | ) 71 | 72 | @tasks.loop() 73 | async def change_statuses(): 74 | statuses = [ 75 | f'with {len(bot.guilds)} servers | s!help', 76 | f'with {len(bot.users)} users | s!help', 77 | 'with everything that you can ever want! | s!help' 78 | ] 79 | for status in statuses: 80 | await bot.change_presence(activity = discord.Game(name = status)) 81 | await asyncio.sleep(60) 82 | 83 | @change_statuses 84 | async def before_change_statuses(): 85 | await bot.wait_until_ready() 86 | 87 | async def load_extensions: 88 | for cog in listdir('cogs'): 89 | await bot.load_extension(f'cogs.{cog[:-3]}') #:-3 to remove the .py extension 90 | await bot.load_extension('dshell') 91 | asyncio.run(load_extensions()) 92 | bot.dshell_config['shell_channels'] = [930143692545744896, 808404030380441600] 93 | bot.run(TOKEN) 94 | ``` 95 | 96 | # These are only examples for you to get a basic idea of how to implement dshell into your bot. This is not a hard set of rules and you can change this according to your needs. -------------------------------------------------------------------------------- /Documentation/FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | > Why and when would I use this? 4 | 5 | Check the "When would I use this?" section under [the README](https://github.com/ImNimboss/dshell/blob/main/.github/README.md). 6 | 7 | > Why is it called "discordshell" on PyPI instead of dshell? 8 | 9 | PyPI wouldn't let me register the project with the name "dshell", presumably because it was too similar to another project named "dbshell". -------------------------------------------------------------------------------- /Documentation/Message Commands.md: -------------------------------------------------------------------------------- 1 | # Message commands that DShell adds to your bot 2 | 3 | ## Intro 4 | 5 | DShell adds traditional prefixed commands to your bot. 6 | The type of commands where you type `{your bot prefix}{command_name} {arguments}` into chat. 7 | Since slash commands were never officially supported in discord.py, these commands are not slash commands. 8 | 9 | In the examples below `--` will be the bot prefix. You are, of course, free to pick your own prefix/prefixes. 10 | 11 | ## Commands 12 | 13 | `{prefix}dshell/dsh` 14 | 15 | This is the root command for DShell. All other commands are subcommanded under it, like `{prefix}dshell/dsh {subcommand} {arguments(if any)}` 16 | 17 | It gives you a short guide to all the DShell commands and some extra info. 18 | 19 |

dshell root command example

20 | 21 | ## You can see all the other commands and what they do in this picture itself, no further explanation should be needed. In case the picture isn't loading or you're on a screenreader, here's the text: 22 |
23 | 24 | > Discord shell or dshell, a Python discord.py library that allows for shell access in Discord. 25 | 26 | > This library is also compatible with discord.py forks that use the `discord` import. It uses standard message commands as slash commands were never implemented in discord.py. 27 | 28 | > dshell version `v{version number goes here, in this case 0.0.1}`, the Shell cog was loaded {a timestamp of when it was loaded.} 29 | 30 | > Subcommands: 31 | 32 | > `--dshell cdcmdoutput/cdout enable/true/yes/y/disable/false/no/n` - Changes the `show_cd_command_output` config property. Enable or disable the output of the `cd` or change directory command in the shell. 33 | 34 | > `--dshell clearcommandconfirmation/ccc enable/true/yes/y/disable/false/no/n` - Changes the `give_clear_command_confirmation_warning` config property. Enable or disable the confirmation message of the `clear` command in the shell. 35 | 36 | > `--dshell dmshell/dmsh enable/true/yes/y/disable/false/no/n` - Changes the `shell_in_dms` config property. Enable or disable the usage of the shell in DMs. 37 | 38 | > `--dshell addshellchannel/addchannel/ach [text channel]` - Changes the `shell_channels` config property. Adds a channel to the list of channels that the shell can be used in. 39 | 40 | > `--dshell removeshellchannel/removechannel/rch [text channel]` - Changes the `shell_channels` config property. Removes a channel from the list of channels that the shell can be used in. 41 | 42 | > `--dshell shellchannels/sc` - Shows a list of all the channels that the shell can be used in. 43 | 44 | > `--dshell showshellconfig/ssc` - Shows your DShell cog's configuration dict. 45 | 46 | > `--dshell shellwhitelist/shw [user]` - Changes the `shell_whitelisted_users` config property. Whitelist someone to the shell, allowing them to use the shell. This command should be used with caution as giving someone shell access is the equivalent of giving them full access to whatever device the bot is running on. Bot owners are by default whitelisted and this cannot be changed. 47 | 48 | > `--dshell shellunwhitelist/shuw [user]` - Changes the `shell_whitelisted_users` config property. Unwhitelist someone to the shell. The user to unwhitelist cannot be a bot owner. -------------------------------------------------------------------------------- /Documentation/README.md: -------------------------------------------------------------------------------- 1 | ## Hi! If you're new you should start with [the basics](https://github.com/ImNimboss/dshell/blob/main/Documentation/Basics.md). -------------------------------------------------------------------------------- /Documentation/Shell Commands.md: -------------------------------------------------------------------------------- 1 | # Shell commands documentation 2 | 3 | ## Commands that behave differently from other shell commands 4 | 5 | There are a few shell commands that behave differently from other shell commands since they don't run in the shell, but rather directly in Python. If they were to run directly in the shell, they simply wouldn't serve their purpose because of how processes work. 6 | 7 | These commands: 8 | 9 | ### `cd`: 10 | 11 | When you run `cd` it does not change your bot's working directory as that would probably break a lot of stuff in your bot's functioning. Instead it stores the directory and instructs the other commands to run *in* the other directory. This way nothing breaks, purpose achieved and everyone's happy. 12 | 13 | Supported operations: 14 | 15 | `cd ~` or `cd`: Changes the process directory to your system's home directory. 16 | 17 | `cd ..`: Changes the process directory to the parent directory of the current process directory. 18 | 19 | `cd directory` or `cd "directory"`: Changes the process directory to the directory specified. You do not need quotes to change to a directory with spaces, however they are supported. 20 | 21 | ### `clear`: 22 | 23 | Running clear in a seperate new process does, well, absolutely nothing since that process is already empty. Instead the `clear` command deletes the channel (with a confirmation message if `bot.dshell_config["give_clear_command_confirmation_warning"] is True`) and creates an exactly same channel, then initializes that channel as a shell channel. 24 | 25 | That way all your messages and commands are cleared exactly like a normal shell. Of course, it needs the Manage Channels permission for this. 26 | 27 | ## A special feature for very long shell commands 28 | 29 | If your shell command is (somehow) longer than 2000 characters (or 4000 characters if you have nitro) then you can type `[DSHELL EXEC COMMAND]` in the shell channel along with an attachment in the same message that stores your shell command. The shell command that will be processed will be the one read from the attachment. The module only reads from the first attachment in your message. 30 | 31 | You **can** do this with any command, its just that doing it with short commands would be rather pointless. 32 | 33 |

[DSHELL EXEC COMMAND] example

-------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021-present ImNimboss (https://github.com/ImNimboss) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /dshell/__init__.py: -------------------------------------------------------------------------------- 1 | from dshell.cog import DShell, setup 2 | 3 | __author__: str = 'ImNimboss' 4 | __license__: str = 'MIT' 5 | __version__: str = '0.0.3' 6 | GITHUB: str = 'https://github.com/ImNimboss/dshell' 7 | ISSUE_TRACKER: str = 'https://github.com/ImNimboss/dshell/issues' 8 | DOCUMENTATION: str = 'https://github.com/ImNimboss/dshell/tree/main/Documentation' 9 | SPONSOR: str = 'https://patreon.com/nimboss' -------------------------------------------------------------------------------- /dshell/cog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | """ 3 | This file stores the main DShell cog that's added to the bot. 4 | 5 | LICENSE - [MIT](https://opensource.org/licenses/MIT) 6 | 7 | Copyright 2021-present ImNimboss (https://github.com/ImNimboss) 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 10 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 11 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 12 | persons to whom the Software is furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 17 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 19 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 20 | OTHER DEALINGS IN THE SOFTWARE. 21 | """ 22 | from discord.ext import commands 23 | import discord 24 | from os import getcwd 25 | from os.path import dirname, isdir 26 | from pathlib import Path 27 | from asyncio import TimeoutError, run 28 | import dshell.jskshell as jskshell 29 | from datetime import datetime as dt 30 | from datetime import timezone as dt_timezone 31 | from typing import Optional 32 | from io import StringIO 33 | from json import dumps 34 | 35 | class DShell(commands.Cog): 36 | """ 37 | The cog that holds the dshell command and the main functioning of the shell. 38 | """ 39 | def __init__(self, bot: commands.Bot) -> None: 40 | self.bot: commands.Bot = bot 41 | self.bot.dshell_config = { 42 | 'show_cd_command_output': True, 43 | 'give_clear_command_confirmation_warning': True, 44 | 'shell_channels': [], 45 | 'shell_in_dms': False 46 | } 47 | if bot.owner_id: 48 | self.bot.dshell_config['shell_whitelisted_users'] = [bot.owner_id] 49 | self._owners = [bot.owner_id] 50 | elif bot.owner_ids: 51 | self.bot.dshell_config['shell_whitelisted_users'] = bot.owner_ids 52 | self._owners = bot.owner_ids 53 | else: 54 | self.bot.dshell_config['shell_whitelisted_users'] = [] 55 | self._owners = [] 56 | self._on_ready_flag: bool = False 57 | self._og_working_directory: str = getcwd() 58 | self._cwd: str = self._og_working_directory # this changes with cd commands, of course 59 | self._home_directory: str = str(Path.home()) 60 | self._load_time: int = int(dt.now(dt_timezone.utc).timestamp()) 61 | self._bool_flags_true: list = ['enable', 'true', 'yes', 'y'] 62 | self._bool_flags_false: list = ['disable', 'false', 'no', 'n'] 63 | 64 | @commands.group(invoke_without_command = True, aliases = ['dsh']) 65 | @commands.is_owner() 66 | async def dshell(self, ctx: commands.Context) -> None: 67 | """ 68 | The root dshell command. Discord shell or dshell is a customizable Python package that allows you to have a shell in a Discord channel. 69 | """ 70 | from dshell import __version__ as version 71 | 72 | if ctx.prefix == self.bot.user.mention: 73 | prefix = f'@{self.bot.name} ' 74 | elif ctx.prefix is None: 75 | prefix == '[prefix]' 76 | else: 77 | prefix = ctx.prefix 78 | await ctx.send( 79 | embed = discord.Embed( 80 | description = f""" 81 | Discord shell or dshell, a Python discord.py library that allows for shell access in Discord. 82 | This library is also compatible with discord.py forks that use the `discord` import. It uses standard message commands as slash commands were never implemented in discord.py. 83 | dshell version `v{version}`, the Shell cog was loaded (). 84 | 85 | Subcommands: 86 | `{prefix}{ctx.invoked_with} cdcmdoutput/cdout enable/true/yes/y/disable/false/no/n` - Changes the `show_cd_command_output` config property. Enable or disable the output of the `cd` or change directory command in the shell. 87 | 88 | `{prefix}{ctx.invoked_with} clearcommandconfirmation/ccc enable/true/yes/y/disable/false/no/n` - Changes the `give_clear_command_confirmation_warning` config property. Enable or disable the confirmation message of the `clear` command in the shell. 89 | 90 | `{prefix}{ctx.invoked_with} dmshell/dmsh enable/true/yes/y/disable/false/no/n` - Changes the `shell_in_dms` config property. Enable or disable the usage of the shell in DMs. 91 | 92 | `{prefix}{ctx.invoked_with} addshellchannel/addchannel/ach [text channel]` - Changes the `shell_channels` config property. Adds a channel to the list of channels that the shell can be used in. 93 | 94 | `{prefix}{ctx.invoked_with} removeshellchannel/removechannel/rch [text channel]` - Changes the `shell_channels` config property. Removes a channel from the list of channels that the shell can be used in. 95 | 96 | `{prefix}{ctx.invoked_with} shellchannels/sc` - Shows a list of all the channels that the shell can be used in. 97 | 98 | `{prefix}{ctx.invoked_with} showshellconfig/ssc` - Shows your DShell cog's configuration dict. 99 | 100 | `{prefix}{ctx.invoked_with} shellwhitelist/shw [user]` - Changes the `shell_whitelisted_users` config property. Whitelist someone to the shell, allowing them to use the shell. This command should be used with caution as giving someone shell access is the equivalent of giving them full access to whatever device the bot is running on. Bot owners are by default whitelisted and this cannot be changed. 101 | 102 | `{prefix}{ctx.invoked_with} shellunwhitelist/shuw [user]` - Changes the `shell_whitelisted_users` config property. Unwhitelist someone to the shell. The user to unwhitelist cannot be a bot owner. 103 | """.strip(), 104 | color = ctx.author.color 105 | ) 106 | ) 107 | 108 | @dshell.command(name = 'cdcmdoutput', aliases = ['cdout']) 109 | @commands.is_owner() 110 | async def cd_cmd_output(self, ctx: commands.Context, argument: str) -> Optional[discord.Message]: 111 | """ 112 | Changes the `show_cd_command_output` config property. Enable or disable the output of the `cd` or change directory command in the shell. 113 | """ 114 | if argument.lower() in self._bool_flags_true: 115 | if self.bot.dshell_config['show_cd_command_output']: 116 | return await ctx.send('CD command output is already enabled.') 117 | self.bot.dshell_config['show_cd_command_output'] = True 118 | await ctx.message.add_reaction('👍') 119 | elif argument.lower() in self._bool_flags_false: 120 | if not self.bot.dshell_config['show_cd_command_output']: 121 | return await ctx.send('CD command output is already disabled.') 122 | self.bot.dshell_config['show_cd_command_output'] = False 123 | await ctx.message.add_reaction('👍') 124 | else: 125 | await ctx.send('Invalid argument. Your argument must be one of `enable`, `true`, `yes`, `y`, `disable`, `false`, `no` or `n`.') 126 | 127 | @dshell.command(name = 'clearcommandconfirmation', aliases = ['ccc']) 128 | @commands.is_owner() 129 | async def clear_command_confirmation(self, ctx: commands.Context, argument: str) -> Optional[discord.Message]: 130 | """ 131 | Changes the `give_clear_command_confirmation_warning` config property. Enable or disable the confirmation message of the `clear` command in the shell. 132 | """ 133 | if argument.lower() in self._bool_flags_true: 134 | if self.bot.dshell_config['give_clear_command_confirmation_warning']: 135 | return await ctx.send('The clear command confirmation warning is already enabled.') 136 | self.bot.dshell_config['give_clear_command_confirmation_warning'] = True 137 | await ctx.message.add_reaction('👍') 138 | elif argument.lower() in self._bool_flags_false: 139 | if not self.bot.dshell_config['give_clear_command_confirmation_warning']: 140 | return await ctx.send('The clear command confirmation warning is already disabled.') 141 | self.bot.dshell_config['give_clear_command_confirmation_warning'] = False 142 | await ctx.message.add_reaction('👍') 143 | else: 144 | await ctx.send('Invalid argument. Your argument must be one of `enable`, `true`, `yes`, `y`, `disable`, `false`, `no` or `n`.') 145 | 146 | @dshell.command(name = 'dmshell', aliases = ['dmsh']) 147 | @commands.is_owner() 148 | async def dm_shell(self, ctx: commands.Context, argument: str) -> Optional[discord.Message]: 149 | """ 150 | Changes the `shell_in_dms` config property. Enable or disable the usage of the shell in DMs. 151 | """ 152 | if argument.lower() in self._bool_flags_true: 153 | if self.bot.dshell_config['shell_in_dms']: 154 | return await ctx.send('Shell is already enabled in DMs.') 155 | self.bot.dshell_config['shell_in_dms'] = True 156 | await ctx.message.add_reaction('👍') 157 | elif argument.lower() in self._bool_flags_false: 158 | if not self.bot.dshell_config['shell_in_dms']: 159 | return await ctx.send('Shell is already disabled in DMs.') 160 | self.bot.dshell_config['shell_in_dms'] = False 161 | await ctx.message.add_reaction('👍') 162 | else: 163 | await ctx.send('Invalid argument. Your argument must be one of `enable`, `true`, `yes`, `y`, `disable`, `false`, `no` or `n`.') 164 | 165 | @dshell.command(name = 'addshellchannel', aliases = ['addchannel', 'ach']) 166 | @commands.is_owner() 167 | async def add_shell_channel(self, ctx: commands.Context, channel: discord.TextChannel) -> Optional[discord.Message]: 168 | """ 169 | Changes the `shell_channels` config property. Adds a channel to the list of channels that the shell can be used in. 170 | """ 171 | if channel.id in self.bot.dshell_config['shell_channels']: 172 | return await ctx.send('This channel is already a shell channel.') 173 | self.bot.dshell_config['shell_channels'].append(channel.id) 174 | await ctx.message.add_reaction('👍') 175 | 176 | @dshell.command(name = 'removeshellchannel', aliases = ['removechannel', 'rch']) 177 | @commands.is_owner() 178 | async def remove_shell_channel(self, ctx: commands.Context, channel: discord.TextChannel) -> Optional[discord.Message]: 179 | """ 180 | Changes the `shell_channels` config property. Removes a channel from the list of channels that the shell can be used in. 181 | """ 182 | if channel.id not in self.bot.dshell_config['shell_channels']: 183 | return await ctx.send('This channel is not a shell channel.') 184 | self.bot.dshell_config['shell_channels'].remove(channel.id) 185 | await ctx.message.add_reaction('👍') 186 | 187 | @dshell.command(name = 'shellchannels', aliases = ['sc']) 188 | @commands.is_owner() 189 | async def shell_channels(self, ctx: commands.Context) -> Optional[discord.Message]: 190 | """ 191 | Shows a list of all the channels that the shell can be used in. 192 | """ 193 | if len(self.bot.dshell_config['shell_channels']) == 0: 194 | return await ctx.send('There are no shell channels, add one using the `dshell/dsh addshellchannel` subcommand.') 195 | channels = [f'<#{id_}>' for id_ in self.bot.dshell_config['shell_channels']] 196 | chunks = [channels[i : i + 25] for i in range(0, len(channels), 25)] 197 | for chunk in chunks: 198 | await ctx.send(', '.join(chunk)) 199 | 200 | @dshell.command(name = 'showshellconfig', aliases = ['ssc']) 201 | @commands.is_owner() 202 | async def show_shell_config(self, ctx: commands.Context) -> None: 203 | """ 204 | Shows your DShell cog's configuration dict. 205 | """ 206 | file = discord.File( 207 | StringIO(dumps(self.bot.dshell_config, indent = 4)), 208 | 'configdict.json' 209 | ) 210 | try: 211 | await ctx.author.send( 212 | file = discord.File( 213 | StringIO(dumps(self.bot.dshell_config, indent = 4)), 214 | 'configdict.json' 215 | ) 216 | ) 217 | except: # bare except, i know, no one get angry but i need to suppress any error 218 | message = await ctx.send('I couldn\'t DM you the file. Would you like me to send it in this channel?') 219 | await message.add_reaction('✅') 220 | await message.add_reaction('❌') 221 | def check(reaction, user): 222 | return ( 223 | str(reaction.emoji) in {'✅', '❌'} 224 | and user == ctx.author 225 | and reaction.message.id == message.id 226 | and reaction.message.channel.id == ctx.channel.id 227 | ) 228 | try: 229 | reaction, user = await self.bot.wait_for('reaction_add', check = check, timeout = 10) 230 | if str(reaction.emoji) == '✅': 231 | await ctx.send( 232 | file = discord.File( 233 | StringIO(dumps(self.bot.dshell_config, indent = 4)), 234 | 'configdict.json' 235 | ) 236 | ) 237 | else: 238 | await ctx.send('Aborted.') 239 | except TimeoutError: 240 | await ctx.send('Aborted.') 241 | else: 242 | await ctx.message.add_reaction('👍') 243 | 244 | @dshell.command(name = 'shellwhitelist', aliases = ['shw']) 245 | @commands.is_owner() 246 | async def shell_whitelist(self, ctx: commands.Context, user: discord.User) -> Optional[discord.Message]: 247 | """ 248 | Changes the `shell_whitelisted_users` config property. Whitelist someone to the shell, allowing them to use the shell. This command should be used with caution as giving someone shell access is the equivalent of giving them full access to whatever device the bot is running on. Bot owners are by default whitelisted and this cannot be changed. 249 | """ 250 | if user.id in self.bot.dshell_config['shell_whitelisted_users']: 251 | return await ctx.send('This person is already whitelisted.') 252 | message = await ctx.send(f'{ctx.author.mention}, are you sure you want to whitelist this person? This will give them full access to the computer/system this bot is running on. Press :+1: to confirm, you have 10 seconds.', allowed_mentions = discord.AllowedMentions(users = True)) 253 | await message.add_reaction('👍') 254 | def check(reaction, user_): 255 | return str(reaction.emoji == '👍') and user_ == ctx.author and reaction.message.id == message.id and reaction.message.channel.id == ctx.channel.id 256 | try: 257 | await self.bot.wait_for('reaction_add', check = check, timeout = 10) 258 | self.bot.dshell_config['shell_whitelisted_users'].append(user.id) 259 | await ctx.send(f'Whitelisted {user.mention}.', allowed_mentions = discord.AllowedMentions(users = True)) 260 | except TimeoutError: 261 | await ctx.send('Aborted.') 262 | 263 | @dshell.command(name = 'shellunwhitelist', aliases = ['shuw']) 264 | @commands.is_owner() 265 | async def shell_unwhitelist(self, ctx: commands.Context, user: discord.User) -> Optional[discord.Message]: 266 | """ 267 | Changes the `shell_whitelisted_users` config property. Unwhitelist someone to the shell. The user to unwhitelist cannot be a bot owner. 268 | """ 269 | if user.id not in self.bot.dshell_config['shell_whitelisted_users']: 270 | return await ctx.send('This person is not whitelisted.') 271 | if user.id in self._owners: 272 | return await ctx.send('The owner of this bot cannot be unwhitelisted from the shell.') 273 | self.bot.dshell_config['shell_whitelisted_users'].remove(user.id) 274 | await ctx.send(f'Unwhitelisted {user.mention}.', allowed_mentions = discord.AllowedMentions(users = True)) 275 | 276 | async def _do_cd_command(self, msg: discord.Message) -> bool: 277 | if msg.content.strip() == 'cd' or msg.content == 'cd ~': 278 | if self.bot.dshell_config['show_cd_command_output'] is True: 279 | await msg.channel.send( 280 | f'```sh\n$ {msg.content}\n\n' \ 281 | f'Changing process directory to home directory, {self._home_directory}.\n' \ 282 | f'Current process directory: {self._cwd}.\n' \ 283 | f'Original process directory: {self._og_working_directory}.\n\n[status] Return code 0\n```' 284 | ) 285 | await msg.add_reaction('✅') 286 | self._cwd = self._home_directory 287 | return False 288 | 289 | if msg.content == 'cd ..': 290 | directory = dirname(self._cwd) 291 | if self.bot.dshell_config['show_cd_command_output'] is True: 292 | await msg.channel.send( 293 | f'```sh\n$ {msg.content}\n\n' \ 294 | f'Changing process directory to {directory}.\n' \ 295 | f'Current process directory: {self._cwd}.\n' \ 296 | f'Original process directory: {self._og_working_directory}.\n\n[status] Return code 0\n```' 297 | ) 298 | await msg.add_reaction('✅') 299 | self._cwd = directory 300 | return False 301 | 302 | if msg.content.startswith('cd '): 303 | directory = msg.content[3:] 304 | if directory.startswith('"') and directory.endswith('"'): 305 | directory = directory[1:][:-1] 306 | if not directory.startswith('/'): # implying that a relative path has been entered 307 | directory = f'{self._cwd}/{directory}' 308 | if not isdir(directory): 309 | await msg.channel.send( 310 | f'```sh\n$ {msg.content}\n\n' \ 311 | f'[stderr] cd: {directory}: Not a valid directory.\n\n[status] Return code 1\n```' 312 | ) 313 | await msg.add_reaction('✅') 314 | return False 315 | if self.bot.dshell_config['show_cd_command_output'] is True: 316 | await msg.channel.send( 317 | f'```sh\n$ {msg.content}\n\n' \ 318 | f'Changing process directory to {directory}.\n' \ 319 | f'Current process directory: {self._cwd}.\n' \ 320 | f'Original process directory: {self._og_working_directory}.\n\n[status] Return code 0\n```' 321 | ) 322 | await msg.add_reaction('✅') 323 | self._cwd = directory 324 | return False 325 | 326 | return True 327 | 328 | async def _clear_command(self, msg: discord.Message) -> None: 329 | if self.bot.dshell_config['give_clear_command_confirmation_warning']: 330 | message = await msg.channel.send(f'{msg.author.mention}, clearing in 5 seconds. Press :x: to abort the process. This will delete this shell channel and create an empty copy.', allowed_mentions = discord.AllowedMentions(users = True)) 331 | await message.add_reaction('❌') 332 | def check(reaction: discord.Reaction, user): 333 | return str(reaction.emoji == '❌') and user == msg.author and reaction.message.id == message.id and reaction.message.channel.id == message.channel.id 334 | try: 335 | await self.bot.wait_for('reaction_add', check = check, timeout = 5) 336 | await msg.channel.send('Aborted.') 337 | except TimeoutError: 338 | new_channel = await msg.channel.clone() 339 | await msg.channel.delete() 340 | del self.bot.dshell_config['shell_channels'][self.bot.dshell_config['shell_channels'].index(msg.channel.id)] 341 | self.bot.dshell_config['shell_channels'].append(new_channel.id) 342 | await new_channel.send(f'{msg.author.mention}, the new shell has been made. This message will be deleted after 10 seconds.', allowed_mentions = discord.AllowedMentions(users = True), delete_after = 10) 343 | else: 344 | new_channel = await msg.channel.clone() 345 | await msg.channel.delete() 346 | del self.bot.dshell_config['shell_channels'][self.bot.dshell_config['shell_channels'].index(msg.channel.id)] 347 | self.bot.dshell_config['shell_channels'].append(new_channel.id) 348 | await new_channel.send(f'{msg.author.mention}, the new shell has been made. This message will be deleted after 10 seconds.', allowed_mentions = discord.AllowedMentions(users = True), delete_after = 10) 349 | 350 | @commands.Cog.listener(name = 'on_message') 351 | async def main_shell(self, msg: discord.Message) -> Optional[discord.Message]: 352 | if ( 353 | msg.channel.id not in self.bot.dshell_config['shell_channels'] 354 | and (not self.bot.dshell_config['shell_in_dms'] or msg.guild) 355 | or msg.author.id == self.bot.user.id 356 | or not msg.content 357 | or msg.author.id 358 | not in self.bot.dshell_config['shell_whitelisted_users'] 359 | or msg.content.startswith('#') 360 | ): 361 | return 362 | if msg.content.startswith('`') and msg.content.endswith('`'): 363 | msg.content = msg.content[1:][:-1] 364 | if msg.content == '[DSHELL EXEC FILE]' and msg.attachments: 365 | msg.content = (await msg.attachments[0].read()).decode('utf-8') 366 | if msg.content == 'clear': 367 | return await self._clear_command(msg) 368 | if await self._do_cd_command(msg): 369 | ctx = await self.bot.get_context(msg) 370 | return await jskshell.jsk_shell(ctx, argument = msg, cwd = self._cwd) 371 | 372 | @commands.Cog.listener(name = 'on_ready') 373 | async def initialize_bot_owners(self): 374 | if not self._on_ready_flag and not self._owners: 375 | owner = (await self.bot.application_info()).owner.id 376 | self.bot.dshell_config['shell_whitelisted_users'] = [owner] 377 | self._owners = [owner] 378 | self._on_ready_flag = True 379 | 380 | async def setup(bot): 381 | await bot.add_cog(DShell(bot)) -------------------------------------------------------------------------------- /dshell/jskshell.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2021 Devon (Gorialis) R 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | 22 | NOTE: This whole file is copy pasted code from Jishaku with some alterations. 23 | This file is not for normal use. 24 | This is so that Jishaku itself doesn't need to be a dependency for this package, as only a part of Jishaku is needed. 25 | [Link to Jishaku](https://github.com/Gorialis/jishaku) 26 | """ 27 | 28 | import asyncio, discord 29 | from discord.ext import commands 30 | from contextlib import contextmanager 31 | from os import getenv 32 | from sys import platform 33 | from collections import namedtuple, deque 34 | from pathlib import Path 35 | from subprocess import Popen, PIPE, TimeoutExpired 36 | from re import sub 37 | from time import perf_counter 38 | from traceback import format_exception 39 | 40 | EmojiSettings = namedtuple('EmojiSettings', 'start back forward end close') 41 | 42 | CommandTask = namedtuple('CommandTask', 'index ctx task') 43 | Codeblock = namedtuple('Codeblock', 'language content') 44 | EmojiSettings = namedtuple('EmojiSettings', 'start back forward end close') 45 | EMOJI_DEFAULT = EmojiSettings( 46 | start = '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', 47 | back = '\N{BLACK LEFT-POINTING TRIANGLE}', 48 | forward = '\N{BLACK RIGHT-POINTING TRIANGLE}', 49 | end = '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', 50 | close = '\N{BLACK SQUARE FOR STOP}' 51 | ) 52 | SHELL = getenv("SHELL") or "/bin/bash" 53 | WINDOWS = platform == "win32" 54 | tasks = deque() 55 | task_count = 0 56 | 57 | def codeblock_converter(argument): 58 | if not argument.startswith('`'): 59 | return Codeblock(None, argument) 60 | 61 | # keep a small buffer of the last chars we've seen 62 | last = deque(maxlen=3) 63 | backticks = 0 64 | in_language = False 65 | in_code = False 66 | language = [] 67 | code = [] 68 | 69 | for char in argument: 70 | if char == '`' and not in_code and not in_language: 71 | backticks += 1 # to help keep track of closing backticks 72 | if last and last[-1] == '`' and char != '`' or in_code and ''.join(last) != '`' * backticks: 73 | in_code = True 74 | code.append(char) 75 | if char == '\n': # \n delimits language and code 76 | in_language = False 77 | in_code = True 78 | elif ''.join(last) == '`' * 3 and char != '`': 79 | in_language = True 80 | language.append(char) 81 | elif in_language: # we're in the language after the first non-backtick character 82 | language.append(char) 83 | 84 | last.append(char) 85 | 86 | if not code and not language: 87 | code[:] = last 88 | 89 | return Codeblock(''.join(language), ''.join(code[len(language):-backticks])) 90 | 91 | @contextmanager 92 | def submit(ctx: commands.Context): 93 | global task_count 94 | global tasks 95 | task_count += 1 96 | 97 | try: 98 | current_task = asyncio.current_task() # pylint: disable=no-member 99 | except RuntimeError: 100 | # asyncio.current_task doesn't document that it can raise RuntimeError, but it does. 101 | # It propagates from asyncio.get_running_loop(), so it happens when there is no loop running. 102 | # It's unclear if this is a regression or an intentional change, since in 3.6, 103 | # asyncio.Task.current_task() would have just returned None in this case. 104 | current_task = None 105 | 106 | cmdtask = CommandTask(task_count, ctx, current_task) 107 | 108 | tasks.append(cmdtask) 109 | 110 | try: 111 | yield cmdtask 112 | finally: 113 | if cmdtask in tasks: 114 | tasks.remove(cmdtask) 115 | 116 | def background_reader(stream, loop: asyncio.AbstractEventLoop, callback): 117 | for line in iter(stream.readline, b''): 118 | loop.call_soon_threadsafe(loop.create_task, callback(line)) 119 | 120 | async def send_traceback(destination, verbosity: int, *exc_info): 121 | # to make pylint stop moaning 122 | etype, value, trace = exc_info 123 | 124 | traceback_content = "".join(format_exception(etype, value, trace, verbosity)).replace("``", "`\u200b`") 125 | 126 | paginator = commands.Paginator(prefix='```py') 127 | for line in traceback_content.split('\n'): 128 | paginator.add_line(line) 129 | 130 | message = None 131 | 132 | for page in paginator.pages: 133 | message = await destination.send(page) 134 | 135 | return message 136 | 137 | async def attempt_add_reaction(msg, reaction): 138 | try: 139 | return await msg.add_reaction(reaction) 140 | except discord.HTTPException: 141 | pass 142 | 143 | async def do_after_sleep(delay: float, coro, *args, **kwargs): 144 | await asyncio.sleep(delay) 145 | return await coro(*args, **kwargs) 146 | 147 | class WrappedPaginator(commands.Paginator): 148 | def __init__(self, *args, wrap_on = ('\n', ' '), include_wrapped=True, force_wrap=False, **kwargs): 149 | super().__init__(*args, **kwargs) 150 | self.wrap_on = wrap_on 151 | self.include_wrapped = include_wrapped 152 | self.force_wrap = force_wrap 153 | 154 | def add_line(self, line='', *, empty=False): 155 | true_max_size = self.max_size - self._prefix_len - self._suffix_len - 2 156 | original_length = len(line) 157 | 158 | while len(line) > true_max_size: 159 | search_string = line[:true_max_size - 1] 160 | wrapped = False 161 | 162 | for delimiter in self.wrap_on: 163 | position = search_string.rfind(delimiter) 164 | 165 | if position > 0: 166 | super().add_line(line[:position], empty=empty) 167 | wrapped = True 168 | 169 | if self.include_wrapped: 170 | line = line[position:] 171 | else: 172 | line = line[position + len(delimiter):] 173 | 174 | break 175 | 176 | if not wrapped: 177 | if not self.force_wrap: 178 | raise ValueError( 179 | f"Line of length {original_length} had sequence of {len(line)} characters" 180 | f" (max is {true_max_size}) that WrappedPaginator could not wrap with" 181 | f" delimiters: {self.wrap_on}" 182 | ) 183 | 184 | super().add_line(line[:true_max_size - 1]) 185 | line = line[true_max_size - 1:] 186 | super().add_line(line, empty=empty) 187 | 188 | class PaginatorInterface: # pylint: disable=too-many-instance-attributes 189 | def __init__(self, bot: commands.Bot, paginator: commands.Paginator, **kwargs): 190 | if not isinstance(paginator, commands.Paginator): 191 | raise TypeError('paginator must be a commands.Paginator instance') 192 | 193 | self._display_page = 0 194 | 195 | self.bot = bot 196 | 197 | self.message = None 198 | self.paginator = paginator 199 | 200 | self.owner = kwargs.pop('owner', None) 201 | self.emojis = kwargs.pop('emoji', EMOJI_DEFAULT) 202 | self.timeout = kwargs.pop('timeout', 7200) 203 | self.delete_message = kwargs.pop('delete_message', False) 204 | 205 | self.sent_page_reactions = False 206 | 207 | self.task: asyncio.Task = None 208 | self.send_lock: asyncio.Event = asyncio.Event() 209 | 210 | self.close_exception: Exception = None 211 | 212 | if self.page_size > self.max_page_size: 213 | raise ValueError( 214 | f'Paginator passed has too large of a page size for this interface. ' 215 | f'({self.page_size} > {self.max_page_size})' 216 | ) 217 | 218 | @property 219 | def pages(self): 220 | # protected access has to be permitted here to not close the paginator's pages 221 | 222 | # pylint: disable=protected-access 223 | paginator_pages = list(self.paginator._pages) 224 | if len(self.paginator._current_page) > 1: 225 | paginator_pages.append('\n'.join(self.paginator._current_page) + '\n' + (self.paginator.suffix or '')) 226 | # pylint: enable=protected-access 227 | 228 | return paginator_pages 229 | 230 | @property 231 | def page_count(self): 232 | return len(self.pages) 233 | 234 | @property 235 | def display_page(self): 236 | self._display_page = max(0, min(self.page_count - 1, self._display_page)) 237 | return self._display_page 238 | 239 | @display_page.setter 240 | def display_page(self, value): 241 | self._display_page = max(0, min(self.page_count - 1, value)) 242 | 243 | max_page_size = 2000 244 | 245 | @property 246 | def page_size(self) -> int: 247 | page_count = self.page_count 248 | return self.paginator.max_size + len(f'\nPage {page_count}/{page_count}') 249 | 250 | @property 251 | def send_kwargs(self) -> dict: 252 | display_page = self.display_page 253 | page_num = f'\nPage {display_page + 1}/{self.page_count}' 254 | content = self.pages[display_page] + page_num 255 | return {'content': content} 256 | 257 | async def add_line(self, *args, **kwargs): 258 | display_page = self.display_page 259 | page_count = self.page_count 260 | 261 | self.paginator.add_line(*args, **kwargs) 262 | 263 | new_page_count = self.page_count 264 | 265 | if display_page + 1 == page_count: 266 | # To keep position fixed on the end, update position to new last page and update message. 267 | self._display_page = new_page_count 268 | 269 | # Unconditionally set send lock to try and guarantee page updates on unfocused pages 270 | self.send_lock.set() 271 | 272 | async def send_to(self, destination: discord.abc.Messageable): 273 | self.message = await destination.send(**self.send_kwargs) 274 | 275 | # add the close reaction 276 | await self.message.add_reaction(self.emojis.close) 277 | 278 | self.send_lock.set() 279 | 280 | if self.task: 281 | self.task.cancel() 282 | 283 | self.task = self.bot.loop.create_task(self.wait_loop()) 284 | 285 | # if there is more than one page, and the reactions haven't been sent yet, send navigation emotes 286 | if not self.sent_page_reactions and self.page_count > 1: 287 | await self.send_all_reactions() 288 | 289 | return self 290 | 291 | async def send_all_reactions(self): 292 | for emoji in filter(None, self.emojis): 293 | try: 294 | await self.message.add_reaction(emoji) 295 | except discord.NotFound: 296 | # the paginator has probably already been closed 297 | break 298 | self.sent_page_reactions = True 299 | 300 | @property 301 | def closed(self): 302 | return False if not self.task else self.task.done() 303 | 304 | async def send_lock_delayed(self): 305 | gathered = await self.send_lock.wait() 306 | self.send_lock.clear() 307 | await asyncio.sleep(1) 308 | return gathered 309 | 310 | async def wait_loop(self): # pylint: disable=too-many-branches,too-many-statements 311 | start, back, forward, end, close = self.emojis 312 | 313 | def check(payload: discord.RawReactionActionEvent): 314 | owner_check = not self.owner or payload.user_id == self.owner.id 315 | 316 | emoji = payload.emoji 317 | if isinstance(emoji, discord.PartialEmoji) and emoji.is_unicode_emoji(): 318 | emoji = emoji.name 319 | 320 | tests = ( 321 | owner_check, 322 | payload.message_id == self.message.id, 323 | emoji, 324 | emoji in self.emojis, 325 | payload.user_id != self.bot.user.id 326 | ) 327 | 328 | return all(tests) 329 | 330 | task_list = [ 331 | self.bot.loop.create_task(coro) for coro in { 332 | self.bot.wait_for('raw_reaction_add', check=check), 333 | self.bot.wait_for('raw_reaction_remove', check=check), 334 | self.send_lock_delayed() 335 | } 336 | ] 337 | 338 | try: # pylint: disable=too-many-nested-blocks 339 | last_kwargs = None 340 | 341 | while not self.bot.is_closed(): 342 | done, _ = await asyncio.wait(task_list, timeout=self.timeout, return_when=asyncio.FIRST_COMPLETED) 343 | 344 | if not done: 345 | raise asyncio.TimeoutError 346 | 347 | for task in done: 348 | task_list.remove(task) 349 | payload = task.result() 350 | 351 | if isinstance(payload, discord.RawReactionActionEvent): 352 | emoji = payload.emoji 353 | if isinstance(emoji, discord.PartialEmoji) and emoji.is_unicode_emoji(): 354 | emoji = emoji.name 355 | 356 | if emoji == close: 357 | await self.message.delete() 358 | return 359 | 360 | if emoji == start: 361 | self._display_page = 0 362 | elif emoji == end: 363 | self._display_page = self.page_count - 1 364 | elif emoji == back: 365 | self._display_page -= 1 366 | elif emoji == forward: 367 | self._display_page += 1 368 | 369 | if payload.event_type == 'REACTION_ADD': 370 | task_list.append(self.bot.loop.create_task( 371 | self.bot.wait_for('raw_reaction_add', check=check) 372 | )) 373 | elif payload.event_type == 'REACTION_REMOVE': 374 | task_list.append(self.bot.loop.create_task( 375 | self.bot.wait_for('raw_reaction_remove', check=check) 376 | )) 377 | else: 378 | # Send lock was released 379 | task_list.append(self.bot.loop.create_task(self.send_lock_delayed())) 380 | 381 | if not self.sent_page_reactions and self.page_count > 1: 382 | self.bot.loop.create_task(self.send_all_reactions()) 383 | self.sent_page_reactions = True # don't spawn any more tasks 384 | 385 | if self.send_kwargs != last_kwargs: 386 | try: 387 | await self.message.edit(**self.send_kwargs) 388 | except discord.NotFound: 389 | # something terrible has happened 390 | return 391 | 392 | last_kwargs = self.send_kwargs 393 | 394 | except (asyncio.CancelledError, asyncio.TimeoutError) as exception: 395 | self.close_exception = exception 396 | 397 | if self.bot.is_closed(): 398 | # Can't do anything about the messages, so just close out to avoid noisy error 399 | return 400 | 401 | if self.delete_message: 402 | return await self.message.delete() 403 | 404 | for emoji in filter(None, self.emojis): 405 | try: 406 | await self.message.remove_reaction(emoji, self.bot.user) 407 | except (discord.Forbidden, discord.NotFound): 408 | pass 409 | 410 | finally: 411 | for task in task_list: 412 | task.cancel() 413 | 414 | async def jsk_shell(ctx, *, argument: codeblock_converter, cwd: str): # ALL the other things in this file are solely for this one function to do its job... 415 | async with ReplResponseReactor(ctx.message): 416 | with submit(ctx): 417 | with ShellReader(argument.content, cwd) as reader: 418 | prefix = f'```{reader.highlight}' 419 | 420 | paginator = WrappedPaginator(prefix=prefix, max_size=1975) 421 | paginator.add_line(f"{reader.ps1} {argument.content}\n") 422 | 423 | interface = PaginatorInterface(ctx.bot, paginator, owner=ctx.author) 424 | ctx.bot.loop.create_task(interface.send_to(ctx)) # it was originally self.bot.loop.create_task 425 | 426 | async for line in reader: 427 | if interface.closed: 428 | return 429 | await interface.add_line(line) 430 | 431 | await interface.add_line(f"\n[status] Return code {reader.close_code}") 432 | 433 | class ShellReader: 434 | def __init__(self, code: str, cwd: str, timeout: int = 90, loop: asyncio.AbstractEventLoop = None): 435 | if WINDOWS: 436 | # Check for powershell 437 | if Path(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe").exists(): 438 | sequence = ['powershell', code] 439 | self.ps1 = "PS >" 440 | self.highlight = "powershell" 441 | else: 442 | sequence = ['cmd', '/c', code] 443 | self.ps1 = "cmd >" 444 | self.highlight = "cmd" 445 | else: 446 | sequence = [SHELL, '-c', code] 447 | self.ps1 = "$" 448 | self.highlight = "sh" 449 | 450 | self.process = Popen(sequence, stdout=PIPE, stderr=PIPE, cwd=cwd) 451 | self.close_code = None 452 | 453 | self.loop = loop or asyncio.get_event_loop() 454 | self.timeout = timeout 455 | 456 | self.stdout_task = self.make_reader_task(self.process.stdout, self.stdout_handler) 457 | self.stderr_task = self.make_reader_task(self.process.stderr, self.stderr_handler) 458 | 459 | self.queue = asyncio.Queue(maxsize = 250) 460 | 461 | @property 462 | def closed(self): 463 | return self.stdout_task.done() and self.stderr_task.done() 464 | 465 | async def executor_wrapper(self, *args, **kwargs): 466 | return await self.loop.run_in_executor(None, *args, **kwargs) 467 | 468 | def make_reader_task(self, stream, callback): 469 | return self.loop.create_task(self.executor_wrapper(background_reader, stream, self.loop, callback)) 470 | 471 | @staticmethod 472 | def clean_bytes(line): 473 | text = line.decode('utf-8').replace('\r', '').strip('\n') 474 | return sub(r'\x1b[^m]*m', '', text).replace("``", "`\u200b`").strip('\n') 475 | 476 | async def stdout_handler(self, line): 477 | await self.queue.put(self.clean_bytes(line)) 478 | 479 | async def stderr_handler(self, line): 480 | await self.queue.put(self.clean_bytes(b'[stderr] ' + line)) 481 | 482 | def __enter__(self): 483 | return self 484 | 485 | def __exit__(self, *args): 486 | self.process.kill() 487 | self.process.terminate() 488 | self.close_code = self.process.wait(timeout=0.5) 489 | 490 | def __aiter__(self): 491 | return self 492 | 493 | async def __anext__(self): 494 | start_time = perf_counter() 495 | 496 | while not self.closed or not self.queue.empty(): 497 | try: 498 | return await asyncio.wait_for(self.queue.get(), timeout=1) 499 | except asyncio.TimeoutError as exception: 500 | if perf_counter() - start_time >= self.timeout: 501 | raise exception 502 | 503 | raise StopAsyncIteration() 504 | 505 | class ReactionProcedureTimer: # pylint: disable=too-few-public-methods 506 | __slots__ = ('message', 'loop', 'handle', 'raised') 507 | 508 | def __init__(self, message, loop = None): 509 | self.message = message 510 | self.loop = loop or asyncio.get_event_loop() 511 | self.handle = None 512 | self.raised = False 513 | 514 | async def __aenter__(self): 515 | self.handle = self.loop.create_task(do_after_sleep(1, attempt_add_reaction, self.message, 516 | "\N{BLACK RIGHT-POINTING TRIANGLE}")) 517 | return self 518 | 519 | async def __aexit__(self, exc_type, exc_val, exc_tb): 520 | if self.handle: 521 | self.handle.cancel() 522 | 523 | # no exception, check mark 524 | if not exc_val: 525 | await attempt_add_reaction(self.message, "\N{WHITE HEAVY CHECK MARK}") 526 | return 527 | 528 | self.raised = True 529 | 530 | if isinstance(exc_val, (asyncio.TimeoutError, TimeoutExpired)): 531 | # timed out, alarm clock 532 | await attempt_add_reaction(self.message, "\N{ALARM CLOCK}") 533 | elif isinstance(exc_val, SyntaxError): 534 | # syntax error, single exclamation mark 535 | await attempt_add_reaction(self.message, "\N{HEAVY EXCLAMATION MARK SYMBOL}") 536 | else: 537 | # other error, double exclamation mark 538 | await attempt_add_reaction(self.message, "\N{DOUBLE EXCLAMATION MARK}") 539 | 540 | class ReplResponseReactor(ReactionProcedureTimer): # pylint: disable=too-few-public-methods 541 | async def __aexit__(self, exc_type, exc_val, exc_tb): 542 | await super().__aexit__(exc_type, exc_val, exc_tb) 543 | 544 | # nothing went wrong, who cares lol 545 | if not exc_val: 546 | return 547 | 548 | if isinstance(exc_val, (SyntaxError, asyncio.TimeoutError, TimeoutExpired)): 549 | # short traceback, send to channel 550 | await send_traceback(self.message.channel, 0, exc_type, exc_val, exc_tb) 551 | else: 552 | # this traceback likely needs more info, so increase verbosity, and DM it instead. 553 | await send_traceback( 554 | self.message.author, 8, exc_type, exc_val, exc_tb 555 | ) 556 | 557 | return True # the exception has been handled -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open('.github/README.md') as file: 4 | long_description = file.read() 5 | 6 | with open('dshell/__init__.py') as file: 7 | lines = file.readlines() 8 | 9 | for line in lines: 10 | if line.startswith('__version__: str = '): 11 | version = line[20:-2] 12 | break 13 | 14 | setup( 15 | name = 'discordshell', 16 | version = version, 17 | description = 'DShell is a package that combines with discord.py and Jishaku to turn a Discord channel into a shell on which bash commands can be run.', 18 | long_description = long_description, 19 | long_description_content_type = 'text/markdown', 20 | author = 'ImNimboss', 21 | author_email = 'nimit.grover24@gmail.com', 22 | url = 'https://github.com/ImNimboss/dshell', 23 | license = 'MIT', 24 | packages = ['dshell'], 25 | keywords = [ 26 | 'shell', 'discord', 'terminal', 'powershell', 'command-prompt', 27 | 'internet', 'console', 'shell-access', 'utility' 28 | ], 29 | install_requires = [], 30 | classifiers = [ 31 | 'Development Status :: 5 - Production/Stable', 32 | 'Intended Audience :: Developers', 33 | 'License :: OSI Approved :: MIT License', 34 | 'Natural Language :: English', 35 | 'Operating System :: OS Independent', 36 | 'Programming Language :: Python :: 3', 37 | 'Programming Language :: Python :: 3.6', 38 | 'Programming Language :: Python :: 3.7', 39 | 'Programming Language :: Python :: 3.8', 40 | 'Programming Language :: Python :: 3.9', 41 | 'Programming Language :: Python :: 3.10', 42 | 'Programming Language :: Python :: Implementation :: CPython', 43 | 'Topic :: Communications', 44 | 'Topic :: Communications :: Chat', 45 | 'Topic :: Internet', 46 | 'Topic :: Software Development :: Libraries', 47 | 'Topic :: System :: Shells', 48 | 'Topic :: System :: System Shells', 49 | 'Topic :: System :: Systems Administration', 50 | 'Topic :: Terminals', 51 | 'Topic :: Terminals :: Terminal Emulators/X Terminals', 52 | 'Topic :: Utilities' 53 | ], # https://pypi.org/classifiers/ 54 | python_requires = '>=3.6.0', 55 | project_urls = { 56 | 'Documentation': 'https://github.com/ImNimboss/dshell/tree/main/Documentation', 57 | 'Issue Tracker': 'https://github.com/ImNimboss/dshell/issues', 58 | 'Source': 'https://github.com/ImNimboss/dshell', 59 | 'Funding': 'https://patreon.com/nimboss', 60 | 'Creator': 'https://nimboss.me', 61 | 'Discord': 'https://discord.gg/FcxqdJ7AQq' 62 | } 63 | ) --------------------------------------------------------------------------------