├── .gitignore ├── LICENSE ├── PP.md ├── README.md ├── TOS.md ├── alembic.ini ├── bot ├── __init__.py ├── assets │ ├── earthorbiterxtrabold.ttf │ ├── montserrat_extrabold.otf │ └── welcome_image.jpg ├── cogs │ ├── __init__.py │ ├── auto_resp.py │ ├── automod.py │ ├── fun.py │ ├── internet.py │ ├── misc.py │ ├── mod.py │ ├── reaction_roles.py │ ├── settings.py │ ├── snipe.py │ └── status.py ├── db │ ├── __init__.py │ └── models.py ├── enums.py ├── errors.py ├── slash_cogs │ ├── afk.py │ ├── auto_response.py │ ├── automod.py │ ├── fun.py │ ├── internet.py │ ├── misc.py │ ├── mod.py │ ├── music.py │ ├── reaction_roles.py │ ├── server_logs.py │ ├── settings.py │ ├── snipe.py │ ├── status.py │ └── welcome_leave.py ├── utils.py └── views.py ├── install_deps.sh ├── migrations ├── README ├── env.py ├── script.py.mako └── versions │ ├── 07c05549ae00_welcome_leave_channel_to_string.py │ ├── 0a13ce529f35_initial.py │ ├── 1b6128ae5a4b_added_playlist_and_playlistsong_models.py │ ├── 228333096592_imp_log_table.py │ ├── 24933d669517_added_moderator_id_field.py │ ├── 81217e467554_added_infractions_table.py │ ├── c534a7e185b6_add_guild_id_to_auto_resp.py │ ├── e05490c784a6_added_auto_mod_table.py │ └── ef4c153dc7d7_made_playlist_id_primary_key_as_well.py ├── requirements.txt ├── requirements_no_deps.txt ├── run.py ├── tests └── test_utils.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | 133 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 134 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 135 | 136 | # User-specific stuff 137 | .idea/**/workspace.xml 138 | .idea/**/tasks.xml 139 | .idea/**/usage.statistics.xml 140 | .idea/**/dictionaries 141 | .idea/**/shelf 142 | 143 | # Generated files 144 | .idea/**/contentModel.xml 145 | 146 | # Sensitive or high-churn files 147 | .idea/**/dataSources/ 148 | .idea/**/dataSources.ids 149 | .idea/**/dataSources.local.xml 150 | .idea/**/sqlDataSources.xml 151 | .idea/**/dynamic.xml 152 | .idea/**/uiDesigner.xml 153 | .idea/**/dbnavigator.xml 154 | 155 | # Gradle 156 | .idea/**/gradle.xml 157 | .idea/**/libraries 158 | 159 | # Gradle and Maven with auto-import 160 | # When using Gradle or Maven with auto-import, you should exclude module files, 161 | # since they will be recreated, and may cause churn. Uncomment if using 162 | # auto-import. 163 | # .idea/artifacts 164 | # .idea/compiler.xml 165 | # .idea/jarRepositories.xml 166 | # .idea/modules.xml 167 | # .idea/*.iml 168 | # .idea/modules 169 | # *.iml 170 | # *.ipr 171 | 172 | # CMake 173 | cmake-build-*/ 174 | 175 | # Mongo Explorer plugin 176 | .idea/**/mongoSettings.xml 177 | 178 | # File-based project format 179 | *.iws 180 | 181 | # IntelliJ 182 | out/ 183 | 184 | # mpeltonen/sbt-idea plugin 185 | .idea_modules/ 186 | 187 | # JIRA plugin 188 | atlassian-ide-plugin.xml 189 | 190 | # Cursive Clojure plugin 191 | .idea/replstate.xml 192 | 193 | # Crashlytics plugin (for Android Studio and IntelliJ) 194 | com_crashlytics_export_strings.xml 195 | crashlytics.properties 196 | crashlytics-build.properties 197 | fabric.properties 198 | 199 | # Editor-based Rest Client 200 | .idea/httpRequests 201 | 202 | # Android studio 3.1+ serialized cache file 203 | .idea/caches/build_file_checksums.ser 204 | 205 | # The whole damn stupid folder 206 | .idea/ 207 | 208 | # Stupid fricking macos junk 209 | .DS_Store 210 | 211 | # Bot cache 212 | bot/cache/ 213 | -------------------------------------------------------------------------------- /PP.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy for Sparta Bot 2 | 3 | **Effective Date: 15 January 2024** 4 | 5 | Welcome to Sparta! This Privacy Policy ("Policy") describes how Sparta Dev Team ("we," "us," or "our") collects, uses, and shares information when you use Sparta, a Discord bot. 6 | 7 | By using Sparta, you agree to the practices described in this Privacy Policy. If you do not agree to this Policy, please refrain from using our Discord bot. 8 | 9 | ## 1. Information We Collect 10 | 11 | 1.1 **Discord Data**: Sparta requires access to certain information provided by Discord, such as user IDs, server information, message content, and identifiers of guilds, users, roles, messages, channel webhooks, etc., to function properly. 12 | 13 | 1.2 **Usage Data**: We may collect information about how you interact with Sparta, including command usage, error logs, and other usage patterns. 14 | 15 | ## 2. How We Use Information 16 | 17 | 2.1 **Bot Functionality**: We use the collected information to provide and improve Sparta's functionality, including moderation features and server automation. For example, message content information is used for specific automations. 18 | 19 | 2.2 **Data Storage**: Identifiers of guilds, users, roles, messages, channel webhooks, etc., are stored for the purpose of enhancing Sparta's features and ensuring a consistent user experience. 20 | 21 | 2.3 **Analytics**: We may analyze usage data to monitor and improve the performance and effectiveness of Sparta. 22 | 23 | ## 3. Information Sharing and Disclosure 24 | 25 | 3.1 **Third-Party Services**: We do not sell, trade, or otherwise transfer your information to third parties. However, certain third-party services may be integrated with Sparta for specific features, and your information may be shared with those services in accordance with their privacy policies. 26 | 27 | ## 4. Security 28 | 29 | 4.1 **Data Security**: We take reasonable measures to protect the information collected against unauthorized access, alteration, disclosure, or destruction. 30 | 31 | 4.2 **Open Source Nature**: Sparta is an open-source project. While the source code and the structure of data are visible to the public through the open-source repository, the actual content of the data itself remains private and is not accessible to the public. 32 | 33 | ## 5. Changes to this Privacy Policy 34 | 35 | 5.1 **Modification**: We reserve the right to update or change this Privacy Policy at any time. The effective date will be updated accordingly. 36 | 37 | ## 6. Contact 38 | 39 | 6.1 **Contact Information**: If you have any questions about this Privacy Policy, please contact us on Discord through the [Sparta Support Server](https://discord.gg/RrVY4bP). 40 | 41 | 6.2 **Repositories**: All open-source respositories maintained by us are visible [here](https://github.com/SpartaDevTeam). 42 | 43 | By using Sparta, you acknowledge that you have read, understood, and agree to this Privacy Policy. 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SpartaBot 2 | 3 | A complete rewrite from scratch of the [Original Sparta Bot](https://github.com/SpartaDevTeam/Old-Sparta-Bot), using better technologies. 4 | Moderation, automod, welcome and leave message, reaction roles, fun commands, etc., just some of the stuff that Sparta Bot offers. 5 | 6 | ## How to run 7 | 8 | 1. Install Python 3.10 or newer. Then install the dependencies in `requirements.txt` normally and `requirements_no_deps.txt` with pip's `--no-deps` flag. The `install_deps.sh` script does exactly this and was made for convenience. 9 | 2. Add the following environment variables to a `.env` file in the root directory: 10 | - `TOKEN` - Your Discord Bot Token. 11 | - `URBAN_API_KEY` - API key for accessing Urban Dictionary's API. 12 | - `DBL_TOKEN` - Top.gg bot token for receiving vote data. 13 | - `TESTING_GUILDS` (Optional) - Comma (,) separated string of guild IDs for testing `/` commands. Only applicable when running with `--debug` flag. 14 | - `DB_URI` - URI string of your database. You may have to install additional modules if you're using something other than PostgreSQL. 15 | - `LAVALINK_HOST` - Host address where you're hosting your Lavalink server. 16 | - `LAVALINK_PORT` - Port on which you're hosting your Lavalink server. 17 | - `LAVALINK_PASSWORD` - Password to connect to your Lavalink server. 18 | 3. Run the command `alembic upgrade head` to run database migrations. 19 | 4. Run `python run.py` to start the bot. You can run the command with `--debug` flag to run in debug mode. 20 | 21 | ## Links 22 | 23 | 1. [Sparta](https://discord.com/api/oauth2/authorize?client_id=731763013417435247&permissions=8&scope=bot%20applications.commands) 24 | 2. [Sparta Beta](https://discord.com/api/oauth2/authorize?client_id=775798822844629013&permissions=8&scope=applications.commands%20bot) 25 | 3. [Top.gg](https://top.gg/bot/731763013417435247) 26 | 4. [Bots for Discord](https://botsfordiscord.com/bot/731763013417435247) 27 | 5. [Support Server](https://discord.gg/RrVY4bP) 28 | -------------------------------------------------------------------------------- /TOS.md: -------------------------------------------------------------------------------- 1 | # Terms of Service for Sparta Bot 2 | 3 | **Effective Date: 15 January 2024** 4 | 5 | Welcome to Sparta! These Terms of Service ("Terms") govern your use of Sparta, a Discord bot developed by Sparta Dev Team ("we," "us," or "our"). 6 | By using Sparta, you agree to be bound by these Terms. If you do not agree to these Terms, please refrain from using our Discord bot. 7 | 8 | ## 1. General Conditions 9 | 10 | 1.1 **Age Restrictions**: You must be at least 13 years old to use Sparta. If you are under 13, please do not use our services. 11 | 12 | 1.2 **Discord's Terms**: In addition to these Terms, you must also comply with Discord's Terms of Service, available at https://discord.com/terms. 13 | 14 | 1.3 **Prohibited Activities**: You agree not to engage in any illegal or prohibited activities while using Sparta. This includes but is not limited to: 15 | 16 | - Violating any laws or regulations. 17 | - Disrupting or interfering with the normal functioning of Sparta. 18 | - Misusing or exploiting the open-source nature of Sparta in a manner contrary to the principles of collaborative development. 19 | - Engaging in activities that hinder the positive contributions and growth of the Sparta open-source community. 20 | 21 | ## 2. Intellectual Property 22 | 23 | 2.1 **Ownership**: Sparta Dev Team retains all rights, title, and interest in and to Sparta, including all intellectual property rights. 24 | 25 | 2.2 **Open Source**: Sparta is an open-source project, and its source code is available on [GitHub repository URL]. You are free to view, modify, and distribute the source code under the terms of the GNU General Public License 3.0, which can be found at [GPL 3.0 License URL]. 26 | 27 | 2.3 **User Content**: By using Sparta, you grant Sparta Dev Team a non-exclusive, worldwide, royalty-free license to use, reproduce, and distribute any content you provide through Sparta. 28 | 29 | ## 3. Limitation of Liability 30 | 31 | 3.1 **Disclaimer**: Sparta is provided "as is" without any warranties, express or implied. Sparta Dev Team shall not be liable for any damages arising out of the use or inability to use Sparta. 32 | 33 | ## 4. Termination 34 | 35 | 4.1 **Termination**: We reserve the right to terminate or suspend your access to Sparta at our sole discretion, without notice or liability, for any reason. 36 | 37 | ## 5. Changes to Terms 38 | 39 | 5.1 **Modification**: We may revise these Terms at any time by updating this page. By using Sparta, you agree to be bound by the current version of these Terms. 40 | 41 | ## 6. Contact 42 | 43 | 6.1 **Contact Information**: If you have any questions about these Terms or the open-source nature of Sparta, please contact us on Discord through the [Sparta Support Server](https://discord.gg/RrVY4bP). 44 | 45 | 6.2 **Repositories**: All open-source respositories maintained by us are visible [here](https://github.com/SpartaDevTeam). 46 | 47 | By using Sparta, you acknowledge that you have read, understood, and agree to these Terms of Service. 48 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to bot/db/alembic/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" below. 39 | # version_locations = %(here)s/bar:%(here)s/bat:bot/db/alembic/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 43 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 44 | # Valid values for version_path_separator are: 45 | # 46 | # version_path_separator = : 47 | # version_path_separator = ; 48 | # version_path_separator = space 49 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 50 | 51 | # the output encoding used when revision files 52 | # are written from script.py.mako 53 | # output_encoding = utf-8 54 | 55 | sqlalchemy.url = driver://user:pass@localhost/dbname 56 | 57 | 58 | [post_write_hooks] 59 | # post_write_hooks defines scripts or Python functions that are run 60 | # on newly generated revision scripts. See the documentation for further 61 | # detail and examples 62 | 63 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 64 | # hooks = black 65 | # black.type = console_scripts 66 | # black.entrypoint = black 67 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 68 | 69 | # Logging configuration 70 | [loggers] 71 | keys = root,sqlalchemy,alembic 72 | 73 | [handlers] 74 | keys = console 75 | 76 | [formatters] 77 | keys = generic 78 | 79 | [logger_root] 80 | level = WARN 81 | handlers = console 82 | qualname = 83 | 84 | [logger_sqlalchemy] 85 | level = WARN 86 | handlers = 87 | qualname = sqlalchemy.engine 88 | 89 | [logger_alembic] 90 | level = INFO 91 | handlers = 92 | qualname = alembic 93 | 94 | [handler_console] 95 | class = StreamHandler 96 | args = (sys.stderr,) 97 | level = NOTSET 98 | formatter = generic 99 | 100 | [formatter_generic] 101 | format = %(levelname)-5.5s [%(name)s] %(message)s 102 | datefmt = %H:%M:%S 103 | -------------------------------------------------------------------------------- /bot/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import sys 4 | import time 5 | 6 | import discord 7 | import topgg 8 | from discord.ext import commands, pages 9 | from discord.ext.prettyhelp import PrettyHelp 10 | 11 | from bot import db 12 | from bot.db import models 13 | from bot.errors import DBLVoteRequired 14 | 15 | THEME = discord.Color.purple() 16 | 17 | TESTING_GUILDS = ( 18 | list(map(int, os.getenv("TESTING_GUILDS").split(","))) 19 | if "--debug" in sys.argv and "TESTING_GUILDS" in os.environ 20 | else None 21 | ) 22 | HELP_EMBEDS: list[discord.Embed] = [] 23 | 24 | intents = discord.Intents.default() 25 | intents.members = True 26 | intents.reactions = True 27 | intents.message_content = True 28 | 29 | 30 | class MyBot(commands.Bot): 31 | def __init__(self, *args, **kwargs): 32 | super().__init__(*args, **kwargs) 33 | self.topgg_client = topgg.DBLClient( 34 | bot=self, token=os.environ["DBL_TOKEN"], autopost=True 35 | ) 36 | 37 | async def on_ready(self): 38 | guild_count = len(self.guilds) 39 | print(f"Bot logged into {guild_count} guilds...") 40 | 41 | 42 | async def get_prefix( 43 | client: commands.Bot, message: discord.Message 44 | ) -> list[str]: 45 | if not message.guild: 46 | return commands.when_mentioned_or("s!")(client, message) 47 | 48 | async with db.async_session() as session: 49 | guild_data: models.Guild | None = await session.get( 50 | models.Guild, message.guild.id 51 | ) 52 | 53 | if guild_data: 54 | prefix = guild_data.prefix 55 | else: 56 | new_guild_data = models.Guild(id=message.guild.id) 57 | session.add(new_guild_data) 58 | await session.commit() 59 | 60 | prefix = new_guild_data.prefix 61 | 62 | return commands.when_mentioned_or(prefix)(client, message) 63 | 64 | 65 | help_cmd = PrettyHelp( 66 | color=THEME, 67 | verify_checks=False, 68 | command_attrs={"hidden": True}, 69 | ending_note=( 70 | "Please use the new slash commands (/help), use of prefix commands " 71 | "(s!help) is discouraged. Type {ctx.clean_prefix}{help.invoked_with} " 72 | "command for more info on a command." 73 | ), 74 | ) 75 | bot = MyBot( 76 | command_prefix=get_prefix, 77 | description=( 78 | "I'm a cool moderation and automation bot to help " 79 | "you manage your server better..." 80 | ), 81 | intents=intents, 82 | case_insensitive=True, 83 | help_command=help_cmd, 84 | ) 85 | 86 | 87 | @bot.event 88 | async def on_command_error(ctx: commands.Context, exception): 89 | prefix = await get_prefix(bot, ctx.message) 90 | 91 | if isinstance(exception, commands.MissingRequiredArgument): 92 | await ctx.send( 93 | f"`{exception.param.name}` is a required input, try using " 94 | f"`{prefix[2]}help {ctx.invoked_with}` for more information" 95 | ) 96 | 97 | elif isinstance(exception, commands.MissingPermissions): 98 | msg = "You don't have permission to run this command. You need the following permissions:" 99 | 100 | for missing_perm in exception.missing_permissions: 101 | perm_str = missing_perm.title().replace("_", " ") 102 | msg += f"\n{perm_str}" 103 | 104 | await ctx.send(msg) 105 | 106 | elif isinstance(exception, commands.BotMissingPermissions): 107 | msg = "I don't have permission to run this command. I will need the following permissions:" 108 | 109 | for missing_perm in exception.missing_permissions: 110 | perm_str = missing_perm.title().replace("_", " ") 111 | msg += f"\n{perm_str}" 112 | 113 | await ctx.send(msg) 114 | 115 | elif isinstance(exception, commands.CommandOnCooldown): 116 | now_epoch = time.time() 117 | try_after = f"" 118 | await ctx.send( 119 | f"This commands is on cooldown, try again {try_after}..." 120 | ) 121 | 122 | elif isinstance(exception, commands.NotOwner): 123 | await ctx.send("You must be the bot owner to use this command") 124 | 125 | elif isinstance(exception, commands.CommandNotFound): 126 | pass 127 | 128 | elif isinstance(exception, commands.CommandInvokeError): 129 | await ctx.send( 130 | f"An error occured while running that command:\n```{exception}```" 131 | ) 132 | raise exception 133 | 134 | elif isinstance(exception, commands.NSFWChannelRequired): 135 | await ctx.send( 136 | "Please enable NSFW on this channel to use this command" 137 | ) 138 | 139 | elif isinstance(exception, DBLVoteRequired): 140 | await ctx.send( 141 | f"Please vote for me on Top.gg to use this command. Try using `{prefix[2]}vote` for voting links." 142 | ) 143 | 144 | elif isinstance(exception, commands.CheckFailure): 145 | pass 146 | 147 | else: 148 | raise exception 149 | 150 | 151 | @bot.event 152 | async def on_application_command_error( 153 | ctx: discord.ApplicationContext, exception 154 | ): 155 | if isinstance(exception, commands.MissingPermissions): 156 | msg = "You don't have permission to run this command. You need the following permissions:" 157 | 158 | for missing_perm in exception.missing_permissions: 159 | perm_str = missing_perm.title().replace("_", " ") 160 | msg += f"\n{perm_str}" 161 | 162 | await ctx.respond(msg, ephemeral=True) 163 | 164 | elif isinstance(exception, commands.BotMissingPermissions): 165 | msg = "I don't have permission to run this command. I need the following permissions:" 166 | 167 | for missing_perm in exception.missing_permissions: 168 | perm_str = missing_perm.title().replace("_", " ") 169 | msg += f"\n{perm_str}" 170 | 171 | await ctx.respond(msg, ephemeral=True) 172 | 173 | elif isinstance(exception, commands.CommandOnCooldown): 174 | now_epoch = time.time() 175 | try_after = f"" 176 | await ctx.respond( 177 | f"This commands is on cooldown, try again {try_after}...", 178 | ephemeral=True, 179 | ) 180 | 181 | elif isinstance(exception, commands.NotOwner): 182 | await ctx.respond("You must be the bot owner to use this command") 183 | 184 | elif isinstance(exception, commands.CommandInvokeError): 185 | await ctx.respond( 186 | f"An error occured while running that command:\n```{exception}```", 187 | ephemeral=True, 188 | ) 189 | raise exception 190 | 191 | elif isinstance(exception, commands.NSFWChannelRequired): 192 | await ctx.respond( 193 | "Please enable NSFW on this channel to use this command", 194 | ephemeral=True, 195 | ) 196 | 197 | elif isinstance(exception, DBLVoteRequired): 198 | await ctx.respond( 199 | "Please vote for me on Top.gg to use this command. Try using `/vote` for voting links.", 200 | ephemeral=True, 201 | ) 202 | 203 | else: 204 | raise exception 205 | 206 | 207 | @bot.event 208 | async def on_message(message: discord.Message): 209 | if ( 210 | message.content == f"<@!{bot.user.id}>" 211 | and message.author != bot.user 212 | and not message.reference 213 | and not message.author.bot 214 | ): 215 | prefixes = get_prefix(bot, message) 216 | prefixes.remove(f"{bot.user.mention} ") 217 | prefixes.remove(f"<@!{bot.user.id}> ") 218 | 219 | prefix_count = len(prefixes) 220 | prefixes_string = ", ".join(prefixes) 221 | 222 | if prefix_count == 1: 223 | await message.channel.send( 224 | f"{message.author.mention}, my prefix in this server " 225 | f"is `{prefixes_string}`" 226 | ) 227 | else: 228 | await message.channel.send( 229 | f"{message.author.mention}, my prefixes in this server " 230 | f"are `{prefixes_string}`" 231 | ) 232 | 233 | await bot.process_commands(message) 234 | 235 | 236 | @bot.slash_command(guild_ids=TESTING_GUILDS) 237 | async def help(ctx: discord.ApplicationContext, command: str = None): 238 | """ 239 | Get a list of commands or more information about a specific command 240 | """ 241 | 242 | if command: 243 | cmd_info: discord.SlashCommand = bot.get_application_command(command) 244 | 245 | if not cmd_info: 246 | await ctx.respond(f"Command not found: `{command}`") 247 | return 248 | 249 | cmd_name = cmd_info.qualified_name 250 | formatted_options = [] 251 | 252 | for option in cmd_info.options: 253 | if option.required: 254 | formatted_options.append(f"<{option.name}>") 255 | elif option.default is None: 256 | formatted_options.append(f"[{option.name}]") 257 | else: 258 | formatted_options.append(f"[{option.name}={option.default}]") 259 | 260 | options_str = " ".join(formatted_options) 261 | 262 | help_embed = discord.Embed( 263 | title=f"/{cmd_name}", color=THEME, description=cmd_info.description 264 | ) 265 | help_embed.set_footer( 266 | text=( 267 | "Options wrapped in <> are required\n" 268 | "Options wrapped in [] are optional" 269 | ) 270 | ) 271 | 272 | help_embed.add_field( 273 | name="Usage", 274 | value=f"```/{cmd_name} {options_str}```", 275 | inline=False, 276 | ) 277 | 278 | await ctx.respond(embed=help_embed) 279 | 280 | else: 281 | paginator = pages.Paginator(HELP_EMBEDS) # type: ignore 282 | await paginator.respond(ctx.interaction) 283 | 284 | 285 | def add_cogs(): 286 | # Prefix Command Cogs 287 | cogs_dir = os.path.join( 288 | os.path.dirname(os.path.realpath(__file__)), "cogs" 289 | ) 290 | for filename in os.listdir(cogs_dir): 291 | if filename.endswith(".py") and filename != "__init__.py": 292 | bot.load_extension(f"bot.cogs.{filename[:-3]}") 293 | print(f"Loaded {filename[:-3]} prefix cog!") 294 | 295 | # Slash Command Cogs 296 | # TODO: Remove "Slash" from cog names 297 | slash_cogs_dir = os.path.join( 298 | os.path.dirname(os.path.realpath(__file__)), "slash_cogs" 299 | ) 300 | for filename in os.listdir(slash_cogs_dir): 301 | if filename.endswith(".py") and filename != "__init__.py": 302 | bot.load_extension(f"bot.slash_cogs.{filename[:-3]}") 303 | print(f"Loaded {filename[:-3]} slash cog!") 304 | 305 | # Extensions 306 | bot.load_extension("jishaku") 307 | 308 | 309 | def generate_help_embeds(): 310 | index_embed = discord.Embed( 311 | title="Index", color=THEME, description=bot.description 312 | ) 313 | index_embed.set_footer( 314 | text="You can use /help command to get more information about a command" 315 | ) 316 | 317 | cog_embeds = [] 318 | 319 | for cog_name, cog in list(bot.cogs.items()): 320 | # TODO: Remove conditional and replace call when renaming slash command cogs 321 | 322 | if not cog_name.startswith("Slash"): 323 | continue 324 | 325 | cog_name = cog_name.replace("Slash", "") 326 | 327 | embed = discord.Embed( 328 | title=cog_name, color=THEME, description=cog.description 329 | ) 330 | 331 | for cmd_info in cog.walk_commands(): 332 | embed.add_field( 333 | name=f"/{cmd_info.qualified_name}", 334 | value=cmd_info.description, 335 | ) 336 | 337 | if embed.fields: 338 | index_embed.add_field(name=cog_name, value=cog.description) 339 | cog_embeds.append(embed) 340 | 341 | HELP_EMBEDS.append(index_embed) 342 | HELP_EMBEDS.extend(cog_embeds) 343 | print("Generated Help Embeds!") 344 | 345 | 346 | def main(): 347 | loop = asyncio.get_event_loop() 348 | token = os.environ["TOKEN"] 349 | 350 | try: 351 | db.init_engine() 352 | add_cogs() 353 | generate_help_embeds() 354 | loop.run_until_complete(bot.start(token)) 355 | except KeyboardInterrupt or SystemExit: 356 | pass 357 | finally: 358 | print("Exiting...") 359 | loop.run_until_complete(bot.close()) 360 | loop.run_until_complete(db.close_db()) 361 | -------------------------------------------------------------------------------- /bot/assets/earthorbiterxtrabold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpartaDevTeam/SpartaBot/c8c75dc2cedfee4ab74ada8cfc787b5f7a6b0549/bot/assets/earthorbiterxtrabold.ttf -------------------------------------------------------------------------------- /bot/assets/montserrat_extrabold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpartaDevTeam/SpartaBot/c8c75dc2cedfee4ab74ada8cfc787b5f7a6b0549/bot/assets/montserrat_extrabold.otf -------------------------------------------------------------------------------- /bot/assets/welcome_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpartaDevTeam/SpartaBot/c8c75dc2cedfee4ab74ada8cfc787b5f7a6b0549/bot/assets/welcome_image.jpg -------------------------------------------------------------------------------- /bot/cogs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpartaDevTeam/SpartaBot/c8c75dc2cedfee4ab74ada8cfc787b5f7a6b0549/bot/cogs/__init__.py -------------------------------------------------------------------------------- /bot/cogs/auto_resp.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | from uuid import uuid4 4 | from discord.ext import commands 5 | from sqlalchemy.future import select 6 | 7 | from bot import MyBot, db 8 | from bot.db import models 9 | from bot.utils import dbl_vote_required 10 | 11 | 12 | class AutoResponse(commands.Cog): 13 | def __init__(self, bot): 14 | self.bot: MyBot = bot 15 | self.description = "Commands to setup Sparta Bot to automatically reply to certain phrases" 16 | self.theme_color = discord.Color.purple() 17 | 18 | @commands.command( 19 | name="addautoresponse", 20 | aliases=["addauto", "aar"], 21 | help="Add an auto response phrase.\n\nExample: addautoresponse activation, response\n\nVariables you can use: [member], [nick], [name]", 22 | ) 23 | @dbl_vote_required() 24 | @commands.has_guild_permissions(administrator=True) 25 | async def add_auto_response(self, ctx: commands.Context, *, options: str): 26 | options_split = options.split(",", maxsplit=1) 27 | 28 | if len(options_split) < 2: 29 | await ctx.send("Please provide all the fields.") 30 | return 31 | 32 | activation = options_split[0].strip() 33 | response = options_split[1].strip() 34 | 35 | async with db.async_session() as session: 36 | q = ( 37 | select(models.AutoResponse) 38 | .where(models.AutoResponse.guild_id == ctx.guild.id) 39 | .where(models.AutoResponse.activation == activation) 40 | ) 41 | result = await session.execute(q) 42 | duplicate_auto_resp: models.AutoResponse | None = result.scalar() 43 | 44 | if duplicate_auto_resp: 45 | 46 | def check_msg(message: discord.Message): 47 | return ( 48 | message.author == ctx.author 49 | and message.channel == ctx.channel 50 | ) 51 | 52 | await ctx.send( 53 | "An auto response with this activation already exists and will be overwritten by the new one. Do you want to continue? (Yes to continue, anything else to abort)" 54 | ) 55 | 56 | try: 57 | confirmation: discord.Message = await self.bot.wait_for( 58 | "message", check=check_msg, timeout=30 59 | ) 60 | 61 | if confirmation.content.lower() == "yes": 62 | await ctx.send("Overwriting existing auto response!") 63 | else: 64 | await ctx.send("Aborting!") 65 | return 66 | 67 | except asyncio.TimeoutError: 68 | await ctx.send("No response received, aborting!") 69 | return 70 | 71 | duplicate_auto_resp.response = response 72 | 73 | else: 74 | new_auto_resp = models.AutoResponse( 75 | id=uuid4().hex, 76 | guild_id=ctx.guild.id, 77 | activation=activation, 78 | response=response, 79 | ) 80 | session.add(new_auto_resp) 81 | 82 | await session.commit() 83 | 84 | await ctx.send( 85 | f"New auto response added with\n\nActivation Phrase:```{activation}```\nResponse:```{response}```" 86 | ) 87 | 88 | @commands.command( 89 | name="removeautoresponse", 90 | aliases=["removeauto", "rar"], 91 | help="Remove an auto response phrase", 92 | ) 93 | @commands.has_guild_permissions(administrator=True) 94 | async def remove_auto_response( 95 | self, ctx: commands.Context, id: str = None 96 | ): 97 | if id: 98 | async with db.async_session() as session: 99 | auto_resp: models.AutoResponse | None = await session.get( 100 | models.AutoResponse, id 101 | ) 102 | 103 | if not auto_resp: 104 | await ctx.send( 105 | "An auto response with this ID does not exist" 106 | ) 107 | return 108 | 109 | await session.delete(auto_resp) 110 | await session.commit() 111 | 112 | await ctx.send( 113 | f"Auto response with\nactivation: `{auto_resp.activation}`\nresponse: `{auto_resp.response}`\nhas been removed" 114 | ) 115 | 116 | else: 117 | 118 | def check_msg(message: discord.Message): 119 | return ( 120 | message.author == ctx.author 121 | and message.channel == ctx.channel 122 | ) 123 | 124 | await ctx.send( 125 | "You are about to delete all auto responses in this server. Do you want to continue? (Yes to continue, anything else to abort)" 126 | ) 127 | 128 | try: 129 | confirmation: discord.Message = await self.bot.wait_for( 130 | "message", check=check_msg, timeout=30 131 | ) 132 | 133 | if confirmation.content.lower() == "yes": 134 | async with db.async_session() as session: 135 | q = select(models.AutoResponse).where( 136 | models.AutoResponse.guild_id == ctx.guild.id 137 | ) 138 | results = await session.execute(q) 139 | tasks = [session.delete(r) for r in results.scalars()] 140 | await asyncio.gather(*tasks) 141 | await session.commit() 142 | 143 | await ctx.send( 144 | "All auto responses in this server have been deleted" 145 | ) 146 | else: 147 | await ctx.send("Aborting!") 148 | 149 | except asyncio.TimeoutError: 150 | await ctx.send("No response received, aborting!") 151 | 152 | @commands.command( 153 | name="viewautoresponses", 154 | aliases=["viewauto", "var"], 155 | help="See all the auto responses in your server", 156 | ) 157 | async def view_auto_responses(self, ctx: commands.Context): 158 | async with db.async_session() as session: 159 | q = select(models.AutoResponse).where( 160 | models.AutoResponse.guild_id == ctx.guild.id 161 | ) 162 | result = await session.execute(q) 163 | auto_resps: list[models.AutoResponse] = result.scalars().all() 164 | 165 | if len(auto_resps) > 0: 166 | auto_resps_embed = discord.Embed( 167 | title=f"Auto Responses in {ctx.guild}", color=self.theme_color 168 | ) 169 | 170 | for ar in auto_resps: 171 | field_value = ( 172 | f"Activation: `{ar.activation}`\nResponse: `{ar.response}`" 173 | ) 174 | auto_resps_embed.add_field( 175 | name=ar.id, value=field_value, inline=False 176 | ) 177 | 178 | await ctx.send(embed=auto_resps_embed) 179 | 180 | else: 181 | await ctx.send("This server does not have any auto responses") 182 | 183 | 184 | def setup(bot): 185 | bot.add_cog(AutoResponse(bot)) 186 | -------------------------------------------------------------------------------- /bot/cogs/automod.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | from bot import MyBot, db 5 | from bot.db import models 6 | 7 | 8 | class AutoMod(commands.Cog): 9 | def __init__(self, bot): 10 | self.bot: MyBot = bot 11 | self.description = "Commands to setup Auto-Mod in Sparta" 12 | self.theme_color = discord.Color.purple() 13 | 14 | @commands.command( 15 | name="automod", help="Allows you to enable/disable automod features" 16 | ) 17 | @commands.has_guild_permissions(administrator=True) 18 | async def automod(self, ctx: commands.Context): 19 | def check(message: discord.Message): 20 | return ( 21 | message.channel == ctx.channel 22 | and message.author == ctx.message.author 23 | ) 24 | 25 | async with db.async_session() as session: 26 | auto_mod_data = await session.get(models.AutoMod, ctx.guild.id) 27 | 28 | if not auto_mod_data: 29 | auto_mod_data = models.AutoMod(guild_id=ctx.guild.id) 30 | session.add(auto_mod_data) 31 | 32 | features = { 33 | attr: getattr(auto_mod_data, attr, False) 34 | for attr in dir(auto_mod_data) 35 | if not ( 36 | attr.startswith("_") 37 | or attr.endswith("_") 38 | or attr in ["guild_id", "registry", "metadata"] 39 | ) 40 | } 41 | 42 | async def save(): 43 | for feature, value in list(features.items()): 44 | setattr(auto_mod_data, feature, value) 45 | 46 | await session.commit() 47 | 48 | mod_embed = discord.Embed( 49 | title="Auto Mod", 50 | description=( 51 | "Allow Sparta to administrate on its own. " 52 | "Reply with a particular feature." 53 | ), 54 | color=self.theme_color, 55 | ) 56 | mod_embed.set_footer( 57 | text=( 58 | "Reply with stop if you want to stop " 59 | "adding auto-mod features and save your changes" 60 | ) 61 | ) 62 | mod_embed.add_field( 63 | name="Options", 64 | value="\n".join( 65 | [f"{i + 1}) `{f}`" for i, f in enumerate(features)] 66 | ), 67 | ) 68 | 69 | await ctx.send(embed=mod_embed) 70 | 71 | while True: 72 | msg = await self.bot.wait_for("message", check=check) 73 | msg = str(msg.content).lower() 74 | 75 | if msg in features: 76 | if features[msg]: 77 | await ctx.send(f"Removed `{msg}`!") 78 | features[msg] = False 79 | else: 80 | await ctx.send(f"Added `{msg}`!") 81 | features[msg] = True 82 | 83 | elif msg == "stop": 84 | await save() 85 | await ctx.send("The changes have been saved!") 86 | break 87 | 88 | 89 | def setup(bot): 90 | bot.add_cog(AutoMod(bot)) 91 | -------------------------------------------------------------------------------- /bot/cogs/fun.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | import string 4 | from uuid import uuid4 5 | 6 | import discord 7 | import pyfiglet 8 | from discord.ext import commands 9 | 10 | from bot import MyBot, db 11 | from bot.db import models 12 | 13 | 14 | class Fun(commands.Cog): 15 | def __init__(self, bot): 16 | self.bot: MyBot = bot 17 | self.description = ( 18 | "Commands to have some fun and relieve stress (or induce it)" 19 | ) 20 | self.theme_color = discord.Color.purple() 21 | self.eight_ball_responses = [ 22 | [ 23 | "No.", 24 | "Nope.", 25 | "Highly Doubtful.", 26 | "Not a chance.", 27 | "Not possible.", 28 | "Don't count on it.", 29 | ], 30 | [ 31 | "Yes.", 32 | "Yup", 33 | "Extremely Likely", 34 | "It is possible", 35 | "Very possibly.", 36 | ], 37 | ["I'm not sure", "Maybe get a second opinion", "Maybe"], 38 | ] 39 | 40 | self.emojify_symbols = { 41 | "0": ":zero:", 42 | "1": ":one:", 43 | "2": ":two:", 44 | "3": ":three:", 45 | "4": ":four:", 46 | "5": ":five:", 47 | "6": ":six:", 48 | "7": ":seven:", 49 | "8": ":eight:", 50 | "9": ":nine:", 51 | "!": ":exclamation:", 52 | "#": ":hash:", 53 | "?": ":question:", 54 | "*": ":asterisk:", 55 | } 56 | 57 | self.emoji_numbers = { 58 | 1: "1️⃣", 59 | 2: "2️⃣", 60 | 3: "3️⃣", 61 | 4: "4️⃣", 62 | 5: "5️⃣", 63 | 6: "6️⃣", 64 | 7: "7️⃣", 65 | 8: "8️⃣", 66 | 9: "9️⃣", 67 | } 68 | 69 | @commands.command(name="poll", brief="Makes a poll!") 70 | async def make_poll( 71 | self, ctx: commands.Context, length: float, *, poll: str 72 | ): 73 | """ 74 | Usage: poll time description | option 1 | option 2 75 | The time must be in minutes 76 | Example: poll 30 Cats or Dogs? | Dogs | Cats 77 | """ 78 | 79 | split = poll.split("|") 80 | description = split.pop(0) 81 | 82 | # Limits are in minutes 83 | lower_limit = 1 84 | upper_limit = 4320 # 72 hours 85 | 86 | if length < lower_limit: 87 | await ctx.send( 88 | f"The poll must last at least {lower_limit} minute." 89 | ) 90 | return 91 | if length > upper_limit: 92 | await ctx.send( 93 | f"The poll must last less than {upper_limit} minutes." 94 | ) 95 | return 96 | if len(split) > 9: 97 | await ctx.send("You can only have up to 9 options.") 98 | return 99 | 100 | options = [ 101 | f"{self.emoji_numbers[i+1]} {t}\n" for i, t in enumerate(split) 102 | ] 103 | 104 | embed = discord.Embed( 105 | title=description, 106 | description=("".join(options)), 107 | color=self.theme_color, 108 | ).set_author(name=str(ctx.author), icon_url=ctx.author.avatar.url) 109 | m: discord.Message = await ctx.send(embed=embed) 110 | 111 | for i in range(len(options)): 112 | await m.add_reaction(self.emoji_numbers[i + 1]) 113 | 114 | wait_time_seconds = length * 60 115 | await asyncio.sleep(wait_time_seconds) 116 | 117 | m = await ctx.channel.fetch_message(m.id) 118 | 119 | results = [] 120 | 121 | for r in m.reactions: 122 | results.append((r.emoji, r.count)) 123 | 124 | results.sort(key=lambda t: t[1], reverse=True) 125 | 126 | embed = embed.add_field(name="Result", value=results[0][0]) 127 | await m.edit(embed=embed) 128 | 129 | @commands.command( 130 | name="coinflip", aliases=["coin", "flip"], help="Flip a coin!" 131 | ) 132 | async def coin_flip(self, ctx): 133 | result = random.choice(["heads", "tails"]) 134 | await ctx.send( 135 | f"The coin has been flipped and resulted in **{result}**" 136 | ) 137 | 138 | @commands.command(name="roll", aliases=["dice"], help="Roll a dice!") 139 | async def dice_roll(self, ctx: commands.Context, dice_count: int = 1): 140 | number = random.randint(dice_count, dice_count * 6) 141 | 142 | if dice_count > 1: 143 | await ctx.send( 144 | f"You rolled **{dice_count} dice** and got a **{number}**" 145 | ) 146 | else: 147 | await ctx.send(f"You rolled a **{number}**") 148 | 149 | @commands.command( 150 | name="avatar", 151 | aliases=["av", "pfp"], 152 | help="Get somebody's Discord avatar", 153 | ) 154 | async def avatar( 155 | self, ctx: commands.Context, member: discord.Member = None 156 | ): 157 | if member: 158 | m = member 159 | else: 160 | m = ctx.author 161 | 162 | av_embed = discord.Embed(title=f"{m}'s Avatar", color=self.theme_color) 163 | av_embed.set_image(url=m.avatar.url) 164 | await ctx.send(embed=av_embed) 165 | 166 | @commands.command( 167 | name="choose", 168 | aliases=["choice"], 169 | help="Let Sparta choose the best option for you. Separate the choices with a comma (,)", 170 | ) 171 | async def choose(self, ctx: commands.Context, *, options: str): 172 | items = [ 173 | option.strip().replace("*", "") for option in options.split(",") 174 | ] 175 | choice = random.choice(items) 176 | await ctx.send( 177 | f"I choose **{choice}**", 178 | allowed_mentions=discord.AllowedMentions.none(), 179 | ) 180 | 181 | @commands.command( 182 | name="8ball", 183 | aliases=["8"], 184 | help="Call upon the powers of the all knowing magic 8Ball", 185 | ) 186 | async def eight_ball(self, ctx: commands.Context, question: str): 187 | group = random.choice(self.eight_ball_responses) 188 | response = random.choice(group) 189 | 190 | await ctx.send(response) 191 | 192 | @commands.command( 193 | name="emojify", aliases=["emoji"], help="Turn a sentence into emojis" 194 | ) 195 | async def emojify(self, ctx: commands.Context, *, sentence: str): 196 | emojified_sentence = "" 197 | sentence = sentence.lower() 198 | 199 | for char in sentence: 200 | char_lower = char.lower() 201 | 202 | if char_lower in string.ascii_lowercase: 203 | emojified_sentence += f":regional_indicator_{char}:" 204 | elif char_lower in self.emojify_symbols: 205 | emojified_sentence += self.emojify_symbols[char_lower] 206 | else: 207 | emojified_sentence += char 208 | 209 | await ctx.send(emojified_sentence) 210 | 211 | @commands.command(name="ascii", help="Turn a sentence into cool ASCII art") 212 | async def ascii(self, ctx: commands.Context, *, sentence: str): 213 | ascii_text = pyfiglet.figlet_format(sentence) 214 | await ctx.send(f"```{ascii_text}```") 215 | 216 | @commands.command( 217 | name="impersonate", 218 | aliases=["imp"], 219 | help="Pretend to be another member of your server", 220 | ) 221 | async def impersonate( 222 | self, ctx: commands.Context, member: discord.Member, *, message: str 223 | ): 224 | async with db.async_session() as session: 225 | webhook_data: models.Webhook = await session.get( 226 | models.Webhook, ctx.channel.id 227 | ) 228 | 229 | if webhook_data: 230 | webhook = discord.utils.get( 231 | await ctx.channel.webhooks(), url=webhook_data.webhook_url 232 | ) 233 | 234 | if not webhook: 235 | webhook: discord.Webhook = ( 236 | await ctx.channel.create_webhook( 237 | name="Sparta Impersonate Command", 238 | reason="Impersonation Command", 239 | ) 240 | ) 241 | webhook_data.webhook_url = webhook.url 242 | await session.commit() 243 | 244 | else: 245 | webhook: discord.Webhook = await ctx.channel.create_webhook( 246 | name="Sparta Impersonate Command", 247 | reason="Impersonation Command", 248 | ) 249 | new_webhook_data = models.Webhook( 250 | channel_id=ctx.channel.id, webhook_url=webhook.url 251 | ) 252 | session.add(new_webhook_data) 253 | await session.commit() 254 | 255 | await ctx.message.delete() 256 | msg = await webhook.send( 257 | message, 258 | username=member.display_name, 259 | avatar_url=member.display_avatar.url, 260 | allowed_mentions=discord.AllowedMentions.none(), 261 | wait=True, 262 | ) 263 | 264 | async with db.async_session() as session: 265 | new_imp_log = models.ImpersonationLog( 266 | id=uuid4().hex, 267 | guild_id=ctx.guild.id, 268 | channel_id=ctx.channel.id, 269 | message_id=msg.id, 270 | user_id=member.id, 271 | impersonator_id=ctx.author.id, 272 | message=msg.content, 273 | ) 274 | session.add(new_imp_log) 275 | await session.commit() 276 | 277 | 278 | def setup(bot): 279 | bot.add_cog(Fun(bot)) 280 | -------------------------------------------------------------------------------- /bot/cogs/internet.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib.request 3 | import re 4 | import urbanpython 5 | import discord 6 | from discord.ext import commands 7 | 8 | from bot import MyBot 9 | 10 | 11 | class InternetStuff(commands.Cog): 12 | def __init__(self, bot): 13 | self.bot: MyBot = bot 14 | self.theme_color = discord.Color.purple() 15 | self.description = ( 16 | "Commands to surf the interwebs without leaving Discord" 17 | ) 18 | self.urban = urbanpython.Urban(os.environ["URBAN_API_KEY"]) 19 | self.yt_search_url = "https://www.youtube.com/results?search_query=" 20 | self.yt_video_url = "https://www.youtube.com/watch?v=" 21 | 22 | @commands.command( 23 | name="urban", help="Find word definitions on Urban Dictionary" 24 | ) 25 | @commands.is_nsfw() 26 | async def urban_dictionary(self, ctx: commands.Context, *, query: str): 27 | with ctx.typing(): 28 | try: 29 | result = self.urban.search(query) 30 | except IndexError: 31 | await ctx.send( 32 | f"No definition found for term: {query}", 33 | allowed_mentions=discord.AllowedMentions.none(), 34 | ) 35 | return 36 | 37 | body = f"**Definition:**\n{result.definition}\n\n**Example:\n**{result.example}" 38 | written_on = result.written_on[:10] 39 | 40 | urban_embed = discord.Embed( 41 | title=f"{query} Urban Definition", 42 | color=self.theme_color, 43 | description=body, 44 | url=result.permalink, 45 | ) 46 | urban_embed.set_footer( 47 | text=f"Written by {result.author} on {written_on}\n👍 {result.thumbs_up} | 👎 {result.thumbs_down}" 48 | ) 49 | 50 | if len(urban_embed.description) > 2048: 51 | urban_embed.description = urban_embed.description[:2048] 52 | await ctx.send( 53 | "This definition is too big, so some of the contents were hidden", 54 | embed=urban_embed, 55 | ) 56 | else: 57 | await ctx.send(embed=urban_embed) 58 | 59 | @commands.command( 60 | name="youtube", aliases=["yt"], help="Search YouTube for videos" 61 | ) 62 | async def youtube(self, ctx: commands.Context, *, query: str): 63 | formatted_query = "+".join(query.split()) 64 | html = urllib.request.urlopen(self.yt_search_url + formatted_query) 65 | video_ids = re.findall(r"watch\?v=(\S{11})", html.read().decode()) 66 | first_result = self.yt_video_url + video_ids[0] 67 | await ctx.send(first_result) 68 | 69 | 70 | def setup(bot): 71 | bot.add_cog(InternetStuff(bot)) 72 | -------------------------------------------------------------------------------- /bot/cogs/misc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import uuid 3 | import discord 4 | from datetime import datetime 5 | from discord.ext import commands 6 | 7 | from bot import MyBot, db 8 | from bot.db import models 9 | from bot.utils import str_time_to_timedelta 10 | 11 | 12 | class Miscellaneous(commands.Cog): 13 | def __init__(self, bot): 14 | self.bot: MyBot = bot 15 | self.description = "Some commands to do general tasks" 16 | self.theme_color = discord.Color.purple() 17 | self.launched_at = int(datetime.now().timestamp()) 18 | self.reminders_loaded = False 19 | self.suggestion_channel = 848474796856836117 20 | 21 | # async def load_pending_reminders(self): 22 | # print("Loading pending reminders...") 23 | 24 | # Data.c.execute("SELECT * FROM reminders") 25 | # reminders = Data.c.fetchall() 26 | 27 | # for rem in reminders: 28 | # reminder_id = rem[0] 29 | # user = await self.bot.fetch_user(rem[1]) 30 | # reminder_msg = rem[2] 31 | # started_at = datetime.strptime(rem[3], Data.datetime_format) 32 | 33 | # now = datetime.now() 34 | # due_at = datetime.strptime(rem[4], Data.datetime_format) 35 | 36 | # asyncio.create_task( 37 | # self.reminder( 38 | # reminder_id, 39 | # user, 40 | # (due_at - now).total_seconds(), 41 | # reminder_msg, 42 | # started_at, 43 | # ) 44 | # ) 45 | 46 | # self.reminders_loaded = True 47 | # print(f"Loaded {len(reminders)} pending reminders!") 48 | 49 | async def reminder( 50 | self, 51 | reminder_id: str, 52 | user: discord.User, 53 | seconds: float, 54 | reminder_msg: str, 55 | reminder_start_time: datetime, 56 | ): 57 | await asyncio.sleep(seconds) 58 | rem_start_time_str = f"" 59 | try: 60 | await user.send( 61 | f"You asked me to remind you {rem_start_time_str} about:" 62 | f"\n*{reminder_msg}*", 63 | allowed_mentions=discord.AllowedMentions.none(), 64 | ) 65 | except discord.Forbidden: 66 | pass 67 | 68 | async with db.async_session() as session: 69 | rem_data = await session.get(models.Reminder, reminder_id) 70 | await session.delete(rem_data) 71 | await session.commit() 72 | 73 | @commands.Cog.listener() 74 | async def on_ready(self): 75 | self.reminders_loaded = True # TODO: Remove this line 76 | # await self.load_pending_reminders() 77 | 78 | @commands.command(name="info", help="Display bot information") 79 | async def info(self, ctx: commands.Context): 80 | ping = int(self.bot.latency * 1000) 81 | guild_count = str(len(self.bot.guilds)) 82 | total_member_count = 0 83 | 84 | for guild in self.bot.guilds: 85 | total_member_count += guild.member_count 86 | 87 | info_embed = discord.Embed( 88 | title="Sparta Bot Information", color=self.theme_color 89 | ) 90 | info_embed.set_thumbnail(url=self.bot.user.avatar.url) 91 | 92 | info_embed.add_field( 93 | name="Latency/Ping", value=f"{ping}ms", inline=False 94 | ) 95 | info_embed.add_field( 96 | name="Server Count", value=guild_count, inline=False 97 | ) 98 | info_embed.add_field( 99 | name="Total Member Count", 100 | value=str(total_member_count), 101 | inline=False, 102 | ) 103 | 104 | await ctx.send(embed=info_embed) 105 | 106 | @commands.command(name="invite", help="Invite Sparta to your server") 107 | async def invite(self, ctx: commands.Context): 108 | invite_url = "https://discord.com/api/oauth2/authorize?client_id=731763013417435247&permissions=8&scope=bot%20applications.commands" 109 | beta_invite_url = "https://discord.com/api/oauth2/authorize?client_id=775798822844629013&permissions=8&scope=applications.commands%20bot" 110 | 111 | invite_embed = discord.Embed( 112 | title="Sparta Invite", color=self.theme_color, url=invite_url 113 | ) 114 | beta_invite_embed = discord.Embed( 115 | title="Sparta Beta Invite", 116 | color=self.theme_color, 117 | url=beta_invite_url, 118 | ) 119 | 120 | await ctx.send(embed=invite_embed) 121 | await ctx.send(embed=beta_invite_embed) 122 | 123 | @commands.command(name="github", help="Link to the GitHub Repository") 124 | async def github(self, ctx: commands.Context): 125 | github_link = "https://github.com/SpartaDevTeam/SpartaBot" 126 | await ctx.send(github_link) 127 | 128 | @commands.command( 129 | name="support", help="Invite link for Sparta Support Server" 130 | ) 131 | async def support(self, ctx: commands.Context): 132 | support_link = "https://discord.gg/RrVY4bP" 133 | await ctx.send(support_link) 134 | 135 | @commands.command( 136 | name="vote", help="Get bot list links to vote for Sparta" 137 | ) 138 | async def vote(self, ctx: commands.Context): 139 | top_gg_link = "https://top.gg/bot/731763013417435247" 140 | 141 | vote_embed = discord.Embed( 142 | title="Vote for Sparta Bot", color=self.theme_color 143 | ) 144 | 145 | vote_embed.add_field( 146 | name="Vote every 12 hours", value=f"[Top.gg]({top_gg_link})" 147 | ) 148 | 149 | await ctx.send(embed=vote_embed) 150 | 151 | @commands.command( 152 | name="remind", 153 | aliases=["rem"], 154 | brief="Set a reminder", 155 | help=( 156 | "Set a reminder. Example: 1d 2h 12m 5s, make lunch " 157 | "(Note: all time options are not required)" 158 | ), 159 | ) 160 | async def remind(self, ctx: commands.Context, *, options: str): 161 | # Wait till bot finishes loading all reminders 162 | # Prevents duplicate reminders 163 | if not self.reminders_loaded: 164 | await ctx.send( 165 | "The bot is starting up. Please try again in a few minutes." 166 | ) 167 | return 168 | 169 | args = options.split(",") 170 | if len(args) < 2: 171 | await ctx.send( 172 | "Please separate the time and reminder message with a comma (,)" 173 | ) 174 | return 175 | 176 | remind_time_string = args[0] 177 | reminder_msg = args[1].strip() 178 | 179 | now = datetime.now() 180 | remind_time = str_time_to_timedelta(remind_time_string) 181 | time_to_end = f"" 182 | 183 | await ctx.send( 184 | f"Reminder set for {time_to_end} about:\n*{reminder_msg}*", 185 | allowed_mentions=discord.AllowedMentions.none(), 186 | ) 187 | 188 | reminder_id = uuid.uuid4() 189 | asyncio.create_task( 190 | self.reminder( 191 | reminder_id.hex, 192 | ctx.author, 193 | remind_time.total_seconds(), 194 | reminder_msg, 195 | now, 196 | ) 197 | ) 198 | 199 | new_reminder = models.Reminder( 200 | id=reminder_id.hex, 201 | user_id=ctx.author.id, 202 | message=reminder_msg, 203 | start=now, 204 | due=now + remind_time, 205 | ) 206 | async with db.async_session() as session: 207 | session.add(new_reminder) 208 | await session.commit() 209 | 210 | @commands.command( 211 | name="afk", 212 | help="Lets others know that you are AFK when someone mentions you", 213 | ) 214 | async def afk(self, ctx: commands.Context, *, reason: str): 215 | async with db.async_session() as session: 216 | afk_data: models.AFK | None = await session.get( 217 | models.AFK, ctx.author.id 218 | ) 219 | 220 | if afk_data: 221 | afk_data.message = reason 222 | else: 223 | new_afk_data = models.AFK( 224 | user_id=ctx.author.id, message=reason 225 | ) 226 | session.add(new_afk_data) 227 | 228 | await session.commit() 229 | 230 | await ctx.send( 231 | f"You have been AFK'd for the following reason:\n*{reason}*", 232 | allowed_mentions=discord.AllowedMentions.none(), 233 | ) 234 | 235 | @commands.command(name="unafk", help="Unset your AFK status") 236 | async def unafk(self, ctx: commands.Context): 237 | async with db.async_session() as session: 238 | afk_data: models.AFK | None = await session.get( 239 | models.AFK, ctx.author.id 240 | ) 241 | 242 | if afk_data: 243 | await session.delete(afk_data) 244 | await session.commit() 245 | await ctx.send("You are no longer AFK'd") 246 | else: 247 | await ctx.send("You are not currently AFK'd") 248 | 249 | @commands.command( 250 | name="uptime", help="Check how long the bot has been up for" 251 | ) 252 | async def uptime(self, ctx: commands.Context): 253 | humanized_time = f"" 254 | await ctx.send(f"I was last restarted {humanized_time}") 255 | 256 | @commands.command( 257 | name="suggest", 258 | aliases=["suggestion"], 259 | help="Suggestion a new feature or change to the Devs", 260 | ) 261 | @commands.cooldown(1, 30) 262 | async def suggest(self, ctx: commands.Context, *, suggestion: str): 263 | def check(message: discord.Message): 264 | return ( 265 | ctx.author == message.author and ctx.channel == message.channel 266 | ) 267 | 268 | anonymous = None 269 | try: 270 | while anonymous is None: 271 | await ctx.send( 272 | "Do you want to include your username with the suggestion, so we can contact you if needed?" 273 | ) 274 | anony_confirm: discord.Message = await self.bot.wait_for( 275 | "message", check=check, timeout=30 276 | ) 277 | 278 | if anony_confirm.content.lower() in ["yes", "y"]: 279 | anonymous = False 280 | elif anony_confirm.content.lower() in ["no", "n"]: 281 | anonymous = True 282 | 283 | except asyncio.TimeoutError: 284 | await ctx.send( 285 | "No response received, falling back to anonymous suggestion" 286 | ) 287 | 288 | if anonymous: 289 | suggestion_msg = ( 290 | f"Anonymous user has given a suggestion:\n{suggestion}" 291 | ) 292 | else: 293 | suggestion_msg = ( 294 | f"**{ctx.author}** has given a suggestion:\n{suggestion}" 295 | ) 296 | 297 | await self.bot.get_channel(self.suggestion_channel).send( 298 | suggestion_msg, allowed_mentions=discord.AllowedMentions.none() 299 | ) 300 | 301 | if anonymous: 302 | await ctx.send( 303 | f"Thank you {ctx.author.mention}, your anonymous suggestion has been recorded." 304 | ) 305 | else: 306 | await ctx.send( 307 | f"Thank you {ctx.author.mention}, your suggestion has been recorded." 308 | ) 309 | 310 | 311 | def setup(bot): 312 | bot.add_cog(Miscellaneous(bot)) 313 | -------------------------------------------------------------------------------- /bot/cogs/reaction_roles.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import emoji 3 | import discord 4 | from uuid import uuid4 5 | from discord.ext import commands 6 | from sqlalchemy.future import select 7 | 8 | from bot import MyBot, db 9 | from bot.db import models 10 | from bot.utils import dbl_vote_required 11 | 12 | 13 | class ReactionRoles(commands.Cog): 14 | def __init__(self, bot): 15 | self.bot: MyBot = bot 16 | self.theme_color = discord.Color.purple() 17 | self.description = "Commands to setup reaction roles for members of your server to give themselves roles" 18 | 19 | async def react_prompt( 20 | self, ctx: commands.Context, prompt_msg: str 21 | ) -> discord.Reaction: 22 | message = await ctx.send(prompt_msg) 23 | 24 | def check_reaction(reaction: discord.Reaction, member: discord.Member): 25 | return member == ctx.author and reaction.message == message 26 | 27 | response = await self.bot.wait_for( 28 | "reaction_add", check=check_reaction, timeout=120 29 | ) 30 | return response[0] 31 | 32 | async def msg_prompt( 33 | self, ctx: commands.Context, prompt_msg: str 34 | ) -> discord.Message: 35 | def check_msg(message: discord.Message): 36 | return ( 37 | message.author == ctx.author and message.channel == ctx.channel 38 | ) 39 | 40 | await ctx.send(prompt_msg) 41 | response: discord.Message = await self.bot.wait_for( 42 | "message", check=check_msg, timeout=120 43 | ) 44 | return response 45 | 46 | # @commands.Cog.listener() 47 | # async def on_raw_reaction_add( 48 | # self, payload: discord.RawReactionActionEvent 49 | # ): 50 | # guild: discord.Guild = await self.bot.fetch_guild(payload.guild_id) 51 | # member: discord.Member = await guild.fetch_member(payload.user_id) 52 | 53 | # if member == self.bot.user: 54 | # return 55 | 56 | # Data.c.execute( 57 | # "SELECT channel_id, message_id, emoji, role_id FROM reaction_roles WHERE guild_id = :guild_id", 58 | # {"guild_id": guild.id}, 59 | # ) 60 | # react_roles = Data.c.fetchall() 61 | 62 | # for rr in react_roles: 63 | # rr_channel_id = rr[0] 64 | # rr_message_id = rr[1] 65 | 66 | # try: 67 | # rr_emoji: discord.Emoji = await guild.fetch_emoji(int(rr[2])) 68 | # except ValueError: 69 | # rr_emoji: discord.PartialEmoji = discord.PartialEmoji( 70 | # name=emoji.emojize(rr[2]) 71 | # ) 72 | 73 | # rr_role: discord.Role = guild.get_role(int(rr[3])) 74 | 75 | # if ( 76 | # payload.channel_id == rr_channel_id 77 | # and payload.message_id == rr_message_id 78 | # and payload.emoji.name == rr_emoji.name 79 | # ): 80 | # await member.add_roles(rr_role, reason="Sparta Reaction Role") 81 | # await member.send( 82 | # f"You have been given the **{rr_role}** role in **{guild}**" 83 | # ) 84 | 85 | # @commands.Cog.listener() 86 | # async def on_raw_reaction_remove( 87 | # self, payload: discord.RawReactionActionEvent 88 | # ): 89 | # guild: discord.Guild = await self.bot.fetch_guild(payload.guild_id) 90 | # member: discord.Member = await guild.fetch_member(payload.user_id) 91 | 92 | # if member == self.bot.user: 93 | # return 94 | 95 | # Data.c.execute( 96 | # "SELECT channel_id, message_id, emoji, role_id FROM reaction_roles WHERE guild_id = :guild_id", 97 | # {"guild_id": guild.id}, 98 | # ) 99 | # react_roles = Data.c.fetchall() 100 | 101 | # for rr in react_roles: 102 | # rr_channel_id = rr[0] 103 | # rr_message_id = rr[1] 104 | 105 | # try: 106 | # rr_emoji: discord.Emoji = await guild.fetch_emoji(int(rr[2])) 107 | # except ValueError: 108 | # rr_emoji: discord.PartialEmoji = discord.PartialEmoji( 109 | # name=emoji.emojize(rr[2]) 110 | # ) 111 | 112 | # rr_role: discord.Role = guild.get_role(int(rr[3])) 113 | 114 | # if ( 115 | # payload.channel_id == rr_channel_id 116 | # and payload.message_id == rr_message_id 117 | # and payload.emoji.name == rr_emoji.name 118 | # ): 119 | # await member.remove_roles( 120 | # rr_role, reason="Sparta Reaction Role" 121 | # ) 122 | # await member.send( 123 | # f"Your **{rr_role}** role in **{guild}** has been removed" 124 | # ) 125 | 126 | @commands.command( 127 | name="addreactionrole", 128 | aliases=["addrr", "reactionrole"], 129 | help="Add a reaction role", 130 | ) 131 | @commands.bot_has_guild_permissions(manage_roles=True, add_reactions=True) 132 | @commands.has_guild_permissions(manage_roles=True) 133 | @dbl_vote_required() 134 | async def add_reaction_role(self, ctx: commands.Context): 135 | guild: discord.Guild = ctx.guild 136 | 137 | try: 138 | rr_channel = None 139 | while not rr_channel: 140 | channel_msg = await self.msg_prompt( 141 | ctx, 142 | "Please mention the channel where you want to setup a reaction role", 143 | ) 144 | channel_mentions = channel_msg.channel_mentions 145 | 146 | if channel_mentions: 147 | rr_channel = channel_mentions[0] 148 | 149 | except asyncio.TimeoutError: 150 | await ctx.send("No response received, aborting!") 151 | return 152 | 153 | try: 154 | rr_message = None 155 | while not rr_message: 156 | message_msg = await self.msg_prompt( 157 | ctx, 158 | "Please send the ID of the message where you want to setup a reaction role", 159 | ) 160 | try: 161 | message = await rr_channel.fetch_message( 162 | int(message_msg.content) 163 | ) 164 | 165 | if message: 166 | rr_message = message 167 | except ValueError: 168 | continue 169 | 170 | except discord.NotFound: 171 | await ctx.send("Could not fetch message with that ID") 172 | 173 | except asyncio.TimeoutError: 174 | await ctx.send("No response received, aborting!") 175 | return 176 | 177 | try: 178 | rr_emoji = None 179 | while not rr_emoji: 180 | emoji_react = await self.react_prompt( 181 | ctx, 182 | "Please react to this message with the emoji you want to use for the reaction role", 183 | ) 184 | rr_emoji = emoji_react.emoji 185 | 186 | except asyncio.TimeoutError: 187 | await ctx.send("No response received, aborting!") 188 | return 189 | 190 | try: 191 | rr_role = None 192 | while not rr_role: 193 | role_msg = await self.msg_prompt( 194 | ctx, 195 | "Please mention or send the ID of the role you want to give to members", 196 | ) 197 | 198 | try: 199 | role_id = int(role_msg.content) 200 | role = guild.get_role(role_id) 201 | if role: 202 | rr_role = role 203 | 204 | except ValueError: 205 | role_mentions = role_msg.role_mentions 206 | if role_mentions: 207 | rr_role = role_mentions[0] 208 | 209 | except asyncio.TimeoutError: 210 | await ctx.send("No response received, aborting!") 211 | return 212 | 213 | await rr_message.add_reaction(rr_emoji) 214 | 215 | if isinstance(rr_emoji, str): 216 | em = rr_emoji 217 | else: 218 | em = str(rr_emoji.id) 219 | 220 | async with db.async_session() as session: 221 | q = ( 222 | select(models.ReactionRole) 223 | .where(models.ReactionRole.guild_id == ctx.guild.id) 224 | .where(models.ReactionRole.channel_id == rr_channel.id) 225 | .where(models.ReactionRole.message_id == rr_message.id) 226 | .where(models.ReactionRole.emoji == em) 227 | .where(models.ReactionRole.role_id == rr_role.id) 228 | ) 229 | result = await session.execute(q) 230 | existing_rr: models.ReactionRole = result.scalar() 231 | 232 | if existing_rr: 233 | await ctx.send( 234 | f"A reaction role with this configuration already exists with ID `{existing_rr.id}`" 235 | ) 236 | else: 237 | new_rr_id = uuid4() 238 | 239 | async with db.async_session() as session: 240 | new_rr = models.ReactionRole( 241 | id=new_rr_id.hex, 242 | guild_id=ctx.guild.id, 243 | channel_id=rr_channel.id, 244 | message_id=rr_message.id, 245 | emoji=em, 246 | role_id=rr_role.id, 247 | ) 248 | session.add(new_rr) 249 | await session.commit() 250 | 251 | await ctx.send( 252 | f"Reaction Role for {rr_role.mention} has been created with {rr_emoji} at {rr_channel.mention}", 253 | allowed_mentions=discord.AllowedMentions.none(), 254 | ) 255 | 256 | @commands.command( 257 | name="removereactionrole", 258 | aliases=["rrr", "removerr"], 259 | help="Remove a reaction role", 260 | ) 261 | @commands.bot_has_guild_permissions(manage_roles=True, add_reactions=True) 262 | @commands.has_guild_permissions(manage_roles=True) 263 | async def remove_reaction_role(self, ctx: commands.Context, id: str): 264 | async with db.async_session() as session: 265 | rr = await session.get(models.ReactionRole, id) 266 | 267 | if rr: 268 | await session.delete(rr) 269 | await session.commit() 270 | await ctx.send( 271 | f"Reaction Role with ID `{id}` has been removed" 272 | ) 273 | else: 274 | await ctx.send( 275 | "Could not find a reaction role with the given ID" 276 | ) 277 | 278 | @commands.command( 279 | name="viewreactionroles", 280 | aliases=["vrr", "viewrr"], 281 | help="See the reaction roles setup in your server. If you've deleted any channel, emoji, or role that was used in an RR, this command will cleanup their entries from your server.", 282 | ) 283 | async def view_reaction_roles(self, ctx: commands.Context): 284 | async with db.async_session() as session: 285 | q = select(models.ReactionRole).where( 286 | models.ReactionRole.guild_id == ctx.guild.id 287 | ) 288 | result = await session.execute(q) 289 | reaction_roles: list[models.ReactionRole] = result.scalars().all() 290 | 291 | if not reaction_roles: 292 | await ctx.send( 293 | "There aren't any reaction roles setup in this server" 294 | ) 295 | return 296 | 297 | guild_roles = await ctx.guild.fetch_roles() 298 | rr_embed = discord.Embed( 299 | title=f"{ctx.guild.name} Reaction Roles", color=self.theme_color 300 | ) 301 | 302 | async with ctx.typing(): 303 | for rr in reaction_roles: 304 | 305 | async def delete_rr(): 306 | async with db.async_session() as session: 307 | await session.delete(rr) 308 | await session.commit() 309 | 310 | try: 311 | rr_channel: discord.TextChannel = ( 312 | await ctx.guild.fetch_channel(rr.channel_id) 313 | ) 314 | except discord.NotFound: 315 | await delete_rr() 316 | continue 317 | 318 | try: 319 | rr_message: discord.Message = ( 320 | await rr_channel.fetch_message(rr.message_id) 321 | ) 322 | except discord.NotFound: 323 | await delete_rr() 324 | continue 325 | 326 | if rr.emoji.isnumeric(): 327 | try: 328 | rr_emoji: discord.Emoji = await ctx.guild.fetch_emoji( 329 | int(rr.emoji) 330 | ) 331 | except discord.NotFound: 332 | await delete_rr() 333 | continue 334 | else: 335 | rr_emoji = emoji.emojize(rr.emoji) 336 | 337 | if not ( 338 | rr_role := discord.utils.get(guild_roles, id=rr.role_id) 339 | ): 340 | await delete_rr() 341 | continue 342 | 343 | embed_str = ( 344 | f"Emoji: {rr_emoji}\n" 345 | f"Role: {rr_role.mention}\n" 346 | f"Channel: {rr_channel.mention}\n" 347 | f"[Jump to Message]({rr_message.jump_url})" 348 | ) 349 | rr_embed.add_field( 350 | name=f"ID: {rr.id}", value=embed_str, inline=False 351 | ) 352 | 353 | await ctx.send(embed=rr_embed) 354 | 355 | 356 | def setup(bot): 357 | bot.add_cog(ReactionRoles(bot)) 358 | -------------------------------------------------------------------------------- /bot/cogs/settings.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import aiohttp 3 | import discord 4 | from discord.ext import commands 5 | from discord import utils 6 | 7 | from bot import MyBot, db 8 | from bot.db import models 9 | 10 | 11 | class Settings(commands.Cog): 12 | def __init__(self, bot): 13 | self.bot: MyBot = bot 14 | self.description = ( 15 | "Commands to change Sparta settings for the current server" 16 | ) 17 | self.theme_color = discord.Color.purple() 18 | 19 | @commands.command( 20 | name="setmuterole", 21 | aliases=["setmute", "smr"], 22 | help="Set a role to give to people when you mute them", 23 | ) 24 | @commands.has_guild_permissions(manage_roles=True) 25 | async def set_mute_role(self, ctx: commands.Context, role: discord.Role): 26 | async with db.async_session() as session: 27 | guild: models.Guild | None = await session.get( 28 | models.Guild, ctx.guild.id 29 | ) 30 | 31 | if guild: 32 | guild.mute_role = role.id 33 | else: 34 | new_guild_data = models.Guild( 35 | id=ctx.guild.id, mute_role=role.id 36 | ) 37 | session.add(new_guild_data) 38 | 39 | await session.commit() 40 | 41 | await ctx.send(f"The mute role has been set to **{role}**") 42 | 43 | @commands.command( 44 | name="setwelcomemessage", 45 | aliases=["wmsg"], 46 | brief="Change the welcome message of the current server", 47 | help="Change the welcome message of the current server. Variables you can use: [mention], [member], [server]", 48 | ) 49 | @commands.has_guild_permissions(administrator=True) 50 | async def set_welcome_message( 51 | self, ctx: commands.Context, *, message: str = None 52 | ): 53 | async with db.async_session() as session: 54 | guild: models.Guild | None = await session.get( 55 | models.Guild, ctx.guild.id 56 | ) 57 | 58 | if guild: 59 | guild.welcome_message = message 60 | else: 61 | new_guild_data = models.Guild( 62 | id=ctx.guild.id, welcome_message=message 63 | ) 64 | session.add(new_guild_data) 65 | 66 | await session.commit() 67 | 68 | if message: 69 | await ctx.send( 70 | f"This server's welcome message has been set to:\n{message}" 71 | ) 72 | else: 73 | await ctx.send( 74 | "This server's welcome message has been reset to default" 75 | ) 76 | 77 | @commands.command( 78 | name="setleavemessage", 79 | aliases=["lmsg"], 80 | brief="Change the leave message of the current server", 81 | help="Change the leave message of the current server. Variables you can use: [member], [server]", 82 | ) 83 | @commands.has_guild_permissions(administrator=True) 84 | async def set_leave_message( 85 | self, ctx: commands.Context, *, message: str = None 86 | ): 87 | async with db.async_session() as session: 88 | guild: models.Guild | None = await session.get( 89 | models.Guild, ctx.guild.id 90 | ) 91 | 92 | if guild: 93 | guild.leave_message = message 94 | else: 95 | new_guild_data = models.Guild( 96 | id=ctx.guild.id, leave_message=message 97 | ) 98 | session.add(new_guild_data) 99 | 100 | await session.commit() 101 | 102 | if message: 103 | await ctx.send( 104 | f"This server's leave message has been set to:\n{message}" 105 | ) 106 | else: 107 | await ctx.send( 108 | "This server's leave message has been reset to default" 109 | ) 110 | 111 | @commands.command( 112 | name="setwelcomechannel", 113 | aliases=["wchannel"], 114 | help="Change the channel where welcome messages are sent. Leave the channel field empty to disable welcome messages.", 115 | ) 116 | @commands.has_guild_permissions(administrator=True) 117 | async def set_welcome_channel( 118 | self, ctx: commands.Context, *, channel: discord.TextChannel = None 119 | ): 120 | async with db.async_session() as session: 121 | guild: models.Guild | None = await session.get( 122 | models.Guild, ctx.guild.id 123 | ) 124 | 125 | ch = str(channel.id) if channel else "disabled" 126 | 127 | if guild: 128 | guild.welcome_channel = ch 129 | else: 130 | new_guild_data = models.Guild( 131 | id=ctx.guild.id, welcome_channel=ch 132 | ) 133 | session.add(new_guild_data) 134 | 135 | await session.commit() 136 | 137 | if channel: 138 | await ctx.send( 139 | f"The server's welcome channel has been set to {channel.mention}" 140 | ) 141 | else: 142 | await ctx.send("The server's welcome message has been disabled") 143 | 144 | @commands.command( 145 | name="setleavechannel", 146 | aliases=["lchannel"], 147 | help="Change the channel where leave messages are sent. Leave the channel field empty to disable leave messages.", 148 | ) 149 | @commands.has_guild_permissions(administrator=True) 150 | async def set_leave_channel( 151 | self, ctx: commands.Context, *, channel: discord.TextChannel = None 152 | ): 153 | async with db.async_session() as session: 154 | guild: models.Guild | None = await session.get( 155 | models.Guild, ctx.guild.id 156 | ) 157 | 158 | ch = str(channel.id) if channel else "disabled" 159 | 160 | if guild: 161 | guild.leave_channel = ch 162 | else: 163 | new_guild_data = models.Guild( 164 | id=ctx.guild.id, leave_channel=ch 165 | ) 166 | session.add(new_guild_data) 167 | 168 | await session.commit() 169 | 170 | if channel: 171 | await ctx.send( 172 | f"The server's leave channel has been set to {channel.mention}" 173 | ) 174 | else: 175 | await ctx.send("The server's leave message has been disabled") 176 | 177 | @commands.command( 178 | name="setautorole", 179 | aliases=["setauto", "autorole", "arole"], 180 | help="Set a role to give to new members of the server", 181 | ) 182 | @commands.bot_has_guild_permissions(manage_roles=True) 183 | @commands.has_guild_permissions(administrator=True) 184 | async def set_auto_role( 185 | self, ctx: commands.Context, *, role: discord.Role 186 | ): 187 | async with db.async_session() as session: 188 | guild: models.Guild | None = await session.get( 189 | models.Guild, ctx.guild.id 190 | ) 191 | 192 | role_id = role.id if role else None 193 | 194 | if guild: 195 | guild.auto_role = role_id 196 | else: 197 | new_guild_data = models.Guild( 198 | id=ctx.guild.id, auto_role=role_id 199 | ) 200 | session.add(new_guild_data) 201 | 202 | await session.commit() 203 | 204 | await ctx.send( 205 | f"The auto role has been set to **{role.mention}**", 206 | allowed_mentions=discord.AllowedMentions.none(), 207 | ) 208 | 209 | @commands.command( 210 | name="serverinfo", 211 | aliases=["si"], 212 | help="Get general information about the server", 213 | ) 214 | async def server_info(self, ctx): 215 | guild: discord.Guild = ctx.guild 216 | human_count = len( 217 | [member for member in guild.members if not member.bot] 218 | ) 219 | bot_count = guild.member_count - human_count 220 | 221 | si_embed = discord.Embed( 222 | title=f"{guild.name} Information", color=self.theme_color 223 | ) 224 | si_embed.set_thumbnail(url=guild.icon.url) 225 | 226 | si_embed.add_field( 227 | name="Human Members", value=str(human_count), inline=False 228 | ) 229 | si_embed.add_field( 230 | name="Bot Members", value=str(bot_count), inline=False 231 | ) 232 | si_embed.add_field( 233 | name="Total Members", value=str(guild.member_count), inline=False 234 | ) 235 | si_embed.add_field( 236 | name="Role Count", value=str(len(guild.roles)), inline=False 237 | ) 238 | si_embed.add_field( 239 | name="Server Owner", value=str(guild.owner), inline=False 240 | ) 241 | si_embed.add_field(name="Server ID", value=guild.id, inline=False) 242 | si_embed.add_field( 243 | name="Server Region", value=str(guild.region).title(), inline=False 244 | ) 245 | 246 | await ctx.send(embed=si_embed) 247 | 248 | @commands.command( 249 | name="memberinfo", 250 | aliases=["mi", "ui"], 251 | help="Get general information about a member", 252 | ) 253 | async def member_info( 254 | self, ctx: commands.Context, member: discord.Member = None 255 | ): 256 | if member: 257 | m = member 258 | else: 259 | m = ctx.author 260 | 261 | time_format = "%-d %b %Y %-I:%M %p" 262 | created_at = m.created_at.strftime(time_format) 263 | joined_at = m.joined_at.strftime(time_format) 264 | 265 | mi_embed = discord.Embed( 266 | title=f"{m} Information", color=self.theme_color 267 | ) 268 | mi_embed.set_thumbnail(url=m.avatar.url) 269 | 270 | mi_embed.add_field(name="Member ID", value=m.id, inline=False) 271 | mi_embed.add_field( 272 | name="Joined Discord", value=created_at, inline=False 273 | ) 274 | mi_embed.add_field(name="Joined Server", value=joined_at, inline=False) 275 | mi_embed.add_field( 276 | name="Highest Role", value=m.top_role.mention, inline=False 277 | ) 278 | mi_embed.add_field( 279 | name="Bot?", value="Yes" if m.bot else "No", inline=False 280 | ) 281 | 282 | await ctx.send(embed=mi_embed) 283 | 284 | @commands.command( 285 | name="prefix", 286 | help="Change the command prefix for Sparta in this server", 287 | ) 288 | @commands.has_guild_permissions(administrator=True) 289 | async def prefix(self, ctx: commands.Context, pref: str = "s!"): 290 | async with db.async_session() as session: 291 | guild: models.Guild | None = await session.get( 292 | models.Guild, ctx.guild.id 293 | ) 294 | 295 | if guild: 296 | guild.prefix = pref 297 | else: 298 | new_guild_data = models.Guild(id=ctx.guild.id, prefix=pref) 299 | session.add(new_guild_data) 300 | 301 | await session.commit() 302 | 303 | await ctx.send(f"The prefix has been changed to **{pref}**") 304 | 305 | @commands.command( 306 | name="steal", 307 | help="Add an emoji from another server to yours", 308 | ) 309 | @commands.bot_has_guild_permissions(manage_emojis=True) 310 | @commands.has_guild_permissions(manage_emojis=True) 311 | async def steal( 312 | self, 313 | ctx: commands.Context, 314 | name: str, 315 | emoji: Union[discord.Emoji, str], 316 | ): 317 | emoji = str(emoji).replace("<", "") 318 | emoji = str(emoji).replace(">", "") 319 | emoji = emoji.split(":") 320 | 321 | if emoji[0] == "a": 322 | url = ( 323 | "https://cdn.discordapp.com/emojis/" 324 | + str(emoji[2]) 325 | + ".gif?v=1" 326 | ) 327 | else: 328 | url = ( 329 | "https://cdn.discordapp.com/emojis/" 330 | + str(emoji[2]) 331 | + ".png?v=1" 332 | ) 333 | 334 | try: 335 | async with aiohttp.request("GET", url) as resp: 336 | image_data = await resp.read() 337 | 338 | if name: 339 | await ctx.guild.create_custom_emoji( 340 | name=name, image=image_data 341 | ) 342 | emote = utils.get(ctx.guild.emojis, name=name) 343 | if emote.animated: 344 | add = "a" 345 | else: 346 | add = "" 347 | emote_display = f"<{add}:{emote.name}:{emote.id}>" 348 | await ctx.send(f'{emote_display} added with the name "{name}"') 349 | else: 350 | await ctx.guild.create_custom_emoji( 351 | name=emoji[1], image=image_data 352 | ) 353 | emote = utils.get(ctx.guild.emojis, name=emoji[1]) 354 | if emote.animated: 355 | add = "a" 356 | else: 357 | add = "" 358 | emote_display = f"<{add}:{emote.name}:{emote.id}>" 359 | await ctx.send(f'{emote_display} has been added as "{name}"') 360 | 361 | except discord.HTTPException: 362 | await ctx.send("Failed to add emoji.") 363 | 364 | @commands.command(name="setclearcap", aliases=["clearcap", "cc"]) 365 | @commands.has_guild_permissions(administrator=True) 366 | async def set_clear_cap(self, ctx: commands.Context, limit: int = None): 367 | async with db.async_session() as session: 368 | guild: models.Guild | None = await session.get( 369 | models.Guild, ctx.guild.id 370 | ) 371 | 372 | if guild: 373 | guild.clear_cap = limit 374 | else: 375 | new_guild_data = models.Guild(id=ctx.guild.id, clear_cap=limit) 376 | session.add(new_guild_data) 377 | 378 | await session.commit() 379 | 380 | if limit: 381 | await ctx.send( 382 | f"Clear command limit has been set to **{limit} messages** at a time." 383 | ) 384 | else: 385 | await ctx.send("Clear command limit has been removed.") 386 | 387 | 388 | def setup(bot): 389 | bot.add_cog(Settings(bot)) 390 | -------------------------------------------------------------------------------- /bot/cogs/snipe.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | from bot import MyBot 5 | 6 | 7 | class Snipe(commands.Cog): 8 | def __init__(self, bot): 9 | self.bot: MyBot = bot 10 | self.description = ( 11 | "Commands to snipe out messages that people try to hide" 12 | ) 13 | self.theme_color = discord.Color.purple() 14 | 15 | self.deleted_msgs = {} 16 | self.edited_msgs = {} 17 | self.snipe_limit = 7 18 | 19 | @commands.Cog.listener() 20 | async def on_message_delete(self, message: discord.Message): 21 | ch_id = message.channel.id 22 | 23 | if not message.author.bot: 24 | if message.content: 25 | if ch_id not in self.deleted_msgs: 26 | self.deleted_msgs[ch_id] = [] 27 | 28 | self.deleted_msgs[ch_id].append(message) 29 | 30 | if len(self.deleted_msgs[ch_id]) > self.snipe_limit: 31 | self.deleted_msgs[ch_id].pop(0) 32 | 33 | @commands.Cog.listener() 34 | async def on_message_edit( 35 | self, before: discord.Message, after: discord.Message 36 | ): 37 | ch_id = before.channel.id 38 | 39 | if not before.author.bot: 40 | if before.content and after.content: 41 | if ch_id not in self.edited_msgs: 42 | self.edited_msgs[ch_id] = [] 43 | 44 | self.edited_msgs[ch_id].append((before, after)) 45 | 46 | if len(self.edited_msgs[ch_id]) > self.snipe_limit: 47 | self.edited_msgs[ch_id].pop(0) 48 | 49 | @commands.command( 50 | name="snipe", 51 | aliases=["sn"], 52 | help="See recently deleted messages in the current channel", 53 | ) 54 | async def snipe(self, ctx: commands.Context, limit: int = 1): 55 | if limit > self.snipe_limit: 56 | await ctx.send(f"Maximum snipe limit is {self.snipe_limit}") 57 | return 58 | 59 | try: 60 | msgs: list[discord.Message] = self.deleted_msgs[ctx.channel.id][ 61 | ::-1 62 | ][:limit] 63 | snipe_embed = discord.Embed( 64 | title="Message Snipe", color=self.theme_color 65 | ) 66 | 67 | if msgs: 68 | async with ctx.typing(): 69 | top_author: discord.Member = ( 70 | await self.bot.get_or_fetch_user(msgs[0].author.id) 71 | ) 72 | 73 | if top_author: 74 | snipe_embed.set_thumbnail(url=str(top_author.avatar.url)) 75 | 76 | for msg in msgs: 77 | snipe_embed.add_field( 78 | name=str(msg.author), value=msg.content, inline=False 79 | ) 80 | 81 | await ctx.send(embed=snipe_embed) 82 | 83 | except KeyError: 84 | await ctx.send("There's nothing to snipe here...") 85 | 86 | @commands.command( 87 | name="editsnipe", 88 | aliases=["esn"], 89 | help="See recently edited messages in the current channel", 90 | ) 91 | async def editsnipe(self, ctx: commands.Context, limit: int = 1): 92 | if limit > self.snipe_limit: 93 | await ctx.send(f"Maximum snipe limit is {self.snipe_limit}") 94 | return 95 | 96 | try: 97 | msgs = self.edited_msgs[ctx.channel.id][::-1][:limit] 98 | editsnipe_embed = discord.Embed( 99 | title="Edit Snipe", color=self.theme_color 100 | ) 101 | 102 | if msgs: 103 | async with ctx.typing(): 104 | top_author: discord.Member = ( 105 | await self.bot.get_or_fetch_user(msgs[0][0].author.id) 106 | ) 107 | 108 | if top_author: 109 | editsnipe_embed.set_thumbnail( 110 | url=str(top_author.avatar.url) 111 | ) 112 | 113 | for msg in msgs: 114 | editsnipe_embed.add_field( 115 | name=str(msg[0].author), 116 | value=f"{msg[0].content} **-->** {msg[1].content}", 117 | inline=False, 118 | ) 119 | 120 | await ctx.send(embed=editsnipe_embed) 121 | 122 | except KeyError: 123 | await ctx.send("There's nothing to snipe here...") 124 | 125 | 126 | def setup(bot): 127 | bot.add_cog(Snipe(bot)) 128 | -------------------------------------------------------------------------------- /bot/cogs/status.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands, tasks 3 | 4 | from bot import MyBot 5 | 6 | 7 | class Status(commands.Cog): 8 | def __init__(self, bot): 9 | self.bot: MyBot = bot 10 | self.theme_color = discord.Color.purple() 11 | self.status_msgs = [ 12 | (discord.ActivityType.watching, "[guild_count] servers"), 13 | (discord.ActivityType.playing, "Hypixel"), 14 | (discord.ActivityType.listening, "Shawty's Like a Melody"), 15 | (discord.ActivityType.streaming, "Minecraft Bedwars"), 16 | (discord.ActivityType.playing, "Amogus"), 17 | (discord.ActivityType.watching, "out for s!help"), 18 | ] 19 | self.status_index = 0 20 | 21 | @commands.Cog.listener() 22 | async def on_ready(self): 23 | self.status_task.start() 24 | 25 | def cog_unload(self): 26 | self.status_task.cancel() 27 | 28 | @tasks.loop(seconds=120) 29 | async def status_task(self): 30 | activity = self.status_msgs[self.status_index] 31 | activ_type = activity[0] 32 | activ_msg = activity[1] 33 | 34 | if "[guild_count]" in activ_msg: 35 | guild_count = len(self.bot.guilds) 36 | activ_msg = activ_msg.replace("[guild_count]", str(guild_count)) 37 | 38 | activ = discord.Activity(type=activ_type, name=activ_msg) 39 | await self.bot.change_presence(activity=activ) 40 | 41 | self.status_index += 1 42 | if self.status_index >= len(self.status_msgs): 43 | self.status_index = 0 44 | 45 | 46 | def setup(bot): 47 | # bot.add_cog(Status(bot)) 48 | pass 49 | -------------------------------------------------------------------------------- /bot/db/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from sqlalchemy.ext.asyncio import ( 3 | create_async_engine, 4 | AsyncEngine, 5 | AsyncSession, 6 | ) 7 | 8 | # from .models import Base 9 | 10 | ENGINE: AsyncEngine 11 | 12 | 13 | # async def create_tables(): 14 | # async with engine.begin() as conn: 15 | # await conn.run_sync(Base.metadata.create_all) 16 | 17 | 18 | def init_engine(): 19 | global ENGINE 20 | ENGINE = create_async_engine(os.environ["DB_URI"]) 21 | 22 | 23 | async def close_db(): 24 | global ENGINE 25 | await ENGINE.dispose() 26 | 27 | 28 | def async_session() -> AsyncSession: 29 | global ENGINE 30 | return AsyncSession(ENGINE, expire_on_commit=False) 31 | -------------------------------------------------------------------------------- /bot/db/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import ( 2 | Column, 3 | String, 4 | BigInteger, 5 | Integer, 6 | DateTime, 7 | Boolean, 8 | ForeignKey, 9 | ) 10 | from sqlalchemy.sql import func 11 | from sqlalchemy.orm import relationship 12 | from sqlalchemy.ext.declarative import declarative_base 13 | 14 | Base = declarative_base() 15 | 16 | 17 | class Guild(Base): 18 | __tablename__ = "guilds" 19 | 20 | id = Column(BigInteger, primary_key=True) 21 | mute_role = Column(BigInteger, default=None, nullable=True) 22 | 23 | welcome_message = Column(String, default=None, nullable=True) 24 | leave_message = Column(String, default=None, nullable=True) 25 | 26 | welcome_channel = Column(String, default=None, nullable=True) 27 | leave_channel = Column(String, default=None, nullable=True) 28 | 29 | auto_role = Column(BigInteger, default=None, nullable=True) 30 | prefix = Column(String, default="s!", nullable=False) 31 | clear_cap = Column(Integer, default=None, nullable=True) 32 | 33 | 34 | class AFK(Base): 35 | __tablename__ = "afks" 36 | 37 | user_id = Column(BigInteger, primary_key=True) 38 | message = Column(String, nullable=False) 39 | 40 | 41 | class ReactionRole(Base): 42 | __tablename__ = "reaction_roles" 43 | 44 | id = Column(String, primary_key=True) 45 | guild_id = Column(BigInteger, nullable=False) 46 | channel_id = Column(BigInteger, nullable=False) 47 | message_id = Column(BigInteger, nullable=False) 48 | emoji = Column(String, nullable=False) 49 | role_id = Column(BigInteger, nullable=False) 50 | 51 | 52 | class Reminder(Base): 53 | __tablename__ = "reminders" 54 | 55 | id = Column(String, primary_key=True) 56 | user_id = Column(BigInteger, nullable=False) 57 | message = Column(String, nullable=False) 58 | start = Column(DateTime, nullable=False) 59 | due = Column(DateTime, nullable=False) 60 | 61 | 62 | class Webhook(Base): 63 | __tablename__ = "webhooks" 64 | 65 | channel_id = Column(BigInteger, primary_key=True) 66 | webhook_url = Column(String, nullable=False) 67 | 68 | 69 | class AutoResponse(Base): 70 | __tablename__ = "auto_responses" 71 | 72 | id = Column(String, primary_key=True) 73 | guild_id = Column(BigInteger, nullable=False) 74 | activation = Column(String, nullable=False) 75 | response = Column(String, nullable=False) 76 | 77 | 78 | class AutoMod(Base): 79 | __tablename__ = "auto_mod" 80 | 81 | guild_id = Column(BigInteger, primary_key=True) 82 | links = Column(Boolean, default=False, nullable=False) 83 | images = Column(Boolean, default=False, nullable=False) 84 | ping_spam = Column(Boolean, default=False, nullable=False) 85 | 86 | 87 | class Infraction(Base): 88 | __tablename__ = "infractions" 89 | 90 | id = Column(String, primary_key=True) 91 | guild_id = Column(BigInteger, nullable=False) 92 | user_id = Column(BigInteger, nullable=False) 93 | moderator_id = Column(BigInteger, nullable=False) 94 | reason = Column(String, nullable=False) 95 | 96 | 97 | class ImpersonationLog(Base): 98 | __tablename__ = "impersonation_logs" 99 | 100 | id = Column(String, primary_key=True) 101 | guild_id = Column(BigInteger, nullable=False) 102 | channel_id = Column(BigInteger, nullable=False) 103 | message_id = Column(BigInteger, nullable=False) 104 | user_id = Column(BigInteger, nullable=False) 105 | impersonator_id = Column(BigInteger, nullable=False) 106 | message = Column(String, nullable=False) 107 | timestamp = Column(DateTime(timezone=True), server_default=func.now()) 108 | 109 | 110 | class Playlist(Base): 111 | __tablename__ = "playlists" 112 | 113 | id = Column(String, primary_key=True) 114 | owner_id = Column(BigInteger, nullable=False) 115 | name = Column(String, nullable=False) 116 | songs = relationship("PlaylistSong", back_populates="playlist") 117 | 118 | 119 | class PlaylistSong(Base): 120 | __tablename__ = "playlist_songs" 121 | 122 | uri = Column(String, primary_key=True) 123 | 124 | playlist_id = Column( 125 | ForeignKey("playlists.id", ondelete="CASCADE"), primary_key=True 126 | ) 127 | playlist = relationship("Playlist", back_populates="songs") 128 | -------------------------------------------------------------------------------- /bot/enums.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class AutoModFeatures(enum.Enum): 5 | LINKS = "Bans links from being sent to this server" 6 | IMAGES = "Bans attachments from being sent to this server" 7 | PING_SPAM = "Temporarily mutes users who are spamming pings in this server" 8 | -------------------------------------------------------------------------------- /bot/errors.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands.errors import CheckFailure 2 | 3 | 4 | class DBLVoteRequired(CheckFailure): 5 | def __init__(self): 6 | super().__init__( 7 | "You must vote for the bot on Top.gg to use this command." 8 | ) 9 | -------------------------------------------------------------------------------- /bot/slash_cogs/afk.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | from discord.ext import commands 4 | from sqlalchemy.future import select 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | from bot import db 8 | from bot.db import models 9 | 10 | 11 | class SlashAFK(commands.Cog): 12 | """ 13 | Manage your AFK status 14 | """ 15 | 16 | def __init__(self, bot: commands.Bot): 17 | self.bot = bot 18 | 19 | async def process_afk(self, message: discord.Message): 20 | async def run_afk(session: AsyncSession, member: discord.Member): 21 | q = select(models.AFK).where(models.AFK.user_id == member.id) 22 | result = await session.execute(q) 23 | 24 | if afk_data := result.scalar(): 25 | await message.channel.send( 26 | f"{member} is currently AFK because:\n*{afk_data.message}*", 27 | allowed_mentions=discord.AllowedMentions.none(), 28 | ) 29 | 30 | async with db.async_session() as session: 31 | afk_tasks = [ 32 | run_afk(session, member) for member in message.mentions 33 | ] 34 | await asyncio.gather(*afk_tasks) 35 | 36 | @commands.Cog.listener() 37 | async def on_message(self, message: discord.Message): 38 | if message.author != self.bot.user: 39 | await self.process_afk(message) 40 | 41 | 42 | def setup(bot): 43 | bot.add_cog(SlashAFK(bot)) 44 | -------------------------------------------------------------------------------- /bot/slash_cogs/auto_response.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | from uuid import uuid4 4 | from discord.ext import commands 5 | from sqlalchemy.future import select 6 | 7 | from bot import TESTING_GUILDS, THEME, db 8 | from bot.db import models 9 | from bot.utils import dbl_vote_required 10 | from bot.views import ConfirmView 11 | 12 | 13 | class SlashAutoResponse(commands.Cog): 14 | """ 15 | Commands to make Sparta automatically reply to certain phrases 16 | """ 17 | 18 | @commands.Cog.listener() 19 | async def on_message(self, message: discord.Message): 20 | if message.author.bot: 21 | return 22 | 23 | content: str = message.content 24 | channel: discord.TextChannel = message.channel 25 | 26 | async with db.async_session() as session: 27 | q = ( 28 | select(models.AutoResponse) 29 | .where(models.AutoResponse.guild_id == message.guild.id) 30 | .where(models.AutoResponse.activation == content) 31 | ) 32 | results = await session.execute(q) 33 | auto_resp: models.AutoResponse | None = results.scalar() 34 | 35 | if not auto_resp: 36 | return 37 | 38 | response = auto_resp.response 39 | 40 | # Auto Response Variables 41 | response = response.replace("[member]", str(message.author)) 42 | response = response.replace("[nick]", message.author.display_name) 43 | response = response.replace("[name]", message.author.name) 44 | 45 | await channel.send( 46 | response, allowed_mentions=discord.AllowedMentions.none() 47 | ) 48 | 49 | @dbl_vote_required() 50 | @commands.slash_command(name="addautoresponse", guild_ids=TESTING_GUILDS) 51 | @commands.has_guild_permissions(administrator=True) 52 | async def add_auto_response( 53 | self, ctx: discord.ApplicationContext, activation: str, response: str 54 | ): 55 | """ 56 | Add an auto response phrase. Variables you can use: [member], [nick], [name] 57 | """ 58 | 59 | async with db.async_session() as session: 60 | q = ( 61 | select(models.AutoResponse) 62 | .where(models.AutoResponse.guild_id == ctx.guild.id) 63 | .where(models.AutoResponse.activation == activation) 64 | ) 65 | result = await session.execute(q) 66 | duplicate_auto_resp: models.AutoResponse | None = result.scalar() 67 | 68 | if duplicate_auto_resp: 69 | confirm_view = ConfirmView(ctx.author.id) 70 | await ctx.respond( 71 | "An auto response with this activation already exists and will be overwritten by the new one. Do you want to continue?", 72 | view=confirm_view, 73 | ) 74 | await confirm_view.wait() 75 | 76 | if confirm_view.do_action: 77 | duplicate_auto_resp.response = response 78 | ar_id = duplicate_auto_resp.id 79 | else: 80 | return 81 | 82 | else: 83 | new_auto_resp = models.AutoResponse( 84 | id=uuid4().hex, 85 | guild_id=ctx.guild.id, 86 | activation=activation, 87 | response=response, 88 | ) 89 | session.add(new_auto_resp) 90 | ar_id = new_auto_resp.id 91 | 92 | await session.commit() 93 | 94 | ar_embed = discord.Embed(title="New Auto Response", color=THEME) 95 | ar_embed.add_field(name="ID", value=ar_id, inline=False) 96 | ar_embed.add_field(name="Activation", value=activation, inline=False) 97 | ar_embed.add_field(name="Response", value=response, inline=False) 98 | await ctx.respond(embed=ar_embed) 99 | 100 | @commands.slash_command( 101 | name="removeautoresponse", guild_ids=TESTING_GUILDS 102 | ) 103 | @commands.has_guild_permissions(administrator=True) 104 | async def remove_auto_response( 105 | self, ctx: discord.ApplicationContext, id: str = None 106 | ): 107 | """ 108 | Remove an auto response phrase 109 | """ 110 | 111 | if id: 112 | async with db.async_session() as session: 113 | auto_resp: models.AutoResponse | None = await session.get( 114 | models.AutoResponse, id 115 | ) 116 | 117 | if not auto_resp: 118 | await ctx.respond( 119 | "An auto response with this ID does not exist" 120 | ) 121 | return 122 | 123 | await session.delete(auto_resp) 124 | await session.commit() 125 | 126 | ar_embed = discord.Embed( 127 | title="Deleted Auto Response", color=THEME 128 | ) 129 | ar_embed.add_field(name="ID", value=id, inline=False) 130 | ar_embed.add_field( 131 | name="Activation", 132 | value=auto_resp.activation, 133 | inline=False, 134 | ) 135 | ar_embed.add_field( 136 | name="Response", value=auto_resp.response, inline=False 137 | ) 138 | await ctx.respond(embed=ar_embed) 139 | 140 | else: 141 | confirm_view = ConfirmView(ctx.author.id) 142 | await ctx.respond( 143 | "You are about to delete all auto responses in this server. Do you want to continue?", 144 | view=confirm_view, 145 | ) 146 | await confirm_view.wait() 147 | 148 | if confirm_view.do_action: 149 | async with db.async_session() as session: 150 | q = select(models.AutoResponse).where( 151 | models.AutoResponse.guild_id == ctx.guild.id 152 | ) 153 | results = await session.execute(q) 154 | tasks = [session.delete(r) for r in results.scalars()] 155 | await asyncio.gather(*tasks) 156 | await session.commit() 157 | 158 | await ctx.respond( 159 | "All auto responses in this server have been deleted" 160 | ) 161 | 162 | @commands.slash_command(name="viewautoresponses", guild_ids=TESTING_GUILDS) 163 | async def view_auto_responses(self, ctx: discord.ApplicationContext): 164 | """ 165 | See all the auto responses in the server 166 | """ 167 | 168 | async with db.async_session() as session: 169 | q = select(models.AutoResponse).where( 170 | models.AutoResponse.guild_id == ctx.guild.id 171 | ) 172 | result = await session.execute(q) 173 | auto_resps: list[models.AutoResponse] = result.scalars().all() 174 | 175 | if auto_resps: 176 | auto_resps_embed = discord.Embed( 177 | title=f"Auto Responses in {ctx.guild}", color=THEME 178 | ) 179 | 180 | for ar in auto_resps: 181 | field_value = ( 182 | f"Activation: `{ar.activation}`\nResponse: `{ar.response}`" 183 | ) 184 | auto_resps_embed.add_field( 185 | name=ar.id, value=field_value, inline=False 186 | ) 187 | 188 | await ctx.respond(embed=auto_resps_embed) 189 | 190 | else: 191 | await ctx.respond("This server does not have any auto responses") 192 | 193 | 194 | def setup(bot): 195 | bot.add_cog(SlashAutoResponse()) 196 | -------------------------------------------------------------------------------- /bot/slash_cogs/automod.py: -------------------------------------------------------------------------------- 1 | import re 2 | import discord 3 | from datetime import datetime 4 | from discord.ext import commands 5 | from discord.utils import _URL_REGEX 6 | 7 | from bot import TESTING_GUILDS, THEME, db 8 | from bot.db import models 9 | from bot.enums import AutoModFeatures 10 | from bot.views import AutoModView 11 | 12 | 13 | class SlashAutoMod(commands.Cog): 14 | """ 15 | Commands to setup Auto-Mod in Sparta 16 | """ 17 | 18 | def __init__(self, bot: commands.Bot): 19 | self.bot = bot 20 | 21 | @commands.slash_command(guild_ids=TESTING_GUILDS) 22 | @commands.has_guild_permissions(administrator=True) 23 | async def automod(self, ctx: discord.ApplicationContext): 24 | """ 25 | Allows you to enable/disable automod features 26 | """ 27 | 28 | async with db.async_session() as session: 29 | auto_mod_data = await session.get(models.AutoMod, ctx.guild.id) 30 | 31 | if not auto_mod_data: 32 | auto_mod_data = models.AutoMod(guild_id=ctx.guild.id) 33 | session.add(auto_mod_data) 34 | 35 | features = { 36 | attr: getattr(auto_mod_data, attr, False) 37 | for attr in dir(auto_mod_data) 38 | if not ( 39 | attr.startswith("_") 40 | or attr.endswith("_") 41 | or attr in ["guild_id", "registry", "metadata"] 42 | ) 43 | } 44 | 45 | mod_embed = discord.Embed( 46 | title="Auto Mod", 47 | description="Allow Sparta to administrate on its own", 48 | color=THEME, 49 | ) 50 | 51 | for feature in AutoModFeatures: 52 | if feature.name.lower() in features: 53 | mod_embed.add_field( 54 | name=feature.name.replace("_", " ").title(), 55 | value=feature.value, 56 | inline=False, 57 | ) 58 | 59 | mod_embed.set_footer(text="Enable or disable an Auto Mod feature") 60 | 61 | automod_view = AutoModView(features, ctx.author.id) 62 | await ctx.respond(embed=mod_embed, view=automod_view) 63 | await automod_view.wait() 64 | 65 | for feature, value in list(automod_view.features.items()): 66 | setattr(auto_mod_data, feature, value) 67 | 68 | await session.commit() 69 | 70 | @commands.Cog.listener() 71 | async def on_message(self, message: discord.Message): 72 | if not message.guild or message.author.bot: 73 | return 74 | 75 | def ping_spam_check(msg: discord.Message) -> bool: 76 | return ( 77 | (msg.author == message.author) 78 | and msg.mentions 79 | and message.mentions 80 | and ( 81 | datetime.utcnow().replace(tzinfo=msg.created_at.tzinfo) 82 | - msg.created_at 83 | ).seconds 84 | < 5 85 | ) 86 | 87 | async with db.async_session() as session: 88 | if data := await session.get(models.AutoMod, message.guild.id): 89 | auto_mod: models.AutoMod = data 90 | else: 91 | return 92 | 93 | if auto_mod.links: 94 | if re.search(_URL_REGEX, message.content): 95 | await message.delete() 96 | await message.channel.send( 97 | f"{message.author.mention}, You cannot send links " 98 | "in this channel!", 99 | delete_after=3, 100 | ) 101 | 102 | if auto_mod.images: 103 | if any([hasattr(a, "width") for a in message.attachments]): 104 | await message.delete() 105 | await message.channel.send( 106 | f"{message.author.mention}, You cannot send images " 107 | "in this channel!", 108 | delete_after=3, 109 | ) 110 | 111 | if auto_mod.ping_spam: 112 | if any(filter(ping_spam_check, self.bot.cached_messages)): 113 | await message.channel.send( 114 | f"{message.author.mention}, Do not spam mentions " 115 | "in this channel!", 116 | delete_after=3, 117 | ) 118 | 119 | 120 | def setup(bot): 121 | bot.add_cog(SlashAutoMod(bot)) 122 | -------------------------------------------------------------------------------- /bot/slash_cogs/fun.py: -------------------------------------------------------------------------------- 1 | import time 2 | import random 3 | import string 4 | import pyfiglet 5 | import discord 6 | from discord.ext import commands 7 | from uuid import uuid4 8 | 9 | from bot import THEME, TESTING_GUILDS, db 10 | from bot.db import models 11 | from bot.views import PollView 12 | 13 | 14 | class SlashFun(commands.Cog): 15 | """ 16 | Commands to have some fun and relieve stress (or induce it) 17 | """ 18 | 19 | eight_ball_responses = [ 20 | [ 21 | "No.", 22 | "Nope.", 23 | "Highly Doubtful.", 24 | "Not a chance.", 25 | "Not possible.", 26 | "Don't count on it.", 27 | ], 28 | [ 29 | "Yes.", 30 | "Yup", 31 | "Extremely Likely", 32 | "It is possible", 33 | "Very possibly.", 34 | ], 35 | ["I'm not sure", "Maybe get a second opinion", "Maybe"], 36 | ] 37 | 38 | emojify_symbols = { 39 | "0": ":zero:", 40 | "1": ":one:", 41 | "2": ":two:", 42 | "3": ":three:", 43 | "4": ":four:", 44 | "5": ":five:", 45 | "6": ":six:", 46 | "7": ":seven:", 47 | "8": ":eight:", 48 | "9": ":nine:", 49 | "!": ":exclamation:", 50 | "#": ":hash:", 51 | "?": ":question:", 52 | "*": ":asterisk:", 53 | } 54 | 55 | @commands.slash_command(guild_ids=TESTING_GUILDS) 56 | async def poll( 57 | self, 58 | ctx: discord.ApplicationContext, 59 | length: float, 60 | question: str, 61 | options: str, 62 | ): 63 | """ 64 | Ask a question. The time must be in minutes. Separate each option with a comma (,). 65 | """ 66 | 67 | length_lower_limit = 1 # 1 minute 68 | length_upper_limit = 7200 # 5 days 69 | option_limit = 10 70 | 71 | # Convert options in string format to list 72 | options = list(map(lambda x: x.strip(), options.split(","))) 73 | 74 | if length < length_lower_limit: 75 | await ctx.respond( 76 | f"The poll must last at least {length_lower_limit} minute." 77 | ) 78 | return 79 | 80 | if length > length_upper_limit: 81 | await ctx.respond( 82 | f"The poll must last less than {length_upper_limit} minutes." 83 | ) 84 | return 85 | 86 | if len(options) > option_limit: 87 | await ctx.respond( 88 | f"You can only have up to {option_limit} options." 89 | ) 90 | return 91 | 92 | end_time = round(time.time() + length * 60) 93 | poll_embed = discord.Embed( 94 | title=question, 95 | color=THEME, 96 | description=f"Ends ", 97 | ) 98 | poll_embed.set_author( 99 | name=str(ctx.author), icon_url=ctx.author.avatar.url 100 | ) 101 | 102 | options_str = "\n".join( 103 | [f"{i}) {option}" for i, option in enumerate(options, start=1)] 104 | ) 105 | poll_embed.add_field(name="Options", value=options_str, inline=False) 106 | 107 | poll_view = PollView(options, length) 108 | msg = await ctx.respond(embed=poll_embed, view=poll_view) 109 | 110 | if isinstance(msg, discord.Interaction): 111 | reference = (await msg.original_message()).to_reference( 112 | fail_if_not_exists=False 113 | ) 114 | elif isinstance(msg, discord.WebhookMessage): 115 | reference = msg.to_reference(fail_if_not_exists=False) 116 | 117 | await poll_view.wait() 118 | 119 | sorted_votes = sorted( 120 | list(poll_view.votes.items()), key=lambda x: x[1], reverse=True 121 | ) 122 | 123 | poll_over_embed = discord.Embed( 124 | title="Poll Ended", 125 | color=THEME, 126 | ) 127 | poll_over_embed.set_author( 128 | name=str(ctx.author), icon_url=ctx.author.avatar.url 129 | ) 130 | poll_over_embed.add_field( 131 | name="Question", value=question, inline=False 132 | ) 133 | 134 | results_str = "\n".join( 135 | [ 136 | f"{i}) {option} - {vote_count} votes" 137 | for i, (option, vote_count) in enumerate(sorted_votes, start=1) 138 | ] 139 | ) 140 | poll_over_embed.add_field( 141 | name="Results", value=results_str, inline=False 142 | ) 143 | 144 | poll_over_embed.add_field( 145 | name="Total Votes", value=len(poll_view.voters) 146 | ) 147 | poll_over_embed.add_field(name="Top Voted", value=sorted_votes[0][0]) 148 | 149 | channel: discord.TextChannel = await ctx.guild.fetch_channel( 150 | ctx.channel_id 151 | ) 152 | await channel.send( 153 | embed=poll_over_embed, 154 | reference=reference, 155 | ) 156 | 157 | @commands.slash_command(guild_ids=TESTING_GUILDS) 158 | async def coinflip(self, ctx: discord.ApplicationContext): 159 | """ 160 | Flip a coin 161 | """ 162 | 163 | result = random.choice(["heads", "tails"]) 164 | await ctx.respond( 165 | f"The coin has been flipped and resulted in **{result}**" 166 | ) 167 | 168 | @commands.slash_command(guild_ids=TESTING_GUILDS) 169 | async def roll(self, ctx: discord.ApplicationContext, dice_count: int = 1): 170 | """ 171 | Roll a dice 172 | """ 173 | 174 | number = random.randint(dice_count, dice_count * 6) 175 | 176 | if dice_count > 1: 177 | await ctx.respond( 178 | f"You rolled **{dice_count} dice** and got a **{number}**" 179 | ) 180 | else: 181 | await ctx.respond(f"You rolled a **{number}**") 182 | 183 | @commands.slash_command(guild_ids=TESTING_GUILDS) 184 | async def avatar( 185 | self, ctx: discord.ApplicationContext, member: discord.Member = None 186 | ): 187 | """ 188 | Get somebody's Discord avatar 189 | """ 190 | 191 | if not member: 192 | member = ctx.author 193 | 194 | av_embed = discord.Embed(title=f"{member}'s Avatar", color=THEME) 195 | av_embed.set_image(url=member.avatar.url) 196 | await ctx.respond(embed=av_embed) 197 | 198 | @commands.slash_command(guild_ids=TESTING_GUILDS) 199 | async def choose(self, ctx: commands.Context, options: str): 200 | """ 201 | Let Sparta choose the best option for you. Separate the choices with a comma (,). 202 | """ 203 | 204 | items = list(map(lambda x: x.strip(), options.split(","))) 205 | choice = random.choice(items) 206 | await ctx.respond( 207 | f"I choose {choice}", 208 | allowed_mentions=discord.AllowedMentions.none(), 209 | ) 210 | 211 | @commands.slash_command(name="8ball", guild_ids=TESTING_GUILDS) 212 | async def eight_ball(self, ctx: discord.ApplicationContext, question: str): 213 | """ 214 | Call upon the powers of the all knowing magic 8Ball 215 | """ 216 | 217 | group = random.choice(self.eight_ball_responses) 218 | response = random.choice(group) 219 | await ctx.respond(response) 220 | 221 | @commands.slash_command(guild_ids=TESTING_GUILDS) 222 | async def emojify(self, ctx: discord.ApplicationContext, sentence: str): 223 | """ 224 | Turn a sentence into emojis 225 | """ 226 | 227 | emojified_sentence = "" 228 | sentence = sentence.lower() 229 | 230 | for char in sentence: 231 | char_lower = char.lower() 232 | 233 | if char_lower in string.ascii_lowercase: 234 | emojified_sentence += f":regional_indicator_{char}:" 235 | elif char_lower in self.emojify_symbols: 236 | emojified_sentence += self.emojify_symbols[char_lower] 237 | elif char_lower == " ": 238 | emojified_sentence += " " 239 | else: 240 | emojified_sentence += char 241 | 242 | await ctx.respond(emojified_sentence) 243 | 244 | @commands.slash_command(guild_ids=TESTING_GUILDS) 245 | async def ascii(self, ctx: discord.ApplicationContext, sentence: str): 246 | """ 247 | Turn a sentence into cool ASCII art 248 | """ 249 | 250 | ascii_text = pyfiglet.figlet_format(sentence) 251 | await ctx.respond(f"```{ascii_text}```") 252 | 253 | @commands.slash_command(guild_ids=TESTING_GUILDS) 254 | async def impersonate( 255 | self, 256 | ctx: discord.ApplicationContext, 257 | member: discord.Member, 258 | message: str, 259 | ): 260 | """ 261 | Pretend to be another member of your server 262 | """ 263 | 264 | await ctx.defer(ephemeral=True) 265 | 266 | async with db.async_session() as session: 267 | webhook_data: models.Webhook = await session.get( 268 | models.Webhook, ctx.channel.id 269 | ) 270 | 271 | if webhook_data: 272 | webhook = discord.utils.get( 273 | await ctx.channel.webhooks(), url=webhook_data.webhook_url 274 | ) 275 | 276 | if not webhook: 277 | webhook: discord.Webhook = ( 278 | await ctx.channel.create_webhook( 279 | name="Sparta Impersonate Command", 280 | reason="Impersonation Command", 281 | ) 282 | ) 283 | webhook_data.webhook_url = webhook.url 284 | await session.commit() 285 | 286 | else: 287 | webhook: discord.Webhook = await ctx.channel.create_webhook( 288 | name="Sparta Impersonate Command", 289 | reason="Impersonation Command", 290 | ) 291 | new_webhook_data = models.Webhook( 292 | channel_id=ctx.channel.id, webhook_url=webhook.url 293 | ) 294 | session.add(new_webhook_data) 295 | await session.commit() 296 | 297 | msg = await webhook.send( 298 | message, 299 | username=member.display_name, 300 | avatar_url=member.display_avatar.url, 301 | allowed_mentions=discord.AllowedMentions.none(), 302 | wait=True, 303 | ) 304 | 305 | async with db.async_session() as session: 306 | new_imp_log = models.ImpersonationLog( 307 | id=uuid4().hex, 308 | guild_id=ctx.guild_id, 309 | channel_id=ctx.channel_id, 310 | message_id=msg.id, 311 | user_id=member.id, 312 | impersonator_id=ctx.author.id, 313 | message=msg.content, 314 | ) 315 | session.add(new_imp_log) 316 | await session.commit() 317 | 318 | await ctx.respond("Amogus", ephemeral=True) 319 | 320 | 321 | def setup(bot): 322 | bot.add_cog(SlashFun()) 323 | -------------------------------------------------------------------------------- /bot/slash_cogs/internet.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urbanpython 3 | import discord 4 | from discord.ext import commands 5 | 6 | from bot import TESTING_GUILDS, THEME 7 | from bot.utils import search_youtube 8 | 9 | 10 | class SlashInternetStuff(commands.Cog): 11 | """ 12 | Commands to surf the interwebs without leaving Discord 13 | """ 14 | 15 | urban = urbanpython.Urban(os.environ["URBAN_API_KEY"]) 16 | 17 | @commands.slash_command(name="urban", guild_ids=TESTING_GUILDS) 18 | async def urban_dictionary( 19 | self, ctx: discord.ApplicationContext, query: str 20 | ): 21 | """ 22 | Find word definitions on Urban Dictionary 23 | """ 24 | 25 | await ctx.defer() 26 | try: 27 | result = self.urban.search(query) 28 | except IndexError: 29 | await ctx.respond( 30 | f"No definition found for term: {query}", 31 | allowed_mentions=discord.AllowedMentions.none(), 32 | ) 33 | return 34 | 35 | body = f"**Definition:**\n{result.definition}\n\n**Example:\n**{result.example}" 36 | written_on = result.written_on[:10] 37 | 38 | urban_embed = discord.Embed( 39 | title=f"Urban Dictionary: {query}", 40 | color=THEME, 41 | description=body, 42 | url=result.permalink, 43 | ) 44 | urban_embed.set_author( 45 | name=str(ctx.author), icon_url=ctx.author.avatar.url 46 | ) 47 | urban_embed.set_footer( 48 | text=f"By {result.author} on {written_on}\n👍 {result.thumbs_up} | 👎 {result.thumbs_down}" 49 | ) 50 | 51 | if len(urban_embed) > 6000: 52 | urban_embed.description = urban_embed.description[:5900] + "..." 53 | await ctx.respond( 54 | "This definition is too big, so some of the contents were hidden", 55 | embed=urban_embed, 56 | ) 57 | else: 58 | await ctx.respond(embed=urban_embed) 59 | 60 | @commands.slash_command(guild_ids=TESTING_GUILDS) 61 | async def youtube(self, ctx: discord.ApplicationContext, query: str): 62 | """ 63 | Search YouTube for videos 64 | """ 65 | 66 | await ctx.defer() 67 | first_result = await search_youtube(query) 68 | await ctx.respond(first_result) 69 | 70 | 71 | def setup(bot): 72 | bot.add_cog(SlashInternetStuff()) 73 | -------------------------------------------------------------------------------- /bot/slash_cogs/misc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import uuid 3 | import discord 4 | from datetime import datetime 5 | from discord.ext import commands 6 | from sqlalchemy.future import select 7 | 8 | from bot import TESTING_GUILDS, THEME, db 9 | from bot.db import models 10 | from bot.utils import str_time_to_timedelta 11 | from bot.views import SuggestView 12 | 13 | 14 | class SlashMiscellaneous(commands.Cog): 15 | """ 16 | Commands to do general tasks 17 | """ 18 | 19 | launched_at = int(datetime.now().timestamp()) 20 | reminders_loaded = False 21 | 22 | def __init__(self, bot: commands.Bot): 23 | self.bot = bot 24 | 25 | async def reminder( 26 | self, 27 | reminder_id: str, 28 | user_id: discord.User, 29 | seconds: float, 30 | reminder_msg: str, 31 | reminder_start_time: datetime, 32 | ): 33 | await asyncio.sleep(seconds) 34 | rem_start_time_str = f"" 35 | 36 | try: 37 | user = await self.bot.fetch_user(user_id) 38 | await user.send( 39 | f"You asked me to remind you {rem_start_time_str} about:" 40 | f"\n*{reminder_msg}*", 41 | allowed_mentions=discord.AllowedMentions.none(), 42 | ) 43 | except discord.Forbidden: 44 | pass 45 | 46 | async with db.async_session() as session: 47 | rem_data = await session.get(models.Reminder, reminder_id) 48 | await session.delete(rem_data) 49 | await session.commit() 50 | 51 | def load_reminder(self, reminder_data: models.Reminder): 52 | now = datetime.now() 53 | time_diff = (reminder_data.due - now).total_seconds() 54 | 55 | asyncio.create_task( 56 | self.reminder( 57 | reminder_data.id, 58 | reminder_data.user_id, 59 | time_diff, 60 | reminder_data.message, 61 | reminder_data.start, 62 | ) 63 | ) 64 | 65 | async def load_pending_reminders(self): 66 | print("Loading pending reminders...") 67 | 68 | async with db.async_session() as session: 69 | q = select(models.Reminder) 70 | result = await session.execute(q) 71 | 72 | for reminder in result.scalars(): 73 | self.load_reminder(reminder) 74 | 75 | self.reminders_loaded = True # TODO: Make this globally accessible 76 | print("Loaded reminders!") 77 | 78 | @commands.Cog.listener() 79 | async def on_ready(self): 80 | await self.load_pending_reminders() 81 | 82 | @commands.slash_command(guild_ids=TESTING_GUILDS) 83 | async def info(self, ctx: discord.ApplicationContext): 84 | """ 85 | Display bot information 86 | """ 87 | 88 | ping = int(self.bot.latency * 1000) 89 | guild_count = str(len(self.bot.guilds)) 90 | total_members = set() 91 | 92 | for guild in self.bot.guilds: 93 | total_members.update(mem.id for mem in guild.members) 94 | 95 | total_member_count = len(total_members) 96 | 97 | info_embed = discord.Embed(title="Sparta Bot Information", color=THEME) 98 | info_embed.set_author( 99 | name=str(ctx.author), icon_url=ctx.author.avatar.url 100 | ) 101 | info_embed.set_thumbnail(url=self.bot.user.avatar.url) 102 | 103 | info_embed.add_field( 104 | name="Latency/Ping", value=f"{ping}ms", inline=False 105 | ) 106 | info_embed.add_field( 107 | name="Server Count", value=guild_count, inline=False 108 | ) 109 | info_embed.add_field( 110 | name="Total Member Count", 111 | value=str(total_member_count), 112 | inline=False, 113 | ) 114 | 115 | await ctx.respond(embed=info_embed) 116 | 117 | @commands.slash_command(guild_ids=TESTING_GUILDS) 118 | async def invite(self, ctx: discord.ApplicationContext): 119 | """ 120 | Get Sparta's invite URL 121 | """ 122 | 123 | invite_url = "https://discord.com/api/oauth2/authorize?client_id=731763013417435247&permissions=8&scope=bot%20applications.commands" 124 | beta_invite_url = "https://discord.com/api/oauth2/authorize?client_id=775798822844629013&permissions=8&scope=applications.commands%20bot" 125 | 126 | invite_embed = discord.Embed(title="Invite Links", color=THEME) 127 | invite_embed.add_field( 128 | name="Sparta", value=f"[Click here]({invite_url})", inline=False 129 | ) 130 | invite_embed.add_field( 131 | name="Sparta Beta", 132 | value=f"[Click here]({beta_invite_url})", 133 | inline=False, 134 | ) 135 | 136 | await ctx.respond(embed=invite_embed) 137 | 138 | @commands.slash_command(guild_ids=TESTING_GUILDS) 139 | async def github(self, ctx: discord.ApplicationContext): 140 | """ 141 | Link to the GitHub Repository 142 | """ 143 | 144 | github_link = "https://github.com/SpartaDevTeam/SpartaBot" 145 | await ctx.respond(github_link) 146 | 147 | @commands.slash_command(guild_ids=TESTING_GUILDS) 148 | async def support(self, ctx: discord.ApplicationContext): 149 | """ 150 | Invite link for Sparta Support Server 151 | """ 152 | 153 | support_link = "https://discord.gg/RrVY4bP" 154 | await ctx.respond(support_link) 155 | 156 | @commands.slash_command(guild_ids=TESTING_GUILDS) 157 | async def vote(self, ctx: discord.ApplicationContext): 158 | """ 159 | Get bot list links to vote for Sparta 160 | """ 161 | 162 | top_gg_link = "https://top.gg/bot/731763013417435247" 163 | 164 | vote_embed = discord.Embed(title="Vote for Sparta Bot", color=THEME) 165 | vote_embed.add_field( 166 | name="Vote every 12 hours", value=f"[Top.gg]({top_gg_link})" 167 | ) 168 | 169 | await ctx.respond(embed=vote_embed) 170 | 171 | @commands.slash_command(guild_ids=TESTING_GUILDS) 172 | async def remind( 173 | self, 174 | ctx: discord.ApplicationContext, 175 | remind_time: str, 176 | message: str, 177 | ): 178 | """ 179 | Set a reminder. Example: /remind 1d 2h 12m 5s make lunch (All time options are not required) 180 | """ 181 | 182 | # Wait till bot finishes loading all reminders 183 | # Prevents duplicate reminders 184 | if not self.reminders_loaded: 185 | await ctx.respond( 186 | "The bot is starting up. Please try again in a few minutes." 187 | ) 188 | return 189 | 190 | now = datetime.now() 191 | remind_timedelta = str_time_to_timedelta(remind_time) 192 | time_to_end = f"" 193 | 194 | reminder_id = uuid.uuid4() 195 | new_reminder = models.Reminder( 196 | id=reminder_id.hex, 197 | user_id=ctx.author.id, 198 | message=message, 199 | start=now, 200 | due=now + remind_timedelta, 201 | ) 202 | self.load_reminder(new_reminder) 203 | 204 | async with db.async_session() as session: 205 | session.add(new_reminder) 206 | await session.commit() 207 | 208 | await ctx.respond( 209 | f"Reminder set for {time_to_end} about:\n{message}", 210 | allowed_mentions=discord.AllowedMentions.none(), 211 | ) 212 | 213 | @commands.slash_command(guild_ids=TESTING_GUILDS) 214 | async def afk(self, ctx: discord.ApplicationContext, reason: str): 215 | """ 216 | Sets your AFK status 217 | """ 218 | 219 | async with db.async_session() as session: 220 | afk_data: models.AFK | None = await session.get( 221 | models.AFK, ctx.author.id 222 | ) 223 | 224 | if afk_data: 225 | afk_data.message = reason 226 | else: 227 | new_afk_data = models.AFK( 228 | user_id=ctx.author.id, message=reason 229 | ) 230 | session.add(new_afk_data) 231 | 232 | await session.commit() 233 | 234 | await ctx.respond( 235 | f"You have been AFK'd for the following reason:\n{reason}", 236 | allowed_mentions=discord.AllowedMentions.none(), 237 | ) 238 | 239 | @commands.slash_command(guild_ids=TESTING_GUILDS) 240 | async def unafk(self, ctx: discord.ApplicationContext): 241 | """ 242 | Unset your AFK status 243 | """ 244 | 245 | async with db.async_session() as session: 246 | afk_data: models.AFK | None = await session.get( 247 | models.AFK, ctx.author.id 248 | ) 249 | 250 | if afk_data: 251 | await session.delete(afk_data) 252 | await session.commit() 253 | await ctx.respond("You are no longer AFK'd") 254 | else: 255 | await ctx.respond("You are not currently AFK'd") 256 | 257 | @commands.slash_command(guild_ids=TESTING_GUILDS) 258 | async def uptime(self, ctx: discord.ApplicationContext): 259 | """ 260 | Check how long the bot has been up for 261 | """ 262 | 263 | humanized_time = f"" 264 | await ctx.respond(f"I was last restarted {humanized_time}") 265 | 266 | @commands.slash_command(guild_ids=TESTING_GUILDS) 267 | async def suggest(self, ctx: discord.ApplicationContext, suggestion: str): 268 | """ 269 | Suggest a new feature or change to the Devs 270 | """ 271 | 272 | suggest_view = SuggestView(ctx.author.id) 273 | await ctx.respond( 274 | "Do you want to include your username with the suggestion, so we can contact you if needed?", 275 | view=suggest_view, 276 | ) 277 | timed_out = await suggest_view.wait() 278 | 279 | if timed_out or suggest_view.anonymous: 280 | suggestion_msg = ( 281 | f"Anonymous user has given a suggestion:\n{suggestion}" 282 | ) 283 | await ctx.respond( 284 | f"Thank you {ctx.author.mention}, your anonymous suggestion has been recorded." 285 | ) 286 | 287 | else: 288 | suggestion_msg = ( 289 | f"**{ctx.author}** has given a suggestion:\n{suggestion}" 290 | ) 291 | await ctx.respond( 292 | f"Thank you {ctx.author.mention}, your non-anonymous suggestion has been recorded." 293 | ) 294 | 295 | suggestion_channel = 848474796856836117 296 | suggest_channel = await self.bot.fetch_channel(suggestion_channel) 297 | await suggest_channel.send( 298 | suggestion_msg, allowed_mentions=discord.AllowedMentions.none() 299 | ) 300 | 301 | 302 | def setup(bot): 303 | bot.add_cog(SlashMiscellaneous(bot)) 304 | -------------------------------------------------------------------------------- /bot/slash_cogs/reaction_roles.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from uuid import uuid4 3 | from discord.ext import commands 4 | from emoji import emojize 5 | from sqlalchemy.future import select 6 | 7 | from bot import TESTING_GUILDS, THEME, db 8 | from bot.db import models 9 | from bot.utils import dbl_vote_required 10 | 11 | 12 | class SlashReactionRoles(commands.Cog): 13 | """ 14 | Commands to setup reaction roles for members of your server to give 15 | themselves roles 16 | """ 17 | 18 | def __init__(self, bot: commands.Bot): 19 | self.bot = bot 20 | 21 | @commands.Cog.listener() 22 | async def on_raw_reaction_add( 23 | self, payload: discord.RawReactionActionEvent 24 | ): 25 | if not payload.guild_id: 26 | return 27 | 28 | guild: discord.Guild = await self.bot.fetch_guild(payload.guild_id) 29 | member: discord.Member = await guild.fetch_member(payload.user_id) 30 | emoji: str | None = str(payload.emoji.id or payload.emoji.name) 31 | 32 | if member == self.bot.user or not emoji: 33 | return 34 | 35 | async with db.async_session() as session: 36 | q = ( 37 | select(models.ReactionRole) 38 | .where(models.ReactionRole.guild_id == guild.id) 39 | .where(models.ReactionRole.channel_id == payload.channel_id) 40 | .where(models.ReactionRole.message_id == payload.message_id) 41 | .where(models.ReactionRole.emoji == emoji) 42 | ) 43 | result = await session.execute(q) 44 | rr: models.ReactionRole = result.scalar() 45 | 46 | if rr: 47 | if role := guild.get_role(rr.role_id): # type: ignore 48 | await member.add_roles(role, reason="Reaction Role") 49 | await member.send( 50 | f"You have been given the **{role}** role in **{guild}**" 51 | ) 52 | 53 | @commands.Cog.listener() 54 | async def on_raw_reaction_remove( 55 | self, payload: discord.RawReactionActionEvent 56 | ): 57 | if not payload.guild_id: 58 | return 59 | 60 | guild: discord.Guild = await self.bot.fetch_guild(payload.guild_id) 61 | member: discord.Member = await guild.fetch_member(payload.user_id) 62 | emoji: str | None = str(payload.emoji.id or payload.emoji.name) 63 | 64 | if member == self.bot.user or not emoji: 65 | return 66 | 67 | async with db.async_session() as session: 68 | q = ( 69 | select(models.ReactionRole) 70 | .where(models.ReactionRole.guild_id == guild.id) 71 | .where(models.ReactionRole.channel_id == payload.channel_id) 72 | .where(models.ReactionRole.message_id == payload.message_id) 73 | .where(models.ReactionRole.emoji == emoji) 74 | ) 75 | result = await session.execute(q) 76 | rr: models.ReactionRole = result.scalar() 77 | 78 | if rr: 79 | if role := guild.get_role(rr.role_id): # type: ignore 80 | await member.remove_roles(role, reason="Reaction Role") 81 | await member.send( 82 | f"Your **{role}** role in **{guild}** has been removed" 83 | ) 84 | 85 | @commands.slash_command(name="addreactionrole", guild_ids=TESTING_GUILDS) 86 | @commands.bot_has_guild_permissions(manage_roles=True, add_reactions=True) 87 | @commands.has_guild_permissions(manage_roles=True) 88 | @dbl_vote_required() 89 | async def add_reaction_role( 90 | self, 91 | ctx: discord.ApplicationContext, 92 | channel: discord.TextChannel, 93 | role: discord.Role, 94 | emoji: str, 95 | message_id: str, 96 | ): 97 | """ 98 | Add a reaction role 99 | """ 100 | 101 | original_emoji = emoji = emoji.strip() 102 | 103 | if emoji.startswith("<") and emoji.endswith(">"): 104 | try: 105 | emoji = emoji.strip("<>").split(":")[-1] 106 | except ValueError: 107 | await ctx.respond("Unable to read the given custom emoji") 108 | return 109 | 110 | if message_id.isnumeric(): 111 | message_id = int(message_id) 112 | else: 113 | await ctx.respond("Invalid message ID was provided") 114 | return 115 | 116 | await ctx.defer() 117 | 118 | async with db.async_session() as session: 119 | q = ( 120 | select(models.ReactionRole) 121 | .where(models.ReactionRole.guild_id == ctx.guild_id) 122 | .where(models.ReactionRole.channel_id == channel.id) 123 | .where(models.ReactionRole.message_id == message_id) 124 | .where(models.ReactionRole.emoji == emoji) 125 | .where(models.ReactionRole.role_id == role.id) 126 | ) 127 | result = await session.execute(q) 128 | existing_rr: models.ReactionRole = result.scalar() 129 | 130 | if existing_rr: 131 | await ctx.respond( 132 | f"A reaction role with this configuration already exists with ID `{existing_rr.id}`" 133 | ) 134 | 135 | else: 136 | try: 137 | message = await channel.fetch_message(message_id) 138 | await message.add_reaction(original_emoji) 139 | new_rr_id = uuid4() 140 | 141 | async with db.async_session() as session: 142 | new_rr = models.ReactionRole( 143 | id=new_rr_id.hex, 144 | guild_id=ctx.guild_id, 145 | channel_id=channel.id, 146 | message_id=message_id, 147 | emoji=emoji, 148 | role_id=role.id, 149 | ) 150 | session.add(new_rr) 151 | await session.commit() 152 | 153 | await ctx.respond( 154 | f"Reaction Role with ID `{new_rr_id.hex}` for {role.mention} has been created with {original_emoji}.\n\nJump to message: {message.jump_url}", 155 | allowed_mentions=discord.AllowedMentions.none(), 156 | ) 157 | 158 | except discord.NotFound: 159 | await ctx.respond("Could not find a message with the given ID") 160 | 161 | except discord.Forbidden: 162 | await ctx.respond( 163 | "Cannot access the message with the given ID" 164 | ) 165 | 166 | @commands.slash_command( 167 | name="removereactionrole", guild_ids=TESTING_GUILDS 168 | ) 169 | @commands.bot_has_guild_permissions(manage_roles=True) 170 | @commands.has_guild_permissions(manage_roles=True) 171 | async def remove_reaction_role( 172 | self, ctx: discord.ApplicationContext, id: str 173 | ): 174 | """ 175 | Remove a reaction role 176 | """ 177 | 178 | async with db.async_session() as session: 179 | rr = await session.get(models.ReactionRole, id) 180 | 181 | if rr: 182 | await session.delete(rr) 183 | await session.commit() 184 | await ctx.respond( 185 | f"Reaction Role with ID `{id}` has been removed" 186 | ) 187 | else: 188 | await ctx.respond( 189 | "Could not find a reaction role with the given ID" 190 | ) 191 | 192 | @commands.slash_command(name="viewreactionroles", guild_ids=TESTING_GUILDS) 193 | async def view_reaction_roles(self, ctx: discord.ApplicationContext): 194 | """ 195 | See the reaction roles setup in your server. 196 | """ 197 | 198 | await ctx.defer() 199 | 200 | async with db.async_session() as session: 201 | q = select(models.ReactionRole).where( 202 | models.ReactionRole.guild_id == ctx.guild_id 203 | ) 204 | result = await session.execute(q) 205 | reaction_roles: list[models.ReactionRole] = result.scalars().all() 206 | 207 | if not reaction_roles: 208 | await ctx.respond( 209 | "There aren't any reaction roles setup in this server" 210 | ) 211 | return 212 | 213 | guild_roles = await ctx.guild.fetch_roles() 214 | rr_embed = discord.Embed( 215 | title=f"{ctx.guild.name} Reaction Roles", color=THEME 216 | ) 217 | 218 | for rr in reaction_roles: 219 | 220 | async def delete_rr(): 221 | async with db.async_session() as session: 222 | await session.delete(rr) 223 | await session.commit() 224 | 225 | try: 226 | rr_channel: discord.TextChannel = ( 227 | await ctx.guild.fetch_channel(rr.channel_id) 228 | ) 229 | except discord.NotFound: 230 | await delete_rr() 231 | continue 232 | 233 | try: 234 | rr_message: discord.Message = await rr_channel.fetch_message( 235 | rr.message_id 236 | ) 237 | except discord.NotFound: 238 | await delete_rr() 239 | continue 240 | 241 | if rr.emoji.isnumeric(): 242 | try: 243 | rr_emoji: discord.Emoji = await ctx.guild.fetch_emoji( 244 | int(rr.emoji) 245 | ) 246 | except discord.NotFound: 247 | await delete_rr() 248 | continue 249 | else: 250 | rr_emoji = emojize(rr.emoji) 251 | 252 | if not (rr_role := discord.utils.get(guild_roles, id=rr.role_id)): 253 | await delete_rr() 254 | continue 255 | 256 | embed_str = ( 257 | f"Emoji: {rr_emoji}\n" 258 | f"Role: {rr_role.mention}\n" 259 | f"Channel: {rr_channel.mention}\n" 260 | f"[Jump to Message]({rr_message.jump_url})" 261 | ) 262 | rr_embed.add_field( 263 | name=f"ID: {rr.id}", value=embed_str, inline=False 264 | ) 265 | 266 | await ctx.respond(embed=rr_embed) 267 | 268 | 269 | def setup(bot): 270 | bot.add_cog(SlashReactionRoles(bot)) 271 | -------------------------------------------------------------------------------- /bot/slash_cogs/server_logs.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import datetime 3 | from discord.ext import commands 4 | 5 | from bot import THEME 6 | 7 | 8 | class SlashServerLogs(commands.Cog): 9 | """ 10 | Shows when the bot joins or leaves a guild 11 | """ 12 | 13 | logs_channel = 843726111360024586 14 | 15 | def __init__(self, bot: commands.Bot): 16 | self.bot = bot 17 | 18 | @commands.Cog.listener() 19 | async def on_guild_join(self, guild): 20 | embed = discord.Embed( 21 | title="I Have Joined A New Guild!", 22 | description=guild.name, 23 | timestamp=datetime.datetime.now(), 24 | color=THEME, 25 | ) 26 | embed.add_field( 27 | name=f"This Guild Has {guild.member_count} Members!", 28 | value=f"Yay Another Server! We Are Now At {len(self.bot.guilds)} Guilds!", 29 | ) 30 | await self.bot.get_channel(self.logs_channel).send(embed=embed) 31 | 32 | @commands.Cog.listener() 33 | async def on_guild_remove(self, guild): 34 | embed = discord.Embed( 35 | title="I Have Left A Guild!", 36 | description=f"{guild.name}", 37 | timestamp=datetime.datetime.now(), 38 | color=THEME, 39 | ) 40 | embed.add_field( 41 | name=f"We Are Now At {len(self.bot.guilds)} Guilds!", value="T-T" 42 | ) 43 | await self.bot.get_channel(self.logs_channel).send(embed=embed) 44 | 45 | 46 | def setup(bot): 47 | bot.add_cog(SlashServerLogs(bot)) 48 | -------------------------------------------------------------------------------- /bot/slash_cogs/settings.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import discord 3 | from discord.ext import commands 4 | 5 | from bot import TESTING_GUILDS, THEME, db 6 | from bot.db import models 7 | 8 | 9 | class SlashSettings(commands.Cog): 10 | """ 11 | Commands to change Sparta settings for the current server 12 | """ 13 | 14 | @commands.slash_command(name="muterole", guild_ids=TESTING_GUILDS) 15 | @commands.has_guild_permissions(manage_roles=True) 16 | async def mute_role( 17 | self, ctx: discord.ApplicationContext, role: discord.Role 18 | ): 19 | """ 20 | Set a role to give to people when you mute them 21 | """ 22 | 23 | async with db.async_session() as session: 24 | guild: models.Guild | None = await session.get( 25 | models.Guild, ctx.guild_id 26 | ) 27 | 28 | if guild: 29 | guild.mute_role = role.id 30 | else: 31 | new_guild_data = models.Guild( 32 | id=ctx.guild_id, mute_role=role.id 33 | ) 34 | session.add(new_guild_data) 35 | 36 | await session.commit() 37 | 38 | await ctx.respond( 39 | f"The mute role has been set to {role.mention}", 40 | allowed_mentions=discord.AllowedMentions.none(), 41 | ) 42 | 43 | @commands.slash_command(name="welcomemessage", guilds_ids=TESTING_GUILDS) 44 | @commands.has_guild_permissions(administrator=True) 45 | async def welcome_message( 46 | self, ctx: discord.ApplicationContext, message: str = None 47 | ): 48 | """ 49 | Change the welcome message of your server. Variables you can use: [mention], [member], [server] 50 | """ 51 | 52 | async with db.async_session() as session: 53 | guild: models.Guild | None = await session.get( 54 | models.Guild, ctx.guild_id 55 | ) 56 | 57 | if guild: 58 | guild.welcome_message = message 59 | else: 60 | new_guild_data = models.Guild( 61 | id=ctx.guild_id, welcome_message=message 62 | ) 63 | session.add(new_guild_data) 64 | 65 | await session.commit() 66 | 67 | if message: 68 | await ctx.respond( 69 | f"This server's welcome message has been set to:\n{message}" 70 | ) 71 | else: 72 | await ctx.respond( 73 | "This server's welcome message has been reset to default" 74 | ) 75 | 76 | @commands.slash_command(name="leavemessage", guild_ids=TESTING_GUILDS) 77 | @commands.has_guild_permissions(administrator=True) 78 | async def leave_message( 79 | self, ctx: discord.ApplicationContext, message: str = None 80 | ): 81 | """ 82 | Change the leave message of your server. Variables you can use: [member], [server] 83 | """ 84 | 85 | async with db.async_session() as session: 86 | guild: models.Guild | None = await session.get( 87 | models.Guild, ctx.guild_id 88 | ) 89 | 90 | if guild: 91 | guild.leave_message = message 92 | else: 93 | new_guild_data = models.Guild( 94 | id=ctx.guild_id, leave_message=message 95 | ) 96 | session.add(new_guild_data) 97 | 98 | await session.commit() 99 | 100 | if message: 101 | await ctx.respond( 102 | f"This server's leave message has been set to:\n{message}" 103 | ) 104 | else: 105 | await ctx.respond( 106 | "This server's leave message has been reset to default" 107 | ) 108 | 109 | @commands.slash_command(name="welcomechannel", guild_ids=TESTING_GUILDS) 110 | @commands.has_guild_permissions(administrator=True) 111 | async def welcome_channel( 112 | self, 113 | ctx: discord.ApplicationContext, 114 | channel: discord.TextChannel = None, 115 | ): 116 | """ 117 | Change the channel where welcome messages are sent (don't pass a channel to disable welcome message) 118 | """ 119 | 120 | async with db.async_session() as session: 121 | guild: models.Guild | None = await session.get( 122 | models.Guild, ctx.guild_id 123 | ) 124 | 125 | ch = str(channel.id) if channel else "disabled" 126 | 127 | if guild: 128 | guild.welcome_channel = ch 129 | else: 130 | new_guild_data = models.Guild( 131 | id=ctx.guild_id, welcome_channel=ch 132 | ) 133 | session.add(new_guild_data) 134 | 135 | await session.commit() 136 | 137 | if channel: 138 | await ctx.respond( 139 | f"The server's welcome channel has been set to {channel.mention}" 140 | ) 141 | else: 142 | await ctx.respond("The server's welcome message has been disabled") 143 | 144 | @commands.slash_command(name="leavechannel", guild_ids=TESTING_GUILDS) 145 | @commands.has_guild_permissions(administrator=True) 146 | async def leave_channel( 147 | self, 148 | ctx: discord.ApplicationContext, 149 | channel: discord.TextChannel = None, 150 | ): 151 | """ 152 | Change the channel where leave messages are sent (don't pass a channel to disable leave message) 153 | """ 154 | 155 | async with db.async_session() as session: 156 | guild: models.Guild | None = await session.get( 157 | models.Guild, ctx.guild_id 158 | ) 159 | 160 | ch = str(channel.id) if channel else "disabled" 161 | 162 | if guild: 163 | guild.leave_channel = ch 164 | else: 165 | new_guild_data = models.Guild( 166 | id=ctx.guild_id, leave_channel=ch 167 | ) 168 | session.add(new_guild_data) 169 | 170 | await session.commit() 171 | 172 | if channel: 173 | await ctx.respond( 174 | f"The server's leave channel has been set to {channel.mention}" 175 | ) 176 | else: 177 | await ctx.respond("The server's leave message has been disabled") 178 | 179 | @commands.slash_command(name="autorole", guild_ids=TESTING_GUILDS) 180 | @commands.has_guild_permissions(administrator=True) 181 | async def auto_role( 182 | self, ctx: discord.ApplicationContext, role: discord.Role = None 183 | ): 184 | """ 185 | Set a role to give to new members who join your server 186 | """ 187 | 188 | async with db.async_session() as session: 189 | guild: models.Guild | None = await session.get( 190 | models.Guild, ctx.guild_id 191 | ) 192 | 193 | role_id = role.id if role else None 194 | 195 | if guild: 196 | guild.auto_role = role_id 197 | else: 198 | new_guild_data = models.Guild( 199 | id=ctx.guild_id, auto_role=role_id 200 | ) 201 | session.add(new_guild_data) 202 | 203 | await session.commit() 204 | 205 | if role: 206 | await ctx.respond( 207 | f"Auto role has been set to {role.mention}", 208 | allowed_mentions=discord.AllowedMentions.none(), 209 | ) 210 | else: 211 | await ctx.respond("Auto role has been removed") 212 | 213 | @commands.slash_command(name="serverinfo", guild_ids=TESTING_GUILDS) 214 | async def server_info(self, ctx: discord.ApplicationContext): 215 | """ 216 | Get general information about the server 217 | """ 218 | 219 | human_count = len( 220 | [member for member in ctx.guild.members if not member.bot] 221 | ) 222 | bot_count = ctx.guild.member_count - human_count 223 | 224 | si_embed = discord.Embed( 225 | title=f"{ctx.guild.name} Information", color=THEME 226 | ) 227 | if icon := ctx.guild.icon: 228 | si_embed.set_thumbnail(url=icon.url) 229 | 230 | si_embed.add_field( 231 | name="Human Members", value=str(human_count), inline=False 232 | ) 233 | si_embed.add_field( 234 | name="Bot Members", value=str(bot_count), inline=False 235 | ) 236 | si_embed.add_field( 237 | name="Total Members", 238 | value=str(ctx.guild.member_count), 239 | inline=False, 240 | ) 241 | si_embed.add_field( 242 | name="Role Count", value=str(len(ctx.guild.roles)), inline=False 243 | ) 244 | si_embed.add_field( 245 | name="Server Owner", value=str(ctx.guild.owner), inline=False 246 | ) 247 | si_embed.add_field(name="Server ID", value=ctx.guild.id, inline=False) 248 | si_embed.add_field( 249 | name="Server Age", 250 | value=f"Created on ", 251 | inline=False, 252 | ) 253 | 254 | await ctx.respond(embed=si_embed) 255 | 256 | @commands.slash_command(name="memberinfo", guild_ids=TESTING_GUILDS) 257 | async def member_info( 258 | self, ctx: discord.ApplicationContext, member: discord.Member = None 259 | ): 260 | """ 261 | Get general information about a member 262 | """ 263 | 264 | if not member: 265 | member = ctx.author 266 | 267 | mi_embed = discord.Embed(title=f"{member} Information", color=THEME) 268 | if avatar := member.avatar: 269 | mi_embed.set_thumbnail(url=avatar.url) 270 | 271 | mi_embed.add_field(name="Member ID", value=member.id, inline=False) 272 | mi_embed.add_field( 273 | name="Joined Discord", 274 | value=f"", 275 | inline=False, 276 | ) 277 | mi_embed.add_field( 278 | name="Joined Server", 279 | value=f"", 280 | inline=False, 281 | ) 282 | mi_embed.add_field( 283 | name="Highest Role", value=member.top_role.mention, inline=False 284 | ) 285 | mi_embed.add_field( 286 | name="Bot?", value="Yes" if member.bot else "No", inline=False 287 | ) 288 | 289 | await ctx.respond(embed=mi_embed) 290 | 291 | @commands.slash_command(name="steal", guild_ids=TESTING_GUILDS) 292 | @commands.bot_has_guild_permissions(manage_emojis=True) 293 | @commands.has_guild_permissions(manage_emojis=True) 294 | async def steal_emoji( 295 | self, 296 | ctx: discord.ApplicationContext, 297 | emoji: str, 298 | new_name: str = None, 299 | ): 300 | """ 301 | Steal another server's emoji 302 | """ 303 | 304 | emoji = emoji.strip() 305 | 306 | if not emoji.startswith("<") and emoji.endswith(">"): 307 | await ctx.respond("Please provide a valid custom emoji") 308 | return 309 | 310 | emoji_split = emoji.strip("<>").split(":") 311 | emoji_id = emoji_split[-1] 312 | 313 | if not emoji_id.isnumeric(): 314 | await ctx.respond("Unable to read the given custom emoji") 315 | return 316 | 317 | if emoji_split[0] == "a": 318 | url = ( 319 | "https://cdn.discordapp.com/emojis/" 320 | + str(emoji_split[2]) 321 | + ".gif?v=1" 322 | ) 323 | else: 324 | url = ( 325 | "https://cdn.discordapp.com/emojis/" 326 | + str(emoji_split[2]) 327 | + ".png?v=1" 328 | ) 329 | 330 | await ctx.defer() 331 | async with aiohttp.request("GET", url) as resp: 332 | image_data = await resp.read() 333 | 334 | if not new_name: 335 | new_name = emoji_split[1] 336 | 337 | try: 338 | await ctx.guild.create_custom_emoji( 339 | name=new_name, 340 | image=image_data, 341 | reason=f"Added by {ctx.author} using /steal command", 342 | ) 343 | await ctx.respond(f"{emoji} has been added as `:{new_name}:`") 344 | 345 | except discord.HTTPException as e: 346 | await ctx.respond( 347 | f"An error occured while added the emoji: `{e.text}`" 348 | ) 349 | 350 | @commands.slash_command(name="clearcap", guild_ids=TESTING_GUILDS) 351 | @commands.has_guild_permissions(administrator=True) 352 | async def clear_cap( 353 | self, ctx: discord.ApplicationContext, limit: int = None 354 | ): 355 | """ 356 | Set the maximum number of messages that can be cleared using /clear 357 | """ 358 | 359 | async with db.async_session() as session: 360 | guild: models.Guild | None = await session.get( 361 | models.Guild, ctx.guild_id 362 | ) 363 | 364 | if guild: 365 | guild.clear_cap = limit 366 | else: 367 | new_guild_data = models.Guild(id=ctx.guild_id, clear_cap=limit) 368 | session.add(new_guild_data) 369 | 370 | await session.commit() 371 | 372 | if limit: 373 | await ctx.respond( 374 | f"Clear command limit has been set to **{limit} messages** at a time." 375 | ) 376 | else: 377 | await ctx.respond("Clear command limit has been removed.") 378 | 379 | 380 | def setup(bot): 381 | bot.add_cog(SlashSettings()) 382 | -------------------------------------------------------------------------------- /bot/slash_cogs/snipe.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | from bot import TESTING_GUILDS, THEME 5 | 6 | 7 | class SlashSnipe(commands.Cog): 8 | """ 9 | Commands to snipe out messages that people try to hide 10 | """ 11 | 12 | deleted_msgs = {} 13 | edited_msgs = {} 14 | snipe_limit = 7 15 | 16 | @commands.Cog.listener() 17 | async def on_message_delete(self, message: discord.Message): 18 | ch_id = message.channel.id 19 | 20 | if not message.author.bot: 21 | if message.content: 22 | if ch_id not in self.deleted_msgs: 23 | self.deleted_msgs[ch_id] = [] 24 | 25 | self.deleted_msgs[ch_id].append(message) 26 | 27 | if len(self.deleted_msgs[ch_id]) > self.snipe_limit: 28 | self.deleted_msgs[ch_id].pop(0) 29 | 30 | @commands.Cog.listener() 31 | async def on_message_edit( 32 | self, before: discord.Message, after: discord.Message 33 | ): 34 | ch_id = before.channel.id 35 | 36 | if not before.author.bot: 37 | if before.content and after.content: 38 | if ch_id not in self.edited_msgs: 39 | self.edited_msgs[ch_id] = [] 40 | 41 | self.edited_msgs[ch_id].append((before, after)) 42 | 43 | if len(self.edited_msgs[ch_id]) > self.snipe_limit: 44 | self.edited_msgs[ch_id].pop(0) 45 | 46 | @commands.slash_command(guild_ids=TESTING_GUILDS) 47 | async def snipe(self, ctx: discord.ApplicationContext, limit: int = 1): 48 | """ 49 | See recently deleted messages in the current channel 50 | """ 51 | 52 | if limit > self.snipe_limit: 53 | await ctx.respond( 54 | f"Maximum snipe limit is {self.snipe_limit}", ephemeral=True 55 | ) 56 | return 57 | 58 | try: 59 | msgs: list[discord.Message] = self.deleted_msgs[ctx.channel.id][ 60 | ::-1 61 | ][:limit] 62 | snipe_embed = discord.Embed(title="Message Snipe", color=THEME) 63 | 64 | if msgs: 65 | await ctx.defer() 66 | top_author: discord.Member = await ctx.bot.get_or_fetch_user( 67 | msgs[0].author.id 68 | ) 69 | 70 | if top_author: 71 | snipe_embed.set_thumbnail( 72 | url=top_author.display_avatar.url 73 | ) 74 | 75 | for msg in msgs: 76 | snipe_embed.add_field( 77 | name=str(msg.author), value=msg.content, inline=False 78 | ) 79 | 80 | await ctx.respond(embed=snipe_embed) 81 | 82 | except KeyError: 83 | await ctx.respond( 84 | "There's nothing to snipe here...", ephemeral=True 85 | ) 86 | 87 | @commands.slash_command(name="editsnipe", guild_ids=TESTING_GUILDS) 88 | async def edit_snipe( 89 | self, ctx: discord.ApplicationContext, limit: int = 1 90 | ): 91 | """ 92 | See recently edited messages in the current channel 93 | """ 94 | 95 | if limit > self.snipe_limit: 96 | await ctx.respond( 97 | f"Maximum snipe limit is {self.snipe_limit}", ephemeral=True 98 | ) 99 | return 100 | 101 | try: 102 | msgs = self.edited_msgs[ctx.channel.id][::-1][:limit] 103 | editsnipe_embed = discord.Embed(title="Edit Snipe", color=THEME) 104 | 105 | if msgs: 106 | await ctx.defer() 107 | top_author: discord.Member = await ctx.bot.get_or_fetch_user( 108 | msgs[0][0].author.id 109 | ) 110 | 111 | if top_author: 112 | editsnipe_embed.set_thumbnail( 113 | url=top_author.display_avatar.url 114 | ) 115 | 116 | for msg in msgs: 117 | editsnipe_embed.add_field( 118 | name=str(msg[0].author), 119 | value=f"{msg[0].content} **-->** {msg[1].content}", 120 | inline=False, 121 | ) 122 | 123 | await ctx.respond(embed=editsnipe_embed) 124 | 125 | except KeyError: 126 | await ctx.respond( 127 | "There's nothing to snipe here...", ephemeral=True 128 | ) 129 | 130 | 131 | def setup(bot): 132 | bot.add_cog(SlashSnipe()) 133 | -------------------------------------------------------------------------------- /bot/slash_cogs/status.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands, tasks 3 | 4 | 5 | class SlashStatus(commands.Cog): 6 | """ 7 | Automatically changing status 8 | """ 9 | 10 | status_msgs = [ 11 | (discord.ActivityType.listening, "my new music commands"), 12 | (discord.ActivityType.watching, "[guild_count] servers"), 13 | (discord.ActivityType.playing, "Hypixel"), 14 | (discord.ActivityType.competing, "Steel Ball Run"), 15 | (discord.ActivityType.watching, "JoJo"), 16 | (discord.ActivityType.playing, "Amogus"), 17 | (discord.ActivityType.watching, "out for /help and s!help"), 18 | ] 19 | status_index = 0 20 | 21 | def __init__(self, bot: commands.Bot): 22 | self.bot = bot 23 | 24 | @commands.Cog.listener() 25 | async def on_ready(self): 26 | self.status_task.start() 27 | 28 | def cog_unload(self): 29 | self.status_task.cancel() 30 | 31 | @tasks.loop(seconds=120) 32 | async def status_task(self): 33 | activity = self.status_msgs[self.status_index] 34 | activ_type = activity[0] 35 | activ_msg = activity[1] 36 | 37 | if "[guild_count]" in activ_msg: 38 | guild_count = len(self.bot.guilds) 39 | activ_msg = activ_msg.replace("[guild_count]", str(guild_count)) 40 | 41 | activ = discord.Activity(type=activ_type, name=activ_msg) 42 | await self.bot.change_presence(activity=activ) 43 | 44 | self.status_index += 1 45 | if self.status_index >= len(self.status_msgs): 46 | self.status_index = 0 47 | 48 | 49 | def setup(bot): 50 | bot.add_cog(SlashStatus(bot)) 51 | -------------------------------------------------------------------------------- /bot/slash_cogs/welcome_leave.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import discord 4 | from discord.ext import commands 5 | from PIL import Image, ImageFont, ImageDraw 6 | 7 | from bot import db 8 | from bot.db import models 9 | 10 | 11 | class SlashWelcomeLeave(commands.Cog): 12 | """ 13 | Welcome and leave message sender 14 | """ 15 | 16 | assets_dir = os.path.join( 17 | os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 18 | "assets", 19 | ) 20 | cache_dir = os.path.join( 21 | os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 22 | "cache", 23 | ) 24 | 25 | def __init__(self): 26 | # Make sure that cache dir exists 27 | if "cache" not in os.listdir(os.path.dirname(self.cache_dir)): 28 | os.mkdir(self.cache_dir) 29 | 30 | def default_welcome_msg(self, guild: discord.Guild) -> str: 31 | return f"Hello [mention], welcome to {guild.name}!" 32 | 33 | def default_leave_msg(self, guild: discord.Guild) -> str: 34 | return f"Goodbye [member], thanks for staying at {guild.name}!" 35 | 36 | def get_asset(self, asset_name: str) -> str: 37 | return os.path.join(self.assets_dir, asset_name) 38 | 39 | def center_to_corner( 40 | self, center_pos: tuple[int], size: tuple[int] 41 | ) -> tuple[int]: 42 | return ( 43 | center_pos[0] - size[0] // 2, 44 | center_pos[1] - size[1] // 2, 45 | ) 46 | 47 | async def find_welcome_channel( 48 | self, guild: discord.Guild 49 | ) -> discord.TextChannel or None: 50 | channels: list[discord.TextChannel] = await guild.fetch_channels() 51 | 52 | for channel in channels: 53 | if "welcome" in channel.name: 54 | return channel 55 | 56 | return None 57 | 58 | async def find_leave_channel( 59 | self, guild: discord.Guild 60 | ) -> discord.TextChannel or None: 61 | channels: list[discord.TextChannel] = await guild.fetch_channels() 62 | 63 | for channel in channels: 64 | if "bye" in channel.name: 65 | return channel 66 | 67 | return None 68 | 69 | @commands.Cog.listener() 70 | async def on_member_join(self, member: discord.Member): 71 | guild: discord.Guild = member.guild 72 | 73 | async with db.async_session() as session: 74 | guild_data: models.Guild | None = await session.get( 75 | models.Guild, guild.id 76 | ) 77 | 78 | if guild_data: 79 | welcome_message = guild_data.welcome_message 80 | welcome_channel_id = guild_data.welcome_channel 81 | auto_role_id = guild_data.auto_role 82 | else: 83 | new_guild_data = models.Guild(id=guild.id) 84 | session.add(new_guild_data) 85 | await session.commit() 86 | 87 | welcome_message = new_guild_data.welcome_message 88 | welcome_channel_id = new_guild_data.welcome_channel 89 | auto_role_id = new_guild_data.auto_role 90 | 91 | if welcome_channel_id == "disabled": 92 | return 93 | 94 | if not welcome_channel_id: 95 | welcome_channel = await self.find_welcome_channel(guild) 96 | 97 | # Exit the function if no welcome channel is provided or 98 | # automatically found 99 | if not welcome_channel: 100 | return 101 | else: 102 | welcome_channel = guild.get_channel(int(welcome_channel_id)) 103 | 104 | if auto_role_id: 105 | auto_role = guild.get_role(int(auto_role_id)) 106 | else: 107 | auto_role = None 108 | 109 | if not welcome_message: 110 | welcome_message = self.default_welcome_msg(guild) 111 | 112 | # Replace placeholders with actual information 113 | welcome_message = welcome_message.replace("[mention]", member.mention) 114 | welcome_message = welcome_message.replace("[member]", str(member)) 115 | welcome_message = welcome_message.replace("[server]", str(guild)) 116 | 117 | # Get user's avatar 118 | avatar_path = os.path.join(self.cache_dir, "pfp.jpg") 119 | await member.display_avatar.save(avatar_path) 120 | 121 | # Welcome image variables 122 | avatar_center_pos = (1920, 867) 123 | username_center_pos = (1920, 150) 124 | welcome_msg = "Welcome To" 125 | welcome_msg_center_pos = (1920, 1600) 126 | server_center_pos = (1920, 1900) 127 | 128 | # Prepare circle avatar 129 | im = Image.open(avatar_path) 130 | im = im.convert("RGB") 131 | im = im.resize((1024, 1024)) 132 | bigsize = (im.size[0] * 3, im.size[1] * 3) 133 | mask = Image.new("L", bigsize, 0) 134 | draw = ImageDraw.Draw(mask) 135 | draw.ellipse((0, 0) + bigsize, fill=255) 136 | mask = mask.resize(im.size, Image.Resampling.BILINEAR) 137 | im.putalpha(mask) 138 | im.save(os.path.join(self.cache_dir, "lol.png")) 139 | 140 | # output = ImageOps.fit(im, mask.size, centering=(0.5, 0.5)) 141 | # output.putalpha(mask) 142 | # output.save(os.path.join(self.cache_dir, 'circle_pfp.png')) 143 | 144 | # Prepare welcome image 145 | w_img_path = os.path.join(self.cache_dir, "welcome.jpg") 146 | w_img = Image.open(self.get_asset("welcome_image.jpg")) 147 | avatar_corner_pos = self.center_to_corner(avatar_center_pos, im.size) 148 | 149 | # If error occurs during paste function try again 150 | error_count = 0 151 | while True: 152 | if error_count > 5: 153 | # just don't send a welcome message if paste shits itself 154 | # multiple times in a row 155 | return 156 | 157 | try: 158 | w_img.paste(im, avatar_corner_pos, im) 159 | break 160 | except MemoryError: 161 | error_count += 1 162 | continue 163 | 164 | w_img_draw = ImageDraw.Draw(w_img) 165 | username_font = ImageFont.truetype( 166 | self.get_asset("montserrat_extrabold.otf"), 165 167 | ) 168 | welcome_font = ImageFont.truetype( 169 | self.get_asset("earthorbiterxtrabold.ttf"), 250 170 | ) 171 | server_font_size = 285 172 | 173 | # Make sure that server name doesnt overflow 174 | while True: 175 | server_font = ImageFont.truetype( 176 | self.get_asset("earthorbiterxtrabold.ttf"), server_font_size 177 | ) 178 | server_length = server_font.getlength(guild.name) 179 | 180 | # Check whether text overflows 181 | if server_length >= w_img.size[0]: 182 | server_font_size -= 5 183 | else: 184 | break 185 | 186 | # Add username to image 187 | username_bbox = username_font.getbbox(str(member)) 188 | username_size = (username_bbox[2] - username_bbox[0], username_bbox[3] - username_bbox[1]) 189 | username_corner_pos = self.center_to_corner( 190 | username_center_pos, username_size 191 | ) 192 | w_img_draw.text( 193 | username_corner_pos, 194 | str(member), 195 | fill=(255, 255, 255), 196 | font=username_font, 197 | ) 198 | 199 | # Add welcome message to image 200 | welcome_msg_bbox = welcome_font.getbbox(welcome_msg) 201 | welcome_msg_size = (welcome_msg_bbox[2] - welcome_msg_bbox[0], welcome_msg_bbox[3] - welcome_msg_bbox[1]) 202 | welcome_msg_corner_pos = self.center_to_corner( 203 | welcome_msg_center_pos, welcome_msg_size 204 | ) 205 | w_img_draw.text( 206 | welcome_msg_corner_pos, 207 | welcome_msg, 208 | fill=(255, 255, 255), 209 | font=welcome_font, 210 | ) 211 | 212 | # Add server name to image 213 | server_bbox = server_font.getbbox(guild.name) 214 | server_size = (server_bbox[2] - server_bbox[0], server_bbox[3] - server_bbox[1]) 215 | server_corner_pos = self.center_to_corner( 216 | server_center_pos, server_size 217 | ) 218 | w_img_draw.text( 219 | server_corner_pos, 220 | guild.name, 221 | fill=(255, 255, 255), 222 | font=server_font, 223 | ) 224 | 225 | # Save the image to cache 226 | loop = asyncio.get_event_loop() 227 | await loop.run_in_executor(None, w_img.save, w_img_path) 228 | 229 | await welcome_channel.send( 230 | welcome_message, file=discord.File(w_img_path) 231 | ) 232 | 233 | # Give auto role to new member if they are not a bot 234 | if not member.bot and auto_role: 235 | await member.add_roles(auto_role) 236 | 237 | @commands.Cog.listener() 238 | async def on_member_remove(self, member: discord.Member): 239 | guild: discord.Guild = member.guild 240 | 241 | async with db.async_session() as session: 242 | guild_data: models.Guild | None = await session.get( 243 | models.Guild, guild.id 244 | ) 245 | 246 | if guild_data: 247 | leave_message = guild_data.leave_message 248 | leave_channel_id = guild_data.leave_channel 249 | else: 250 | new_guild_data = models.Guild(id=guild.id) 251 | session.add(new_guild_data) 252 | await session.commit() 253 | 254 | leave_message = new_guild_data.leave_message 255 | leave_channel_id = new_guild_data.leave_channel 256 | 257 | if leave_channel_id == "disabled": 258 | return 259 | 260 | if not leave_channel_id: 261 | leave_channel = await self.find_leave_channel(guild) 262 | 263 | # Exit the function if no leave channel is provided or 264 | # automatically found 265 | if not leave_channel: 266 | return 267 | else: 268 | leave_channel = guild.get_channel(int(leave_channel_id)) 269 | 270 | if not leave_message: 271 | leave_message = self.default_leave_msg(guild) 272 | 273 | # Replace placeholders with actual information 274 | leave_message = leave_message.replace("[member]", str(member)) 275 | leave_message = leave_message.replace("[server]", str(guild)) 276 | 277 | await leave_channel.send(leave_message) 278 | 279 | 280 | def setup(bot): 281 | bot.add_cog(SlashWelcomeLeave()) 282 | -------------------------------------------------------------------------------- /bot/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from typing import Any 4 | from datetime import timedelta 5 | import aiohttp 6 | import discord 7 | from discord.ext import commands 8 | 9 | from bot import MyBot 10 | from bot.errors import DBLVoteRequired 11 | 12 | 13 | def get_time( 14 | key: str, string: str 15 | ) -> int: # CircuitSacul == pog (he made this) 16 | string = f" {string} " 17 | results = re.findall(f" [0-9]+{key}", string) 18 | if len(list(results)) < 1: 19 | return 0 20 | r = results[0] 21 | r = r[1 : 0 - len(key)] 22 | return int(r) 23 | 24 | 25 | def str_time_to_timedelta( 26 | time_string: str, 27 | ) -> timedelta: # CircuitSacul == pog (he made this) 28 | time_string = time_string.lower() 29 | 30 | days = get_time("d", time_string) 31 | hours = get_time("h", time_string) 32 | minutes = get_time("m", time_string) 33 | seconds = get_time("s", time_string) 34 | 35 | actual_seconds = 0 36 | if hours: 37 | actual_seconds += hours * 3600 38 | if minutes: 39 | actual_seconds += minutes * 60 40 | if seconds: 41 | actual_seconds += seconds 42 | 43 | datetime_obj = timedelta( 44 | days=days, 45 | seconds=actual_seconds, 46 | ) 47 | return datetime_obj 48 | 49 | 50 | def dbl_vote_required(): 51 | async def predicate(ctx: commands.Context | discord.ApplicationContext): 52 | bot: MyBot = ctx.bot 53 | 54 | if "--debug" in sys.argv or await bot.topgg_client.get_user_vote( 55 | ctx.author.id 56 | ): 57 | return True 58 | 59 | raise DBLVoteRequired() 60 | 61 | return commands.check(predicate) 62 | 63 | 64 | async def async_mirror(obj: Any): 65 | """ 66 | Coroutine to return the passed object. Useful for returning a default 67 | value when using `asyncio.gather`. 68 | 69 | Args: 70 | obj (Any): The object to be returned. 71 | 72 | Returns: 73 | Any: The object that was passed. 74 | """ 75 | 76 | return obj 77 | 78 | 79 | async def search_youtube(query: str) -> str: 80 | """ 81 | Search YouTube and returns the top video's URL 82 | 83 | Args: 84 | query (str): Search term 85 | 86 | Returns: 87 | str: URL of the first video 88 | """ 89 | 90 | yt_search_url = "https://www.youtube.com/results?search_query=" 91 | yt_video_url = "https://www.youtube.com/watch?v=" 92 | 93 | formatted_query = "+".join(query.split()) 94 | request_url = yt_search_url + formatted_query 95 | 96 | async with aiohttp.request("GET", request_url) as resp: 97 | html = (await resp.read()).decode() 98 | 99 | video_ids = re.findall(r"watch\?v=(\S{11})", html) 100 | first_result = yt_video_url + video_ids[0] 101 | return first_result 102 | -------------------------------------------------------------------------------- /bot/views.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from math import ceil 3 | import discord 4 | from discord import ButtonStyle 5 | 6 | 7 | class ConfirmView(discord.ui.View): 8 | do_action: bool = False 9 | 10 | def __init__( 11 | self, 12 | author_id: int, 13 | confirm_msg: str = "Confirming...", 14 | cancel_msg: str = "Cancelling...", 15 | ): 16 | super().__init__() 17 | self.author_id = author_id 18 | self.confirm_msg = confirm_msg 19 | self.cancel_msg = cancel_msg 20 | 21 | @discord.ui.button(label="Confirm", style=ButtonStyle.danger) 22 | async def confirm( 23 | self, button: discord.ui.Button, interaction: discord.Interaction 24 | ): 25 | if self.author_id == interaction.user.id: 26 | self.do_action = True 27 | self.stop() 28 | await interaction.message.edit( 29 | content=self.confirm_msg, view=None, embed=None 30 | ) 31 | else: 32 | await interaction.followup.send( 33 | "This interaction is not for you", ephemeral=True 34 | ) 35 | 36 | @discord.ui.button(label="Cancel", style=ButtonStyle.grey) 37 | async def cancel( 38 | self, button: discord.ui.Button, interaction: discord.Interaction 39 | ): 40 | if self.author_id == interaction.user.id: 41 | self.do_action = False 42 | self.stop() 43 | await interaction.message.edit( 44 | content=self.cancel_msg, view=None, embed=None 45 | ) 46 | else: 47 | await interaction.followup.send( 48 | "This interaction is not for you", ephemeral=True 49 | ) 50 | 51 | 52 | class AutoModButton(discord.ui.Button): 53 | def __init__( 54 | self, label: str, feature_name: str, enabled: bool, author_id: int 55 | ): 56 | self.feature_name = feature_name 57 | self.author_id = author_id 58 | self.enabled = enabled 59 | 60 | if enabled: 61 | style = ButtonStyle.success 62 | else: 63 | style = ButtonStyle.danger 64 | 65 | super().__init__(label=label, style=style) 66 | 67 | async def callback(self, interaction: discord.Interaction): 68 | if self.author_id != interaction.user.id: 69 | return 70 | 71 | self.enabled = not self.enabled 72 | 73 | if self.enabled: 74 | self.style = ButtonStyle.success 75 | else: 76 | self.style = ButtonStyle.danger 77 | 78 | self.view.set_feature(self.feature_name, self.enabled) 79 | await interaction.message.edit(view=self.view) 80 | 81 | 82 | class AutoModView(discord.ui.View): 83 | def __init__(self, feature_options: dict[str, bool], author_id: int): 84 | self.author_id = author_id 85 | self.features = feature_options 86 | children = [] 87 | 88 | for feature, enabled in list(feature_options.items()): 89 | button = AutoModButton( 90 | feature.replace("_", " ").title(), feature, enabled, author_id 91 | ) 92 | children.append(button) 93 | 94 | super().__init__(*children) 95 | 96 | def set_feature(self, feature: str, value: bool): 97 | self.features[feature] = value 98 | 99 | @discord.ui.button(label="Save", style=ButtonStyle.secondary) 100 | async def save( 101 | self, button: discord.ui.Button, interaction: discord.Interaction 102 | ): 103 | if self.author_id == interaction.user.id: 104 | self.stop() 105 | await interaction.message.edit( 106 | content="Options have been saved", view=None, embed=None 107 | ) 108 | 109 | 110 | class PollButton(discord.ui.Button): 111 | def __init__(self, number: int): 112 | self.number = number 113 | super().__init__(label=str(number), style=ButtonStyle.primary) 114 | 115 | async def callback(self, interaction: discord.Interaction): 116 | if view := self.view: 117 | await view.user_vote(interaction, self.number) 118 | 119 | 120 | class PollView(discord.ui.View): 121 | def __init__(self, options: list[str], poll_length: float): 122 | self.options = options 123 | 124 | self.votes: dict[str, int] = {} # key: option, value: numbers of votes 125 | self.voters: list[int] = [] # list of user ids 126 | 127 | children = [] 128 | for number, option_name in enumerate(options, start=1): 129 | self.votes[option_name] = 0 130 | button = PollButton(number) 131 | children.append(button) 132 | 133 | length_seconds = int(poll_length * 60) 134 | super().__init__(*children, timeout=None) 135 | asyncio.create_task(self.stop_poll(length_seconds)) 136 | 137 | async def stop_poll(self, time: int): 138 | await asyncio.sleep(time) 139 | self.stop() 140 | 141 | async def user_vote(self, interaction: discord.Interaction, number: int): 142 | if not interaction.user: 143 | return 144 | 145 | if interaction.user.id not in self.voters: 146 | option_name = self.options[number - 1] 147 | self.votes[option_name] += 1 148 | 149 | self.voters.append(interaction.user.id) 150 | await interaction.response.send_message( 151 | f"You voted for **{option_name}**", ephemeral=True 152 | ) 153 | 154 | else: 155 | await interaction.response.send_message( 156 | "You cannot vote in the same poll twice!", ephemeral=True 157 | ) 158 | 159 | 160 | class SuggestView(discord.ui.View): 161 | anonymous: bool 162 | 163 | def __init__(self, author_id: int): 164 | super().__init__(timeout=5) 165 | self.author_id = author_id 166 | 167 | @discord.ui.button(label="Yes", style=ButtonStyle.primary) 168 | async def yes( 169 | self, button: discord.ui.Button, interaction: discord.Interaction 170 | ): 171 | if self.author_id == interaction.user.id: 172 | self.anonymous = False 173 | self.stop() 174 | await interaction.message.edit(content="Sending...", view=None) 175 | 176 | @discord.ui.button(label="No", style=ButtonStyle.secondary) 177 | async def no( 178 | self, button: discord.ui.Button, interaction: discord.Interaction 179 | ): 180 | if self.author_id == interaction.user.id: 181 | self.anonymous = True 182 | self.stop() 183 | await interaction.message.edit(content="Sending...", view=None) 184 | 185 | 186 | class PaginatedSelectView(discord.ui.View): 187 | """ 188 | Paginated select menu for more than 25 options. 189 | 190 | Args: 191 | author_id (int): ID of the user who issued the command which instances this view 192 | options (list): List of selectable options. 193 | values (list): List of values corresponding to the options. 194 | descriptions (list, optional): List of descriptions corresponding to the options. Defaults to []. 195 | emojis (list, optional): List of emojis corresponding to the options. Defaults to []. 196 | max_values (int, optional): Maximum options that can be selected. Defaults to 1. 197 | """ 198 | 199 | selected_values = [] 200 | current_page = 0 201 | 202 | def __init__( 203 | self, 204 | author_id: int, 205 | options: list, 206 | values: list, 207 | descriptions: list = [], 208 | emojis: list = [], 209 | max_values: int = 1, 210 | ): 211 | self.author_id = author_id 212 | self.options = options 213 | self.values = values 214 | self.descriptions = descriptions 215 | self.emojis = emojis 216 | self.max_values = max_values 217 | 218 | items = self.build_view() 219 | super().__init__(*items) 220 | 221 | def build_view(self) -> list[discord.ui.Item]: 222 | items = [] 223 | 224 | limits = (self.current_page * 25, (self.current_page + 1) * 25) 225 | options = self.options[limits[0] : limits[1]] 226 | values = self.values[limits[0] : limits[1]] 227 | descriptions = self.descriptions[limits[0] : limits[1]] 228 | emojis = self.emojis[limits[0] : limits[1]] 229 | zipped_options = zip(options, values, descriptions, emojis) 230 | 231 | max_pages = ceil(len(self.options) / 25) 232 | select_menu = discord.ui.Select( 233 | placeholder=f"Page {self.current_page + 1} of {max_pages}", 234 | max_values=min(len(options), self.max_values), 235 | row=0, 236 | ) 237 | select_menu.callback = self.select_menu_callback 238 | 239 | for option, value, description, emoji in zipped_options: 240 | select_menu.add_option( 241 | label=option, value=value, description=description, emoji=emoji 242 | ) 243 | 244 | items.append(select_menu) 245 | 246 | if len(self.options) > 25: 247 | prev_button = discord.ui.Button(emoji="⏪", row=1) 248 | prev_button.callback = self.previous_page 249 | prev_button.disabled = self.current_page <= 0 250 | items.append(prev_button) 251 | 252 | next_button = discord.ui.Button(emoji="⏩", row=1) 253 | next_button.callback = self.next_page 254 | next_button.disabled = ( 255 | self.current_page + 1 >= len(self.options) / 25 256 | ) 257 | items.append(next_button) 258 | 259 | delete_button = discord.ui.Button( 260 | label="Delete", style=ButtonStyle.danger, row=2 261 | ) 262 | delete_button.callback = self.delete 263 | items.append(delete_button) 264 | 265 | return items 266 | 267 | async def next_page(self, interaction: discord.Interaction): 268 | if ( 269 | interaction.user.id == self.author_id 270 | and self.current_page + 1 < len(self.options) / 25 271 | ): 272 | self.current_page += 1 273 | self.clear_items() 274 | 275 | for item in self.build_view(): 276 | self.add_item(item) 277 | 278 | await interaction.message.edit(view=self) 279 | 280 | async def previous_page(self, interaction: discord.Interaction): 281 | if interaction.user.id == self.author_id and self.current_page > 0: 282 | self.current_page -= 1 283 | self.clear_items() 284 | 285 | for item in self.build_view(): 286 | self.add_item(item) 287 | 288 | await interaction.message.edit(view=self) 289 | 290 | async def select_menu_callback(self, interaction: discord.Interaction): 291 | if interaction.user.id == self.author_id: 292 | self.selected_values = interaction.data["values"] 293 | 294 | async def delete(self, interaction: discord.Interaction): 295 | if interaction.user.id == self.author_id: 296 | self.stop() 297 | 298 | 299 | class PaginatedEmbedView(discord.ui.View): 300 | current_embed_index = 0 301 | 302 | def __init__(self, author_id: int, embeds: list[discord.Embed]): 303 | super().__init__() 304 | self.author_id = author_id 305 | self.embeds = embeds 306 | 307 | @discord.ui.button(emoji="⏪") 308 | async def previous( 309 | self, button: discord.ui.Button, interaction: discord.Interaction 310 | ): 311 | if not interaction.user.id == self.author_id: 312 | return 313 | 314 | self.current_embed_index -= 1 315 | 316 | if self.current_embed_index < 0: 317 | self.current_embed_index = len(self.embeds) - 1 318 | 319 | embed = self.embeds[self.current_embed_index] 320 | await interaction.message.edit(embed=embed) 321 | 322 | @discord.ui.button(emoji="⏩") 323 | async def next( 324 | self, button: discord.ui.Button, interaction: discord.Interaction 325 | ): 326 | if not interaction.user.id == self.author_id: 327 | return 328 | 329 | self.current_embed_index += 1 330 | 331 | if self.current_embed_index >= len(self.embeds): 332 | self.current_embed_index = 0 333 | 334 | embed = self.embeds[self.current_embed_index] 335 | await interaction.message.edit(embed=embed) 336 | 337 | @discord.ui.button(emoji="❌") 338 | async def close( 339 | self, button: discord.ui.Button, interaction: discord.Interaction 340 | ): 341 | if interaction.id == self.author_id: 342 | await interaction.message.delete() 343 | self.stop() 344 | -------------------------------------------------------------------------------- /install_deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pip install --upgrade pip 3 | pip install --upgrade -r requirements.txt 4 | pip install --upgrade --no-deps -r requirements_no_deps.txt -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import dotenv 4 | 5 | from logging.config import fileConfig 6 | 7 | from sqlalchemy import engine_from_config 8 | from sqlalchemy import pool 9 | from sqlalchemy.ext.asyncio import AsyncEngine 10 | 11 | from alembic import context 12 | 13 | dotenv.load_dotenv() 14 | from bot.db.models import Base 15 | 16 | # this is the Alembic Config object, which provides 17 | # access to the values within the .ini file in use. 18 | config = context.config 19 | 20 | # Interpret the config file for Python logging. 21 | # This line sets up loggers basically. 22 | if config.config_file_name is not None: 23 | fileConfig(config.config_file_name) 24 | 25 | # add your model's MetaData object here 26 | # for 'autogenerate' support 27 | target_metadata = Base.metadata 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | 34 | 35 | def run_migrations_offline(): 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure( 49 | url=url, 50 | target_metadata=target_metadata, 51 | literal_binds=True, 52 | dialect_opts={"paramstyle": "named"}, 53 | ) 54 | 55 | with context.begin_transaction(): 56 | context.run_migrations() 57 | 58 | 59 | def do_run_migrations(connection): 60 | context.configure(connection=connection, target_metadata=target_metadata) 61 | 62 | with context.begin_transaction(): 63 | context.run_migrations() 64 | 65 | 66 | async def run_migrations_online(): 67 | """Run migrations in 'online' mode. 68 | 69 | In this scenario we need to create an Engine 70 | and associate a connection with the context. 71 | 72 | """ 73 | connectable = AsyncEngine( 74 | engine_from_config( 75 | config.get_section(config.config_ini_section), 76 | prefix="sqlalchemy.", 77 | poolclass=pool.NullPool, 78 | future=True, 79 | ) 80 | ) 81 | 82 | async with connectable.connect() as connection: 83 | await connection.run_sync(do_run_migrations) 84 | 85 | await connectable.dispose() 86 | 87 | 88 | config.set_main_option("sqlalchemy.url", os.getenv("DB_URI")) 89 | 90 | if context.is_offline_mode(): 91 | run_migrations_offline() 92 | else: 93 | asyncio.run(run_migrations_online()) 94 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/07c05549ae00_welcome_leave_channel_to_string.py: -------------------------------------------------------------------------------- 1 | """welcome_leave_channel_to_string 2 | 3 | Revision ID: 07c05549ae00 4 | Revises: 24933d669517 5 | Create Date: 2022-04-20 16:01:08.452443 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "07c05549ae00" 14 | down_revision = "24933d669517" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.alter_column("guilds", "welcome_channel", type_=sa.String) 21 | op.alter_column("guilds", "leave_channel", type_=sa.String) 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | pass 27 | # ### end Alembic commands ### 28 | -------------------------------------------------------------------------------- /migrations/versions/0a13ce529f35_initial.py: -------------------------------------------------------------------------------- 1 | """initial 2 | 3 | Revision ID: 0a13ce529f35 4 | Revises: 5 | Create Date: 2022-04-05 14:27:50.687033 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "0a13ce529f35" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "afks", 23 | sa.Column("user_id", sa.BigInteger(), nullable=False), 24 | sa.Column("message", sa.String(), nullable=False), 25 | sa.PrimaryKeyConstraint("user_id"), 26 | ) 27 | op.create_table( 28 | "auto_responses", 29 | sa.Column("id", sa.String(), nullable=False), 30 | sa.Column("activation", sa.String(), nullable=False), 31 | sa.Column("response", sa.String(), nullable=False), 32 | sa.PrimaryKeyConstraint("id"), 33 | ) 34 | op.create_table( 35 | "guilds", 36 | sa.Column("id", sa.BigInteger(), nullable=False), 37 | sa.Column("infractions", sa.JSON(), nullable=False), 38 | sa.Column("mute_role", sa.BigInteger(), nullable=True), 39 | sa.Column("activated_automod", sa.JSON(), nullable=False), 40 | sa.Column("welcome_message", sa.String(), nullable=True), 41 | sa.Column("leave_message", sa.String(), nullable=True), 42 | sa.Column("welcome_channel", sa.BigInteger(), nullable=True), 43 | sa.Column("leave_channel", sa.BigInteger(), nullable=True), 44 | sa.Column("auto_role", sa.BigInteger(), nullable=True), 45 | sa.Column("prefix", sa.String(), nullable=False), 46 | sa.Column("clear_cap", sa.Integer(), nullable=True), 47 | sa.PrimaryKeyConstraint("id"), 48 | ) 49 | op.create_table( 50 | "reaction_roles", 51 | sa.Column("id", sa.String(), nullable=False), 52 | sa.Column("guild_id", sa.BigInteger(), nullable=False), 53 | sa.Column("channel_id", sa.BigInteger(), nullable=False), 54 | sa.Column("message_id", sa.BigInteger(), nullable=False), 55 | sa.Column("emoji", sa.String(), nullable=False), 56 | sa.Column("role_id", sa.BigInteger(), nullable=False), 57 | sa.PrimaryKeyConstraint("id"), 58 | ) 59 | op.create_table( 60 | "reminders", 61 | sa.Column("id", sa.String(), nullable=False), 62 | sa.Column("user_id", sa.BigInteger(), nullable=False), 63 | sa.Column("message", sa.String(), nullable=False), 64 | sa.Column("start", sa.DateTime(), nullable=False), 65 | sa.Column("due", sa.DateTime(), nullable=False), 66 | sa.PrimaryKeyConstraint("id"), 67 | ) 68 | op.create_table( 69 | "webhooks", 70 | sa.Column("channel_id", sa.BigInteger(), nullable=False), 71 | sa.Column("webhook_url", sa.String(), nullable=False), 72 | sa.PrimaryKeyConstraint("channel_id"), 73 | ) 74 | # ### end Alembic commands ### 75 | 76 | 77 | def downgrade(): 78 | # ### commands auto generated by Alembic - please adjust! ### 79 | op.drop_table("webhooks") 80 | op.drop_table("reminders") 81 | op.drop_table("reaction_roles") 82 | op.drop_table("guilds") 83 | op.drop_table("auto_responses") 84 | op.drop_table("afks") 85 | # ### end Alembic commands ### 86 | -------------------------------------------------------------------------------- /migrations/versions/1b6128ae5a4b_added_playlist_and_playlistsong_models.py: -------------------------------------------------------------------------------- 1 | """Added Playlist and PlaylistSong models 2 | 3 | Revision ID: 1b6128ae5a4b 4 | Revises: 228333096592 5 | Create Date: 2022-11-13 20:47:10.607861 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "1b6128ae5a4b" 14 | down_revision = "228333096592" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "playlists", 23 | sa.Column("id", sa.String(), nullable=False), 24 | sa.Column("owner_id", sa.BigInteger(), nullable=False), 25 | sa.Column("name", sa.String(), nullable=False), 26 | sa.PrimaryKeyConstraint("id"), 27 | ) 28 | op.create_table( 29 | "playlist_songs", 30 | sa.Column("uri", sa.String(), nullable=False), 31 | sa.Column("playlist_id", sa.String(), nullable=False), 32 | sa.ForeignKeyConstraint( 33 | ["playlist_id"], ["playlists.id"], ondelete="CASCADE" 34 | ), 35 | sa.PrimaryKeyConstraint("uri"), 36 | ) 37 | # ### end Alembic commands ### 38 | 39 | 40 | def downgrade(): 41 | # ### commands auto generated by Alembic - please adjust! ### 42 | op.drop_table("playlist_songs") 43 | op.drop_table("playlists") 44 | # ### end Alembic commands ### 45 | -------------------------------------------------------------------------------- /migrations/versions/228333096592_imp_log_table.py: -------------------------------------------------------------------------------- 1 | """imp_log_table 2 | 3 | Revision ID: 228333096592 4 | Revises: 07c05549ae00 5 | Create Date: 2022-04-21 11:50:59.700208 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "228333096592" 14 | down_revision = "07c05549ae00" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "impersonation_logs", 23 | sa.Column("id", sa.String(), nullable=False), 24 | sa.Column("guild_id", sa.BigInteger(), nullable=False), 25 | sa.Column("channel_id", sa.BigInteger(), nullable=False), 26 | sa.Column("message_id", sa.BigInteger(), nullable=False), 27 | sa.Column("user_id", sa.BigInteger(), nullable=False), 28 | sa.Column("impersonator_id", sa.BigInteger(), nullable=False), 29 | sa.Column("message", sa.String(), nullable=False), 30 | sa.Column( 31 | "timestamp", 32 | sa.DateTime(timezone=True), 33 | server_default=sa.text("now()"), 34 | nullable=True, 35 | ), 36 | sa.PrimaryKeyConstraint("id"), 37 | ) 38 | # ### end Alembic commands ### 39 | 40 | 41 | def downgrade(): 42 | # ### commands auto generated by Alembic - please adjust! ### 43 | op.drop_table("impersonation_logs") 44 | # ### end Alembic commands ### 45 | -------------------------------------------------------------------------------- /migrations/versions/24933d669517_added_moderator_id_field.py: -------------------------------------------------------------------------------- 1 | """added moderator_id field 2 | 3 | Revision ID: 24933d669517 4 | Revises: 81217e467554 5 | Create Date: 2022-04-14 19:01:57.155489 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "24933d669517" 14 | down_revision = "81217e467554" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column( 22 | "infractions", 23 | sa.Column("moderator_id", sa.BigInteger(), nullable=False), 24 | ) 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade(): 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.drop_column("infractions", "moderator_id") 31 | # ### end Alembic commands ### 32 | -------------------------------------------------------------------------------- /migrations/versions/81217e467554_added_infractions_table.py: -------------------------------------------------------------------------------- 1 | """added infractions table 2 | 3 | Revision ID: 81217e467554 4 | Revises: e05490c784a6 5 | Create Date: 2022-04-14 18:47:05.307436 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "81217e467554" 14 | down_revision = "e05490c784a6" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "infractions", 23 | sa.Column("id", sa.String(), nullable=False), 24 | sa.Column("guild_id", sa.BigInteger(), nullable=False), 25 | sa.Column("user_id", sa.BigInteger(), nullable=False), 26 | sa.Column("reason", sa.String(), nullable=False), 27 | sa.PrimaryKeyConstraint("id"), 28 | ) 29 | op.drop_column("guilds", "infractions") 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.add_column( 36 | "guilds", 37 | sa.Column( 38 | "infractions", 39 | postgresql.JSON(astext_type=sa.Text()), 40 | autoincrement=False, 41 | nullable=False, 42 | ), 43 | ) 44 | op.drop_table("infractions") 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /migrations/versions/c534a7e185b6_add_guild_id_to_auto_resp.py: -------------------------------------------------------------------------------- 1 | """add guild_id to auto resp 2 | 3 | Revision ID: c534a7e185b6 4 | Revises: 0a13ce529f35 5 | Create Date: 2022-04-05 18:39:59.392001 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "c534a7e185b6" 14 | down_revision = "0a13ce529f35" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column( 22 | "auto_responses", 23 | sa.Column("guild_id", sa.BigInteger(), nullable=False), 24 | ) 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade(): 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.drop_column("auto_responses", "guild_id") 31 | # ### end Alembic commands ### 32 | -------------------------------------------------------------------------------- /migrations/versions/e05490c784a6_added_auto_mod_table.py: -------------------------------------------------------------------------------- 1 | """added auto_mod table 2 | 3 | Revision ID: e05490c784a6 4 | Revises: c534a7e185b6 5 | Create Date: 2022-04-06 17:53:35.163875 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "e05490c784a6" 14 | down_revision = "c534a7e185b6" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "auto_mod", 23 | sa.Column("guild_id", sa.BigInteger(), nullable=False), 24 | sa.Column("links", sa.Boolean(), nullable=False), 25 | sa.Column("images", sa.Boolean(), nullable=False), 26 | sa.Column("ping_spam", sa.Boolean(), nullable=False), 27 | sa.PrimaryKeyConstraint("guild_id"), 28 | ) 29 | op.drop_column("guilds", "activated_automod") 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.add_column( 36 | "guilds", 37 | sa.Column( 38 | "activated_automod", 39 | postgresql.JSON(astext_type=sa.Text()), 40 | autoincrement=False, 41 | nullable=False, 42 | ), 43 | ) 44 | op.drop_table("auto_mod") 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /migrations/versions/ef4c153dc7d7_made_playlist_id_primary_key_as_well.py: -------------------------------------------------------------------------------- 1 | """Made playlist_id primary key as well 2 | 3 | Revision ID: ef4c153dc7d7 4 | Revises: 1b6128ae5a4b 5 | Create Date: 2022-11-16 09:21:40.000057 6 | 7 | """ 8 | from alembic import op 9 | 10 | # import sqlalchemy as sa 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "ef4c153dc7d7" 15 | down_revision = "1b6128ae5a4b" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | op.drop_constraint("playlist_songs_pkey", "playlist_songs") 22 | op.create_primary_key( 23 | "playlist_songs_pkey", "playlist_songs", ["uri", "playlist_id"] 24 | ) 25 | 26 | 27 | def downgrade(): 28 | op.drop_constraint("playlist_songs_pkey", "playlist_songs") 29 | op.create_primary_key("playlist_songs_pkey", "playlist_songs", ["uri"]) 30 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dotenv 2 | sqlalchemy[asyncio] 3 | alembic 4 | asyncpg 5 | py-cord[voice,speed]==2.4.0 6 | pycord-prettyhelp 7 | wavelink==1.0.0 8 | pyfiglet 9 | jishaku 10 | aiohttp 11 | pytest 12 | emoji 13 | urbanpython 14 | pillow 15 | pynacl 16 | flake8 17 | black 18 | -------------------------------------------------------------------------------- /requirements_no_deps.txt: -------------------------------------------------------------------------------- 1 | topggpy # Messes with py-cord installation by installing d.py -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import dotenv 2 | 3 | if __name__ == "__main__": 4 | dotenv.load_dotenv() 5 | import bot 6 | 7 | bot.main() 8 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | path = os.path.dirname(os.path.abspath(__file__)) 5 | sys.path.insert(0, path + "/../") 6 | 7 | from bot.utils import get_time 8 | 9 | 10 | def test_get_time(): 11 | time1_str = "5d 13h 4m 21s" 12 | assert get_time("d", time1_str) == 5 13 | assert get_time("h", time1_str) == 13 14 | assert get_time("m", time1_str) == 4 15 | assert get_time("s", time1_str) == 21 16 | 17 | time2_str = "2d 17h 32m 46s" 18 | assert get_time("d", time2_str) == 2 19 | assert get_time("h", time2_str) == 17 20 | assert get_time("m", time2_str) == 32 21 | assert get_time("s", time2_str) == 46 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E501, W503, E402 --------------------------------------------------------------------------------