├── .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 |

2 |
3 | # DShell
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 | 
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](../.github/assets/command-examples/dshell-exec-command-example.png)
--------------------------------------------------------------------------------
/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 | )
--------------------------------------------------------------------------------